# 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, async DB helper (`await Database.QuerySingleAsync`) 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):

```csharp
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 async Task QuerySingleAsync_returns_first_column_with_bound_parameter()
    {
        var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
        var code = await helper.QuerySingleAsync<string>("BTDB",
            "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" });
        Assert.Equal("Z28061A", code);
    }

    [Fact]
    public async Task QuerySingleAsync_returns_default_when_no_rows()
    {
        var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None);
        var code = await helper.QuerySingleAsync<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`:

```csharp
using System.Data.Common;
using System.Globalization;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;

namespace ZB.MOM.WW.ScadaBridge.InboundAPI;

/// <summary>
/// Async database access exposed to inbound API scripts. All ADO.NET stays
/// internal here — scripts call (and await) QuerySingleAsync/QueryAsync 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 async Task<T?> QuerySingleAsync<T>(string connectionName, string sql, object? parameters = null)
    {
        await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        AddParameters(cmd, parameters);
        var result = await cmd.ExecuteScalarAsync(_ct);
        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 async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
        string connectionName, string sql, object? parameters = null)
    {
        await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
        await using var cmd = conn.CreateCommand();
        cmd.CommandText = sql;
        AddParameters(cmd, parameters);
        await using var reader = await cmd.ExecuteReaderAsync(_ct);
        var rows = new List<IReadOnlyDictionary<string, object?>>();
        while (await reader.ReadAsync(_ct))
        {
            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:** `QuerySingleAsync<T>` binds anonymous-object params and (awaited) 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 = await Database.QuerySingleAsync<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`:

```csharp
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:

```csharp
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 `await Database.QuerySingleAsync`, 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 `await Database.QuerySingleAsync`; 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 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):**
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):

```csharp
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 = await Database.QuerySingleAsync<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
```json
{"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):

```csharp
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:
```json
{"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.
