22 KiB
Ipsen MES MoveIn Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Turn the stub POST /api/IpsenMESMoveIn into a real MoveIn that resolves the SAP number to a reactor instance via BTDB, then writes the MoveIn onto the correct Left/Right MES-receiver child — gated on that receiver's MoveInReadyFlag.
Architecture: The inbound /api script gains a scoped, read-only DB helper (Database.QuerySingle) so it can do the dbo.Machine.SAPID → Code lookup, derive the bare instance code, then Route.To(instance).Call("IpsenMoveIn", {…params, side}). A new on-demand template script IpsenMoveIn on the reactor template (IpsenFurnaceTitan, T1) gates on MoveInReadyFlag and writes the fields onto Children["{side}MESReceiver"] (DCL → Galaxy authorized write).
Tech Stack: .NET 10, Roslyn CSharpScript (inbound + template scripts), Akka.NET (Instance/Script actors), IDatabaseGateway/ADO.NET (SQL Server BTDB), ScadaBridge management API (POST /management).
Design doc: docs/plans/2026-06-16-ipsen-mes-movein-design.md. Branch: feat/ipsen-mes-movein.
Decisions locked: input = <SAP#>-<side> (-A=Left, -B=Right; -LT/other → WasSuccessful=false); match Machine.SAPID, Code (minus a trailing A/B) = instance; write all provided fields except MoveInReadyFlag (gate) and SAPID (routing); MoveInComplete/Successful/ErrorText are PLC-owned (not written); return BatchID = echoed input MoveInBatchID.
Key facts (verified): inbound globals = Parameters/Route/CancellationToken (no DB) → add Database. IDatabaseGateway.GetConnectionAsync(name) registered on Central via Host/Program.cs. RouteHelper only targets an instance code. Mgmt commands: UpdateApiMethod {apiMethodId,script,timeoutSeconds,parameterDefinitions?,returnDefinition?}, AddTemplateScript {templateId,name,code,triggerType?,triggerConfiguration?,isLocked,parameterDefinitions?,returnDefinition?}. On-demand script → triggerType:"call". Array attrs: write via the Attributes[name]=value indexer which runs AttributeValueCodec.Encode (IEnumerable/JsonElement → JSON array string), and InstanceActor decodes List attrs and rejects malformed list JSON. Reactor = T1 IpsenFurnaceTitan, instances Z28061/Z28062 (Site 2); children LeftMESReceiver/RightMESReceiver; full MoveIn set on base T3 (arrays List<String>). ApiMethod id = 1. Trust policy forbids System.IO/Reflection/Net/Threading/Process/Interop/Win32 + dynamic/Activator; System.Text.Json + anonymous types + await are allowed; keep ADO internal to the helper regardless.
Task 1: InboundDatabaseHelper (read-only DB for inbound scripts)
Classification: standard Estimated implement time: ~5 min Parallelizable with: none
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs - Test:
tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs
Step 1 — Write the failing test (uses an in-memory SQLite IDatabaseGateway fake; add Microsoft.Data.Sqlite to the test project if not already referenced):
using System.Data.Common;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.InboundAPI;
using Xunit;
public class InboundDatabaseHelperTests
{
private sealed class SqliteGateway : IDatabaseGateway
{
private readonly string _cs;
public SqliteGateway(string cs) => _cs = cs;
public async Task<DbConnection> GetConnectionAsync(string name, CancellationToken ct = default)
{ var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; }
public Task<ExternalCallResult> CachedWriteAsync(string c, string s,
IReadOnlyDictionary<string, object?>? p = null, string? o = null, CancellationToken ct = default,
TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null)
=> throw new NotImplementedException();
}
private static SqliteGateway SeededGateway()
{
// shared in-memory db kept alive by an open keep-alive connection
var keep = new SqliteConnection("DataSource=file:movein?mode=memory&cache=shared");
keep.Open();
using var cmd = keep.CreateCommand();
cmd.CommandText = "CREATE TABLE Machine(Code TEXT, SAPID TEXT); INSERT INTO Machine VALUES('Z28061A','131453');";
cmd.ExecuteNonQuery();
return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared");
}
[Fact]
public void QuerySingle_returns_first_column_with_bound_parameter()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB",
"SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
Assert.Equal("Z28061A", code);
}
[Fact]
public void QuerySingle_returns_default_when_no_rows()
{
var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
var code = helper.QuerySingle<string>("BTDB",
"SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" });
Assert.Null(code);
}
}
Step 2 — Run it, expect FAIL (InboundDatabaseHelper not defined):
dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests --filter InboundDatabaseHelperTests
Step 3 — Implement InboundDatabaseHelper.cs:
using System.Data.Common;
using System.Globalization;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// Read-only database access exposed to inbound API scripts. All ADO.NET stays
/// internal here — scripts call QuerySingle/Query by name and never reference
/// System.Data. Named connections only; parameters are bound (anonymous-object
/// properties become @-prefixed SQL parameters), never string-concatenated.
/// </summary>
public sealed class InboundDatabaseHelper
{
private readonly IDatabaseGateway _gateway;
private readonly CancellationToken _ct;
public InboundDatabaseHelper(IDatabaseGateway gateway, CancellationToken ct)
{ _gateway = gateway; _ct = ct; }
/// <summary>First column of the first row converted to T (default if no rows).</summary>
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
{
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
var result = cmd.ExecuteScalar();
if (result is null or DBNull) return default;
if (result is T t) return t;
return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture);
}
/// <summary>All rows as column→value dictionaries (case-insensitive keys).</summary>
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
string connectionName, string sql, object? parameters = null)
{
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
AddParameters(cmd, parameters);
using var reader = cmd.ExecuteReader();
var rows = new List<IReadOnlyDictionary<string, object?>>();
while (reader.Read())
{
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
var v = reader.GetValue(i);
row[reader.GetName(i)] = v is DBNull ? null : v;
}
rows.Add(row);
}
return rows;
}
private static void AddParameters(DbCommand cmd, object? parameters)
{
if (parameters is null) return;
foreach (var prop in parameters.GetType().GetProperties())
{
var p = cmd.CreateParameter();
p.ParameterName = "@" + prop.Name;
p.Value = prop.GetValue(parameters) ?? DBNull.Value;
cmd.Parameters.Add(p);
}
}
}
Step 4 — Run tests, expect PASS.
Step 5 — Commit:
git add src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs && git commit -m "feat(inbound): read-only InboundDatabaseHelper for inbound scripts"
Acceptance: QuerySingle<T> binds anonymous-object params, returns first column or default; ADO.NET stays internal.
Task 2: Expose Database to inbound scripts (wire context + executor)
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Task 1)
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs(theInboundScriptContextclass ~321-353; theExecuteAsynccontext construction ~246-249; addusing Microsoft.Extensions.DependencyInjection;) - Test:
tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs(add cases)
Step 1 — Write failing tests: (a) a script body return new { v = Database.QuerySingle<string>("BTDB","SELECT Code FROM Machine WHERE SAPID=@s", new { s = (string)Parameters["sap"] }) }; resolves via a seeded SQLite gateway; (b) an existing script that does NOT use Database still compiles + runs (no regression). Use the same SQLite-gateway fake from Task 1; construct the executor with a real IServiceProvider (e.g. new ServiceCollection().AddScoped<IDatabaseGateway>(_ => gateway).BuildServiceProvider()).
Step 2 — Run, expect FAIL (Database not a member of context).
Step 3 — Implement. Add the property + ctor param to InboundScriptContext:
public class InboundScriptContext
{
public ScriptParameters Parameters { get; }
public RouteHelper Route { get; }
public InboundDatabaseHelper Database { get; } // NEW
public CancellationToken CancellationToken { get; }
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
InboundDatabaseHelper database, // NEW
CancellationToken cancellationToken = default)
{
Parameters = new ScriptParameters(parameters);
Route = route;
Database = database;
CancellationToken = cancellationToken;
}
}
In ExecuteAsync, build the helper from a DI scope (gateway is scoped) and pass it. Replace the existing var context = new InboundScriptContext(...) block:
using var scope = _serviceProvider.CreateScope();
var gateway = scope.ServiceProvider.GetRequiredService<IDatabaseGateway>();
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
dbHelper,
cts.Token);
Add using Microsoft.Extensions.DependencyInjection; at the top. No ScriptOptions.WithReferences change is needed — InboundDatabaseHelper lives in the same assembly as RouteHelper, which is already referenced (line ~157). Leave imports as-is (script calls Database.QuerySingle, fully qualified through the globals object; no using required).
Step 4 — Run tests, expect PASS (both the Database-using script and the no-Database script).
Step 5 — Commit:
git add -p the two files; git commit -m "feat(inbound): expose read-only Database helper on InboundScriptContext"
Acceptance: inbound scripts can call Database.QuerySingle; the scope is disposed after each execution; existing no-DB scripts still compile + run; ScriptTrustValidator still passes (no forbidden API in the helper-call text).
Task 3: Build + regression-test ScadaBridge
Classification: small Estimated implement time: ~4 min Parallelizable with: none (depends on Task 2)
Files: none (verification)
Steps:
dotnet build ZB.MOM.WW.ScadaBridge.slnx→ 0 errors.dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests→ all green.dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests→ all green (trust model unaffected).- If anything fails, fix before proceeding (do NOT deploy a red build).
Acceptance: clean build + green InboundAPI & ScriptAnalysis suites.
Task 4: Commit Component A
Classification: trivial Estimated implement time: ~1 min Parallelizable with: none
Steps: ensure Tasks 1–2 are committed; git log --oneline -3 shows the two feature commits on feat/ipsen-mes-movein. (No push unless the user asks.)
Task 5: Deploy ScadaBridge Central (Component A) to wonder-app-vd03
Classification: high-risk (production deploy) Estimated implement time: ~6 min Parallelizable with: none (depends on Tasks 3–4) Requires explicit go-ahead before running.
Steps (mirror this session's deploy mechanics):
- Publish self-contained:
dotnet publish src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj -c Release -r win-x64 --self-contained -o <out>. - Zip
out; SFTP toC:\Users\dohertj2\Desktop\win64(servecli-p 2222 -i ~/.ssh/servecli_wonder). - On box (PS via
/tmp/wonder-ps.sh): stopScadaBridge-Central; back up currentE:\ApiInstall\ScadaBridge\app; Expand-Archive; swap the app dir preservingapp\data\and allappsettings*.json; restartScadaBridge-Central. - No new EF migration in Component A (no schema change) — confirm none was generated; if one was, apply it BEFORE boot (Central is Production, no auto-migrate).
- Verify: service Running; health endpoint 200; no startup errors in log;
POST /api/IpsenMESMoveInstill reachable (stub behaviour OK at this point — script not yet updated).
Acceptance: Central runs the new binary with the Database capability; data preserved; health green.
Task 6: Apply Component B — update the inbound IpsenMESMoveIn script
Classification: high-risk (production data) Estimated implement time: ~3 min Parallelizable with: Task 7 (different surfaces; both depend on Task 5) Requires explicit go-ahead.
Step 1 — Script body (the script payload value):
try {
var raw = (Parameters["SAPID"] as string ?? "").Trim();
var dash = raw.IndexOf('-');
if (dash < 0)
return new { WasSuccessful = false, ErrorText = "SAPID missing -A/-B side suffix", BatchID = 0 };
var sap = raw.Substring(0, dash);
var suf = raw.Substring(dash + 1); // "A","B","A-LT",...
var side = suf == "A" ? "Left" : suf == "B" ? "Right" : null;
if (side == null)
return new { WasSuccessful = false, ErrorText = "Unsupported side suffix '" + suf + "' (only -A/-B supported)", BatchID = 0 };
var code = Database.QuerySingle<string>("BTDB",
"SELECT TOP 1 Code FROM dbo.Machine WHERE SAPID=@s", new { s = sap });
if (string.IsNullOrEmpty(code))
return new { WasSuccessful = false, ErrorText = "No machine found for SAP " + sap, BatchID = 0 };
var instance = (code.EndsWith("A") || code.EndsWith("B")) ? code.Substring(0, code.Length - 1) : code;
return await Route.To(instance).Call("IpsenMoveIn", new {
side,
MoveInMesContainerNum = Parameters["MoveInMesContainerNum"],
MoveInBatchID = Parameters["MoveInBatchID"],
MoveInJobSequenceNumber = Parameters["MoveInJobSequenceNumber"],
MoveInNumberWorkOrders = Parameters["MoveInNumberWorkOrders"],
MoveInWorkOrderNumbers = Parameters["MoveInWorkOrderNumbers"],
MoveInPartNumbers = Parameters["MoveInPartNumbers"],
MoveInOperatorName = Parameters["MoveInOperatorName"],
MoveInFlag = Parameters["MoveInFlag"],
});
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "MoveIn failed: " + ex.Message, BatchID = 0 };
}
Step 2 — Apply via management API on the box (no auth under DisableLogin):
POST http://localhost:8085/management with
{"command":"UpdateApiMethod","payload":{"apiMethodId":1,"script":"<the script above>","timeoutSeconds":30,"parameterDefinitions":"<existing ParameterDefinitions verbatim>","returnDefinition":"<existing ReturnDefinition verbatim>"}}
Preserve the current ParameterDefinitions/ReturnDefinition (read them first; they already declare the 13 inputs incl. the two arrays, and the {WasSuccessful,ErrorText,BatchID} return). Use a here-string/file for the script to avoid JSON-escaping pain.
Step 3 — Verify the method recompiled: a malformed-side call (SAPID:"x" with no dash) returns {WasSuccessful:false, ErrorText:"SAPID missing -A/-B side suffix", BatchID:0} (proves the new script is live and DB-capable without needing a ready receiver).
Acceptance: UpdateApiMethod succeeds; method recompiles; negative path returns the structured error.
Task 7: Apply Component C — IpsenMoveIn template script on T1
Classification: high-risk (production data) Estimated implement time: ~4 min Parallelizable with: Task 6 (depends on Task 5) Requires explicit go-ahead.
Pre-step — verify two facts (local + box):
- Local: confirm
CompositionAccessor/AttributeAccessorindexer setter (src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs) routes throughAttributeValueCodec.Encode(it does —Attributes[name]=value). Use the indexer setter for ALL writes so List values encode to JSON arrays automatically. - Box: confirm T5/T6 (
LeftMESReceiver/RightMESReceiver) array-attr rows areIsInherited=trueplaceholders (so the T3Listtypes apply at flatten) —SELECT TemplateId,Name,DataType,ElementDataType,IsInherited FROM dbo.TemplateAttributes WHERE Name IN ('MoveInWorkOrderNumbers','MoveInPartNumbers'). If any derived row is a real override withDataType='String', fix it (delete the placeholder / re-inherit) before relying on array writes.
Step 1 — Script body (the code payload value):
var side = (Parameters["side"] as string) ?? "Left";
var recv = Children[side == "Right" ? "RightMESReceiver" : "LeftMESReceiver"];
int batchId = 0;
try { batchId = Convert.ToInt32(Parameters["MoveInBatchID"] ?? 0); } catch { }
var ready = recv.Attributes["MoveInReadyFlag"];
var rs = ready?.ToString();
bool isReady = (ready is bool b && b)
|| rs == "1" || string.Equals(rs, "true", StringComparison.OrdinalIgnoreCase);
if (!isReady)
return new { WasSuccessful = false, ErrorText = "MoveInReadyFlag is not true", BatchID = batchId };
try {
recv.Attributes["MoveInMesContainerNum"] = Parameters["MoveInMesContainerNum"];
recv.Attributes["MoveInBatchID"] = Parameters["MoveInBatchID"];
recv.Attributes["MoveInJobSequenceNumber"] = Parameters["MoveInJobSequenceNumber"];
recv.Attributes["MoveInNumberWorkOrders"] = Parameters["MoveInNumberWorkOrders"];
recv.Attributes["MoveInWorkOrderNumbers"] = Parameters["MoveInWorkOrderNumbers"]; // List<String> -> JSON
recv.Attributes["MoveInPartNumbers"] = Parameters["MoveInPartNumbers"]; // List<String> -> JSON
recv.Attributes["MoveInOperatorName"] = Parameters["MoveInOperatorName"];
recv.Attributes["MoveInFlag"] = Parameters["MoveInFlag"];
} catch (Exception ex) {
return new { WasSuccessful = false, ErrorText = "Write failed: " + ex.Message, BatchID = batchId };
}
return new { WasSuccessful = true, ErrorText = "", BatchID = batchId };
Step 2 — Apply via management API:
{"command":"AddTemplateScript","payload":{"templateId":1,"name":"IpsenMoveIn","code":"<the script above>","triggerType":"call","triggerConfiguration":null,"isLocked":false,"parameterDefinitions":null,"returnDefinition":null}}
(triggerType:"call" → on-demand only. parameterDefinitions/returnDefinition can stay null — the inbound passes a typed anonymous object and the result is validated by the inbound method's own ReturnDefinition.)
Step 3 — Redeploy config so the Site picks up the new T1 script on its instances (use the same deploy/redeploy path used in this session — management deploy or POST /api/deployments). Confirm Z28061/Z28062 are re-applied.
Acceptance: AddTemplateScript succeeds (script id returned); after redeploy the reactor instances expose the IpsenMoveIn call-only script.
Task 8: On-box end-to-end verification
Classification: standard (production verify) Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 6, 7)
Steps (use a SAP that resolves to a deployed reactor — confirm/populate Machine.SAPID for the target first; e.g. set Z28061A.SAPID to a known test value, or use the existing data):
- Resolve + not-ready: with the target receiver's
MoveInReadyFlagfalse (read it at the gateway first),POST /api/IpsenMESMoveIn {"SAPID":"<sap>-A", ...minimal payload}→ expect{WasSuccessful:false, ErrorText:"MoveInReadyFlag is not true", BatchID:<echo>}. - Happy path: when
MoveInReadyFlagis true, POST a full payload (incl.MoveInWorkOrderNumbers/MoveInPartNumbersarrays) → expect{WasSuccessful:true, ErrorText:"", BatchID:<echo>}; then verify the values landed on the Galaxy receiver via the MxGateway (readMESReceiver_023.MoveInBatchID/.MoveInWorkOrderNumbersetc., as in the earlier enrichment) — especially confirm the arrays wrote correctly. - Negatives: bad suffix (
-A-LT, none) → unsupported-side error; unknown SAP → "No machine found" error. - If array writes don't land correctly, adjust the encoding in the Task 7 script (e.g. pre-serialize arrays with
System.Text.Json— allowed) and re-apply viaUpdateTemplateScript {scriptId,...}.
Acceptance: resolution, the MoveInReadyFlag gate, scalar + array writes, and all negative paths behave per the design; values verified live on the Galaxy receiver.
Post-completion
- Commit any source adjustments; keep this plan + the design doc on
feat/ipsen-mes-movein. Push/merge only when the user asks. - Update memory: the inbound MoveIn flow + the new
Databaseinbound capability +IpsenMoveInT1 script. - Out of scope (future): leak-test (
-LT) receivers/routing; writing PLC-output flags;Z28062BTDB data completeness.