Files
ScadaBridge/docs/plans/2026-06-16-ipsen-mes-movein.md
T
2026-06-16 22:00:10 -04:00

22 KiB
Raw Blame History

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 (the InboundScriptContext class ~321-353; the ExecuteAsync context construction ~246-249; add using 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:

  1. dotnet build ZB.MOM.WW.ScadaBridge.slnx → 0 errors.
  2. dotnet test tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests → all green.
  3. dotnet test tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests → all green (trust model unaffected).
  4. 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 12 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 34) Requires explicit go-ahead before running.

Steps (mirror this session's deploy mechanics):

  1. 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>.
  2. Zip out; SFTP to C:\Users\dohertj2\Desktop\win64 (servecli -p 2222 -i ~/.ssh/servecli_wonder).
  3. On box (PS via /tmp/wonder-ps.sh): stop ScadaBridge-Central; back up current E:\ApiInstall\ScadaBridge\app; Expand-Archive; swap the app dir preserving app\data\ and all appsettings*.json; restart ScadaBridge-Central.
  4. 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).
  5. Verify: service Running; health endpoint 200; no startup errors in log; POST /api/IpsenMESMoveIn still 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/AttributeAccessor indexer setter (src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScopeAccessors.cs) routes through AttributeValueCodec.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 are IsInherited=true placeholders (so the T3 List types 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 with DataType='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):

  1. Resolve + not-ready: with the target receiver's MoveInReadyFlag false (read it at the gateway first), POST /api/IpsenMESMoveIn {"SAPID":"<sap>-A", ...minimal payload} → expect {WasSuccessful:false, ErrorText:"MoveInReadyFlag is not true", BatchID:<echo>}.
  2. Happy path: when MoveInReadyFlag is true, POST a full payload (incl. MoveInWorkOrderNumbers/MoveInPartNumbers arrays) → expect {WasSuccessful:true, ErrorText:"", BatchID:<echo>}; then verify the values landed on the Galaxy receiver via the MxGateway (read MESReceiver_023.MoveInBatchID / .MoveInWorkOrderNumbers etc., as in the earlier enrichment) — especially confirm the arrays wrote correctly.
  3. Negatives: bad suffix (-A-LT, none) → unsupported-side error; unknown SAP → "No machine found" error.
  4. 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 via UpdateTemplateScript {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 Database inbound capability + IpsenMoveIn T1 script.
  • Out of scope (future): leak-test (-LT) receivers/routing; writing PLC-output flags; Z28062 BTDB data completeness.