toga-ai 1.0.36 → 1.0.38

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.
@@ -3,4 +3,4 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [Library (1.0 Framework) Architecture](architecture.md) | `library` is the shared library repository for **all 1.0 (legacy) applications** — the `App_` framework. | library/_.php, library/app/, library/browser/ |
6
- | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` contains three private helpers that handle bidirectional file-attachment sync between TOGA 2 and other systems fo | library/app/api/toga2.php |
6
+ | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
@@ -17,77 +17,150 @@ related:
17
17
 
18
18
  ## Summary
19
19
 
20
- `App_Api_Toga2` in `library/app/api/toga2.php` contains three private helpers that handle
21
- bidirectional file-attachment sync between TOGA 2 and other systems for Elite:
20
+ `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between
21
+ TOGA 2 and TOGaDesk. The top-level entry point is `syncWithTogadesk()`. Three private
22
+ helper methods handle file-attachment sync within that orchestration.
22
23
 
23
- | Method | Direction | Purpose |
24
- |---|---|---|
25
- | `syncFreshserviceConversationAttachmentsIntoToga2` | Freshservice → TOGA 2 | Download attachments from Freshservice presigned S3 URLs and store in TOGA 2 |
26
- | `syncToga2NoteFilesIntoTogadesk1` | TOGA 2 → TOGaDesk | Copy TOGA 2 note files to TOGaDesk reply attachments |
27
- | `syncTogadesk1NoteFilesIntoToga2` | TOGaDesk → TOGA 2 | Copy TOGaDesk reply attachments to TOGA 2 note files |
28
-
29
- ## Key files / entry points
30
-
31
- - `library/app/api/toga2.php` — all three methods live here, called from larger
32
- sync orchestration methods in the same class
33
- - `App_Api_Toga2::send()` — the HTTP client used by all three
24
+ ## Top-level entry point `syncWithTogadesk()`
34
25
 
35
- ## How dedup works (all three methods)
26
+ 14-parameter static method. Called from a cron script for each client.
36
27
 
37
- All three methods avoid re-uploading files by checking what is already linked before
38
- uploading. The pattern uses `depth=5` on the parent ticket-note:
28
+ ```php
29
+ App_Api_Toga2::syncWithTogadesk(
30
+ int $togadeskClientId,
31
+ int $togadeskTicketDepartmentId,
32
+ string $togaClientUuid,
33
+ string $togaApiKey,
34
+ string $togaApiSecret,
35
+ string $integrationType, // 'REPAIR_ORDERS' or 'TICKETS'
36
+ bool $isEnabledToga2ToTogadeskIntegration,
37
+ bool $isEnabledTogadeskToToga2Integration,
38
+ bool $isEnabledToga2ContactsToTogadeskPeopleIntegration,
39
+ bool $isEnabledToga2ItemsUnitsToTogadeskAssetsIntegration,
40
+ bool $isEnabledToga2PredefinedRepliesToTogadeskTicketsPrIntegration,
41
+ bool $isEnabledToga2TicketTeamsToTogadeskGroupsIntegration,
42
+ bool $isEnabledToga2TicketCategoriesToTogadeskCategorySubcategoryItemsIntegration,
43
+ array $monitorTogadeskDepartmentIds = []
44
+ );
45
+ ```
39
46
 
47
+ Constants for `$integrationType`:
40
48
  ```php
41
- $togaNote = App_Api_Toga2::send($clientUuid, $apiKey, $apiSecret,
42
- 'GET', '/ticket-notes/' . $ticketNoteUuid, null, ['depth' => 5], true, $endpoint);
43
-
44
- $existingFileNames = [];
45
- foreach (($togaNote->data->ticketNotes->ticketNoteFiles ?? []) as $link) {
46
- if (!empty($link->file->name)) {
47
- $existingFileNames[] = $link->file->name;
48
- }
49
- }
49
+ App_Api_Toga2::SYNC_WITH_TOGADESK_INTEGRATION_TYPE__REPAIR_ORDERS // = 'REPAIR_ORDERS'
50
+ App_Api_Toga2::SYNC_WITH_TOGADESK_INTEGRATION_TYPE__TICKETS // = 'TICKETS'
50
51
  ```
51
52
 
52
- Dedup is by **filename** — if `$file->name` is already in `$existingFileNames`, skip.
53
+ ## Watermark timestamps
53
54
 
54
- ## TOGA 2 API critical gotchas
55
+ The sync uses two watermarks stored as TOGA 2 `/parameters` records. These are fetched
56
+ at the start of each run and updated at the end:
55
57
 
56
- **Response key is `ticketNoteFiles`, not `ticketNotesFiles`.**
57
- The extra `s` before `Files` causes a silent empty array — dedup never fires and files
58
- get re-uploaded on every sync run. Always use `ticketNoteFiles`.
58
+ | TOGA 2 parameter key | Meaning | Used for |
59
+ |---|---|---|
60
+ | `TOGADESK_LAST_TICKET_INTEGRATION_DATETIME` | Newest `_updated` timestamp of tickets synced from TOGA 2 | Drives TOGA 2 → TOGaDesk query |
61
+ | `TOGA_LAST_TICKET_INTEGRATION_DATETIME` | Newest `timestamp` of tickets/replies synced from TOGaDesk | Drives TOGaDesk → TOGA 2 query |
62
+
63
+ Both default to `2000-01-01 00:00:00` if not yet set (full initial sync).
64
+
65
+ ## TOGA 2 → TOGaDesk sync (`isEnabledToga2ToTogadeskIntegration`)
66
+
67
+ Runs when this flag is `true`. Processes 200 records per page.
68
+
69
+ **Step 1 — Changed tickets:**
70
+ - `GET /tickets?where[_updated > TOGADESK_LAST_TICKET_INTEGRATION_DATETIME]` (paged)
71
+ - Each ticket re-fetched at `depth=5` for full details
72
+ - Calls `syncToga2TicketIntoTogadesk1Ticket($togaTicket)`
73
+
74
+ **Step 2 — Changed ticket-notes (catches notes on already-processed tickets):**
75
+ - `GET /ticket-notes?where[_updated > TOGADESK_LAST_TICKET_INTEGRATION_DATETIME]` (paged)
76
+ - Skips tickets already processed in Step 1 (tracked in `$alreadyProcessedToga2TicketUuids`)
77
+ - Re-fetches the parent ticket at `depth=5`, calls `syncToga2TicketIntoTogadesk1Ticket`
78
+
79
+ **Inside `syncToga2TicketIntoTogadesk1Ticket`:**
80
+ - `isExternal = true` note → creates `tickets_replies` record with `referenceid = ticketNote->uuid`
81
+ - `isExternal = false` note → creates `comments` record with `referenceid = ticketNote->uuid`
82
+ - After creating a new reply: calls `syncToga2NoteFilesIntoTogadesk1($ticketNote, $togadeskReplyId)`
83
+ - Also writes a `tickets_history` row for each new reply
84
+
85
+ ## TOGaDesk → TOGA 2 sync (`isEnabledTogadeskToToga2Integration`)
86
+
87
+ Runs when this flag is `true`.
88
+
89
+ **Ticket selection:**
90
+ ```sql
91
+ SELECT tickets.id,
92
+ GREATEST(
93
+ COALESCE(latestReply.timestamp, tickets.timestamp),
94
+ COALESCE(latestComment.timestamp, tickets.timestamp)
95
+ ) AS ticketIntegrationTimestamp
96
+ FROM tickets
97
+ LEFT JOIN (SELECT ticketid, MAX(timestamp) FROM tickets_replies GROUP BY ticketid) AS latestReply ...
98
+ LEFT JOIN (SELECT ticketid, MAX(timestamp) FROM comments GROUP BY ticketid) AS latestComment ...
99
+ WHERE tickets.clientid = ? AND tickets.departmentId IN (?)
100
+ AND GREATEST(...) > '$dtTogaLastTicketIntegration'
101
+ ```
59
102
 
60
- **`GET /ticket-notes/{uuid}/ticket-note-files` is NOT a list endpoint.**
61
- This nested route uses `childPolicy = MATCH`:
62
- - 0 bridge records → 404 EV-6
63
- - 1 record → 200 single object
64
- - 2+ records → 404 EV-14 ("multiple found, expected one")
103
+ **Per ticket:** calls `syncTogaDesk1TicketIntoToga2Ticket($togadeskTicketId)`
65
104
 
66
- Always use `depth=5` on the parent note for listing — never the nested route.
105
+ **Inside that method (replies loop):**
106
+ - For each `tickets_replies` row without a `referenceId`: POST to `/ticket-notes` in TOGA 2
107
+ with `c_eliteConversationId` (for Elite) or `c_wjeConversationId` (for WJE)
108
+ - Stores returned `ticketNotes.uuid` back into `tickets_replies.referenceId`
109
+ - Calls `syncTogadesk1NoteFilesIntoToga2($togadeskReplyId, $ticketNoteUuid)`
67
110
 
68
- **`throwExceptionOnApiError` (8th param of `App_Api_Toga2::send()`).**
69
- Pass `true` for the dedup GETa valid note UUID should always return 200. Passing
70
- `false` silently swallows errors and the dedup array stays empty.
111
+ **`referenceid` field** in TOGaDesk (`tickets_replies.referenceId`, `comments.referenceId`)
112
+ is the foreign key to TOGA 2 it holds the TOGA 2 `ticketNotes.uuid`. When `referenceId`
113
+ is already populated the row was previously synced and is skipped.
71
114
 
72
- ## Bridge route for linking files to notes
115
+ ## Other sync operations within `syncWithTogadesk`
73
116
 
74
- ```php
75
- // POST /ticket-notes/{uuid}/ticket-note-files
76
- App_Api_Toga2::send($clientUuid, $apiKey, $apiSecret, 'POST',
77
- '/ticket-notes/' . $ticketNoteUuid . '/ticket-note-files',
78
- ['file' => ['uuid' => $fileUuid]],
79
- null, true, $endpoint);
80
- ```
117
+ | Flag | What it syncs | TOGA 2 source → TOGaDesk target |
118
+ |---|---|---|
119
+ | `isEnabledToga2ContactsToTogadeskPeopleIntegration` | Contacts → People | `/contacts` → `people` table |
120
+ | `isEnabledToga2ItemsUnitsToTogadeskAssetsIntegration` | Units → Assets | `/units` → `assets` table |
121
+ | `isEnabledToga2PredefinedRepliesToTogadeskTicketsPrIntegration` | Predefined replies → TicketsPR | `/predefined-replies` → `tickets_pr` table |
122
+ | `isEnabledToga2TicketTeamsToTogadeskGroupsIntegration` | Ticket teams → Groups | `/ticket-teams` → `groups` table |
123
+ | `isEnabledToga2TicketCategoriesToTogadeskCategorySubcategoryItemsIntegration` | Categories → custom field | Three-level tree flattened to comma-separated string, stored in TOGaDesk `tickets_customfields` |
124
+
125
+ ## File sync helpers
126
+
127
+ ### `syncToga2NoteFilesIntoTogadesk1(object $ticketNote, int $togadeskReplyId)`
128
+ Direction: **TOGA 2 → TOGaDesk**
129
+
130
+ 1. `GET /ticket-notes/{uuid}?depth=5` → reads `ticketNoteFiles` array
131
+ 2. `SELECT name FROM files WHERE ticketreplyid = $togadeskReplyId` — existing filename dedup
132
+ 3. For each new file:
133
+ - `GET /files/{uuid}?depth=1` → `base64_decode($fileData->data->files->data)`
134
+ - INSERT `App_Model_TogaDesk_File` (ticketreplyid, name) → get `$fileId`
135
+ - Write to disk: `/var/www/html/ontrack/desk/uploads/{fileId}-{name}`
136
+ - `UPDATE files SET file='{fileId}-{name}', filetype=?, filesize=? WHERE id=?`
137
+
138
+ ### `syncTogadesk1NoteFilesIntoToga2(int $togadeskReplyId, string $ticketNoteUuid)`
139
+ Direction: **TOGaDesk → TOGA 2**
140
+
141
+ 1. `SELECT id, name, file FROM files WHERE ticketreplyid = $togadeskReplyId`
142
+ 2. `GET /ticket-notes/{uuid}?depth=5` → reads `ticketNoteFiles` — existing filename dedup
143
+ 3. For each new file:
144
+ - Read from disk: `/var/www/html/ontrack/desk/uploads/{file}`
145
+ - `POST /files` with base64-encoded data → get `$fileUuid`
146
+ - `POST /ticket-notes/{uuid}/ticket-note-files` with `{file: {uuid: $fileUuid}}`
147
+
148
+ ### `syncFreshserviceConversationAttachmentsIntoToga2(...)`
149
+ Direction: **Freshservice → TOGA 2** — see worker2 doc.
81
150
 
82
- ## Freshservice attachment handling
151
+ ## TOGA 2 API — critical gotchas
83
152
 
84
- Freshservice attachment URLs are **presigned S3 URLs that expire**. The sync must run
85
- immediately at webhook timedo not defer or re-queue the download step.
153
+ **Response key is `ticketNoteFiles`, not `ticketNotesFiles`.**
154
+ The extra `s` causes a silent empty array dedup never fires, files re-upload every run.
86
155
 
87
- ## TOGaDesk file storage
156
+ **`GET /ticket-notes/{uuid}/ticket-note-files` is NOT a list endpoint.**
157
+ `childPolicy = MATCH`: 0 records → 404 EV-6, 2+ records → 404 EV-14.
158
+ Always use `depth=5` on the parent note for listing.
88
159
 
89
- `syncToga2NoteFilesIntoTogadesk1` writes files to disk at:
90
- ```
91
- /var/www/html/ontrack/desk/uploads/{id}-{filename}
92
- ```
93
- Stored as `{id}-{filename}` in the TOGaDesk `files.file` column.
160
+ **`throwExceptionOnApiError` (8th param of `App_Api_Toga2::send()`).**
161
+ Pass `true` for the dedup GET — errors must not silently empty the dedup array.
162
+
163
+ ## Error handling
164
+
165
+ Each ticket sync is wrapped in `try/catch` — exceptions are sent to Sentry and the run
166
+ continues with the next ticket. A single bad ticket does not abort the whole sync.
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
5
5
  ## 1.0 framework
6
6
 
7
7
  - **library** (Library) _(framework core)_ — 2 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 1 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
8
+ - **worker** (Worker) — 2 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
 
10
10
  ## 2.0 framework
11
11
 
@@ -20,4 +20,5 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
20
20
  - **compass-canada** → [clients/compass-canada/INDEX.md](clients/compass-canada/INDEX.md)
21
21
  - **compass-usa** → [clients/compass-usa/INDEX.md](clients/compass-usa/INDEX.md)
22
22
  - **elite** → [clients/elite/INDEX.md](clients/elite/INDEX.md)
23
+ - **nycdoe** → [clients/nycdoe/INDEX.md](clients/nycdoe/INDEX.md)
23
24
 
@@ -0,0 +1,6 @@
1
+ # Client: nycdoe
2
+
3
+ | Doc | Framework | Summary | Files |
4
+ |-----|-----------|---------|-------|
5
+ | [NYCDOE ServiceNow / ASN Integration](features/servicenow-integration.md) | 1.0 | The NYCDOE/ServiceNow integration mirrors DOE's ServiceNow tickets (Incidents + RITMs) into local tables, turns vendor shipment notices into NetSuite Sales Orde | worker/crons/sync/nycdoe/import_asn.php, worker/crons/sync/nycdoe/import_inc.php, worker/crons/sync/nycdoe/legacy_import_asn.php, worker/crons/sync/nycdoe/legacy_process_asn_queue.php, worker/crons/sync/nycdoe/process_tickets.php, worker/crons/sync/nycdoe/1_send_asn_to_netsuite.php, worker/crons/sync/nycdoe/2_send_serials_to_netsuite.php, worker/crons/sync/nycdoe/3_create_installation_ticket.php, worker/crons/sync/nycdoe/send_ticket_updates.php, worker/crons/sync/nycdoe/send_request_item_updates.php, worker/crons/sync/nycdoe/send_nycdoe_proof_of_delivery.php, worker/crons/sync/nycdoe/sync_nycdoe_locations.php, worker/crons/sync/nycdoe/receive_edi_purchase_orders.php, worker/crons/sync/nycdoe/send_edi_open_invoices.php, worker/crons/notifications/nycdoe/, worker/schedules/cron.worker.sync.json, worker/schedules/cron.worker.notification.json, library/app/api/nycdoe.php, library/app/api/nycdoev2.php, library/app/asnprocessor/manufacturer.php, library/app/edi.php |
6
+ | [NYC DOE (New York City Department of Education)](profile.md) | 1.0 | NYC DOE (New York City Department of Education) is a TOGA client whose entire integration runs in the **1.0 worker tier** (~30 cron scripts under `worker/crons/ | |
@@ -0,0 +1,196 @@
1
+ ---
2
+ title: NYCDOE ServiceNow / ASN Integration
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: nycdoe
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-06-10
10
+ owners: [mhammontree]
11
+ files:
12
+ - worker/crons/sync/nycdoe/import_asn.php
13
+ - worker/crons/sync/nycdoe/import_inc.php
14
+ - worker/crons/sync/nycdoe/legacy_import_asn.php
15
+ - worker/crons/sync/nycdoe/legacy_process_asn_queue.php
16
+ - worker/crons/sync/nycdoe/process_tickets.php
17
+ - worker/crons/sync/nycdoe/1_send_asn_to_netsuite.php
18
+ - worker/crons/sync/nycdoe/2_send_serials_to_netsuite.php
19
+ - worker/crons/sync/nycdoe/3_create_installation_ticket.php
20
+ - worker/crons/sync/nycdoe/send_ticket_updates.php
21
+ - worker/crons/sync/nycdoe/send_request_item_updates.php
22
+ - worker/crons/sync/nycdoe/send_nycdoe_proof_of_delivery.php
23
+ - worker/crons/sync/nycdoe/sync_nycdoe_locations.php
24
+ - worker/crons/sync/nycdoe/receive_edi_purchase_orders.php
25
+ - worker/crons/sync/nycdoe/send_edi_open_invoices.php
26
+ - worker/crons/notifications/nycdoe/
27
+ - worker/schedules/cron.worker.sync.json
28
+ - worker/schedules/cron.worker.notification.json
29
+ - library/app/api/nycdoe.php
30
+ - library/app/api/nycdoev2.php
31
+ - library/app/asnprocessor/manufacturer.php
32
+ - library/app/edi.php
33
+ related:
34
+ - ../profile.md
35
+ - ../../../1.0/apps/worker/architecture.md
36
+ ---
37
+
38
+ ## Summary
39
+
40
+ The NYCDOE/ServiceNow integration mirrors DOE's ServiceNow tickets (Incidents + RITMs) into
41
+ local tables, turns vendor shipment notices into NetSuite Sales Orders and TogaDesk
42
+ installation repair orders (the **ASN pipeline** — the heart of the integration), and pushes
43
+ ticket state / ETA / proof-of-delivery back to ServiceNow. A parallel EDI surface exchanges
44
+ 850 POs and invoices over DOE's SFTP.
45
+
46
+ > **Schedule files are the source of truth for what runs** (`worker/schedules/
47
+ > cron.worker.sync.json`, `cron.worker.notification.json`). Header comments inside the
48
+ > scripts are frequently stale about cadence. A script on disk but absent from the schedule
49
+ > never runs.
50
+
51
+ ## Core local tables (`db_common` unless noted)
52
+
53
+ | Table | Purpose |
54
+ |---|---|
55
+ | `NYCDOETickets` | Local ticket mirror (keyed `referenceSysId`; `type` = INCIDENT / RITM) |
56
+ | `NYCDOETicketsDepartments` | Allowlist of valid assignment groups — others are not processed |
57
+ | `NYCDOELocations` | Site-id → address mirror (backs ASN ship-to resolution) |
58
+ | `AdvanceShippingNoticeQueue` | Stage-1 landing zone; **UNIQUE index `idx_dedupeKey`** |
59
+ | `AdvanceShippingNotices` / `…Items` / `…Units` | ASN header (vendor+PO) → per-part items (`qtyOrder`) → individual units |
60
+ | `managed_service_orders`, `repair_orders` (`db_togadesk`) | TogaDesk-side fulfillment (one MSO per ASN; `nycDoeTicketId` links back to the RITM) |
61
+
62
+ ## Integration surfaces (live schedule)
63
+
64
+ | Surface | Script | Schedule |
65
+ |---|---|---|
66
+ | Incidents in | `import_inc.php` (500/batch into `NYCDOETickets`) | every 15 min |
67
+ | RITMs in (ASN Stage 1a) | `import_asn.php` via `App_Api_NYCDOEV2::listRequestItems(500)` | every 15 min |
68
+ | Vendor SFTP in (ASN Stage 1b) | `legacy_import_asn.php` — "Will NOT be turned off" | every 5 min |
69
+ | Incident → TogaDesk ticket | `process_tickets.php` (client 16; throws if >200 unprocessed — flood valve) | every 15 min |
70
+ | Queue → ASN records (Stage 2) | `legacy_process_asn_queue.php` | every 15 min |
71
+ | NetSuite SO create (Stage 3, **freeze**) | `1_send_asn_to_netsuite.php` | every 2 h at :07 |
72
+ | NetSuite PO/serials (Stage 4) | `2_send_serials_to_netsuite.php` | every 2 h at :27 |
73
+ | TogaDesk repair orders (Stage 5) | `3_create_installation_ticket.php` | every 5 min |
74
+ | NetSuite Item Fulfillment (Stage 6) | `4_create_ns_item_fulfillment.php` (**paused**, `active: 0`); `5_DOA_…` unscheduled | not running |
75
+ | ETA → ServiceNow | `send_ticket_updates.php` | every 6 min |
76
+ | RITM state → ServiceNow | `send_request_item_updates.php` | every 5 min |
77
+ | Proof of delivery → ServiceNow | `send_nycdoe_proof_of_delivery.php` | every 5 min |
78
+ | Locations sync | `sync_nycdoe_locations.php` | daily 3:00 AM |
79
+ | Site-id monitor | `email_notification_siteid.php` | daily 7:01 AM |
80
+ | EDI 850 POs in → NetSuite SO + 997 ack | `receive_edi_purchase_orders.php` (`senderId == 'NYCDOE'` only) | 9:30 AM & 3:30 PM |
81
+ | EDI invoices out (NetSuite saved search 2108) | `send_edi_open_invoices.php` | every 2 h at :13 |
82
+ | Installation schedule reports | `notifications/nycdoe/send_installation_schedules_doe.php` (3 business days ahead, weekends skipped, **holidays NOT skipped**), daily + current-week variants, weekly part-orders | 5–8 AM daily/Mondays |
83
+
84
+ **Unscheduled / dormant:** `download_tickets*.php` (superseded by `import_inc.php` /
85
+ `import_asn.php`), `DOA_*` variants, `doe_install_fix.php`,
86
+ `send_installation_schedules.php` (base variant). Note `sync/syncro/download_tickets.php`
87
+ in the schedule is the **Syncro** integration, not NYCDOE. A second
88
+ `crons/infrastructure/import_asn.php` also exists — confirm which variant before editing.
89
+
90
+ ## The ASN pipeline
91
+
92
+ ```
93
+ ServiceNow API ──(import_asn.php, SERIALIZED ONLY)──┐
94
+ ├─► AdvanceShippingNoticeQueue ──(legacy_process_asn_queue.php)──►
95
+ Vendor SFTP ───(legacy_import_asn.php, ser+non-ser)─┘ [UNIQUE dedupeKey]
96
+
97
+ AdvanceShippingNotices ─► Items (qtyOrder) ─► Units (1/serial; 1 per non-serial item row)
98
+ │ │ │
99
+ │ 1_send_asn_to_netsuite.php 3_create_installation_ticket.php
100
+ │ ▼ ▼
101
+ │ NetSuite Sales Order TogaDesk repair orders (1 per Unit)
102
+ │ ★ FREEZE qtyOrder ★
103
+ │ │
104
+ ▼ 2_send_serials_to_netsuite.php
105
+ TogaDesk "Receiving Summary" → "#received / #ordered" (denominator = SUM(qtyOrder))
106
+ ```
107
+
108
+ - **Stage 1a (API):** vendor detected by string match on `u_asset_mfg` (apple=1, acer=2,
109
+ lenovo=3, lexmark=4; unknown → Lexmark). Serial 'S'-prefix stripped for Lexmark/Lenovo.
110
+ Ship-to resolved via `App_Api_NYCDOEV2::resolveShippingAddress()`; on failure a tracking
111
+ `NYCDOETicket` is recorded but **ASN creation is skipped**.
112
+ `dedupeKey = vendorId|PO|DOE-part|serialNumber`. Dedup is SELECT-before-INSERT with
113
+ per-row try/catch (unique index is the race backstop).
114
+ - **Stage 1b (SFTP):** the only source of **non-serialized** items (in practice the Lexmark
115
+ feed). Serial-less lines of the same PO+part are summed per file into one queue row with
116
+ `dedupeKey = vendorId|PO|DOE-part|<file token>`. Dedup is INSERT + catch "Duplicate entry"
117
+ (race-safe).
118
+ - **Stage 2:** queue → ASN/Items/Units/TrackingNumbers in a transaction. Serialized: one
119
+ Unit per serial. Non-serialized: the file's quantity is a **cumulative** total —
120
+ `unsent item qtyOrder = cumulative − SUM(committed qtyOrder)`; post-freeze growth becomes
121
+ a **fresh item row**, a decrease fires a discrepancy email and changes nothing. Exactly
122
+ one Unit per serial-less item row regardless of quantity. Failed rows retry 5× then alert
123
+ `devteam@goagilant.com`.
124
+ - **Stage 3 (THE FREEZE):** creates the NetSuite SO and stamps
125
+ `AdvanceShippingNoticeItems.netSuiteInternalSalesOrderId`. **Once stamped, that item's
126
+ `qtyOrder` is immutable** — there is no update path to a sent SO line; growth must be a
127
+ new item row → new SO line.
128
+ - **Stage 4:** reconciles the NetSuite **PO** (`netSuiteInternalPurchaseOrderId` — not the
129
+ SO id) and sends serial inventory assignments.
130
+ - **Stage 5:** for units `WHERE togadeskRepairOrderId IS NULL`, reads serials from NetSuite
131
+ item receipts (so TogaDesk RO serials are **driven by NetSuite**, not the ASN unit),
132
+ creates/reuses TogaDesk manufacturers/models/assets/serial-numbers/MSO and creates **a
133
+ new repair_orders row unconditionally per loop iteration** — one RO per Unit (a 50-cable
134
+ line = 1 unit = 1 RO). Existing-serial lookup is scoped **per item row** (`$asnItemId`).
135
+ - **"#/N received" UI** (`togadesk/desk/template/pages/managedserviceorders/view.php`):
136
+ denominator = `SUM(qtyOrder)` per part (committed + unsent); numerator = Units with
137
+ `togadeskRepairOrderId IS NOT NULL`.
138
+
139
+ ## Critical business rules (SME-confirmed — re-confirm, don't infer)
140
+
141
+ 1. **An ASN file is a CUMULATIVE restatement** of a PO+part total, not a delta (50 grows to
142
+ 75; the file says "75").
143
+ 2. **Match non-serialized items on customer PO + part number** — tracking/waybill numbers
144
+ are NOT reliable.
145
+ 3. **ServiceNow only delivers serialized items.** Non-serialized comes only via vendor SFTP
146
+ — this is why both ingestion paths exist.
147
+ 4. **The NetSuite freeze:** `netSuiteInternalSalesOrderId IS NOT NULL` ⇒ `qtyOrder` locked.
148
+ Growth = new item row; decrease = discrepancy alert, never a reduction.
149
+ 5. **One unit per non-serialized item row** — a 50-cable line is 1 unit and 1 repair order.
150
+ 6. **The `-RPL` PO suffix** forces a brand-new ASN/MSO/RO chain for replacements.
151
+
152
+ ## Gotchas / known issues
153
+
154
+ - **The queue `dedupeKey` format is load-bearing.** A 2026 incident (WR260236464 duplicate
155
+ repair order) was caused by dedupeKey format drift (4-part keys with serial vs 3-part
156
+ without) letting old rows re-import; Stage 2 then created a sibling item row (frozen
157
+ original not reusable), and Stage 5's per-item-row serial scoping duplicated the unit →
158
+ duplicate RO. Any change to the key format needs a backfill or transitional matcher.
159
+ Forensic fingerprint: a Unit with `trackingNumberId IS NULL` was added by Stage 5 from
160
+ NetSuite, not by the importer.
161
+ - **Two dedupe strategies coexist:** SFTP path = INSERT+catch (race-safe); API path =
162
+ check-then-insert (unique index backstop).
163
+ - **Timezone traps:** worker runs `America/Chicago`; `repair_orders.dtEta` is stored
164
+ **Eastern**; ServiceNow `u_eta` is sent as **UTC** `YYYY-MM-DD HH:MM:SS` (converted via
165
+ `App_Date::convertTimezone()`, only sent when first 19 chars differ from remote).
166
+ - **EDI PO104 leading-zero suppression** (go-live 2026-06-07): DOE's IBM-based translator
167
+ sends `.94` instead of `0.94` — exposure is in `App_Edi::translatePurchaseOrder850()`,
168
+ not the cron.
169
+ - **Installation schedule reports skip weekends but not holidays** — empty output near a
170
+ holiday is expected, not a bug. Empty also just means no ROs with `dtEta` on the exact
171
+ target date.
172
+ - **Stage cadences differ** (5 min / 15 min / 6 min / 2 h) — "missing" data downstream is
173
+ often just a stage that hasn't ticked yet.
174
+ - **Stage 6 is not currently running** (`4_` paused with `active: 0`, `5_` unscheduled).
175
+ - TRUE-77354: Stage 5 lookups changed `LIKE` → `=` to stop `_`/`%` wildcard matches in
176
+ serials.
177
+
178
+ ## Operating rules when changing this integration
179
+
180
+ 1. **Trace the full pipeline downstream before fixing** — the constraint that makes an edit
181
+ unsafe usually lives downstream (the freeze point and the consumer/display query).
182
+ 2. **Ask the SME for business-data semantics — never guess** (cumulative vs delta, identity
183
+ keys, what counts as one unit, whether totals can decrease).
184
+ 3. All ServiceNow calls go through `App_Api_NYCDOEV2` public static methods.
185
+ 4. Never mutate a frozen item's quantity; carry deltas forward as new rows.
186
+ 5. With no staging environment, verify with a standalone arithmetic simulation plus a read
187
+ of the consumer query; `php -l` every touched file.
188
+
189
+ ## Related docs
190
+
191
+ - [NYC DOE client profile](../profile.md)
192
+ - [Worker (1.0) architecture](../../../1.0/apps/worker/architecture.md) — cron dispatch,
193
+ role election, schedule files
194
+ - Local deep-dive (full WR260236464 case study with record IDs):
195
+ `doe_service_now_integration.md` in Mark's WWW workspace root; also the
196
+ `service-now-integration` skill (`asn-import-flow.md`)
@@ -0,0 +1,50 @@
1
+ ---
2
+ title: NYC DOE (New York City Department of Education)
3
+ framework: "1.0"
4
+ project: Worker
5
+ client: nycdoe
6
+ type: profile
7
+ status: active
8
+ updated: 2026-06-10
9
+ owners: [mhammontree]
10
+ files: []
11
+ related:
12
+ - features/servicenow-integration.md
13
+ ---
14
+
15
+ ## Summary
16
+ NYC DOE (New York City Department of Education) is a TOGA client whose entire integration
17
+ runs in the **1.0 worker tier** (~30 cron scripts under `worker/crons/sync/nycdoe/` and
18
+ `worker/crons/notifications/nycdoe/`, on the `sync` and `notification` worker roles).
19
+ ServiceNow is DOE's system of record for tickets (Incidents and RITMs); TOGA mirrors those
20
+ locally, drives fulfillment through TogaDesk and NetSuite, and pushes status / ETA /
21
+ proof-of-delivery back to ServiceNow. A parallel EDI relationship (850 POs in, invoices out)
22
+ runs over DOE's SFTP.
23
+
24
+ ## Platforms & data
25
+ - **1.0 worker tier only** — no 2.0 footprint. Local mirror tables live in `db_common`
26
+ (`NYCDOETickets`, `NYCDOELocations`, `AdvanceShippingNotice*`).
27
+ - **TogaDesk:** DOE is client id **16** (`db_togadesk`) — repair orders, managed service
28
+ orders, and the "#received / #ordered" Receiving Summary UI.
29
+ - **NetSuite:** Sales Orders, PO reconciliation, item receipts, Item Fulfillments, invoices
30
+ (SuiteTalk toolkit in `library`).
31
+
32
+ ## Endpoints
33
+ | System | Endpoint | Notes |
34
+ |---|---|---|
35
+ | ServiceNow prod | `https://nycdoeprod.service-now.com` | Incidents + RITMs |
36
+ | ServiceNow stage | `https://nycdoedev2.service-now.com` | used when `App_Registry::inTestMode()` |
37
+ | ServiceNow disposals | `https://supportadmin.schools.nyc` (stage `nycdoedev3`) | disposal service requests |
38
+ | DOE EDI SFTP | `transfer.schools.nyc` (SSH key `doedimedu.pem`) | EDI 850 in, invoices out |
39
+ | Vendor SFTP feeds | Apple / Lexmark / Lenovo (Lenovo: `ftp.asisystem.com`) | legacy ASN file ingestion |
40
+
41
+ ## API clients
42
+ - `App_Api_NYCDOE` (`library/app/api/nycdoe.php`) — V1 client (legacy ticket downloaders).
43
+ - `App_Api_NYCDOEV2` (`library/app/api/nycdoev2.php`) — centralized V2 client. **All
44
+ ServiceNow calls must go through its public static methods** — never call endpoints
45
+ directly from a cron.
46
+
47
+ ## Key features (this client)
48
+ - [ServiceNow / ASN Integration](features/servicenow-integration.md) — the full integration
49
+ map: ticket sync, the ASN pipeline (the core flow), outbound status sync, EDI, and the
50
+ SME-confirmed business rules.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.36",
3
+ "version": "1.0.38",
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",