diff --git a/docs/plans/2026-06-16-ipsen-mes-movein-design.md b/docs/plans/2026-06-16-ipsen-mes-movein-design.md new file mode 100644 index 00000000..7784f562 --- /dev/null +++ b/docs/plans/2026-06-16-ipsen-mes-movein-design.md @@ -0,0 +1,188 @@ +# Design — Ipsen MES MoveIn → reactor MES receiver + +**Date:** 2026-06-16 +**Status:** Approved (design); implementation pending +**Scope:** ScadaBridge — inbound API `POST /api/IpsenMESMoveIn`, a small inbound-script DB +capability, and a new template script on the reactor template that writes a MoveIn to the +correct MES-receiver child. +**Target environment:** production `wonder-app-vd03` (Central + Site, ConfigDb `ScadaBridge` +on `wonder-app-vd03,1433`; reactors live on Site 2). BTDB = db-connection id 1 (`dbo.Machine` +on `wonder-sql-vd03/BT`). + +## 1. Problem + +The deployed `IpsenMESMoveIn` inbound method is a stub (`Script = Parameters["SAPID"]`). It must +become a real MoveIn: take the MES payload, resolve which reactor instance + side it targets, +and write the values onto that reactor's correct MES-receiver child — but only when that +receiver is ready to accept a MoveIn. + +## 2. Verified facts (current system) + +- **Inbound `/api` script context** (`InboundScriptContext`, `InboundScriptExecutor.cs`) + exposes only `Parameters`, `Route`, `CancellationToken`. **No DB access.** It returns a + JSON object validated against the method's `ReturnDefinition`; a thrown exception becomes a + generic HTTP 500 ("Internal script error"), and a return-shape mismatch becomes a 500 + ("…did not match its return definition"). `Route.To(instanceCode).Call(scriptName, params)` + routes to the instance's site and returns that script's result object. RouteHelper can only + target an **instance code** (no shared-script entry point). +- **Template/site scripts** (`ScriptGlobals` / `ScriptRuntimeContext`) expose + `Database.Connection(name)` + `Database.CachedWrite(...)`, `Attributes[...]`, + `Children[""].Attributes/.SetAttribute/.GetAttribute`, `Instance.SetAttribute` (string + value) which routes a `SetStaticAttributeCommand` to the Instance Actor → for data-connected + attributes the DCL performs the **authorized Galaxy write**, synchronously, throwing on + failure. +- **`DatabaseGateway`/`IDatabaseGateway`** is registered via `AddExternalSystemGateway()` in + **`Host/Program.cs`** (shared root) and `Host/SiteServiceRegistration.cs`. So the **Central** + host can also open BTDB. `DatabaseConnectionDefinition` has no `SiteId` (global). +- **Reactor template** = `IpsenFurnaceTitan` (TemplateId **1**). Instances **`Z28061`** (id 1) + and **`Z28062`** (id 2), both Site 2, Enabled. Children (TemplateCompositions on T1): + `LeftMESReceiver` (→T5), `RightMESReceiver` (→T6), `LeftReactorSide` (→T8), + `RightReactorSide` (→T9). **No leak-test receiver.** No existing TemplateScripts/SharedScripts. +- **MES-receiver attributes** live on base template T3 `MESReceiver` (full set; + `MoveInPartNumbers` + `MoveInWorkOrderNumbers` are `List`; also `MoveInType`). The + derived T5/T6 still carry stale `String` placeholder rows for those two array attrs — expected + to be `IsInherited` placeholders skipped at flatten time (the T3 `List` definition wins); + **must be verified** before relying on array writes. +- **BTDB `dbo.Machine`** (141 rows): match column **`SAPID`** (nvarchar 10) → result column + **`Code`** (nvarchar 50); also `ZTag`, `Name`. The side suffix is baked into `Code` + (`Z28061A` = T5A, `Z28061B` = T5B); `ZTag` of the A-row = bare `Z28061`. `SAPID` is null on + the present reactor rows and there are no `Z28062*` rows — i.e. these are partial test rows; + production must have `SAPID` populated. The ScadaBridge instance UniqueName is the **bare** + reactor (`Z28061`/`Z28062`). + +## 3. Decisions + +1. **Lookup:** input value = `-` (e.g. `131453-A`). Strip the suffix → SAP#; + `SELECT Code FROM dbo.Machine WHERE SAPID=@sap`; the matched `Code` with a single trailing + `A`/`B` removed = the **instance UniqueName**; the suffix maps `A`→Left, `B`→Right. +2. **Leak test deferred:** only `-A`/`-B` are handled now. Any other suffix (`-A-LT`, + `-B-LT`, missing, unknown) → `WasSuccessful=false` with an "unsupported side/target" message. + Leak test can be added later once its MES-receiver child + Galaxy reference exist. +3. **Architecture:** the **inbound script does the lookup** via a new scoped, read-only DB helper + on the inbound context (named connections only; no raw `DbConnection`; parameterized). Single + routed hop to the reactor template script. (Chosen over a config-only resolver-instance + + 2-hop approach.) +4. **Write set (Q1):** the reactor script writes every provided param that maps to a receiver + attribute, **except** `MoveInReadyFlag` (read-only gate) and `SAPID` (routing only). + `MoveInCompleteFlag` / `MoveInSuccessfulFlag` / `MoveInErrorText` are treated as **PLC-owned + outputs and are NOT written** (even though the API accepts the first three as params). + Written: `MoveInMesContainerNum`, `MoveInBatchID`, `MoveInJobSequenceNumber`, + `MoveInNumberWorkOrders`, `MoveInWorkOrderNumbers[]`, `MoveInPartNumbers[]`, + `MoveInOperatorName`, `MoveInFlag`. +5. **Return BatchID (Q2):** echo the input `MoveInBatchID` (no read-back). + +## 4. Components + +### A. ScadaBridge source change — inbound read-only DB helper +- New `InboundDatabaseHelper` backed by `IDatabaseGateway`, surface: + `T? QuerySingle(string conn, string sql, object? args = null)` and + `IReadOnlyList> Query(string conn, string sql, object? args = null)`. + **No write methods.** +- Add `Database` property to `InboundScriptContext`; construct it in `InboundScriptExecutor` + from a service scope (gateway is scoped). Add the helper's assembly to the script + `ScriptOptions.WithReferences(...)` and confirm `ForbiddenApiChecker` still passes (the script + references only `Database.QuerySingle`, never `System.Data`). +- Unit tests with a fake `IDatabaseGateway`. + +### B. Rewrite inbound `/api/IpsenMESMoveIn` script (data, via management API) +Pseudocode (always returns the 3-field shape; never throws out): +```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 '{suf}'", BatchID = 0 }; + + var code = Database.QuerySingle("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 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 }; +} +``` +`ParameterDefinitions` / `ReturnDefinition` unchanged from today. + +### C. New template script `IpsenMoveIn` on T1 (data, via management API) +- On-demand (no trigger). Parameters: `side` + the written MoveIn fields. Return: + `{ WasSuccessful, ErrorText, BatchID }`. +- Logic: +```csharp +var recv = Children[ (string)Parameters["side"] == "Right" ? "RightMESReceiver" : "LeftMESReceiver" ]; +var batchId = Parameters.Get("MoveInBatchID"); +var ready = recv.Attributes["MoveInReadyFlag"]; // live Galaxy read +if (!(ready is bool b && b)) // tolerate bool/"True"/1 + return new { WasSuccessful = false, ErrorText = "MoveInReadyFlag is not true", BatchID = batchId }; + +await recv.SetAttribute("MoveInMesContainerNum", ...); +await recv.SetAttribute("MoveInBatchID", ...); +await recv.SetAttribute("MoveInJobSequenceNumber", ...); +await recv.SetAttribute("MoveInNumberWorkOrders", ...); +await recv.SetAttribute("MoveInWorkOrderNumbers", ); // see §5 +await recv.SetAttribute("MoveInPartNumbers", ); +await recv.SetAttribute("MoveInOperatorName", ...); +await recv.SetAttribute("MoveInFlag", ...); +return new { WasSuccessful = true, ErrorText = "", BatchID = batchId }; +``` +- `MoveInReadyFlag` readiness check tolerates the value arriving as `bool`, `"True"`, or `1`. + +## 5. Open implementation items (not blockers) + +- **Array write encoding:** `SetAttribute(name, string)` takes a string. Confirm how the recent + multivalue-array support expects a `List` value to be written from a script (JSON + array string vs. an `Attributes[name] = list` path). If arrays can't be written from a script + today, add a small ScadaBridge item to support it. +- **Derived placeholders:** verify T5/T6's stale `String` rows for the two array attrs are + `IsInherited=true` (skipped at flatten so the T3 `List` types apply). If they are real + overrides, fix them. +- **SAPID format:** match is exact-string; live rows store leading zeros ("002797"). Confirm MES + sends the SAP in the stored format, or normalize in the lookup. +- **Multiple/ambiguous SAP rows:** `SELECT TOP 1`; if a SAP maps to >1 row they must resolve to + the same bare instance — otherwise surface a clear error. + +## 6. Error handling / return contract + +Every path returns the validated `{WasSuccessful, ErrorText, BatchID}`: +- success → `{true, "", batchId}` +- not ready → `{false, "MoveInReadyFlag is not true", batchId}` +- bad/missing side, unknown SAP, route/instance-not-found, write failure → `{false, "", 0}` + +## 7. Testing + +- Unit: `InboundDatabaseHelper` + inbound-script DB path (fake `IDatabaseGateway`); inbound + parse/route logic; reactor script gate + write-set (fake instance/children). +- On-box dry run: `POST /api/IpsenMESMoveIn` with a known SAP+`-A` → resolves to `Z28061`, + gates when `MoveInReadyFlag` false, writes when true (verify values land on the Galaxy + receiver via the gateway, as in the earlier enrichment). Negative: bad suffix, unknown SAP, + not-ready. + +## 8. Deployment + +1. Implement + test Component A; build ScadaBridge. +2. Rebuild + redeploy ScadaBridge Central on `wonder-app-vd03` (preserve `app\data\`; + apply any EF migration BEFORE boot — Central is Production, no auto-migrate). +3. Apply Component B (inbound script update) + Component C (new T1 template script) via the + management API; redeploy config so the reactor instances pick up the new script. +4. Verify on-box; commit source change; keep this design doc + the implementation plan. + +## 9. Out of scope + +- Leak-test (`-LT`) routing/receivers. +- Writing PLC-output flags (Complete/Successful/ErrorText) from the MoveIn. +- `Z28062` BTDB data completeness (operational data fix, not code).