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

409 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```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 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`:
```csharp
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`:
```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 `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):
```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 = 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
```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.