toga-ai 1.0.297 → 1.0.299

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,5 +8,5 @@
8
8
  | [Ticket Email Notifications (notifications table)](features/notifications.md) | Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code: the `TOGaDeskSupport.notifications` table holds one row per `(clientid, | desk/includes/classes/class.notification.php, crons/tickets.php, crons/tickets_prod.php |
9
9
  | [REST API (RPC-over-POST) & API-Key Auth](features/rest-api.md) | TOGa Desk exposes a programmatic API at `desk/api/`. | desk/api/index.php, desk/api/resources/tickets.php, desk/api/resources/assets.php, desk/api/resources/authenticate.php, desk/includes/classes/class.apikey.php, desk/includes/functions.php |
10
10
  | [SMB Contract Editing & the clientMspId Corruption Trap](features/smb-contract-editing.md) | The SMB contracts page (`/desk/?route=toga/smbcontracts&togaClientId=<id>`) edits `TOGA_*.SMBContracts` rows via a modal. | desk/template/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/edit.php |
11
- | [Ticket Lifecycle (class.ticket.php)](features/ticket-lifecycle.md) | All TOGa Desk ticket creation and reply handling funnels through `Ticket` in `desk/includes/classes/class.ticket.php`. | desk/includes/classes/class.ticket.php, desk/includes/controllers/actions.php, desk/api/resources/tickets.php, crons/tickets.php, desk/includes/controllers/actions/tickets/merge.php |
11
+ | [Ticket Lifecycle (class.ticket.php)](features/ticket-lifecycle.md) | All TOGa Desk ticket creation and reply handling funnels through `Ticket` in `desk/includes/classes/class.ticket.php`. | desk/includes/classes/class.ticket.php, desk/includes/controllers/actions.php, desk/includes/controllers/actions/tickets/addReply.php, desk/api/resources/tickets.php, desk/api/resources/ticket_replies.php, crons/tickets.php, crons/pipe.php, desk/includes/controllers/actions/tickets/merge.php |
12
12
  | [Standalone PHP Test Script Bootstrap (TOGa Desk)](workflows/standalone-test-scripts.md) | How to write a standalone CLI PHP script that bootstraps the TOGa Desk framework for read-only testing of desk classes (e.g. | |
@@ -6,8 +6,8 @@ project: TOGa Desk
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-15
10
- owners: ["jcardinal"]
9
+ updated: 2026-07-09
10
+ owners: ["jcardinal", "dfranks"]
11
11
  files:
12
12
  - crons/tickets.php
13
13
  - crons/tickets_prod.php
@@ -61,6 +61,14 @@ Writes `tickets`, `tickets_replies`, attachment `files`; reads config from the `
61
61
  - **Auto-reply filters** are partly hardcoded (e.g. ignore `TechHub@compass-usa.com`
62
62
  "Automatic reply:" messages).
63
63
  - Attachments are deleted locally immediately after the S3 upload with **no retry** if S3 fails.
64
+ - **`adminid` clobber on reply routing:** after `emailToTicket()` sets `$data['adminid']` from
65
+ the FROM address (~line 1407), an asset lookup (~line 1437-1442) overwrites it with the
66
+ replying user's asset's assigned admin. On a matched-ticket reply this makes a user's email
67
+ look like a staff reply in `addReply()` (`isAdminReply=true`), so `Awaiting User` etc. never
68
+ transition. The asset-derived adminid is for NEW-ticket auto-assign in `add()`, not reply
69
+ classification. See the ticket-lifecycle doc.
64
70
 
65
71
  ## Change history
72
+ - 2026-07-09 — documented the `emailToTicket()` adminid-overwrite reply misclassification
73
+ (TRUE-80114 planning investigation) (dfranks)
66
74
  - 2026-06-15 — documented from a source read of `crons/tickets*.php` and `emailToTicket()` (jcardinal)
@@ -6,13 +6,16 @@ project: TOGa Desk
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-12
10
- owners: ["mhammontree"]
9
+ updated: 2026-07-09
10
+ owners: ["mhammontree", "dfranks"]
11
11
  files:
12
12
  - desk/includes/classes/class.ticket.php
13
13
  - desk/includes/controllers/actions.php
14
+ - desk/includes/controllers/actions/tickets/addReply.php
14
15
  - desk/api/resources/tickets.php
16
+ - desk/api/resources/ticket_replies.php
15
17
  - crons/tickets.php
18
+ - crons/pipe.php
16
19
  - desk/includes/controllers/actions/tickets/merge.php
17
20
  related:
18
21
  - 1.0/apps/togadesk/features/smb-contract-editing.md
@@ -40,6 +43,26 @@ Creation paths — all funnel through `Ticket::add($data)`:
40
43
  - Ticket merge (`actions/tickets/merge.php`) inserts a master ticket directly — it copies
41
44
  fields from the child, including `customerid` (since June 2026).
42
45
 
46
+ ### Reply entry points (FOUR doors — only two transition status)
47
+ There are **four** inbound reply paths, but the status-transition switch lives ONLY inside
48
+ `Ticket::addReply()`. Any door that does not route through `addReply()` will not change status:
49
+ - **IMAP-poll cron** — `crons/tickets.php` → `emailToTicket()` → `addReply()`. ✅ transitions.
50
+ - **Mail pipe** — `crons/pipe.php:113` → `emailToTicket()` (legacy 6-arg call) → `addReply()`.
51
+ ✅ transitions.
52
+ - **Desk REST API** — `desk/api/resources/ticket_replies.php` `'add'` → `addReply($data)`
53
+ directly. ✅ transitions.
54
+ - **togaview customer portal** — the portal reply form (`action="/ticket"` + `newReplyBtn`,
55
+ from the generic `common/togaview/ticket.php` and client variants e.g.
56
+ `common/newcenturyholdingsllc/ticket.php`, `common/towfoundation/ticket.php`) POSTs to
57
+ `togaview/mvc/ticket/post.php` `newReplyBtn` handler (~line 388). This handler inserts the
58
+ reply row **directly** via `App_Model_TogaDesk_TicketReply` and **bypasses the `Ticket` class
59
+ entirely** — it never updates `tickets.status`, `tickets_replies.newStatus`, or
60
+ `tickets_history`. ❌ **no status transition.** This is the gap behind "a reply from outside
61
+ Desk doesn't move the ticket."
62
+
63
+ The in-Desk agent reply path is `desk/includes/controllers/actions/tickets/addReply.php` →
64
+ `Ticket::addReply($_POST)`.
65
+
43
66
  ## How it works
44
67
 
45
68
  ### `Ticket::deriveCustomerId(int, string): ?int` (added June 2026)
@@ -64,6 +87,11 @@ means the reply lands but the ticket status silently never changes (no history e
64
87
  re-entry). That was the On Hold SLA bug; `case 'On Hold'` (admin ⇒ stays On Hold, client ⇒
65
88
  Open) was added June 2026.
66
89
 
90
+ - **`origin` is the reliable "reply came from outside Desk" signal.** The in-app agent reply
91
+ path (`addReply.php` → `addReply($_POST)`) carries **no `origin` key**, whereas
92
+ `emailToTicket()` sets `tickets.origin = 'EMAIL'` and togaview sets `'TOGAVIEW'` on new
93
+ tickets. Absence of `origin` on a reply therefore identifies an in-app agent reply; presence
94
+ identifies an external reply. This is the safe hook for any "external reply → status" logic.
67
95
  - Special case: an admin replying to their OWN ticket is treated as a user reply.
68
96
  - Auto-assign on first staff reply requires the role perm `allowAutoAssign` via
69
97
  profiles/profile_departments.
@@ -88,11 +116,29 @@ backfill (e.g. craftex:
88
116
  column (close time would have to come from `tickets_replies.newStatus='Closed'` or
89
117
  `tickets_history`). Implementing it is a product decision: what happens to a lapsed reply
90
118
  (attach-but-closed / new ticket / bounce)?
119
+ - **`emailToTicket()` clobbers `adminid`, misclassifying user email replies as staff replies.**
120
+ After matching the sender and setting `$data['adminid']` from the FROM address (~line 1407),
121
+ an asset lookup (~line 1437-1442) **overwrites** `$data['adminid']` with the replying user's
122
+ asset's assigned admin. When the matched ticket goes to `addReply()`, the user's reply is
123
+ read as an admin/staff reply (`isAdminReply=true`), so states like `Awaiting User` stay put
124
+ instead of transitioning to `Open`. The asset-derived adminid is meant for auto-assigning
125
+ NEW tickets in `add()`, not for classifying replies — `adminid` serves two conflicting
126
+ purposes here.
127
+ - **Cross-app DB coupling:** togadesk and togaview share the `TOGaDeskSupport` database
128
+ (`tickets`, `tickets_replies`, `tickets_history`, `people`), so a reply/status behavior
129
+ change on either side is visible to both immediately. togaview cannot load Desk's
130
+ `class.ticket.php`, so its writes go through the `App_Model_TogaDesk_*` models
131
+ (`TicketReply`, `TicketsHistory`, `Ticket`) — any shared status logic must account for that
132
+ the portal path never instantiates `Ticket`.
91
133
  - Legacy `togadesk/includes/class.ticket.php` has active email-reopen logic; the live
92
134
  `desk/includes` version has it commented out — email replies never set status to "Reopened"
93
135
  via `emailToTicket()`.
94
136
 
95
137
  ## Change history
138
+ - 2026-07-09 — mapped the four reply entry points (togaview portal bypasses `addReply()` so
139
+ never transitions status); documented `emailToTicket()` adminid-overwrite reply
140
+ misclassification, the `origin`-absence agent-reply signal, and togadesk↔togaview shared-DB
141
+ coupling — from TRUE-80114 planning source investigation (dfranks)
96
142
  - 2026-06-12 — documented from craftex MSP portal debugging session (mhammontree)
97
143
  - 2026-06 — added `deriveCustomerId()`; added `On Hold` case to addReply() switch; merge
98
144
  copies `customerid` (mhammontree)
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: nycdoe
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-06-19
10
- owners: [mhammontree]
9
+ updated: 2026-07-09
10
+ owners: [mhammontree, sking]
11
11
  files:
12
12
  - worker/crons/sync/nycdoe/import_asn.php
13
13
  - worker/crons/sync/nycdoe/import_inc.php
@@ -17,6 +17,7 @@ files:
17
17
  - worker/crons/sync/nycdoe/1_send_asn_to_netsuite.php
18
18
  - worker/crons/sync/nycdoe/2_send_serials_to_netsuite.php
19
19
  - worker/crons/sync/nycdoe/3_create_installation_ticket.php
20
+ - worker/crons/sync/nycdoe/test_multi_po_receipt_resolution.php
20
21
  - worker/crons/sync/nycdoe/send_ticket_updates.php
21
22
  - worker/crons/sync/nycdoe/send_request_item_updates.php
22
23
  - worker/crons/sync/nycdoe/send_nycdoe_proof_of_delivery.php
@@ -34,6 +35,7 @@ files:
34
35
  - library/app/asnprocessor/lexmark.php
35
36
  - library/app/asnprocessor/acer.php
36
37
  - library/app/edi.php
38
+ - library/app/netsuite.php
37
39
  related:
38
40
  - ../profile.md
39
41
  - hold-status-sync.md
@@ -131,12 +133,27 @@ Vendor SFTP ───(legacy_import_asn.php, ser+non-ser)─┘ [UNIQUE ded
131
133
  `qtyOrder` is immutable** — there is no update path to a sent SO line; growth must be a
132
134
  new item row → new SO line.
133
135
  - **Stage 4:** reconciles the NetSuite **PO** (`netSuiteInternalPurchaseOrderId` — not the
134
- SO id) and sends serial inventory assignments.
136
+ SO id) and sends serial inventory assignments. It is already multi-PO aware
137
+ (`listPurchaseOrdersCreatedFromSalesOrder`, groups items by PO, stamps each item's
138
+ `netSuiteInternalPurchaseOrderId`) **but only stamps items where the PO is NULL and never
139
+ re-stamps** — so an item stamped to PO-A but later *received* on PO-B is never corrected.
140
+ **TOGA does NOT create NetSuite POs anywhere in the DOE flow.** Stage 3 creates only the SO
141
+ (and, when needed, a special-order inventory Item); POs are generated inside NetSuite by
142
+ procurement/special-order. A single SO can therefore be fulfilled across **multiple POs**
143
+ (back-orders, multi-vendor, re-cut lines) — this is a legitimate NetSuite outcome, so the
144
+ durable fix lives downstream (resolve receipts SO-wide), not in preventing PO splits.
135
145
  - **Stage 5:** for units `WHERE togadeskRepairOrderId IS NULL`, reads serials from NetSuite
136
146
  item receipts (so TOGa Desk RO serials are **driven by NetSuite**, not the ASN unit),
137
147
  creates/reuses TOGa Desk manufacturers/models/assets/serial-numbers/MSO and creates **a
138
148
  new repair_orders row unconditionally per loop iteration** — one RO per Unit (a 50-cable
139
149
  line = 1 unit = 1 RO). Existing-serial lookup is scoped **per item row** (`$asnItemId`).
150
+ Item receipts are now resolved **Sales-Order-wide** via
151
+ `App_NetSuite::listItemReceiptsCreatedFromSalesOrder($soId)` (fans out across ALL POs from
152
+ `listPurchaseOrdersCreatedFromSalesOrder`, dedupes receipts by `internalId`), cached once
153
+ per ASN, with `getItemReceipt` cached per receipt id. The inner item query also requires
154
+ `netSuiteInternalSalesOrderId IS NOT NULL` so a null-SO first row cannot poison the per-ASN
155
+ receipt cache. Self-healing: units previously stuck under an unstamped PO clear on
156
+ subsequent 5-min cron runs.
140
157
  - **"#/N received" UI** (`togadesk/desk/template/pages/managedserviceorders/view.php`):
141
158
  denominator = `SUM(qtyOrder)` per part (committed + unsent); numerator = Units with
142
159
  `togadeskRepairOrderId IS NOT NULL`.
@@ -218,6 +235,36 @@ Vendor SFTP ───(legacy_import_asn.php, ser+non-ser)─┘ [UNIQUE ded
218
235
  - **Stage 6 is not currently running** (`4_` paused with `active: 0`, `5_` unscheduled).
219
236
  - TRUE-77354: Stage 5 lookups changed `LIKE` → `=` to stop `_`/`%` wildcard matches in
220
237
  serials.
238
+ - **Multi-PO-per-SO silently drops install tickets (FIXED 2026-07-09).** Stage 5 used to
239
+ fetch item receipts only for the single stamped `netSuiteInternalPurchaseOrderId`. When a
240
+ NetSuite SO is fulfilled across more than one PO, units received under a PO *other* than the
241
+ stamped one never got a TogaDesk repair order, and the every-5-min cron silently re-queried
242
+ the wrong PO forever. No error was raised — the symptom was serials never syncing. Fixed by
243
+ resolving receipts SO-wide (see Stage 5). Before, no `App_NetSuite` method listed receipts
244
+ by SO — only by PO (`listItemReceiptsCreateFromPurchaseOrder`) or location
245
+ (`listAllItemReceiptsForLocation`); the new `listItemReceiptsCreatedFromSalesOrder`
246
+ (`library/app/netsuite.php`, after `listItemReceiptsCreateFromPurchaseOrder`) fills that gap.
247
+ Concrete case: customer PO `S202643596` (ASN 26339, SO internalId 6971858) split across PO
248
+ 164785 (internalId 6971859 → `38S0500` synced, 15 ROs) and PO 165751 (`50M7280` + `A3L980`
249
+ NOT synced). PRs: library#831 (merge first) then worker#1670.
250
+
251
+ ## Debugging & DB topology (DOE 1.0 sync)
252
+
253
+ Reference for future DOE investigations — the 1.0 worker cannot run on a dev/Windows box;
254
+ use the toga DB MCP + `Logs.API` instead of running prod code locally.
255
+
256
+ - **`db_core` alias → legacy (V1) environment, schema `Core`** — holds the FLAT tables
257
+ `AdvanceShippingNotices` / `AdvanceShippingNoticeItems` / `AdvanceShippingNoticeUnits`
258
+ (columns incl. `togadeskRepairOrderId`, `serialNumber`, `netSuiteInternalSalesOrderId`,
259
+ `netSuiteInternalPurchaseOrderId`, `customerPurchaseOrder`). The 1.0 cron reads **legacy/Core**.
260
+ - **Do NOT confuse with prod (V2) `Client_Nycdoe`** — a DIFFERENT, normalized schema
261
+ (`AdvanceShippingNoticeItemUnits` with a `unitId` FK). The 1.0 cron does not read V2.
262
+ - **NetSuite SOAP request/response payloads** are logged in legacy `Logs.API` — filter
263
+ `sourceJob LIKE '%3_create_installation_ticket%'`; `requestPayload` contains the PO
264
+ internalId. (The multi-PO case above was verified this way: the receipt search for PO
265
+ 6971859 returned `totalRecords=1`, only `38S0500`.)
266
+ - **`Bridge_NetSuite`** (legacy) has `SalesOrders` / `SalesOrderItems` / `SerialNumbers` but
267
+ **no `PurchaseOrders` table** — POs are pulled live from the NetSuite API.
221
268
 
222
269
  ## Operating rules when changing this integration
223
270
 
@@ -231,6 +278,14 @@ Vendor SFTP ───(legacy_import_asn.php, ser+non-ser)─┘ [UNIQUE ded
231
278
  of the consumer query; `php -l` every touched file.
232
279
 
233
280
  ## Change history
281
+ - 2026-07-09 — Fixed silent install-ticket drop when a NetSuite SO is fulfilled across
282
+ multiple POs: Stage 5 now resolves item receipts Sales-Order-wide via new helper
283
+ `App_NetSuite::listItemReceiptsCreatedFromSalesOrder` (fans across all POs from the SO,
284
+ dedupes by internalId, cached per ASN), and its item query requires
285
+ `netSuiteInternalSalesOrderId IS NOT NULL`. Root cause: TOGA doesn't create DOE POs, so
286
+ multi-PO-per-SO is legitimate and units received on an unstamped PO were never ticketed.
287
+ Added multi-PO notes to Stage 4/5, a gotcha, and a DB-topology/debugging section.
288
+ PRs library#831 + worker#1670. (sking)
234
289
  - 2026-06-29 — Outbound status sync now keeps DOE holds authoritative (TRUE-79922). Hold
235
290
  detection moved to the SNOW native `state` field (not `u_status_task`); Assigned-force
236
291
  blocks guarded against local `HOLD_*` to stop the `state:3`↔`state:2` self-clobber; RITM
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.297",
3
+ "version": "1.0.299",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",