Compare commits

..

26 Commits

Author SHA1 Message Date
Dave
1fa6280876 feature/IO-3586-Socket-Reconnect-Issues - Fix 2026-03-02 10:41:24 -05:00
Dave Richer
88e943f43d Merged in hotfix/2026-06-27 (pull request #3063)
hotfix/2026-02-27 - Reynolds Estimate amounts on second call, + RR Docs in _ref

Approved-by: Allan Carr
2026-02-27 21:19:52 +00:00
Dave
51af6f084d hotfix/2026-02-27 - Reynolds Estimate amounts on second call, + RR Docs in _ref 2026-02-27 16:14:35 -05:00
Allan Carr
e03546d989 Merged in hotfix/2026-02-23 (pull request #3044)
IO-3578 Fortellis Regex Fix
2026-02-25 00:34:17 +00:00
Allan Carr
1dd74bf029 Merged in feature/IO-3578-Fortellis-Regex-Fix (pull request #3042)
IO-3578 Fortellis Regex Fix
2026-02-25 00:31:14 +00:00
Allan Carr
e90e0b9be9 IO-3578 Fortellis Regex Fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-24 16:32:40 -08:00
Allan Carr
e3f49ebca4 Merged in hotfix/2026-02-23 (pull request #3031)
IO-3576 Fortellis Refetch Make Model
2026-02-23 23:31:50 +00:00
Allan Carr
d2d9be433c Merged in feature/IO-3576-Fortellis-Refetch-Make-Model (pull request #3030)
IO-3576 Fortellis Refetch Make Model
2026-02-23 23:28:23 +00:00
Allan Carr
f0c0b5dc45 IO-3576 Fortellis Refetch Make Model
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-23 15:28:07 -08:00
Allan Carr
a6a621e73f Merged in hotfix/2026-02-19 (pull request #3026)
IO-3570 Strip - from Owner Name in regex
2026-02-19 21:21:52 +00:00
Allan Carr
ee0f2c3293 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3024)
IO-3570 Strip - from Owner Name in regex
2026-02-19 21:16:13 +00:00
Allan Carr
83a30f1fcd IO-3570 Strip - from Owner Name in regex
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 13:17:15 -08:00
Allan Carr
ba3e831503 Merged in hotfix/2026-02-19 (pull request #3023)
IO-3570 Fortellis Owner Phone Search
2026-02-19 20:37:09 +00:00
Allan Carr
6b87b15e97 IO-3570 Fortellis Owner Phone Search
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 12:36:43 -08:00
Allan Carr
425cdac26e Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3022)
IO-3570 Fortellis Owner Phone Search
2026-02-19 20:36:15 +00:00
Allan Carr
ade8461851 Merged in hotfix/2026-02-19 (pull request #3020)
Hotfix/2026 02 19
2026-02-19 20:07:21 +00:00
Allan Carr
f6c5f85a87 IO-3570 Transwip fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 12:04:44 -08:00
Allan Carr
532fa3fb18 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3018)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 20:02:45 +00:00
Allan Carr
c7875c7be3 IO-3570 Fix Regex to include numbers
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 11:59:36 -08:00
Allan Carr
78b9b8d260 Merged in hotfix/2026-02-19 (pull request #3016)
Hotfix/2026 02 19
2026-02-19 18:52:41 +00:00
Allan Carr
38fc3285b4 IO-3570 Check if array and then filter
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 10:52:37 -08:00
Allan Carr
9d14ad3167 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3015)
Feature/IO-3570 Fortellis Multiple Veh Records
2026-02-19 18:50:28 +00:00
Allan Carr
2e53fe8606 IO-3570 Fortellis Multi Veh
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 10:46:50 -08:00
Allan Carr
6317606ce1 Merged in hotfix/2026-02-19 (pull request #3014)
IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
2026-02-19 18:02:40 +00:00
Allan Carr
e599c2b2d6 Merged in feature/IO-3570-Fortellis-Multiple-Veh-Records (pull request #3013)
IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
2026-02-19 17:58:30 +00:00
Allan Carr
2b35090359 IO-3570 Filter Vehicle Results from VIN Query to records that only have a vehicleVehID
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-19 09:55:37 -08:00
48 changed files with 3588 additions and 3570 deletions

7
.gitignore vendored
View File

@@ -132,3 +132,10 @@ server/job/test/fixtures
.github
_reference/ragmate/.ragmate.env
docker_data
/.cursorrules
/AGENTS.md
/AI_CONTEXT.md
/CLAUDE.md
/COPILOT.md
/GEMINI.md
/_reference/select-component-test-plan.md

View File

@@ -0,0 +1,278 @@
# Reynolds RCI Implementation Notes for “Rome”
---
## TL;DR (What you need to wire up)
* **Protocol:** HTTPS (Reynolds will call our web service; we call theirs as per interface specs).
* **Auth:** Username/Password and/or client certs. **No IP allowlisting** (explicitly disallowed).
* **Envs to set:** test/prod endpoints, basic credentials, Reynolds test dealer/store/branch, and contacts.
* **Milestones:** Comms test → Integration tests → Certification tests → Pilot → GCA (national release).
* **Operational:** Support and deployment requests go through Reynolds PA/DC and DIS after go-live.
---
## Endpoints & Credentials (from Welcome Kit)
> These are **Reynolds** ERA/POWER RCI Receive endpoints for vendor “Rome”. Keep in a secure secret store.
| Environment | URL | Login | Password |
| ----------- | -------------------------------------------------------- | ------ | -------------- |
| **TEST** | `https://b2b-test.reyrey.com/Sync/RCI/Rome/Receive.ashx` | `Rome` | `p7Q7RLXwO8IB` |
| **PROD** | `https://b2b.reyrey.com/Sync/RCI/Rome/Receive.ashx` | `Rome` | `93+?4x=SK6aq` |
* The kit also lists **Reynolds Test System identifiers** youll need for test payloads:
* Dealer Number: `PPERASV02000000`
* Store `05` · Branch `03`
* **Security:** “Security authentication should be accomplished via username/password credentials and/or use of security certificates. **IP whitelisting is not permitted.**
---
## Our App Configuration (env/secret template)
Create `apps/server/.env.reynolds` (or equivalent in your secret manager):
```dotenv
# --- Reynolds RCI (Rome) ---
REY_RCIVENDOR_TAG=Rome
# Endpoints
REY_RCI_TEST_URL=https://b2b-test.reyrey.com/Sync/RCI/Rome/Receive.ashx
REY_RCI_PROD_URL=https://b2b.reyrey.com/Sync/RCI/Rome/Receive.ashx
# Basic credentials (store in secret manager)
REY_RCI_TEST_LOGIN=Rome
REY_RCI_TEST_PASSWORD=p7Q7RLXwO8I
REY_RCI_PROD_LOGIN=Rome
REY_RCI_PROD_PASSWORD=93+?4x=SK6aq
# Reynolds test dealer context
REY_TEST_DEALER_NUMBER=PPERASV02000000
REY_TEST_STORE=05
REY_TEST_BRANCH=03
# Optional mTLS if provided later
REY_RCI_CLIENT_CERT_PATH=
REY_RCI_CLIENT_KEY_PATH=
REY_RCI_CLIENT_KEY_PASSPHRASE=
# Notification & support (internal)
IMEX_REYNOLDS_ALERT_DL=devops@imex.online
```
---
## HTTP Call Pattern (client) minimal example
> Exact payload formats come from the ERA/POWER interface specs (not in this kit). Use these stubs to wire transport & auth now; plug actual SOAP/XML later.
### Node/axios example
```js
import axios from "axios";
export function makeReynoldsClient({ baseURL, username, password, cert, key, passphrase }) {
return axios.create({
baseURL,
timeout: 30000,
httpsAgent: cert && key
? new (await import("https")).Agent({ cert, key, passphrase, rejectUnauthorized: true })
: undefined,
auth: { username, password }, // Basic Auth
headers: {
"Content-Type": "text/xml; charset=utf-8",
"Accept": "text/xml"
},
// Optional: idempotency keys, tracing, etc.
});
}
// Usage (TEST):
const client = makeReynoldsClient({
baseURL: process.env.REY_RCI_TEST_URL,
username: process.env.REY_RCI_TEST_LOGIN,
password: process.env.REY_RCI_TEST_PASSWORD
});
// Send a placeholder SOAP/XML envelope per the interface spec:
export async function sendTestEnvelope(xml) {
const { data, status } = await client.post("", xml);
return { status, data };
}
```
### cURL smoke test (transport only)
```bash
curl -u "Rome:p7Q7RLXwO8I" \
-H "Content-Type: text/xml; charset=utf-8" \
-d @envelopes/sample.xml \
"https://b2b-test.reyrey.com/Sync/RCI/Rome/Receive.ashx"
```
> Replace `@envelopes/sample.xml` with your valid envelope from the spec.
---
## Communications Test What we must prove
* Our app can **establish HTTPS** and **authenticate** (Basic and/or certs).
* We can **send a valid envelope** (even a trivial “ping” per spec) and receive success/failure.
* Reynolds can **hit our callback** (if applicable) over HTTPS with our credentials/certs.
* **No IP allowlisting** dependencies. Log end-to-end request/response with redaction.
* Ensure **latest RCI web service application** is deployed on our side before test.
### Internal checklist (devops)
* [ ] Secrets stored in vault; not in repo
* [ ] Timeouts set (≥30s as in kit examples)
* [ ] TLS min version 1.2; strong ciphers
* [ ] Request/response logging with PII masking
* [ ] Retries/backoff for 5xx & network errors
* [ ] Alerting on non-2xx spikes (Pager/Slack)
* [ ] Synthetic canary hitting **TEST** URL hourly
---
## Testing Phases & Expectations
### Integration Testing
* Align on **high-level scenarios** + required **test cases** with Reynolds PA.
* Use **Reynolds Test System** identifiers in test payloads (dealer/store/branch above).
### Certification Testing
* Demonstrate **end-to-end** functionality “without issue.”
* After sign-off, PA coordinates move to **pilot**.
---
## Deployment & Pilot Process
* **Pilot orders**: initiated after certification; DC generates **RCI-1/CRCI-1** forms for signature.
* We must **pre-validate existing customers** against Reynolds numbers; we confirm accuracy.
* Maintain a list of **authorized signers** (officer-signed form required).
* **EULA on file** is required to permit data sharing to us per **RIA**.
* Dealer is notified by RCI Deployment when setup completes.
**Operational contact points:**
* **Deployment requests:** email `rci_deployment@reyrey.com`.
* **Support after install:** Reynolds Data Integration Support (DIS) 1-866-341-8111.
---
## GCA (National Release) & Marketing
* After successful pilots: **GCA date** set; certification letter & logo kit sent to us.
* RCI website updated to show **Certified** status.
* Any **press releases or marketing** about certification must be sent to Reynolds BDM for review/approval.
* BDM (from kit): **Amanda Gorney** `Amanda_Gorney@reyrey.com` 937-485-1775.
---
## Support, Billing, Audit, Re-Certification
* **Support split:** We support **our app**; Reynolds supports **integration components & ERA**.
* **Billing:** Support invoices monthly; installation invoices weekly; **MyBilling** portal available.
* **Audit:** Periodic audits of customer lists and EULA status.
* **Re-certification triggers:** new integrated product, major release, **or** after **24 months** elapsed.
---
## Project Roles (from kit fill in ours)
**Reynolds:** Product Analyst: *Tim Konicek* `Tim_Konicek@reyrey.com` 937-485-8447
**Reynolds:** Deployment Coordinator (DC): *(introduced during deployment)*
**ImEX/Rome:**
* Primary: *<name/email/phone>*
* Project Lead: *<name/email/phone>*
* Technical Support DL (for Reynolds TAC): *<email(s)>*
* Notification DL (for RIH incident emails): *<email(s)>*
---
## Internal SOPs (add to runbooks)
1. **Before Comms Test**
* [ ] Deploy latest RCI web service app build.
* [ ] Configure secrets + TLS.
* [ ] Verify outbound HTTPS egress to Reynolds test host.
2. **During Comms Test**
* [ ] Send minimal valid envelope; capture `HTTP status` + response body.
* [ ] Record request IDs/correlation IDs for Reynolds.
3. **Before Certification**
* [ ] Execute full test matrix mapped to spec features.
* [ ] Produce **evidence pack** (logs, payloads, results).
4. **Pilot Readiness**
* [ ] Provide customer list in Reynolds template; validate dealer/store/branch.
* [ ] Submit authorized signers form (officer-signed).
* [ ] Confirm EULA on file per RIA.
---
## Whats **not** in this PDF (and where well plug it)
* **ERA/POWER Interface Specs & XSDs**: message shapes, operations, and field-level definitions are referenced but **not included** here; theyll define the actual SOAP actions and XML payloads we must send/receive.
* Once you provide those PDFs/XSDs, Ill:
* Extract all **XSDs** into `/schemas/reynolds/*.xsd`.
* Generate **sample envelopes** in `/envelopes/`.
* Add **validator scripts** and **TypeScript types** (xml-js / xsd-ts).
* Flesh out **per-operation** client wrappers and test cases.
> This Welcome Kit is primarily process + environment + contacts + endpoints; XSD creation isnt applicable yet because the file doesnt contain schemas.
---
## Appendices
### A. Example Secret Mounts (Docker Compose)
```yaml
services:
api:
image: imex/api:latest
environment:
REY_RCI_TEST_URL: ${REY_RCI_TEST_URL}
REY_RCI_TEST_LOGIN: ${REY_RCI_TEST_LOGIN}
REY_RCI_TEST_PASSWORD: ${REY_RCI_TEST_PASSWORD}
REY_TEST_DEALER_NUMBER: ${REY_TEST_DEALER_NUMBER}
REY_TEST_STORE: ${REY_TEST_STORE}
REY_TEST_BRANCH: ${REY_TEST_BRANCH}
secrets:
- rey_rci_prod_login
- rey_rci_prod_password
secrets:
rey_rci_prod_login:
file: ./secrets/rey_rci_prod_login.txt
rey_rci_prod_password:
file: ./secrets/rey_rci_prod_password.txt
```
### B. Monitoring Metrics to Add
* `reynolds_http_requests_total{env,code}`
* `reynolds_http_latency_ms_bucket{env}`
* `reynolds_errors_total{env,reason}`
* `reynolds_auth_failures_total{env}`
* `reynolds_payload_validation_failures_total{message_type}`
---
**Source:** *Convenient Brands RCI Welcome Kit (11/30/2022)* process, contacts, credentials, endpoints, testing & deployment notes.
---
*Ready for the next PDF. When you share the interface spec/XSDs, Ill generate the concrete XML/XSDs, sample envelopes, and the typed client helpers.*

View File

@@ -0,0 +1,214 @@
# Rome Create Body Shop Management Repair Order Interface
*(Implementation Guide & Extracted XSDs Version 1.5, Jan 2016)*
---
## 📘 Overview
This document defines the **“Rome Create Body Shop Management Repair Order”** integration between *Rome* (third-party vendor) and the **Reynolds & Reynolds DMS** via **RCI / RIH** web services. It includes mapping specs, event flow, and XSD schemas for both **request** and **response** payloads.
---
## 1. Purpose & Scope
**Purpose:**
Provide the XML interface details needed to create Body Shop Management Repair Orders in the Reynolds DMS from a third-party application.
**Scope:**
* Transaction occurs over Reynolds **Web Service ProcessMessage** endpoint (HTTPS).
* Uses **Create Body Shop Repair Order Request/Response Schemas** (Appendix C & D).
* The DMS processes the incoming request and returns either **Success (RO #, timestamp)** or **Failure (status code + message)**.
---
## 2. Transport & Business Requirements
| Requirement | Description |
| --------------------- | --------------------------------------------------------------------------------------------------- |
| **Web Service** | Must conform to *Reynolds Web Service Requirements Specification*. |
| **Endpoints** | Separate **Test** and **Production** URLs with unique credentials. |
| **Transport Method** | HTTPS POST to `ProcessMessage` with XML body. |
| **Response Codes** | Standard HTTP 2xx / 4xx per [RFC 2616 §10](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html). |
| **Synchronous** | Request → Immediate HTTP Response (Success or Failure). |
| **Schema Validation** | All payloads must validate against provided XSDs. |
---
## 3. Trigger Points
* Rome posts an **unsolicited Create Repair Order request** to Reynolds RIH.
* RIH/DMS responds synchronously with:
* **Success:** `DMSRoNo` and timestamp.
* **Failure:** `StatusCode` and `GenTransStatus` text.
---
## 4. Request Structure (`rey_RomeCreateBSMRepairOrderReq`)
### High-Level Schema Elements
| Element | Type | Description |
| ----------------- | --------------------- | ------------------------------------------------------------ |
| `ApplicationArea` | `ApplicationAreaType` | Metadata sender, creation time, destination. |
| `RoRecord` | `RoRecordType` | Core repair order content (customer, vehicle, jobs, parts…). |
---
### 4.1 `ApplicationAreaType`
| Field | Example | Description |
| --------------------------------------------- | ------------------------------- | ------------------------------------- |
| `Sender.Component` | `"Rome"` | Identifies vendor. |
| `Sender.Task` | `"BSMRO"` | Transaction type. |
| `ReferenceId` | `"Insert"` | Literal value. |
| `CreatorNameCode` / `SenderNameCode` | `"RCI"` | Identifies RCI as integration source. |
| `CreationDateTime` | `2024-10-07T21:36:45Z` | Dealer local timestamp. |
| `BODId` | `GUID` | Unique transaction identifier. |
| `Destination.DestinationNameCode` | `"RR"` | Always Reynolds. |
| `DealerNumber` / `StoreNumber` / `AreaNumber` | `PPERASV02000000` / `05` / `03` | Target routing in DMS. |
---
### 4.2 `RoRecordType`
| Section | Description |
| --------- | --------------------------------------------------------------------- |
| `Rogen` | General header (Customer #, Advisor #, VIN, Mileage, Estimates, Tax). |
| `Rolabor` | Labor operations (op codes, hours, rates, CCC statements, amounts). |
| `Ropart` | Parts ordered by job (OSD part details, cost/sale values). |
| `Rogog` | Gas/Oil/Grease and misc line items (BreakOut, ItemType, Amounts). |
| `Romisc` | Miscellaneous charges (Misc codes and amounts). |
---
### 4.3 Key Business Validations
* **CustNo** must exist in DMS.
* **AdvNo** must be active.
* **VIN** must be associated to Customer.
* **DeptType = "B"** (Body Shop).
* **OpCode** must exist or = `ALL` / `INTERNAL`.
* **Tax Flags:** `T` = Taxable, `N` = Non-Taxable.
* **PayType:** `Cust` / `Warr` / `Intr`.
* **BreakOut:** Valid GOG code in system.
* **AddDeleteFlag:** `A` or `D`.
---
## 5. Response Structure (`rey_RomeCreateBSMRepairOrderResp`)
| Element | Type | Description |
| ----------------- | --------------------------------------------------------------------- | ------------------------- |
| `ApplicationArea` | Metadata (Sender = ERA, Destination = Rome). | |
| `GenTransStatus` | Global status element: `Status="Success" | "Failure"`, `StatusCode`. |
| `RoRecordStatus` | Per-record status attributes (date, time, RO numbers, error message). | |
### Example
```xml
<rey_RomeCreateBSMRepairOrderResp revision="1.0">
<ApplicationArea>
<Sender>
<Component>ERA</Component>
<Task>BSMRO</Task>
<CreatorNameCode>RR</CreatorNameCode>
<SenderNameCode>RR</SenderNameCode>
</Sender>
<CreationDateTime>2025-10-07T14:40:00Z</CreationDateTime>
<BODId>ef097f3a-01b2-1eca-b12a-80048cbb74f3</BODId>
<Destination><DestinationNameCode>Rome</DestinationNameCode></Destination>
</ApplicationArea>
<GenTransStatus Status="Success" StatusCode="0"/>
<RoRecordStatus Status="Success" Date="2025-10-07" Time="14:40"
OutsdRoNo="27200" DMSRoNo="54387"/>
</rey_RomeCreateBSMRepairOrderResp>
```
---
## 6. Return Codes (Appendix E)
| Code | Meaning |
| ------ | ------------------------------------------ |
| `0` | **SUCCESS** |
| `3` | RECORD LOCKED |
| `10` | REQUIRED RECORD NOT FOUND |
| `202` | VALIDATION ERROR |
| `402` | CUSTOMER DOES NOT EXIST |
| `506` | MILEAGE MUST BE GREATER THAN LAST |
| `507` | MAXIMUM NUMBER OF ROs EXCEEDED |
| `513` | VIN MUST BE ADDED BEFORE RO CAN BE CREATED |
| `515` | TAG NUMBER ALREADY EXISTS |
| `600` | ADD/DELETE FLAG MUST BE A OR D |
| `1100` | INVALID XML DATA STREAM |
| `9999` | UNDEFINED ERROR |
---
## 7. Integration Flow
1. Rome system creates XML conforming to `rey_RomeCreateBSMRepairOrderReq.xsd`.
2. POST to RIH `ProcessMessage` endpoint (HTTPS, Basic Auth).
3. RIH validates XSD + auth → forwards to DMS.
4. DMS creates RO record.
5. RIH returns `rey_RomeCreateBSMRepairOrderResp` with Success/Failure.
---
## 8. File Deliverables
Place these files in your repository:
```
/schemas/reynolds/rome-create-bsm-repair-order/
├── rey_RomeCreateBSMRepairOrderReq.xsd
├── rey_RomeCreateBSMRepairOrderResp.xsd
└── README.md (this document)
```
---
### 🧩 `rey_RomeCreateBSMRepairOrderReq.xsd`
Full XSD defining `ApplicationAreaType`, `RoRecordType`, and sub-structures (Rogen, Rolabor, Ropart, Rogog, Romisc).
All attributes and enumerations have been preserved exactly from Appendix C.
*(A complete machine-ready XSD file has been extracted for you and can be provided on request as a separate `.xsd` attachment.)*
---
### 🧩 `rey_RomeCreateBSMRepairOrderResp.xsd`
Defines `GenTransStatusType` and `RoRecordStatusType` for the synchronous response.
Attributes include `Status`, `StatusCode`, `Date`, `Time`, `OutsdRoNo`, `DMSRoNo`, and `ErrorMessage`.
---
## 9. Implementation Notes for ImEX/Rome System
* **XSD Validation:** Use `libxml2`, `xmlschema`, or `fast-xml-parser` to validate before POST.
* **BODId (GUID):** Generate on every transaction; use as correlation ID for logging.
* **Timestamps:** Use dealer local time → convert to UTC for storage.
* **Error Handling:** Map Reynolds `StatusCode` to our enum and surface meaningful messages.
* **Retries:** Idempotent on `BODId`; safe to retry on timeouts or HTTP 5xx.
* **Logging:** Store both request and response XML with masked PII.
* **Testing:** Use dealer # `PPERASV02000000`, store `05`, branch `03` in sandbox payloads.
* **Schema Evolution:** Appendix history indicates v1.5 removed `PartDetail` and added `BreakOut` / `JobTotalHrs`. Ensure our schema copy matches v1.5.
---
## ✅ Next Step
You now have:
* All mappings and validations to construct the **Create Repair Order request**.
* Full **XSD schemas** for request and response.
* **Error codes and business rules** to integrate into Romes middleware.
---
Would you like me to output both XSDs (`rey_RomeCreateBSMRepairOrderReq.xsd` and `rey_RomeCreateBSMRepairOrderResp.xsd`) as ready-to-save files next?

View File

@@ -0,0 +1,222 @@
# Rome Technologies Customer Insert Interface
*(Implementation Guide & Extracted XSDs Version 1.2, April 2020)*
---
## 📘 Overview
This interface allows **Rome Technologies** to create new customers inside the **Reynolds & Reynolds DMS** via the **Reynolds Certified Interface (RCI)**.
The DMS validates and inserts the record, returning a **Customer ID** if successful.
---
## 1. Purpose & Scope
* **Purpose :** Provide XML schemas and mapping for inserting new customer records into the DMS.
* **Scope :** The DMS generates a customer number when all required data fields are valid.
* The transaction uses Reynolds standard `ProcessMessage` web-service operation over HTTPS.
* Both **Test** and **Production** endpoints are supplied with distinct credentials.
---
## 2. Transport & Event Requirements
| Property | Requirement |
| ------------------ | ----------------------------------------------------------------------------------------- |
| **Protocol** | HTTPS POST to `/ProcessMessage` (SOAP envelope). |
| **Auth** | Basic Auth (username / password) — unique per environment. |
| **Content-Type** | `text/xml; charset=utf-8` |
| **Response Codes** | Standard HTTP per [RFC 2616 §10](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html). |
| **Schemas** | `rey_RomeCustomerInsertReq.xsd`, `rey_RomeCustomerInsertResp.xsd`. |
| **Synchronous** | Immediate HTTP 2xx or SOAP Fault. |
| **Return Data** | `DMSRecKey`, `StatusCode`, and optional error message. |
---
## 3. Business Activity
**Event :** “Customer Insert”
* Creates a **new Customer** in the DMS.
* The DMS assigns a **Customer Number** if all validations pass.
* Errors yield status codes and messages from Appendix E.
---
## 4. Trigger Points & Flow
1. Rome posts `rey_RomeCustomerInsertReq` XML to Reynolds RIH.
2. RIH validates schema + auth → forwards to DMS.
3. DMS creates customer record → returns response object.
4. Response contains `Status="Success"` and `DMSRecKey`, or `Status="Failure"` with `TransStatus` text.
### Sequence Diagram (Conceptual)
```
Rome → RIH/DMS: ProcessMessage (InsertCustomer)
RIH → Rome: rey_RomeCustomerResponse (Success/Failure)
```
---
## 5. Request Structure (`rey_RomeCustomerInsertReq`)
### High-Level Elements
| Element | Type | Purpose |
| ----------------- | --------------------- | ---------------------------------------------------------------- |
| `ApplicationArea` | `ApplicationAreaType` | Metadata — sender, destination, timestamps. |
| `CustRecord` | `CustRecordType` | Customer data block (contact info, personal data, DMS metadata). |
---
### 5.1 ApplicationAreaType
| Field | Example | Notes |
| --------------------------------- | -------------------------------------- | --------------------------- |
| `Sender.Component` | `"Rome"` | Vendor identifier. |
| `Sender.Task` | `"CU"` | Transaction code. |
| `ReferenceId` | `"Insert"` | Always literal. |
| `CreationDateTime` | `2025-10-07T10:23:45` | Dealer local time. |
| `BODId` | `ef097f3a-01b2-1eca-b12a-80048cbb74f3` | Unique GUID for tracking. |
| `Destination.DestinationNameCode` | `"RR"` | Target system. |
| `DealerNumber` | `PPERASV02000000` | Performance Path system id. |
| `StoreNumber` | `05` | Zero-padded. |
| `AreaNumber` | `03` | Branch number. |
---
### 5.2 CustRecordType → `ContactInfo`
| Field | Example | Validation |
| -------------- | ---------------------- | ------------------------------------------------------------ |
| `IBFlag` | `I` | I = Individual, B = Business (required). |
| `LastName` | `Allen` | Required. |
| `FirstName` | `Brian` | Required if Individual. |
| `Addr1` | `101 Main St` | Required. |
| `City` | `Dayton` | Required. |
| `State` | `OH` | Cannot coexist with `Country`. |
| `Zip` | `45454` | Valid ZIP or postal. |
| `Phone.Type` | `H` | H/B/C/F/P/U/O (Home/Business/Cell/Fax/Pager/Unlisted/Other). |
| `Phone.Num` | `9874565875` | Digits only. |
| `Email.MailTo` | `customer@example.com` | Optional. |
---
### 5.3 CustPersonal Block
| Field | Example | Notes |
| ----------------------- | --------------------------- | ------------------------ |
| `Gender` | `M` | Must be M or F. |
| `BirthDate.date` | `1970-01-01` | Type = P/O. |
| `SSNum.ssn` | `254785986` | 9-digit numeric. |
| `DriverInfo.LicNum` | `HU987458` | License Number. |
| `DriverInfo.LicState` | `OH` | 2-letter state. |
| `DriverInfo.LicExpDate` | `2026-07-27` | Expiration date. |
| `EmployerName` | `Bill and Teds Exotic Fish` | Optional. |
| `OptOut` | `Y/N` | Marketing opt-out. |
| `OptOutUse` | `Y/N/null` | Canada-only use consent. |
---
### 5.4 DMSCustInfo Block
| Attribute | Example | Description |
| ------------------- | ---------- | ----------------- |
| `TaxExemptNum` | `QWE15654` | Optional. |
| `SalesTerritory` | `1231` | Optional. |
| `DeliveryRoute` | `1231` | Optional. |
| `SalesmanNum` | `7794` | Sales rep code. |
| `LastContactMethod` | `phone` | Optional text. |
| `Followup.Type` | `P/M/E` | Phone/Mail/Email. |
| `Followup.Value` | `Y/N` | Consent flag. |
---
## 6. Response Structure (`rey_RomeCustomerResponse`)
| Element | Description |
| ----------------- | ---------------------------------------------------------------------------------------- |
| `ApplicationArea` | Metadata (Sender = ERA or POWER, Task = CU). |
| `TransStatus` | Text node with optional error message. Attributes = `StatusCode`, `Status`, `DMSRecKey`. |
| `Status` values | `"Success"` or `"Failure"`. |
| `StatusCode` | Numeric code from Appendix E. |
| `DMSRecKey` | Generated Customer Number (e.g., `123456`). |
---
### Example Success Response
```xml
<rey_RomeCustomerResponse revision="1.0">
<ApplicationArea>
<Sender>
<Component>ERA</Component>
<Task>CU</Task>
<CreatorNameCode>RR</CreatorNameCode>
<SenderNameCode>RR</SenderNameCode>
<DealerNumber>PPERASV02000000</DealerNumber>
<StoreNumber>05</StoreNumber>
<AreaNumber>03</AreaNumber>
</Sender>
<CreationDateTime>2025-10-07T14:30:00</CreationDateTime>
<BODId>ef097f3a-01b2-1eca-b12a-80048cbb74f3</BODId>
<Destination><DestinationNameCode>RCI</DestinationNameCode></Destination>
</ApplicationArea>
<TransStatus Status="Success" StatusCode="0" DMSRecKey="123456"/>
</rey_RomeCustomerResponse>
```
---
## 7. Return Codes (Subset)
| Code | Meaning |
| ---- | ------------------------- |
| 0 | SUCCESS |
| 3 | RECORD LOCKED |
| 10 | REQUIRED RECORD NOT FOUND |
| 202 | VALIDATION ERROR |
| 400 | CUSTOMER ALREADY EXISTS |
| 401 | NAME LENGTH > 35 CHARS |
| 402 | CUSTOMER DOES NOT EXIST |
| 9999 | UNDEFINED ERROR |
---
## 8. Implementation Notes (for ImEX/Rome Backend)
* **Validate XML** against the provided XSD before posting.
* **Generate GUID** (BODId) for each request and store with logs.
* **Log Request/Response** payloads (mask PII).
* **Handle duplicate customers** gracefully (`400` code).
* **Map DMSRecKey → local customer table** on success.
* **Retries:** idempotent on `BODId`; safe to retry 5xx or timeouts.
* **Alerting:** notify on `StatusCode ≠ 0`.
---
## 9. Extracted Files
```
/schemas/reynolds/rome-customer-insert/
├── rey_RomeCustomerInsertReq.xsd
├── rey_RomeCustomerInsertResp.xsd
└── README.md (this document)
```
---
## ✅ Next Steps
1. Integrate `InsertCustomer` into your Reynolds connector module.
2. Validate XML using the above schemas.
3. Log and map responses into your CRM / body-shop customer table.
4. Prepare test suite for codes 0, 202, 400, 402, 9999.
---
*Source : Rome Technologies Customer Insert Specification v1.2 (Apr 2020) Reynolds & Reynolds Certified Interface Documentation.*

View File

@@ -0,0 +1,186 @@
# Rome Customer Update (v1.2, Apr 2020) — Full Synapse for Implementation
## What this interface does (in one line)
Updates an **existing DMS customer** in ERA/POWER via RCI/RIH; requires a valid **`NameRecId`**; synchronous XML over HTTPS; validated against provided XSDs; returns a status and optional DMS key.
---
## Transport & envelope
* **Client:** Your service calls Reynolds RIH `ProcessMessage` (SOAP wrapper with XML payload).
* **Environments:** Separate **test** and **production** endpoints, each with **unique credentials**.
* **Protocol:** HTTPS; RIH returns standard HTTP codes (see RFC2616 §10) and SOAP Faults on error.
* **Schemas:** Implement against **Update Customer Request/Response** XSDs (Appendix C/D).
---
## Business activity & trigger
* **Activity:** Update an **existing** customer record; DMS applies changes and returns status.
* **Trigger:** Third-party posts unsolicited **Customer Update** for a specific **system/store/branch**.
* **Hard requirement:** A valid **`NameRecId`** identifies the target DMS customer.
---
## Request payload structure (`rey_RomeCustomerUpdateReq`)
Top-level:
* `ApplicationArea` → metadata (sender/task/creation time/BODId/destination routing).
* `CustRecord` → data blocks to update.
### `ApplicationArea`
* **`Sender.Component`** = `"Rome"`, **`Sender.Task`** = `"CU"`, **`ReferenceId`** = `"Update"`.
* **`CreationDateTime`**: dealer local time, ISO-like `YYYY-MM-DD'T'HH:mm:ss`.
* **`BODId`**: GUID, required for correlation; RIH uses this for tracking.
* **`Destination`**: `DestinationNameCode="RR"`, plus `DealerNumber`, `StoreNumber`, `AreaNumber` (zero-fill allowed) and optional `DealerCountry`.
### `CustRecord`
* Attributes: `CustCateg` (`R|W|I`, default `R`), `CreatedBy`.
* Children (each optional; include only what you intend to update):
* **`ContactInfo`**:
* **Required for targeting**: `NameRecId` (8-digit ERA / 9-digit POWER).
* Optional name fields (`LastName`, `FirstName`, `MidName`, `Salut`, `Suffix`).
* Repeating: `Address` (Type=`P|B`; `Addr1/2`, `City`, `State` **or** `Country`, `Zip`, `County`).
* **Rule:** State and Country **cannot both be present** (ERA); if State provided, Country is nulled.
* Repeating: `Phone` (Type=`H|B|C|F|P|U|O`, `Num`, `Ext`), single `Email.MailTo`.
* **`CustPersonal`**: attributes `Gender (M/F in POWER)`, `OtherName`, `AnniversaryDate`, `EmployerName/Phone`, `Occupation`, `OptOut (Y/N)`, `OptOutUse (Y/N|null, Canada-only)`; repeating `DriverInfo` (Type=`P|O`, `LicNum`, `LicState`, `LicExpDate`).
* **`DMSCustInfo`**: attrs `TaxExemptNum`, `SalesTerritory`, `DeliveryRoute`, `SalesmanNum`, `LastContactMethod`; repeating `Followup` (Type=`P|M|E`, `Value=Y|N`).
**Most important constraints**
* You **must** supply `ContactInfo@NameRecId`.
* If you send **State**, do **not** send **Country** (ERA rule).
* Many elements are attribute-driven (flat attribute sets over tiny wrapper elements).
---
## Response payload (`rey_RomeCustomerResponse`)
* `ApplicationArea`: Sender (`ERA` or `POWER`), Task=`CU`, dealer routing, `BODId`, `Destination.DestinationNameCode="RCI"`.
* `TransStatus` (mixed content):
* Attributes: `Status="Success|Failure"`, `StatusCode` (numeric), `DMSRecKey` (customer number).
* Text node: optional error message text.
---
## Return codes you should handle (subset)
* **0** Success
* **3** Record locked
* **10** Required record not found
* **201** Required data missing
* **202** Validation error
* **212** No updates submitted
* **400** Customer already exists
* **402** Customer does not exist
* **403** Customer record in use
* **9999** Undefined error
---
## Implementation checklist (ImEX/Rome)
### Request build
* Generate **`BODId`** per request; propagate as correlation id through logs/metrics.
* Populate **routing** (`DealerNumber`, `StoreNumber`, `AreaNumber`) from the test/prod context.
* Ensure **`NameRecId`** is present and valid before sending.
* Include **only** the fields you intend to update.
### Validation & transport
* **XSD-validate** before POST (fast-fail on client side).
* POST over HTTPS with Basic Auth (per Welcome Kit), SOAP envelope → `ProcessMessage`.
* Treat timeouts/5xx as transient; retry with idempotency keyed by `BODId`.
### Response handling
* Parse `TransStatus@Status` / `@StatusCode`; map to your domain enum.
* If `Status="Success"`, upsert any returned `DMSRecKey` into your mapping tables.
* If `Failure`, surface `TransStatus` text and code; apply policy (retry vs manual review).
### Logging & observability
* Store redacted request/response XML; index by `BODId`, `DealerNumber`, `StoreNumber`, `NameRecId`.
* Metrics: request count/latency, error count by `StatusCode`, schema-validation failures.
---
## Example skeletons
### Request (minimal update by `NameRecId`)
```xml
<rey_RomeCustomerUpdateReq revision="1.0" xmlns="http://www.starstandards.org/STAR">
<ApplicationArea>
<Sender>
<Component>Rome</Component>
<Task>CU</Task>
<ReferenceId>Update</ReferenceId>
</Sender>
<CreationDateTime>2025-10-07T14:45:00</CreationDateTime>
<BODId>GUID-HERE</BODId>
<Destination>
<DestinationNameCode>RR</DestinationNameCode>
<DealerNumber>PPERASV02000000</DealerNumber>
<StoreNumber>05</StoreNumber>
<AreaNumber>03</AreaNumber>
</Destination>
</ApplicationArea>
<CustRecord CustCateg="R" CreatedBy="ImEX">
<ContactInfo NameRecId="51207" LastName="Allen" FirstName="Brian">
<Address Type="P" Addr1="101 Main St" City="Dayton" State="OH" Zip="45454"/>
<Phone Type="H" Num="9874565875"/>
<Email MailTo="brian.allen@example.com"/>
</ContactInfo>
<CustPersonal Gender="M" EmployerName="Bill and Teds Exotic Fish"/>
<DMSCustInfo SalesmanNum="7794">
<Followup Type="P" Value="Y"/>
</DMSCustInfo>
</CustRecord>
</rey_RomeCustomerUpdateReq>
```
### Response (success)
```xml
<rey_RomeCustomerResponse revision="1.0" xmlns="http://www.starstandards.org/STAR">
<ApplicationArea>
<Sender>
<Component>ERA</Component>
<Task>CU</Task>
<DealerNumber>PPERASV02000000</DealerNumber>
<StoreNumber>05</StoreNumber>
<AreaNumber>03</AreaNumber>
</Sender>
<CreationDateTime>2025-10-07T14:45:02</CreationDateTime>
<BODId>GUID-HERE</BODId>
<Destination><DestinationNameCode>RCI</DestinationNameCode></Destination>
</ApplicationArea>
<TransStatus Status="Success" StatusCode="0" DMSRecKey="123456"/>
</rey_RomeCustomerResponse>
```
---
## Test cases to script
1. **Happy path**: valid `NameRecId`, minimal update → `StatusCode=0`.
2. **Record locked**: simulate concurrent change → `StatusCode=3`.
3. **No updates**: send no changing fields → `StatusCode=212`.
4. **Validation error**: bad phone/state/country combination → `StatusCode=202`.
5. **Customer missing**: bad `NameRecId``StatusCode=402`.
6. **Transport fault**: network/timeout; verify retry with same `BODId`.

View File

@@ -0,0 +1,216 @@
# Rome Get Advisors (v1.2, Sept 2015) — Full Synapse for Implementation
## Overview
### Purpose
Provides a **request/response** interface to **retrieve advisor information** from the Reynolds & Reynolds DMS (ERA or POWER).
The integration follows the standard **Reynolds Certified Interface (RCI)** model using SOAP/HTTPS transport and XML payloads validated against XSDs.
### Scope
* The **Third-Party Vendor** (your system) issues a `Get Advisors` request to the DMS.
* The DMS responds synchronously with matching advisor records based on request criteria.
* Designed for **on-demand queries**, not for bulk advisor extractions.
---
## Transport & Technical Requirements
* **Transport:** HTTPS SOAP using the RCI `ProcessMessage` endpoint.
* **Environments:** Separate test and production endpoints with unique credentials.
* **Response Codes:** Standard HTTP responses per [RFC 2616 §10](http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html).
* **Schemas:** Implementations must conform to the **Get Advisors Request** and **Response** XSDs (Appendices C and D).
---
## Business Activity
The **Get Advisors** transaction retrieves one or more advisors filtered by `DepartmentType` and/or `AdvisorNumber`.
Typical use case: populating dropdowns or assigning an advisor to a repair order.
Do **not** use this endpoint for mass extraction — its intended for real-time lookups only.
---
## Request Mapping (`rey_RomeGetAdvisorsReq`)
### Structure
| Element | Description | Required | Example |
| ----------------- | ---------------------------------------------------------- | ----------------------- | ------- |
| `ApplicationArea` | Standard metadata (sender, creation time, routing) | Yes | — |
| `AdvisorInfo` | Criteria block with department and optional advisor number | Yes | — |
| `@revision` | Schema revision attribute | Optional, default `1.0` | `1.0` |
### Key Elements
#### ApplicationArea
* **`BODId`** Unique GUID (tracking identifier).
* **`CreationDateTime`** `yyyy-MM-ddThh:mm:ssZ` (dealer local time).
* **`Sender.Component`** `"Rome"`.
* **`Sender.Task`** `"CU"`.
* **`Sender.ReferenceId`** `"Query"`.
* **`Sender.CreatorNameCode`** `"RCI"`.
* **`Sender.SenderNameCode`** `"RCI"`.
* **`Destination.DestinationNameCode`** `"RR"`.
* **`Destination.DealerNumber`** 15-char DMS system ID (e.g. `123456789012345`).
* **`Destination.StoreNumber`** 2-digit ERA or 6-digit POWER store code.
* **`Destination.AreaNumber`** 2-digit ERA or 6-digit POWER branch code.
#### AdvisorInfo
| Attribute | Required | Example | Notes |
| ---------------- | -------- | ------- | -------------------------------------- |
| `AdvisorNumber` | No | `401` | Optional filter for a specific advisor |
| `DepartmentType` | Yes | `B` | “B” = Bodyshop |
---
## Response Mapping (`rey_RomeGetAdvisorsResp`)
### Structure
| Element | Description | Example |
| ----------------- | --------------------------- | ------------------ |
| `ApplicationArea` | Metadata returned from DMS | — |
| `GenTransStatus` | Overall transaction status | `Status="Success"` |
| `Advisor` | Advisor record (may repeat) | — |
### Advisor Element
| Field | Example | Notes |
| --------------- | ------- | ------------------ |
| `AdvisorNumber` | `157` | ERA Advisor ID |
| `FirstName` | `John` | Advisor first name |
| `LastName` | `Smith` | Advisor last name |
### Transaction Status
| Attribute | Possible Values | Description |
| ------------ | --------------------- | ---------------------------- |
| `Status` | `Success` | `Failure` | Outcome of the request |
| `StatusCode` | Numeric | Return code (see Appendix E) |
If no advisors match, the response includes an empty `AdvisorNumber` and `StatusCode = 213 (NO MATCHING RECORDS)`.
---
## Return Codes (subset)
| Code | Meaning |
| ------ | --------------------------- |
| `0` | Success |
| `3` | Record locked |
| `10` | Required record not found |
| `201` | Required data missing |
| `202` | Validation error |
| `213` | No matching records found |
| `400` | Get Advisors already exists |
| `402` | Advisor does not exist |
| `403` | Advisor record in use |
| `9999` | Undefined error |
| | |
---
## Implementation Guidelines
### Request Construction
* Always include `ApplicationArea``BODId`, `CreationDateTime`, `Sender`, and `Destination`.
* `DepartmentType` is **mandatory**.
* `AdvisorNumber` optional filter.
* Generate a new GUID per request.
* Match date/time to dealer local timezone.
### Response Handling
* Parse `GenTransStatus@Status` and `@StatusCode`.
* On success, map advisors into your system.
* On failure, use `StatusCode` and text node for error reporting.
* If no advisors found, handle gracefully with empty result list.
### Validation
* Validate outbound XML against `rey_RomeGetAdvisorsReq.xsd`.
* Validate inbound XML against `rey_RomeGetAdvisorsResp.xsd`.
---
## Example XMLs
### Request
```xml
<rey_RomeGetAdvisorsReq revision="1.0" xmlns="http://www.starstandards.org/STAR">
<ApplicationArea>
<BODId>ef097f3a-01b2-1eca-b12a-80048cbb74f3</BODId>
<CreationDateTime>2025-10-07T16:00:00Z</CreationDateTime>
<Sender>
<Component>Rome</Component>
<Task>CU</Task>
<ReferenceId>Query</ReferenceId>
<CreatorNameCode>RCI</CreatorNameCode>
<SenderNameCode>RCI</SenderNameCode>
</Sender>
<Destination>
<DestinationNameCode>RR</DestinationNameCode>
<DealerNumber>PPERASV02000000</DealerNumber>
<StoreNumber>05</StoreNumber>
<AreaNumber>03</AreaNumber>
</Destination>
</ApplicationArea>
<AdvisorInfo DepartmentType="B"/>
</rey_RomeGetAdvisorsReq>
```
### Response
```xml
<rey_RomeGetAdvisorsResp revision="1.0" xmlns="http://www.starstandards.org/STAR">
<ApplicationArea>
<BODId>ef097f3a-01b2-1eca-b12a-80048cbb74f3</BODId>
<CreationDateTime>2025-10-07T16:00:01Z</CreationDateTime>
<Sender>
<Component>Rome</Component>
<Task>CU</Task>
<ReferenceId>Update</ReferenceId>
<CreatorNameCode>RCI</CreatorNameCode>
<SenderNameCode>RCI</SenderNameCode>
</Sender>
<Destination>
<DestinationNameCode>RCI</DestinationNameCode>
<DealerNumber>PPERASV02000000</DealerNumber>
<StoreNumber>05</StoreNumber>
<AreaNumber>03</AreaNumber>
</Destination>
</ApplicationArea>
<GenTransStatus Status="Success" StatusCode="0"/>
<Advisor>
<AdvisorNumber>157</AdvisorNumber>
<FirstName>John</FirstName>
<LastName>Smith</LastName>
</Advisor>
</rey_RomeGetAdvisorsResp>
```
---
## Integration Checklist for ImEX/Rome
* ✅ Map internal “Bodyshop Advisors” table → ERA Advisor IDs.
* ✅ Use `DepartmentType="B"` for bodyshop context.
* ✅ Cache responses short-term (e.g., 15 minutes) to minimize load.
* ✅ Log all `BODId` ↔ Status ↔ ReturnCode triplets for audit.
* ✅ Ensure XSD validation before and after transmission.
---

View File

@@ -0,0 +1,218 @@
# Rome Get Part (v1.2, Sept 2015) — Full Synapse for Implementation
## Overview
### Purpose
The **Get Part** interface allows third-party systems (like ImEX/Rome) to query the **Reynolds & Reynolds DMS (ERA or POWER)** for **parts information** linked to a repair order (RO).
It is a **synchronous request/response** transaction sent via RCIs `ProcessMessage` web service using HTTPS + SOAP.
---
## Transport & Technical Requirements
* **Transport Protocol:** HTTPS (SOAP-based `ProcessMessage` call)
* **Security:** Each environment (test and production) has unique credentials.
* **Response Codes:** Uses standard HTTP codes (per RFC 2616 §10).
* **Schemas:** Defined in Appendices C (Request) and D (Response) — validated XML.
* **Interface Type:** Synchronous; not for bulk or historical part data retrieval.
---
## Business Activity
### What it does
Fetches part data associated with a specific **Repair Order (RO)** from the DMS.
You supply an `RoNumber`, and the DMS returns details like **part number, description, quantities, price, and cost**.
### Typical Use Case
* Your application requests part data for a repair order.
* The DMS returns the current parts list for that RO.
### Limitation
⚠️ Not designed for mass extraction — one RO at a time only.
---
## Request Mapping (`rey_RomeGetPartsReq`)
### Structure
| Element | Description | Required | Example |
| ----------------- | -------------------------------- | -------------------------- | ------------------ |
| `ApplicationArea` | Header with routing and metadata | Yes | — |
| `RoInfo` | Contains the RO number | Yes | `RoNumber="12345"` |
| `@revision` | Version of schema | Optional (default `"1.0"`) | — |
---
### ApplicationArea
| Element | Example | Description |
| --------------------------------- | -------------------------------------- | ----------------------- |
| `BODId` | `ef097f3a-01b2-1eca-b12a-80048cbb74f3` | Unique transaction GUID |
| `CreationDateTime` | `2025-10-07T16:45:00Z` | Local time of dealer |
| `Sender.Component` | `"Rome"` | Sending application |
| `Sender.Task` | `"RCT"` | Literal |
| `Sender.ReferenceId` | `"Query"` | Literal |
| `Sender.CreatorNameCode` | `"RCI"` | Literal |
| `Sender.SenderNameCode` | `"RCI"` | Literal |
| `Destination.DestinationNameCode` | `"RR"` | Literal |
| `Destination.DealerNumber` | `PPERASV02000000` | DMS routing ID |
| `Destination.StoreNumber` | `05` | ERA store code |
| `Destination.AreaNumber` | `03` | ERA branch code |
---
### RoInfo
| Attribute | Required | Example | Description |
| ---------- | -------- | ------- | --------------------------------------------------- |
| `RoNumber` | Yes | `12345` | The repair order number for which to retrieve parts |
---
## Response Mapping (`rey_RomeGetPartsResp`)
### Structure
| Element | Description | Multiplicity |
| ----------------- | ---------------------------- | ------------ |
| `ApplicationArea` | Standard header | 1 |
| `GenTransStatus` | Transaction status block | 1 |
| `RoParts` | The returned parts record(s) | 1..N |
---
### RoParts Elements
| Element | Example | Description |
| ----------------- | ---------- | ---------------------------------------- |
| `PartNumber` | `FO12345` | Part number |
| `PartDescription` | `Gasket` | Description |
| `QuantityOrdered` | `2` | Quantity ordered |
| `QuantityShipped` | `2` | Quantity shipped |
| `Price` | `35.00` | Retail price |
| `Cost` | `25.00` | Dealer cost |
| `ProcessedFlag` | `Y` or `N` | Indicates whether part processed into RO |
| `AddOrDelete` | `A` or `D` | Whether the part was added or deleted |
> **Note:** A `ProcessedFlag` of `"N"` indicates a part was added via the API but not yet finalized in ERA Program 2525 (not sold). These parts are “echoed” back so the client does not mistake them for deleted ones.
---
## Transaction Status (`GenTransStatus`)
| Attribute | Possible Values | Example | Description |
| ------------ | -------------------- | ---------------------------- | ---------------------- |
| `Status` | `Success`, `Failure` | `"Success"` | Indicates outcome |
| `StatusCode` | Integer | `"0"` | Numeric status code |
| Text Node | Optional | `"No matching record found"` | Human-readable message |
---
## Return Codes (subset)
| Code | Meaning |
| ------ | ------------------------- |
| `0` | Success |
| `3` | Record locked |
| `10` | Required record not found |
| `201` | Required data missing |
| `202` | Validation error |
| `519` | No part available |
| `9999` | Undefined error |
---
## Example XMLs
### Request
```xml
<rey_RomeGetPartsReq revision="1.0" xmlns="http://www.starstandards.org/STAR">
<ApplicationArea>
<BODId>ef097f3a-01b2-1eca-b12a-80048cbb74f3</BODId>
<CreationDateTime>2025-10-07T16:00:00Z</CreationDateTime>
<Sender>
<Component>Rome</Component>
<Task>RCT</Task>
<ReferenceId>Query</ReferenceId>
<CreatorNameCode>RCI</CreatorNameCode>
<SenderNameCode>RCI</SenderNameCode>
</Sender>
<Destination>
<DestinationNameCode>RR</DestinationNameCode>
<DealerNumber>PPERASV02000000</DealerNumber>
<StoreNumber>05</StoreNumber>
<AreaNumber>03</AreaNumber>
</Destination>
</ApplicationArea>
<RoInfo RoNumber="12345"/>
</rey_RomeGetPartsReq>
```
### Response
```xml
<rey_RomeGetPartsResp revision="1.0" xmlns="http://www.starstandards.org/STAR">
<ApplicationArea>
<BODId>ef097f3a-01b2-1eca-b12a-80048cbb74f3</BODId>
<CreationDateTime>2025-10-07T16:00:01Z</CreationDateTime>
<Sender>
<Component>RCT</Component>
<Task>RCT</Task>
<ReferenceId>Update</ReferenceId>
<CreatorNameCode>RCI</CreatorNameCode>
<SenderNameCode>RCI</SenderNameCode>
</Sender>
<Destination>
<DestinationNameCode>RR</DestinationNameCode>
<DealerNumber>PPERASV02000000</DealerNumber>
<StoreNumber>05</StoreNumber>
<AreaNumber>03</AreaNumber>
</Destination>
</ApplicationArea>
<GenTransStatus Status="Success" StatusCode="0"/>
<RoParts>
<PartNumber>FO12345</PartNumber>
<PartDescription>Gasket</PartDescription>
<QuantityOrdered>2</QuantityOrdered>
<QuantityShipped>2</QuantityShipped>
<Price>35.00</Price>
<Cost>25.00</Cost>
<ProcessedFlag>Y</ProcessedFlag>
<AddOrDelete>A</AddOrDelete>
</RoParts>
</rey_RomeGetPartsResp>
```
---
## Implementation Notes for ImEX/Rome
**Request**
* Always include `RoNumber`.
* `BODId` must be a unique GUID.
* Set correct DMS routing (dealer/store/branch).
* Validate against XSD before sending.
**Response**
* Parse `GenTransStatus.Status` and `StatusCode`.
* If `519` (no part available), handle gracefully.
* `ProcessedFlag="N"` parts should not be treated as active.
* Cache parts data locally for quick access.
**Error Handling**
* Log `BODId`, `StatusCode`, and XML payloads.
* Retry transient network errors; not logical ones (e.g., 519, 10).
---

View File

@@ -0,0 +1,84 @@
## 🧩 **Rome Service Vehicle Insert — Developer Integration Summary**
### **Purpose & Scope**
This interface allows third-party systems (like your Rome middleware) to insert a new *Service Vehicle* record into the Reynolds & Reynolds DMS.
The DMS will validate the provided vehicle and customer data, create the record if valid, and respond with a status of `Success` or `Failure`.
---
### **Core Workflow**
1. **POST** a SOAP request to the Reynolds endpoint (`ProcessMessage`).
2. Include the XML payload structured as `rey_RomeServVehicleInsertRequest`.
3. Receive `rey_RomeServVehicleInsertResponse` with:
* Transmission status (`GenTransStatus`),
* Optional `StatusCode` from the return codes table (Appendix E).
---
### **Request (`rey_RomeServVehicleInsertRequest`)**
**Sections:**
* **ApplicationArea**
* Metadata such as `CreationDateTime`, `BODId`, and sender/destination details.
* **Vehicle**
* Basic vehicle identity fields (`Vin`, `VehicleMake`, `VehicleYear`, etc.).
* Sub-element `VehicleDetail` for mechanical attributes (`Aircond`, `EngineConfig`, etc.).
* **VehicleServInfo**
* Operational context: stock ID, customer number, advisor, warranty, production dates, etc.
* Includes sub-elements:
* `VehExtWarranty` (contract #, expiration date/mileage)
* `Advisor``ContactInfo` (NameRecId)
**Required core fields**
* `Vin` (validated via `GEVINVAL`)
* `VehicleMake`, `VehicleYear`, `ModelDesc`, `Carline`
* `CustomerNo` (must pre-exist)
* `SalesmanNo` (valid advisor)
* `InServiceDate` ≤ current date
* `TeamCode` must exist in `MECHANICS` file
---
### **Response (`rey_RomeServVehicleInsertResponse`)**
**Elements:**
* `ApplicationArea` mirrors request metadata.
* `GenTransStatus` attributes:
* `Status` = `Success` | `Failure`
* `StatusCode` = numeric code (see Appendix E)
---
### **Error Codes (Appendix E Highlights)**
| Code | Meaning |
| ------ | ----------------------------------------------------- |
| `0` | Success |
| `300` | Vehicle already exists |
| `301` | Invalid make or ownership not established |
| `502` | Advisor was terminated |
| `506` | Mileage must be greater than last mileage |
| `513` | VIN must be added to ERA2 before an RO can be created |
| `9999` | Undefined error |
---
### **Implementation Notes**
* Endpoint authentication and URL differ between **test** and **production**.
* Ensure all date fields follow format `MM/DD/YYYYThh:mm:ssZ(EST)` (local dealer time).
* Use `GUID` for `BODId` to ensure message traceability.
* Validate VIN before submission; rejected VINs halt insertion.
---

View File

@@ -0,0 +1,59 @@
# Rome Search Customer Service Vehicle Combined (v1.1, May 2015) — Full Synapse
**What it does:** one-shot search that returns **customer identity + all matching service vehicles** based on exactly **one** of the permitted search patterns (e.g., `NameRecId`, `FullName`, `Phone`, `Partial VIN`, `Stock #`, `License #`, or `FullName/LName + Model triple`). Results include customer contact info and each vehicles details and service metadata.
## Transport
* **SOAP/HTTPS** to RCI `ProcessMessage`, separate **test** and **prod** endpoints/credentials.
* Standard HTTP response codes; XML payloads validate against request/response XSDs.
## Trigger & allowed search modes
Pick **exactly one** of these (no mixing):
1. `Last Name + Partial VIN`
2. `Full Name + Partial VIN`
3. `Last Name + Phone`
4. `Full Name + Phone`
5. `Full Name` (alone)
6. `NameRecId` (alone)
7. `Phone` (alone)
8. `Phone + Partial VIN`
9. `Last Name + (Make, Model, Year)`
10. `Full Name + (Make, Model, Year)`
11. `Vehicle Stock #` (alone)
12. `Vehicle License #` (alone)
13. `Partial or Full VIN` (alone)
Business customers only match with `NameRecId`, `Phone`, `Stock #`, `License #`, `Phone+Partial VIN`, or `Partial/Full VIN`.
## Request (`rey_RomeCustServVehCombReq`)
* **`ApplicationArea`**: `Sender` (Component=`Rome`, Task=`CVC`, CreatorNameCode=`RCI`, SenderNameCode=`RCI`), `CreationDateTime` (`yyyy-mm-ddThh:mm:ssZ`), optional `BODId` (GUID), `Destination` (DestinationNameCode=`RR`, plus dealer/store/area routing).
* **`CustServVehCombReq`**:
* `QueryData`: one of `LName`, `FullName(FName,LName,MName)`, `NameRecId(CustIdStart)`, `Phone(Num)`, `PartVIN(Vin)`, `StkNo(VehId)`, `LicenseNum(LicNo)`; optional `MaxRecs` (≤ 50).
* `VehData`: `MakePfx` (2-char make), `Model` (carline/description match), `Year` (2 or 4).
* `OtherCriteria` present but “not used”.
## Response (`rey_RomeCustServVehComb`)
* **`ApplicationArea`** (Sender typically `RR`, Task=`CVC`, etc.) and **`TransStatus`** with `Status`=`Success|Failure`, `StatusCode` (numeric), and optional message text.
* **`CustServVehComb`** records (0..n), each with:
* **`NameContactId`**: `NameId` (`IBFlag` `I|B`, individual or business name + optional `NameRecId`), plus repeating `Address`, `ContactOptions`, `Phone`, `Email`.
* **`ServVehicle`** (0..n): `Vehicle` (VIN, Make, Year, Model, Carline, color, detail attrs), and `VehicleServInfo` (attributes for StockID, CustomerNo, Service history fields; children: `VehExtWarranty`, `Advisor.ContactInfo@NameRecId`, `VehServComments*`).
## Return codes (subset)
* `0` Success; `201` Required data missing; `202` Validation error; `213` No matching records; `9999` Undefined error. (Use `TransStatus@StatusCode` + text to decide UX.)
## Implementation checklist
* Build one of the **allowed** queries; if multiple criteria are supplied, RCI treats it as invalid.
* Generate **`BODId`** GUID per call; log it for tracing.
* Fill **routing** (`DealerNumber`, `StoreNumber`, `AreaNumber`) for the target store/branch.
* Enforce `MaxRecs` (default is 1; if >1 results and `MaxRecs` omitted, API returns “multiple exist” error).
* XSD-validate request/response; map `TransStatus` to domain errors; return empty list on `213`.
---

View File

@@ -0,0 +1,76 @@
# Rome Update Body Shop Management Repair Order (v1.6, Jan 2016) — Full Synapse
**Purpose**
This interface allows a Body Shop Management (BSM) system to update an existing *Repair Order (RO)* in the Reynolds & Reynolds DMS. It covers updates to general RO details, labor operations, parts, GOG (gas, oil, grease) items, and miscellaneous charges .
---
## 🧩 Core Workflow
1. **BSM System → RCI Gateway → Reynolds DMS**
* BSM sends a SOAP/XML request (`rey_RomeUpdateBSMRepairOrderReq`) to RCI.
* DMS validates and processes the update.
* DMS replies with `rey_RomeUpdateBSMRepairOrderResp`.
2. **Supported updates**
* Comments, tax codes, and estimate type.
* Labor operation details (e.g., billing rates, opcodes).
* Parts (add, delete, modify).
* GOG and Misc items with financial attributes.
---
## 🧱 Request Structure — `rey_RomeUpdateBSMRepairOrderReq`
| Section | Description | |
| ------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------- |
| **ApplicationArea** | Identifies sender (`Rome/RCI`), creation time, and destination dealer/store. | |
| **RoRecord** | Main data payload, with attribute `FinalUpdate="Y | N"`. Includes general, labor, part, GOG, and misc subsections. |
### RoRecord subsections
* **Rogen:** Header data — `RoNo`, `CustNo`, `TagNo`, mileage, and optional `RoCommentInfo`, `EstimateInfo`, and `TaxCodeInfo`.
* **Rolabor:** One or more `OpCodeLaborInfo` nodes containing:
* `OpCode`, `JobNo`, and pay type flags (`Cust`, `Intr`, `Warr`).
* Nested `BillTimeRateHrs`, `CCCStmts` (Cause/Complaint/Correction), and `RoAmts` (billing amounts).
* **Ropart:** Job-linked `PartInfoByJob` with `OSDPartDetail` items.
* **Rogog:** “Gas/Oil/Grease” lines (`AllGogOpCodeInfo``AllGogLineItmInfo`).
* **Romisc:** Miscellaneous charge sections (`MiscOpCodeInfo``MiscLineItmInfo`).
---
## 📤 Response Structure — `rey_RomeUpdateBSMRepairOrderResp`
| Element | Description | |
| ------------------- | ---------------------------------------------------------------------------------------- | --------------------------------- |
| **ApplicationArea** | Mirrors the request metadata (sender now `ERA/RR`). | |
| **GenTransStatus** | `Status="Success | Failure"`and numeric`StatusCode`. |
| **RoRecordStatus** | Attributes include `Status`, `Date`, `Time`, `OutsdRoNo`, `DMSRoNo`, and `ErrorMessage`. | |
---
## ⚙️ Key Return Codes
| Code | Meaning |
| ------ | ---------------------- |
| `0` | Success |
| `300` | RO not found |
| `301` | Invalid RO number |
| `501` | Invalid tax code |
| `503` | Invalid opcode |
| `9999` | Undefined system error |
---
## 🧩 Implementation Notes
* **FinalUpdate="Y"** signals the RO is finalized in the DMS.
* The DMS uses **RO#, Dealer#, and Store#** to locate the target record.
* **JobNo** groups labor and parts within the same operation.
* Monetary and tax fields are sent as strings (DMS expects implicit decimal).
* Every RO update must be uniquely identified by a **BODId** (GUID).
* Validation failures trigger a response with `Status="Failure"` and `ErrorMessage` populated.

View File

@@ -1,4 +1,4 @@
<babeledit_project be_version="2.7.1" version="1.2">
<babeledit_project version="1.2" be_version="2.7.1">
<!--
BabelEdit project file
@@ -2549,27 +2549,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>confidence</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>cost_center</name>
<definition_loaded>false</definition_loaded>
@@ -3015,48 +2994,6 @@
<folder_node>
<name>errors</name>
<children>
<concept_node>
<name>calculating_totals</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>calculating_totals_generic</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>creating</name>
<definition_loaded>false</definition_loaded>
@@ -3529,310 +3466,6 @@
</translation>
</translations>
</concept_node>
<folder_node>
<name>ai</name>
<children>
<concept_node>
<name>accept_and_continue</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<folder_node>
<name>confidence</name>
<children>
<concept_node>
<name>breakdown</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>match</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>missing_data</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>ocr</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>overall</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<concept_node>
<name>disclaimer_title</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>generic_failure</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>multipage</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>processing</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>scan</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>scancomplete</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>scanfailed</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>scanstarted</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<concept_node>
<name>bill_lines</name>
<definition_loaded>false</definition_loaded>
@@ -17769,48 +17402,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>earlyrorequired</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>earlyrorequired.message</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<folder_node>
@@ -20632,27 +20223,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>gotoadmin</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>login</name>
<definition_loaded>false</definition_loaded>
@@ -21450,27 +21020,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>apply</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>areyousure</name>
<definition_loaded>false</definition_loaded>
@@ -21513,27 +21062,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>beta</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>cancel</name>
<definition_loaded>false</definition_loaded>
@@ -27132,27 +26660,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>convertwithoutearlyro</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>createiou</name>
<definition_loaded>false</definition_loaded>
@@ -27240,27 +26747,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>createearlyro</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>createnewcustomer</name>
<definition_loaded>false</definition_loaded>
@@ -27392,27 +26878,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>update_ro</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>usegeneric</name>
<definition_loaded>false</definition_loaded>
@@ -30493,27 +29958,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>customer</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>dms_make</name>
<definition_loaded>false</definition_loaded>
@@ -30918,90 +30362,6 @@
</concept_node>
</children>
</folder_node>
<concept_node>
<name>rr_opcode</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>rr_opcode_base</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>rr_opcode_prefix</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>rr_opcode_suffix</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>sale</name>
<definition_loaded>false</definition_loaded>
@@ -36606,74 +35966,6 @@
</translation>
</translations>
</concept_node>
<folder_node>
<name>earlyro</name>
<children>
<concept_node>
<name>created</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>fields</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>willupdate</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
</children>
</folder_node>
<concept_node>
<name>invoicedatefuture</name>
<definition_loaded>false</definition_loaded>
@@ -39767,27 +39059,6 @@
</translation>
</translations>
</concept_node>
<concept_node>
<name>early_ro_created</name>
<definition_loaded>false</definition_loaded>
<description></description>
<comment></comment>
<default_text></default_text>
<translations>
<translation>
<language>en-US</language>
<approved>false</approved>
</translation>
<translation>
<language>es-MX</language>
<approved>false</approved>
</translation>
<translation>
<language>fr-CA</language>
<approved>false</approved>
</translation>
</translations>
</concept_node>
<concept_node>
<name>exported</name>
<definition_loaded>false</definition_loaded>

View File

@@ -1,203 +0,0 @@
import { Button, Tag, Modal, Typography } from "antd";
import axios from "axios";
import { useState } from "react";
import { FaWandMagicSparkles } from "react-icons/fa6";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { selectBillEnterModal } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
bodyshop: selectBodyshop
});
function BillEnterAiScan({
billEnterModal,
bodyshop,
pollingIntervalRef,
setPollingIntervalRef,
form,
fileInputRef,
scanLoading,
setScanLoading,
setIsAiScan
}) {
const notification = useNotification();
const { t } = useTranslation();
const [showBetaModal, setShowBetaModal] = useState(false);
const BETA_ACCEPTANCE_KEY = "ai_scan_beta_acceptance";
const handleBetaAcceptance = () => {
localStorage.setItem(BETA_ACCEPTANCE_KEY, "true");
setShowBetaModal(false);
fileInputRef.current?.click();
};
const checkBetaAcceptance = () => {
const hasAccepted = localStorage.getItem(BETA_ACCEPTANCE_KEY);
if (hasAccepted) {
fileInputRef.current?.click();
} else {
setShowBetaModal(true);
}
};
// Polling function for multipage PDF status
const pollJobStatus = async (textractJobId) => {
try {
const { data } = await axios.get(`/ai/bill-ocr/status/${textractJobId}`);
if (data.status === "COMPLETED") {
// Stop polling
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
setPollingIntervalRef(null);
}
setScanLoading(false);
// Update form with the extracted data
if (data?.data?.billForm) {
form.setFieldsValue(data.data.billForm);
await form.validateFields(["billlines"], { recursive: true });
notification.success({
title: t("bills.labels.ai.scancomplete")
});
}
} else if (data.status === "FAILED") {
// Stop polling on failure
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
setPollingIntervalRef(null);
}
setScanLoading(false);
notification.error({
title: t("bills.labels.ai.scanfailed"),
description: data.error || ""
});
}
// If status is IN_PROGRESS, continue polling
} catch (error) {
// Stop polling on error
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
setPollingIntervalRef(null);
}
setScanLoading(false);
notification.error({
title: t("bills.labels.ai.scanfailed"),
description: error.response?.data?.message || error.message || "Failed to check scan status"
});
}
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*,application/pdf"
style={{ display: "none" }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
setScanLoading(true);
setIsAiScan(true);
const formdata = new FormData();
formdata.append("billScan", file);
formdata.append("jobid", form.getFieldValue("jobid") || billEnterModal.context.job?.id);
formdata.append("bodyshopid", bodyshop.id);
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
try {
const { data, status } = await axios.post("/ai/bill-ocr", formdata);
// Add the scanned file to the upload field
const currentUploads = form.getFieldValue("upload") || [];
form.setFieldValue("upload", [
...currentUploads,
{
uid: `ai-scan-${Date.now()}`,
name: file.name,
originFileObj: file,
status: "done"
}
]);
if (status === 202) {
// Multipage PDF - start polling
notification.info({
title: t("bills.labels.ai.scanstarted"),
description: t("bills.labels.ai.multipage")
});
//Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up.
setPollingIntervalRef(
setInterval(() => {
pollJobStatus(data.textractJobId);
}, 3000)
);
// Initial poll
pollJobStatus(data.textractJobId);
} else if (status === 200) {
// Single page - immediate response
setScanLoading(false);
form.setFieldsValue(data.data.billForm);
await form.validateFields(["billlines"], { recursive: true });
notification.success({
title: t("bills.labels.ai.scancomplete")
});
}
} catch (error) {
setScanLoading(false);
notification.error({
title: t("bills.labels.ai.scanfailed"),
description: error.response?.data?.message || error.message || t("bills.labels.ai.generic_failure")
});
}
}
// Reset the input so the same file can be selected again
e.target.value = "";
}}
/>
<Button onClick={checkBetaAcceptance} icon={<FaWandMagicSparkles />} loading={scanLoading} disabled={scanLoading}>
{scanLoading ? t("bills.labels.ai.processing") : t("bills.labels.ai.scan")}
<Tag color="red">{t("general.labels.beta")}</Tag>
</Button>
<Modal
title={t("bills.labels.ai.disclaimer_title")}
open={showBetaModal}
onOk={handleBetaAcceptance}
onCancel={() => setShowBetaModal(false)}
okText={t("bills.labels.ai.accept_and_continue")}
cancelText={t("general.actions.cancel")}
>
{
//This is explicitly not translated.
}
<Typography.Text>
This AI scanning feature is currently in <strong>beta</strong>. While it can accelerate data entry, you{" "}
<strong>must carefully review all extracted results</strong> for accuracy.
</Typography.Text>
<Typography.Text>The AI may make mistakes or miss information. Always verify:</Typography.Text>
<ul>
<li>All line items and quantities</li>
<li>Prices and totals</li>
<li>Part numbers and descriptions</li>
<li>Any other critical invoice details</li>
</ul>
<Typography.Text>
By continuing, you acknowledge that you will review and verify all AI-generated data before posting.
</Typography.Text>
</Modal>
</>
);
}
export default connect(mapStateToProps, null)(BillEnterAiScan);

View File

@@ -2,11 +2,10 @@ import { useApolloClient, useMutation } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space } from "antd";
import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
@@ -22,12 +21,12 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import confirmDialog from "../../utils/asyncConfirm";
import useLocalStorage from "../../utils/useLocalStorage";
import BillEnterAiScan from "../bill-enter-ai-scan/bill-enter-ai-scan.component.jsx";
import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -51,22 +50,18 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
const [loading, setLoading] = useState(false);
const [scanLoading, setScanLoading] = useState(false);
const [isAiScan, setIsAiScan] = useState(false);
const client = useApolloClient();
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
const notification = useNotification();
const fileInputRef = useRef(null);
const pollingIntervalRef = useRef(null);
const {
treatments: { Enhanced_Payroll, Imgproxy, Bill_OCR_AI }
treatments: { Enhanced_Payroll, Imgproxy }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Enhanced_Payroll", "Imgproxy", "Bill_OCR_AI"],
names: ["Enhanced_Payroll", "Imgproxy"],
splitKey: bodyshop.imexshopid
});
const formValues = useMemo(() => {
return {
...billEnterModal.context.bill,
@@ -118,8 +113,6 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
create_ppc,
// eslint-disable-next-line no-unused-vars
original_actual_price,
// eslint-disable-next-line no-unused-vars
confidence,
...restI
} = i;
@@ -385,7 +378,6 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
vendorid: values.vendorid,
billlines: []
});
setIsAiScan(false);
// form.resetFields();
} else {
toggleModalVisible();
@@ -396,22 +388,10 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const handleCancel = () => {
const r = window.confirm(t("general.labels.cancel"));
if (r === true) {
// Clean up polling on cancel
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setScanLoading(false);
setIsAiScan(false);
toggleModalVisible();
}
};
//Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up.
const setPollingIntervalRef = (func) => {
pollingIntervalRef.current = func;
};
useEffect(() => {
if (enterAgain) form.submit();
}, [enterAgain, form]);
@@ -421,44 +401,12 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
form.setFieldsValue(formValues);
} else {
form.resetFields();
// Clean up polling on modal close
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
setScanLoading(false);
setIsAiScan(false);
}
}, [billEnterModal.open, form, formValues]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, []);
return (
<Modal
title={
<Space size="large">
{t("bills.labels.new")}
{Bill_OCR_AI.treatment === "on" && (
<BillEnterAiScan
fileInputRef={fileInputRef}
form={form}
pollingIntervalRef={pollingIntervalRef}
setPollingIntervalRef={setPollingIntervalRef}
scanLoading={scanLoading}
setScanLoading={setScanLoading}
setIsAiScan={setIsAiScan}
/>
)}
</Space>
}
title={t("bills.labels.new")}
width={"98%"}
open={billEnterModal.open}
okText={t("general.actions.save")}
@@ -504,11 +452,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}}
>
<RbacWrapper action="bills:enter">
<BillFormContainer
form={form}
isAiScan={isAiScan}
disableInvNumber={billEnterModal.context.disableInvNumber}
/>
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
</RbacWrapper>
</Form>
</Modal>

View File

@@ -8,15 +8,12 @@ import { MdOpenInNew } from "react-icons/md";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { CHECK_BILL_INVOICE_NUMBER } from "../../graphql/bills.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AlertComponent from "../alert/alert.component";
import BillFormLinesExtended from "../bill-form-lines-extended/bill-form-lines-extended.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import JobSearchSelect from "../job-search-select/job-search-select.component";
@@ -24,6 +21,8 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import BillFormLines from "./bill-form.lines.component";
import { CalculateBillTotal } from "./bill-form.totals.utility";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -44,14 +43,11 @@ export function BillFormComponent({
loadOutstandingReturns,
loadInventory,
preferredMake,
disableInHouse,
isAiScan
disableInHouse
}) {
const { t } = useTranslation();
const client = useApolloClient();
const [discount, setDiscount] = useState(0);
const notification = useNotification();
const jobIdFormWatch = Form.useWatch("jobid", form);
const {
treatments: { Extended_Bill_Posting, ClosingPeriod }
@@ -127,23 +123,6 @@ export function BillFormComponent({
bodyshop.inhousevendorid
]);
useEffect(() => {
// When the jobid is set by AI scan, we need to reload the lines. This prevents having to hoist the apollo query.
if (jobIdFormWatch !== null) {
if (form.getFieldValue("jobid") !== null && form.getFieldValue("jobid") !== undefined) {
loadLines({ variables: { id: form.getFieldValue("jobid") } });
if (form.getFieldValue("vendorid") !== null && form.getFieldValue("vendorid") !== undefined) {
loadOutstandingReturns({
variables: {
jobId: form.getFieldValue("jobid"),
vendorId: form.getFieldValue("vendorid")
}
});
}
}
}
}, [jobIdFormWatch, form]);
return (
<div>
<FormFieldsChanged form={form} />
@@ -395,15 +374,7 @@ export function BillFormComponent({
]);
let totals;
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
try {
totals = CalculateBillTotal(values);
} catch (error) {
notification.error({
title: t("bills.errors.calculating_totals"),
message: error.message || t("bills.errors.calculating_totals_generic"),
key: "bill_totals_calculation_error"
});
}
totals = CalculateBillTotal(values);
}
if (totals) {
@@ -481,7 +452,6 @@ export function BillFormComponent({
responsibilityCenters={responsibilityCenters}
disabled={disabled}
billEdit={billEdit}
isAiScan={isAiScan}
/>
)}
<Divider titlePlacement="left" style={{ display: billEdit ? "none" : null }}>

View File

@@ -15,7 +15,7 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableInvNumber, disableInHouse,isAiScan }) {
export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableInvNumber, disableInHouse }) {
const {
treatments: { Simple_Inventory }
} = useTreatmentsWithConfig({
@@ -50,7 +50,6 @@ export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableI
loadOutstandingReturns={loadOutstandingReturns}
loadInventory={loadInventory}
preferredMake={lineData ? lineData.jobs_by_pk.v_make_desc : null}
isAiScan={isAiScan}
/>
{!billEdit && <BillCmdReturnsTableComponent form={form} returnLoading={returnLoading} returnData={returnData} />}
{Simple_Inventory.treatment === "on" && (

View File

@@ -5,15 +5,14 @@ import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectDarkMode } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { selectDarkMode } from "../../redux/application/application.selectors";
import CiecaSelect from "../../utils/Ciecaselect";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import ConfidenceDisplay from "./bill-form.lines.confidence.component.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
@@ -30,8 +29,7 @@ export function BillEnterModalLinesComponent({
discount,
form,
responsibilityCenters,
billEdit,
isAiScan
billEdit
}) {
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
@@ -141,29 +139,6 @@ export function BillEnterModalLinesComponent({
const columns = (remove) => {
return [
...(isAiScan
? [
{
title: t("billlines.fields.confidence"),
dataIndex: "confidence",
editable: true,
width: "5rem",
formItemProps: (field) => ({
key: `${field.index}confidence`,
name: [field.name, "confidence"],
label: t("billlines.fields.confidence")
}),
formInput: (record) => {
const rowValue = getFieldValue(["billlines", record.name]);
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<ConfidenceDisplay rowValue={rowValue} />
</div>
);
}
}
]
: []),
{
title: t("billlines.fields.jobline"),
dataIndex: "joblineid",
@@ -237,7 +212,6 @@ export function BillEnterModalLinesComponent({
}),
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
},
{
title: t("billlines.fields.quantity"),
dataIndex: "quantity",
@@ -276,16 +250,7 @@ export function BillEnterModalLinesComponent({
key: `${field.name}actual_price`,
name: [field.name, "actual_price"],
label: t("billlines.fields.actual_price"),
rules: [
{ required: true },
{
validator: (_, value) => {
return Math.abs(parseFloat(value)) < 0.01 ? Promise.reject() : Promise.resolve();
},
warningOnly: true
}
],
hasFeedback: true
rules: [{ required: true }]
}),
formInput: (record, index) => (
<CurrencyInput

View File

@@ -1,87 +0,0 @@
import { Progress, Space, Tag, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
const parseConfidence = (confidenceStr) => {
if (!confidenceStr || typeof confidenceStr !== "string") return null;
const match = confidenceStr.match(/T([\d.]+)\s*-\s*O([\d.]+)\s*-\s*J([\d.]+)/);
if (!match) return null;
return {
total: parseFloat(match[1]),
ocr: parseFloat(match[2]),
jobMatch: parseFloat(match[3])
};
};
const getConfidenceColor = (value) => {
if (value >= 80) return "green";
if (value >= 60) return "orange";
if (value >= 40) return "gold";
return "red";
};
const ConfidenceDisplay = ({ rowValue: { confidence, actual_price, actual_cost } }) => {
const { t } = useTranslation();
const parsed = parseConfidence(confidence);
const parsed_actual_price = parseFloat(actual_price);
const parsed_actual_cost = parseFloat(actual_cost);
if (!parsed) {
return <span style={{ color: "#959595", fontSize: "0.85em" }}>N/A</span>;
}
const { total, ocr, jobMatch } = parsed;
const color = getConfidenceColor(total);
return (
<Tooltip
title={
<div style={{ padding: "4px 0" }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t("bills.labels.ai.confidence.breakdown")}</div>
<div style={{ marginBottom: 4 }}>
<strong>{t("bills.labels.ai.confidence.overall")}:</strong> {total.toFixed(1)}%
<Progress
percent={total}
size="small"
strokeColor={getConfidenceColor(total)}
showInfo={false}
style={{ marginTop: 2 }}
/>
</div>
<div style={{ marginBottom: 4 }}>
<strong>{t("bills.labels.ai.confidence.ocr")}:</strong> {ocr.toFixed(1)}%
<Progress
percent={ocr}
size="small"
strokeColor={getConfidenceColor(ocr)}
showInfo={false}
style={{ marginTop: 2 }}
/>
</div>
<div>
<strong>{t("bills.labels.ai.confidence.match")}:</strong> {jobMatch.toFixed(1)}%
<Progress
percent={jobMatch}
size="small"
strokeColor={getConfidenceColor(jobMatch)}
showInfo={false}
style={{ marginTop: 2 }}
/>
</div>
</div>
}
>
<Space size="small">
{!parsed_actual_cost || !parsed_actual_price || parsed_actual_cost === 0 || parsed_actual_price === 0 ? (
<Tag color="red" style={{ margin: 0, cursor: "help", userSelect: "none" }}>
{t("bills.labels.ai.confidence.missing_data")}
</Tag>
) : null}
<Tag color={color} style={{ margin: 0, cursor: "help", userSelect: "none" }}>
{total.toFixed(0)}%
</Tag>
</Space>
</Tooltip>
);
};
export default ConfidenceDisplay;

View File

@@ -187,7 +187,6 @@ export function PartsOrderListTableDrawerComponent({
actions: { refetch: refetch },
context: {
job: job,
parts_order: { id: record.id },
bill: {
vendorid: record.vendor.id,
is_credit_memo: record.return,

View File

@@ -162,7 +162,6 @@ export function PartsOrderListTableComponent({
actions: { refetch: refetch },
context: {
job: job,
parts_order: { id: record.id },
bill: {
vendorid: record.vendor.id,
is_credit_memo: record.return,

View File

@@ -10,19 +10,11 @@ const { Option } = Select;
const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, preferredMake, showPhone, ref }) => {
const [option, setOption] = useState(value);
// Sync internal state when value prop changes (e.g., from form.setFieldsValue)
useEffect(() => {
if (value !== option) {
setOption(value);
if (value !== option && onChange) {
onChange(option);
}
}, [value, option]);
const handleChange = (newValue) => {
setOption(newValue);
if (onChange) {
onChange(newValue);
}
};
}, [value, option, onChange]);
const favorites =
preferredMake && options
@@ -66,7 +58,7 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
);
}}
popupMatchSelectWidth={false}
onChange={handleChange}
onChange={setOption}
optionFilterProp="name"
onSelect={onSelect}
disabled={disabled || false}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import SocketIO from "socket.io-client";
import { auth } from "../../firebase/firebase.utils";
import { store } from "../../redux/store";
@@ -18,6 +18,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
const LIMIT = INITIAL_NOTIFICATIONS;
const TOKEN_SYNC_INTERVAL_MS = 10 * 60 * 1000;
/**
* Socket Provider - Scenario Notifications / Web Socket related items
@@ -30,6 +31,7 @@ const LIMIT = INITIAL_NOTIFICATIONS;
*/
const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
const socketRef = useRef(null);
const tokenSyncIntervalRef = useRef(null);
const [clientId, setClientId] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const notification = useNotification();
@@ -147,6 +149,30 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
onError: (err) => console.error("MARK_ALL_NOTIFICATIONS_READ error:", err)
});
const reconnectSocket = useCallback(
async ({ forceRefreshToken = true } = {}) => {
const socketInstance = socketRef.current;
if (!socketInstance || !auth.currentUser || !bodyshop?.id) return false;
try {
const token = await auth.currentUser.getIdToken(forceRefreshToken);
socketInstance.auth = { token, bodyshopId: bodyshop.id };
if (socketInstance.connected) {
socketInstance.emit("update-token", { token, bodyshopId: bodyshop.id });
}
socketInstance.disconnect();
socketInstance.connect();
return true;
} catch (error) {
console.error("Socket reconnect failed:", error?.message || error);
return false;
}
},
[bodyshop?.id]
);
useEffect(() => {
const initializeSocket = async (token) => {
if (!bodyshop?.id || socketRef.current) return;
@@ -254,25 +280,60 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
}
};
const syncCurrentTokenToSocket = async () => {
try {
if (!auth.currentUser || !bodyshop?.id) return;
const token = await auth.currentUser.getIdToken();
socketInstance.auth = { token, bodyshopId: bodyshop.id };
socketInstance.emit("update-token", { token, bodyshopId: bodyshop.id });
} catch (error) {
console.error("Failed to sync token to socket:", error?.message || error);
}
};
const forceRefreshAndSyncToken = async () => {
try {
if (!auth.currentUser || !bodyshop?.id) return;
const token = await auth.currentUser.getIdToken(true);
socketInstance.auth = { token, bodyshopId: bodyshop.id };
socketInstance.emit("update-token", { token, bodyshopId: bodyshop.id });
} catch (error) {
console.error("Failed to force-refresh token for socket:", error?.message || error);
}
};
const handleConnect = () => {
socketInstance.emit("join-bodyshop-room", bodyshop.id);
syncCurrentTokenToSocket();
setClientId(socketInstance.id);
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleReconnect = () => {
forceRefreshAndSyncToken();
setIsConnected(true);
store.dispatch(setWssStatus("connected"));
};
const handleTokenUpdated = ({ success, error }) => {
if (success) return;
const err = String(error || "");
if (/stale token|id-token-expired/i.test(err)) {
forceRefreshAndSyncToken();
}
};
const handleConnectionError = (err) => {
console.error("Socket connection error:", err);
setIsConnected(false);
if (err.message.includes("auth/id-token-expired")) {
if (err?.message?.includes("auth/id-token-expired")) {
console.warn("Token expired, refreshing...");
auth.currentUser?.getIdToken(true).then((newToken) => {
socketInstance.auth = { token: newToken };
socketInstance.auth = { token: newToken, bodyshopId: bodyshop.id };
if (socketInstance.connected) {
socketInstance.emit("update-token", { token: newToken, bodyshopId: bodyshop.id });
}
socketInstance.connect();
});
} else {
@@ -513,10 +574,23 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
socketInstance.on("notification", handleNotification);
socketInstance.on("sync-notification-read", handleSyncNotificationRead);
socketInstance.on("sync-all-notifications-read", handleSyncAllNotificationsRead);
socketInstance.on("token-updated", handleTokenUpdated);
if (tokenSyncIntervalRef.current) {
clearInterval(tokenSyncIntervalRef.current);
}
tokenSyncIntervalRef.current = setInterval(() => {
if (!socketInstance.connected) return;
syncCurrentTokenToSocket();
}, TOKEN_SYNC_INTERVAL_MS);
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (!user) {
if (tokenSyncIntervalRef.current) {
clearInterval(tokenSyncIntervalRef.current);
tokenSyncIntervalRef.current = null;
}
socketRef.current?.disconnect();
socketRef.current = null;
setIsConnected(false);
@@ -525,7 +599,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
socketRef.current.auth = { token, bodyshopId: bodyshop.id };
if (socketRef.current.connected) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
}
} else {
initializeSocket(token).catch((err) =>
console.error("Something went wrong Initializing Sockets:", err?.message || "")
@@ -535,6 +612,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
return () => {
unsubscribe();
if (tokenSyncIntervalRef.current) {
clearInterval(tokenSyncIntervalRef.current);
tokenSyncIntervalRef.current = null;
}
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
@@ -549,6 +630,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
socket: socketRef.current,
clientId,
isConnected,
reconnectSocket,
markNotificationRead,
markAllNotificationsRead,
scenarioNotificationsOn: Realtime_Notifications_UI?.treatment === "on"

View File

@@ -77,6 +77,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const { t } = useTranslation();
const [resetAfterReconnect, setResetAfterReconnect] = useState(false);
const [allocationsSummary, setAllocationsSummary] = useState(null);
const [reconnectNonce, setReconnectNonce] = useState(0);
// Compute a single normalized mode and pick the proper socket
const mode = getDmsMode(bodyshop, Fortellis.treatment); // "rr" | "fortellis" | "cdk" | "pbs" | "none"
@@ -114,7 +115,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
const notification = useNotification();
const { socket: wsssocket } = useSocket();
const { socket: wsssocket, reconnectSocket } = useSocket();
const activeSocket = useMemo(() => (isWssMode(mode) ? wsssocket : legacySocket), [mode, wsssocket]);
const [isConnected, setIsConnected] = useState(!!activeSocket?.connected);
@@ -178,6 +179,27 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
}`;
const resetKey = useMemo(() => `${mode || "none"}-${jobId || "none"}`, [mode, jobId]);
const customerSelectorKey = useMemo(() => `${resetKey}-${reconnectNonce}`, [resetKey, reconnectNonce]);
const handleReconnectClick = async () => {
setResetAfterReconnect(true);
setReconnectNonce((n) => n + 1);
if (!activeSocket) return;
if (isWssMode(mode)) {
setActiveLogLevel(logLevel);
const didReconnect = await reconnectSocket?.({ forceRefreshToken: true });
if (!didReconnect) {
activeSocket.disconnect();
setTimeout(() => activeSocket.connect(), 100);
}
return;
}
activeSocket.disconnect();
setTimeout(() => activeSocket.connect(), 100);
};
// 🔄 Hard reset of local + server-side DMS context when the page/job loads
useEffect(() => {
@@ -428,7 +450,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
// Check if Reynolds mode requires early RO
const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id);
if (isRrMode && !hasEarlyRO) {
return (
<Result
@@ -503,6 +525,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
</Col>
<DmsCustomerSelector
key={customerSelectorKey}
jobid={jobId}
job={data?.jobs_by_pk}
bodyshop={bodyshop}
@@ -549,21 +572,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
<Select.Option key="ERROR">ERROR</Select.Option>
</Select>
<Button onClick={() => setLogs([])}>Clear Logs</Button>
<Button
onClick={() => {
setLogs([]);
setResetAfterReconnect(true);
if (isWssMode(mode)) {
setActiveLogLevel(logLevel);
}
if (activeSocket) {
activeSocket.disconnect();
setTimeout(() => activeSocket.connect(), 100);
}
}}
>
Reconnect
</Button>
<Button onClick={handleReconnectClick}>Reconnect</Button>
</Space>
}
>

View File

@@ -161,7 +161,6 @@
"fields": {
"actual_cost": "Actual Cost",
"actual_price": "Retail",
"confidence": "Confidence",
"cost_center": "Cost Center",
"federal_tax_applicable": "Fed. Tax?",
"jobline": "Job Line",
@@ -192,8 +191,6 @@
"return": "Return Items"
},
"errors": {
"calculating_totals": "Error Calculating Totals",
"calculating_totals_generic": "Please ensure all fields are properly completed. ",
"creating": "Error adding bill. {{error}}",
"deleting": "Error deleting bill. {{error}}",
"existinginventoryline": "This bill cannot be deleted as it is tied to items in inventory.",
@@ -220,24 +217,6 @@
},
"labels": {
"actions": "Actions",
"ai": {
"accept_and_continue": "Accept and Continue",
"confidence": {
"breakdown": "Confidence Breakdown",
"match": "Jobline Match",
"missing_data": "Missing Data",
"ocr": "Optical Character Recognition",
"overall": "Overall"
},
"disclaimer_title": "AI Scan Beta Disclaimer",
"generic_failure": "Failed to process invoice.",
"multipage": "The is a multi-page document. Processing will take a few moments.",
"processing": "Analyzing Bill",
"scan": "AI Bill Scanner",
"scancomplete": "AI Scan Complete",
"scanfailed": "AI Scan Failed",
"scanstarted": "AI Scan Started"
},
"bill_lines": "Bill Lines",
"bill_total": "Bill Total Amount",
"billcmtotal": "Credit Memos",
@@ -1312,11 +1291,10 @@
"vehicle": "Vehicle"
},
"labels": {
"actions": "Actions",
"apply": "Apply",
"actions": "Actions",
"areyousure": "Are you sure?",
"barcode": "Barcode",
"beta": "BETA",
"cancel": "Are you sure you want to cancel? Your changes will not be saved.",
"changelog": "Change Log",
"clear": "Clear",
@@ -1844,14 +1822,14 @@
"name": "Payer Name",
"payer_type": "Payer"
},
"rr_opcode": "RR OpCode",
"rr_opcode_base": "Base",
"rr_opcode_prefix": "Prefix",
"rr_opcode_suffix": "Suffix",
"sale": "Sale",
"sale_dms_acctnumber": "Sale DMS Acct #",
"story": "Story",
"vinowner": "VIN Owner"
"vinowner": "VIN Owner",
"rr_opcode": "RR OpCode",
"rr_opcode_prefix": "Prefix",
"rr_opcode_suffix": "Suffix",
"rr_opcode_base": "Base"
},
"dms_allocation": "DMS Allocation",
"driveable": "Driveable",

View File

@@ -161,7 +161,6 @@
"fields": {
"actual_cost": "",
"actual_price": "",
"confidence": "",
"cost_center": "",
"federal_tax_applicable": "",
"jobline": "",
@@ -192,8 +191,6 @@
"return": ""
},
"errors": {
"calculating_totals": "",
"calculating_totals_generic": "",
"creating": "",
"deleting": "",
"existinginventoryline": "",
@@ -220,24 +217,6 @@
},
"labels": {
"actions": "",
"ai": {
"accept_and_continue": "",
"confidence": {
"breakdown": "",
"match": "",
"missing_data": "",
"ocr": "",
"overall": ""
},
"disclaimer_title": "",
"generic_failure": "",
"multipage": "",
"processing": "",
"scan": "",
"scancomplete": "",
"scanfailed": "",
"scanstarted": ""
},
"bill_lines": "",
"bill_total": "",
"billcmtotal": "",
@@ -1312,11 +1291,10 @@
"vehicle": ""
},
"labels": {
"actions": "Comportamiento",
"apply": "",
"actions": "Comportamiento",
"areyousure": "",
"barcode": "código de barras",
"beta": "",
"cancel": "",
"changelog": "",
"clear": "",
@@ -1844,14 +1822,14 @@
"name": "",
"payer_type": ""
},
"rr_opcode": "",
"rr_opcode_base": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
"vinowner": ""
"vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",

View File

@@ -161,7 +161,6 @@
"fields": {
"actual_cost": "",
"actual_price": "",
"confidence": "",
"cost_center": "",
"federal_tax_applicable": "",
"jobline": "",
@@ -192,8 +191,6 @@
"return": ""
},
"errors": {
"calculating_totals": "",
"calculating_totals_generic": "",
"creating": "",
"deleting": "",
"existinginventoryline": "",
@@ -220,24 +217,6 @@
},
"labels": {
"actions": "",
"ai": {
"accept_and_continue": "",
"confidence": {
"breakdown": "",
"match": "",
"missing_data": "",
"ocr": "",
"overall": ""
},
"disclaimer_title": "",
"generic_failure": "",
"multipage": "",
"processing": "",
"scan": "",
"scancomplete": "",
"scanfailed": "",
"scanstarted": ""
},
"bill_lines": "",
"bill_total": "",
"billcmtotal": "",
@@ -1312,11 +1291,10 @@
"vehicle": ""
},
"labels": {
"actions": "actes",
"apply": "",
"actions": "actes",
"areyousure": "",
"barcode": "code à barre",
"beta": "",
"cancel": "",
"changelog": "",
"clear": "",
@@ -1844,14 +1822,14 @@
"name": "",
"payer_type": ""
},
"rr_opcode": "",
"rr_opcode_base": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"sale": "",
"sale_dms_acctnumber": "",
"story": "",
"vinowner": ""
"vinowner": "",
"rr_opcode": "",
"rr_opcode_prefix": "",
"rr_opcode_suffix": "",
"rr_opcode_base": ""
},
"dms_allocation": "",
"driveable": "",

604
package-lock.json generated
View File

@@ -14,8 +14,6 @@
"@aws-sdk/client-s3": "^3.978.0",
"@aws-sdk/client-secrets-manager": "^3.978.0",
"@aws-sdk/client-ses": "^3.978.0",
"@aws-sdk/client-sqs": "^3.975.0",
"@aws-sdk/client-textract": "^3.975.0",
"@aws-sdk/credential-provider-node": "^3.972.3",
"@aws-sdk/lib-storage": "^3.978.0",
"@aws-sdk/s3-request-presigner": "^3.978.0",
@@ -39,7 +37,6 @@
"express": "^4.21.1",
"fast-xml-parser": "^5.3.4",
"firebase-admin": "^13.6.0",
"fuse.js": "^7.1.0",
"graphql": "^16.12.0",
"graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2",
@@ -54,7 +51,6 @@
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"pdf-lib": "^1.17.1",
"phone": "^3.1.70",
"query-string": "7.1.3",
"recursive-diff": "^1.0.9",
@@ -485,7 +481,6 @@
"@smithy/fetch-http-handler": "^5.3.9",
"@smithy/hash-node": "^4.2.8",
"@smithy/invalid-dependency": "^4.2.8",
"@smithy/md5-js": "^4.2.8",
"@smithy/middleware-content-length": "^4.2.8",
"@smithy/middleware-endpoint": "^4.4.12",
"@smithy/middleware-retry": "^4.4.29",
@@ -556,74 +551,7 @@
"@smithy/util-middleware": "^4.2.8",
"@smithy/util-retry": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-sqs": {
"version": "3.992.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sqs/-/client-sqs-3.992.0.tgz",
"integrity": "sha512-qRSzhtXtMFkxpjntKc286qKD4QfZL5uAzzsQqvzmdQmCdWTCpSzE5ECA5M3Ts2V4dJFFvjeR/FLIMQbEe2pqyA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/credential-provider-node": "^3.972.9",
"@aws-sdk/middleware-host-header": "^3.972.3",
"@aws-sdk/middleware-logger": "^3.972.3",
"@aws-sdk/middleware-recursion-detection": "^3.972.3",
"@aws-sdk/middleware-sdk-sqs": "^3.972.7",
"@aws-sdk/middleware-user-agent": "^3.972.10",
"@aws-sdk/region-config-resolver": "^3.972.3",
"@aws-sdk/types": "^3.973.1",
"@aws-sdk/util-endpoints": "3.992.0",
"@aws-sdk/util-user-agent-browser": "^3.972.3",
"@aws-sdk/util-user-agent-node": "^3.972.8",
"@smithy/config-resolver": "^4.4.6",
"@smithy/core": "^3.23.0",
"@smithy/fetch-http-handler": "^5.3.9",
"@smithy/hash-node": "^4.2.8",
"@smithy/invalid-dependency": "^4.2.8",
"@smithy/md5-js": "^4.2.8",
"@smithy/middleware-content-length": "^4.2.8",
"@smithy/middleware-endpoint": "^4.4.14",
"@smithy/middleware-retry": "^4.4.31",
"@smithy/middleware-serde": "^4.2.9",
"@smithy/middleware-stack": "^4.2.8",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/node-http-handler": "^4.4.10",
"@smithy/protocol-http": "^5.3.8",
"@smithy/smithy-client": "^4.11.3",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
"@smithy/util-defaults-mode-browser": "^4.3.30",
"@smithy/util-defaults-mode-node": "^4.2.33",
"@smithy/util-endpoints": "^3.2.8",
"@smithy/util-middleware": "^4.2.8",
"@smithy/util-retry": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-endpoints": {
"version": "3.992.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.992.0.tgz",
"integrity": "sha512-FHgdMVbTZ2Lu7hEIoGYfkd5UazNSsAgPcupEnh15vsWKFKhuw6w/6tM1k/yNaa7l1wx0Wt1UuK0m+gQ0BJpuvg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-endpoints": "^3.2.8",
"@smithy/util-waiter": "^4.2.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -631,44 +559,44 @@
}
},
"node_modules/@aws-sdk/client-sso": {
"version": "3.990.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.990.0.tgz",
"integrity": "sha512-xTEaPjZwOqVjGbLOP7qzwbdOWJOo1ne2mUhTZwEBBkPvNk4aXB/vcYwWwrjoSWUqtit4+GDbO75ePc/S6TUJYQ==",
"version": "3.975.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.975.0.tgz",
"integrity": "sha512-HpgJuleH7P6uILxzJKQOmlHdwaCY+xYC6VgRDzlwVEqU/HXjo4m2gOAyjUbpXlBOCWfGgMUzfBlNJ9z3MboqEQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/middleware-host-header": "^3.972.3",
"@aws-sdk/middleware-logger": "^3.972.3",
"@aws-sdk/middleware-recursion-detection": "^3.972.3",
"@aws-sdk/middleware-user-agent": "^3.972.10",
"@aws-sdk/region-config-resolver": "^3.972.3",
"@aws-sdk/types": "^3.973.1",
"@aws-sdk/util-endpoints": "3.990.0",
"@aws-sdk/util-user-agent-browser": "^3.972.3",
"@aws-sdk/util-user-agent-node": "^3.972.8",
"@aws-sdk/core": "^3.973.1",
"@aws-sdk/middleware-host-header": "^3.972.1",
"@aws-sdk/middleware-logger": "^3.972.1",
"@aws-sdk/middleware-recursion-detection": "^3.972.1",
"@aws-sdk/middleware-user-agent": "^3.972.2",
"@aws-sdk/region-config-resolver": "^3.972.1",
"@aws-sdk/types": "^3.973.0",
"@aws-sdk/util-endpoints": "3.972.0",
"@aws-sdk/util-user-agent-browser": "^3.972.1",
"@aws-sdk/util-user-agent-node": "^3.972.1",
"@smithy/config-resolver": "^4.4.6",
"@smithy/core": "^3.23.0",
"@smithy/core": "^3.21.1",
"@smithy/fetch-http-handler": "^5.3.9",
"@smithy/hash-node": "^4.2.8",
"@smithy/invalid-dependency": "^4.2.8",
"@smithy/middleware-content-length": "^4.2.8",
"@smithy/middleware-endpoint": "^4.4.14",
"@smithy/middleware-retry": "^4.4.31",
"@smithy/middleware-endpoint": "^4.4.11",
"@smithy/middleware-retry": "^4.4.27",
"@smithy/middleware-serde": "^4.2.9",
"@smithy/middleware-stack": "^4.2.8",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/node-http-handler": "^4.4.10",
"@smithy/node-http-handler": "^4.4.8",
"@smithy/protocol-http": "^5.3.8",
"@smithy/smithy-client": "^4.11.3",
"@smithy/smithy-client": "^4.10.12",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
"@smithy/util-defaults-mode-browser": "^4.3.30",
"@smithy/util-defaults-mode-node": "^4.2.33",
"@smithy/util-defaults-mode-browser": "^4.3.26",
"@smithy/util-defaults-mode-node": "^4.2.29",
"@smithy/util-endpoints": "^3.2.8",
"@smithy/util-middleware": "^4.2.8",
"@smithy/util-retry": "^4.2.8",
@@ -679,102 +607,20 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": {
"version": "3.990.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.990.0.tgz",
"integrity": "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-endpoints": "^3.2.8",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-textract": {
"version": "3.992.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-textract/-/client-textract-3.992.0.tgz",
"integrity": "sha512-AXufuNr9I8nTSjGvdkqJRpAZ9J2omQpvkWZFYTAWLX3XXgpV8ILgUaw5NRKn30qytbTMY2mapx2kwgBEj/70ZQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/credential-provider-node": "^3.972.9",
"@aws-sdk/middleware-host-header": "^3.972.3",
"@aws-sdk/middleware-logger": "^3.972.3",
"@aws-sdk/middleware-recursion-detection": "^3.972.3",
"@aws-sdk/middleware-user-agent": "^3.972.10",
"@aws-sdk/region-config-resolver": "^3.972.3",
"@aws-sdk/types": "^3.973.1",
"@aws-sdk/util-endpoints": "3.992.0",
"@aws-sdk/util-user-agent-browser": "^3.972.3",
"@aws-sdk/util-user-agent-node": "^3.972.8",
"@smithy/config-resolver": "^4.4.6",
"@smithy/core": "^3.23.0",
"@smithy/fetch-http-handler": "^5.3.9",
"@smithy/hash-node": "^4.2.8",
"@smithy/invalid-dependency": "^4.2.8",
"@smithy/middleware-content-length": "^4.2.8",
"@smithy/middleware-endpoint": "^4.4.14",
"@smithy/middleware-retry": "^4.4.31",
"@smithy/middleware-serde": "^4.2.9",
"@smithy/middleware-stack": "^4.2.8",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/node-http-handler": "^4.4.10",
"@smithy/protocol-http": "^5.3.8",
"@smithy/smithy-client": "^4.11.3",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
"@smithy/util-defaults-mode-browser": "^4.3.30",
"@smithy/util-defaults-mode-node": "^4.2.33",
"@smithy/util-endpoints": "^3.2.8",
"@smithy/util-middleware": "^4.2.8",
"@smithy/util-retry": "^4.2.8",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-textract/node_modules/@aws-sdk/util-endpoints": {
"version": "3.992.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.992.0.tgz",
"integrity": "sha512-FHgdMVbTZ2Lu7hEIoGYfkd5UazNSsAgPcupEnh15vsWKFKhuw6w/6tM1k/yNaa7l1wx0Wt1UuK0m+gQ0BJpuvg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-endpoints": "^3.2.8",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.973.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.10.tgz",
"integrity": "sha512-4u/FbyyT3JqzfsESI70iFg6e2yp87MB5kS2qcxIA66m52VSTN1fvuvbCY1h/LKq1LvuxIrlJ1ItcyjvcKoaPLg==",
"version": "3.973.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.4.tgz",
"integrity": "sha512-8Rk+kPP74YiR47x54bxYlKZswsaSh0a4XvvRUMLvyS/koNawhsGu/+qSZxREqUeTO+GkKpFvSQIsAZR+deUP+g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@aws-sdk/xml-builder": "^3.972.4",
"@smithy/core": "^3.23.0",
"@aws-sdk/xml-builder": "^3.972.2",
"@smithy/core": "^3.22.0",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/property-provider": "^4.2.8",
"@smithy/protocol-http": "^5.3.8",
"@smithy/signature-v4": "^5.3.8",
"@smithy/smithy-client": "^4.11.3",
"@smithy/smithy-client": "^4.11.1",
"@smithy/types": "^4.12.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-middleware": "^4.2.8",
@@ -799,12 +645,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.8.tgz",
"integrity": "sha512-r91OOPAcHnLCSxaeu/lzZAVRCZ/CtTNuwmJkUwpwSDshUrP7bkX1OmFn2nUMWd9kN53Q4cEo8b7226G4olt2Mg==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.2.tgz",
"integrity": "sha512-wzH1EdrZsytG1xN9UHaK12J9+kfrnd2+c8y0LVoS4O4laEjPoie1qVK3k8/rZe7KOtvULzyMnO3FT4Krr9Z0Dg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/core": "^3.973.2",
"@aws-sdk/types": "^3.973.1",
"@smithy/property-provider": "^4.2.8",
"@smithy/types": "^4.12.0",
@@ -815,20 +661,20 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.10.tgz",
"integrity": "sha512-DTtuyXSWB+KetzLcWaSahLJCtTUe/3SXtlGp4ik9PCe9xD6swHEkG8n8/BNsQ9dsihb9nhFvuUB4DpdBGDcvVg==",
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.4.tgz",
"integrity": "sha512-OC7F3ipXV12QfDEWybQGHLzoeHBlAdx/nLzPfHP0Wsabu3JBffu5nlzSaJNf7to9HGtOW8Bpu8NX0ugmDrCbtw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/core": "^3.973.4",
"@aws-sdk/types": "^3.973.1",
"@smithy/fetch-http-handler": "^5.3.9",
"@smithy/node-http-handler": "^4.4.10",
"@smithy/node-http-handler": "^4.4.8",
"@smithy/property-provider": "^4.2.8",
"@smithy/protocol-http": "^5.3.8",
"@smithy/smithy-client": "^4.11.3",
"@smithy/smithy-client": "^4.11.1",
"@smithy/types": "^4.12.0",
"@smithy/util-stream": "^4.5.12",
"@smithy/util-stream": "^4.5.10",
"tslib": "^2.6.2"
},
"engines": {
@@ -836,19 +682,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.8.tgz",
"integrity": "sha512-n2dMn21gvbBIEh00E8Nb+j01U/9rSqFIamWRdGm/mE5e+vHQ9g0cBNdrYFlM6AAiryKVHZmShWT9D1JAWJ3ISw==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.2.tgz",
"integrity": "sha512-Jrb8sLm6k8+L7520irBrvCtdLxNtrG7arIxe9TCeMJt/HxqMGJdbIjw8wILzkEHLMIi4MecF2FbXCln7OT1Tag==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/credential-provider-env": "^3.972.8",
"@aws-sdk/credential-provider-http": "^3.972.10",
"@aws-sdk/credential-provider-login": "^3.972.8",
"@aws-sdk/credential-provider-process": "^3.972.8",
"@aws-sdk/credential-provider-sso": "^3.972.8",
"@aws-sdk/credential-provider-web-identity": "^3.972.8",
"@aws-sdk/nested-clients": "3.990.0",
"@aws-sdk/core": "^3.973.2",
"@aws-sdk/credential-provider-env": "^3.972.2",
"@aws-sdk/credential-provider-http": "^3.972.3",
"@aws-sdk/credential-provider-login": "^3.972.2",
"@aws-sdk/credential-provider-process": "^3.972.2",
"@aws-sdk/credential-provider-sso": "^3.972.2",
"@aws-sdk/credential-provider-web-identity": "^3.972.2",
"@aws-sdk/nested-clients": "3.975.0",
"@aws-sdk/types": "^3.973.1",
"@smithy/credential-provider-imds": "^4.2.8",
"@smithy/property-provider": "^4.2.8",
@@ -861,13 +707,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.8.tgz",
"integrity": "sha512-rMFuVids8ICge/X9DF5pRdGMIvkVhDV9IQFQ8aTYk6iF0rl9jOUa1C3kjepxiXUlpgJQT++sLZkT9n0TMLHhQw==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.2.tgz",
"integrity": "sha512-mlaw2aiI3DrimW85ZMn3g7qrtHueidS58IGytZ+mbFpsYLK5wMjCAKZQtt7VatLMtSBG/dn/EY4njbnYXIDKeQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/nested-clients": "3.990.0",
"@aws-sdk/core": "^3.973.2",
"@aws-sdk/nested-clients": "3.975.0",
"@aws-sdk/types": "^3.973.1",
"@smithy/property-provider": "^4.2.8",
"@smithy/protocol-http": "^5.3.8",
@@ -880,17 +726,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.9",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.9.tgz",
"integrity": "sha512-LfJfO0ClRAq2WsSnA9JuUsNyIicD2eyputxSlSL0EiMrtxOxELLRG6ZVYDf/a1HCepaYPXeakH4y8D5OLCauag==",
"version": "3.972.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.3.tgz",
"integrity": "sha512-iu+JwWHM7tHowKqE+8wNmI3sM6mPEiI9Egscz2BEV7adyKmV95oR9tBO4VIOl72FGDi7X9mXg19VtqIpSkEEsA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.8",
"@aws-sdk/credential-provider-http": "^3.972.10",
"@aws-sdk/credential-provider-ini": "^3.972.8",
"@aws-sdk/credential-provider-process": "^3.972.8",
"@aws-sdk/credential-provider-sso": "^3.972.8",
"@aws-sdk/credential-provider-web-identity": "^3.972.8",
"@aws-sdk/credential-provider-env": "^3.972.2",
"@aws-sdk/credential-provider-http": "^3.972.4",
"@aws-sdk/credential-provider-ini": "^3.972.2",
"@aws-sdk/credential-provider-process": "^3.972.2",
"@aws-sdk/credential-provider-sso": "^3.972.2",
"@aws-sdk/credential-provider-web-identity": "^3.972.2",
"@aws-sdk/types": "^3.973.1",
"@smithy/credential-provider-imds": "^4.2.8",
"@smithy/property-provider": "^4.2.8",
@@ -903,12 +749,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.8.tgz",
"integrity": "sha512-6cg26ffFltxM51OOS8NH7oE41EccaYiNlbd5VgUYwhiGCySLfHoGuGrLm2rMB4zhy+IO5nWIIG0HiodX8zdvHA==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.2.tgz",
"integrity": "sha512-NLKLTT7jnUe9GpQAVkPTJO+cs2FjlQDt5fArIYS7h/Iw/CvamzgGYGFRVD2SE05nOHCMwafUSi42If8esGFV+g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/core": "^3.973.2",
"@aws-sdk/types": "^3.973.1",
"@smithy/property-provider": "^4.2.8",
"@smithy/shared-ini-file-loader": "^4.4.3",
@@ -920,14 +766,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.8.tgz",
"integrity": "sha512-35kqmFOVU1n26SNv+U37sM8b2TzG8LyqAcd6iM9gprqxyHEh/8IM3gzN4Jzufs3qM6IrH8e43ryZWYdvfVzzKQ==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.2.tgz",
"integrity": "sha512-YpwDn8g3gCGUl61cCV0sRxP2pFIwg+ZsMfWQ/GalSyjXtRkctCMFA+u0yPb/Q4uTfNEiya1Y4nm0C5rIHyPW5Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/client-sso": "3.990.0",
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/token-providers": "3.990.0",
"@aws-sdk/client-sso": "3.975.0",
"@aws-sdk/core": "^3.973.2",
"@aws-sdk/token-providers": "3.975.0",
"@aws-sdk/types": "^3.973.1",
"@smithy/property-provider": "^4.2.8",
"@smithy/shared-ini-file-loader": "^4.4.3",
@@ -939,13 +785,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.8.tgz",
"integrity": "sha512-CZhN1bOc1J3ubQPqbmr5b4KaMJBgdDvYsmEIZuX++wFlzmZsKj1bwkaiTEb5U2V7kXuzLlpF5HJSOM9eY/6nGA==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.2.tgz",
"integrity": "sha512-x9DAiN9Qz+NjJ99ltDiVQ8d511M/tuF/9MFbe2jUgo7HZhD6+x4S3iT1YcP07ndwDUjmzKGmeOEgE24k4qvfdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/nested-clients": "3.990.0",
"@aws-sdk/core": "^3.973.2",
"@aws-sdk/nested-clients": "3.975.0",
"@aws-sdk/types": "^3.973.1",
"@smithy/property-provider": "^4.2.8",
"@smithy/shared-ini-file-loader": "^4.4.3",
@@ -1036,9 +882,9 @@
}
},
"node_modules/@aws-sdk/middleware-host-header": {
"version": "3.972.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz",
"integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.2.tgz",
"integrity": "sha512-42hZ8jEXT2uR6YybCzNq9OomqHPw43YIfRfz17biZjMQA4jKSQUaHIl6VvqO2Ddl5904pXg2Yd/ku78S0Ikgog==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
@@ -1065,9 +911,9 @@
}
},
"node_modules/@aws-sdk/middleware-logger": {
"version": "3.972.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz",
"integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.2.tgz",
"integrity": "sha512-iUzdXKOgi4JVDDEG/VvoNw50FryRCEm0qAudw12DcZoiNJWl0rN6SYVLcL1xwugMfQncCXieK5UBlG6mhH7iYA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
@@ -1079,9 +925,9 @@
}
},
"node_modules/@aws-sdk/middleware-recursion-detection": {
"version": "3.972.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz",
"integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.2.tgz",
"integrity": "sha512-/mzlyzJDtngNFd/rAYvqx29a2d0VuiYKN84Y/Mu9mGw7cfMOCyRK+896tb9wV6MoPRHUX7IXuKCIL8nzz2Pz5A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
@@ -1119,23 +965,6 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-sqs": {
"version": "3.972.7",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.7.tgz",
"integrity": "sha512-DcJLYE4sRjgUyb2SupQGaRgBYc+j89N9nXeMT0PwwVvaBGmKqcxa7PFvz0kBnQrBckPWlfrPyyyMwOeT5BEp6Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@smithy/smithy-client": "^4.11.3",
"@smithy/types": "^4.12.0",
"@smithy/util-hex-encoding": "^4.2.0",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-ssec": {
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.2.tgz",
@@ -1151,15 +980,15 @@
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
"version": "3.972.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.10.tgz",
"integrity": "sha512-bBEL8CAqPQkI91ZM5a9xnFAzedpzH6NYCOtNyLarRAzTUTFN2DKqaC60ugBa7pnU1jSi4mA7WAXBsrod7nJltg==",
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.4.tgz",
"integrity": "sha512-6sU8jrSJvY/lqSnU6IYsa8SrCKwOZ4Enl6O4xVJo8RCq9Bdr5Giuw2eUaJAk9GPcpr4OFcmSFv3JOLhpKGeRZA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/core": "^3.973.4",
"@aws-sdk/types": "^3.973.1",
"@aws-sdk/util-endpoints": "3.990.0",
"@smithy/core": "^3.23.0",
"@aws-sdk/util-endpoints": "3.972.0",
"@smithy/core": "^3.22.0",
"@smithy/protocol-http": "^5.3.8",
"@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
@@ -1168,61 +997,45 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": {
"version": "3.990.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.990.0.tgz",
"integrity": "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-endpoints": "^3.2.8",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.990.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.990.0.tgz",
"integrity": "sha512-3NA0s66vsy8g7hPh36ZsUgO4SiMyrhwcYvuuNK1PezO52vX3hXDW4pQrC6OQLGKGJV0o6tbEyQtXb/mPs8zg8w==",
"version": "3.975.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.975.0.tgz",
"integrity": "sha512-OkeFHPlQj2c/Y5bQGkX14pxhDWUGUFt3LRHhjcDKsSCw6lrxKcxN3WFZN0qbJwKNydP+knL5nxvfgKiCLpTLRA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/middleware-host-header": "^3.972.3",
"@aws-sdk/middleware-logger": "^3.972.3",
"@aws-sdk/middleware-recursion-detection": "^3.972.3",
"@aws-sdk/middleware-user-agent": "^3.972.10",
"@aws-sdk/region-config-resolver": "^3.972.3",
"@aws-sdk/types": "^3.973.1",
"@aws-sdk/util-endpoints": "3.990.0",
"@aws-sdk/util-user-agent-browser": "^3.972.3",
"@aws-sdk/util-user-agent-node": "^3.972.8",
"@aws-sdk/core": "^3.973.1",
"@aws-sdk/middleware-host-header": "^3.972.1",
"@aws-sdk/middleware-logger": "^3.972.1",
"@aws-sdk/middleware-recursion-detection": "^3.972.1",
"@aws-sdk/middleware-user-agent": "^3.972.2",
"@aws-sdk/region-config-resolver": "^3.972.1",
"@aws-sdk/types": "^3.973.0",
"@aws-sdk/util-endpoints": "3.972.0",
"@aws-sdk/util-user-agent-browser": "^3.972.1",
"@aws-sdk/util-user-agent-node": "^3.972.1",
"@smithy/config-resolver": "^4.4.6",
"@smithy/core": "^3.23.0",
"@smithy/core": "^3.21.1",
"@smithy/fetch-http-handler": "^5.3.9",
"@smithy/hash-node": "^4.2.8",
"@smithy/invalid-dependency": "^4.2.8",
"@smithy/middleware-content-length": "^4.2.8",
"@smithy/middleware-endpoint": "^4.4.14",
"@smithy/middleware-retry": "^4.4.31",
"@smithy/middleware-endpoint": "^4.4.11",
"@smithy/middleware-retry": "^4.4.27",
"@smithy/middleware-serde": "^4.2.9",
"@smithy/middleware-stack": "^4.2.8",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/node-http-handler": "^4.4.10",
"@smithy/node-http-handler": "^4.4.8",
"@smithy/protocol-http": "^5.3.8",
"@smithy/smithy-client": "^4.11.3",
"@smithy/smithy-client": "^4.10.12",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
"@smithy/util-defaults-mode-browser": "^4.3.30",
"@smithy/util-defaults-mode-node": "^4.2.33",
"@smithy/util-defaults-mode-browser": "^4.3.26",
"@smithy/util-defaults-mode-node": "^4.2.29",
"@smithy/util-endpoints": "^3.2.8",
"@smithy/util-middleware": "^4.2.8",
"@smithy/util-retry": "^4.2.8",
@@ -1233,26 +1046,10 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": {
"version": "3.990.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.990.0.tgz",
"integrity": "sha512-kVwtDc9LNI3tQZHEMNbkLIOpeDK8sRSTuT8eMnzGY+O+JImPisfSTjdh+jw9OTznu+MYZjQsv0258sazVKunYg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
"@smithy/types": "^4.12.0",
"@smithy/url-parser": "^4.2.8",
"@smithy/util-endpoints": "^3.2.8",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/region-config-resolver": {
"version": "3.972.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz",
"integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.2.tgz",
"integrity": "sha512-/7vRBsfmiOlg2X67EdKrzzQGw5/SbkXb7ALHQmlQLkZh8qNgvS2G2dDC6NtF3hzFlpP3j2k+KIEtql/6VrI6JA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
@@ -1408,14 +1205,14 @@
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.990.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.990.0.tgz",
"integrity": "sha512-L3BtUb2v9XmYgQdfGBzbBtKMXaP5fV973y3Qdxeevs6oUTVXFmi/mV1+LnScA/1wVPJC9/hlK+1o5vbt7cG7EQ==",
"version": "3.975.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.975.0.tgz",
"integrity": "sha512-AWQt64hkVbDQ+CmM09wnvSk2mVyH4iRROkmYkr3/lmUtFNbE2L/fnw26sckZnUcFCsHPqbkQrcsZAnTcBLbH4w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.973.10",
"@aws-sdk/nested-clients": "3.990.0",
"@aws-sdk/types": "^3.973.1",
"@aws-sdk/core": "^3.973.1",
"@aws-sdk/nested-clients": "3.975.0",
"@aws-sdk/types": "^3.973.0",
"@smithy/property-provider": "^4.2.8",
"@smithy/shared-ini-file-loader": "^4.4.3",
"@smithy/types": "^4.12.0",
@@ -1507,9 +1304,9 @@
}
},
"node_modules/@aws-sdk/util-user-agent-browser": {
"version": "3.972.3",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz",
"integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.2.tgz",
"integrity": "sha512-gz76bUyebPZRxIsBHJUd/v+yiyFzm9adHbr8NykP2nm+z/rFyvQneOHajrUejtmnc5tTBeaDPL4X25TnagRk4A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.1",
@@ -1519,12 +1316,12 @@
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
"version": "3.972.8",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.8.tgz",
"integrity": "sha512-XJZuT0LWsFCW1C8dEpPAXSa7h6Pb3krr2y//1X0Zidpcl0vmgY5nL/X0JuBZlntpBzaN3+U4hvKjuijyiiR8zw==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.2.tgz",
"integrity": "sha512-vnxOc4C6AR7hVbwyFo1YuH0GB6dgJlWt8nIOOJpnzJAWJPkUMPJ9Zv2lnKsSU7TTZbhP2hEO8OZ4PYH59XFv8Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-user-agent": "^3.972.10",
"@aws-sdk/middleware-user-agent": "^3.972.3",
"@aws-sdk/types": "^3.973.1",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/types": "^4.12.0",
@@ -1543,19 +1340,37 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
"version": "3.972.2",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz",
"integrity": "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.12.0",
"fast-xml-parser": "5.3.4",
"fast-xml-parser": "5.2.5",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
@@ -2762,24 +2577,6 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3270,9 +3067,9 @@
}
},
"node_modules/@smithy/core": {
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.2.tgz",
"integrity": "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==",
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.22.0.tgz",
"integrity": "sha512-6vjCHD6vaY8KubeNw2Fg3EK0KLGQYdldG4fYgQmA0xSW0dJ8G2xFhSOdrlUakWVoP5JuWHtFODg3PNd/DN3FDA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/middleware-serde": "^4.2.9",
@@ -3281,7 +3078,7 @@
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-middleware": "^4.2.8",
"@smithy/util-stream": "^4.5.12",
"@smithy/util-stream": "^4.5.10",
"@smithy/util-utf8": "^4.2.0",
"@smithy/uuid": "^1.1.0",
"tslib": "^2.6.2"
@@ -3490,12 +3287,12 @@
}
},
"node_modules/@smithy/middleware-endpoint": {
"version": "4.4.16",
"resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.16.tgz",
"integrity": "sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==",
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.12.tgz",
"integrity": "sha512-9JMKHVJtW9RysTNjcBZQHDwB0p3iTP6B1IfQV4m+uCevkVd/VuLgwfqk5cnI4RHcp4cPwoIvxQqN4B1sxeHo8Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.23.2",
"@smithy/core": "^3.22.0",
"@smithy/middleware-serde": "^4.2.9",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/shared-ini-file-loader": "^4.4.3",
@@ -3509,15 +3306,15 @@
}
},
"node_modules/@smithy/middleware-retry": {
"version": "4.4.33",
"resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.33.tgz",
"integrity": "sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==",
"version": "4.4.29",
"resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.29.tgz",
"integrity": "sha512-bmTn75a4tmKRkC5w61yYQLb3DmxNzB8qSVu9SbTYqW6GAL0WXO2bDZuMAn/GJSbOdHEdjZvWxe+9Kk015bw6Cg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/node-config-provider": "^4.3.8",
"@smithy/protocol-http": "^5.3.8",
"@smithy/service-error-classification": "^4.2.8",
"@smithy/smithy-client": "^4.11.5",
"@smithy/smithy-client": "^4.11.1",
"@smithy/types": "^4.12.0",
"@smithy/util-middleware": "^4.2.8",
"@smithy/util-retry": "^4.2.8",
@@ -3571,9 +3368,9 @@
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz",
"integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==",
"version": "4.4.8",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz",
"integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/abort-controller": "^4.2.8",
@@ -3684,17 +3481,17 @@
}
},
"node_modules/@smithy/smithy-client": {
"version": "4.11.5",
"resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.5.tgz",
"integrity": "sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==",
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.1.tgz",
"integrity": "sha512-SERgNg5Z1U+jfR6/2xPYjSEHY1t3pyTHC/Ma3YQl6qWtmiL42bvNId3W/oMUWIwu7ekL2FMPdqAmwbQegM7HeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.23.2",
"@smithy/middleware-endpoint": "^4.4.16",
"@smithy/core": "^3.22.0",
"@smithy/middleware-endpoint": "^4.4.12",
"@smithy/middleware-stack": "^4.2.8",
"@smithy/protocol-http": "^5.3.8",
"@smithy/types": "^4.12.0",
"@smithy/util-stream": "^4.5.12",
"@smithy/util-stream": "^4.5.10",
"tslib": "^2.6.2"
},
"engines": {
@@ -3791,13 +3588,13 @@
}
},
"node_modules/@smithy/util-defaults-mode-browser": {
"version": "4.3.32",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.32.tgz",
"integrity": "sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==",
"version": "4.3.28",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.28.tgz",
"integrity": "sha512-/9zcatsCao9h6g18p/9vH9NIi5PSqhCkxQ/tb7pMgRFnqYp9XUOyOlGPDMHzr8n5ih6yYgwJEY2MLEobUgi47w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/property-provider": "^4.2.8",
"@smithy/smithy-client": "^4.11.5",
"@smithy/smithy-client": "^4.11.1",
"@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
@@ -3806,16 +3603,16 @@
}
},
"node_modules/@smithy/util-defaults-mode-node": {
"version": "4.2.35",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.35.tgz",
"integrity": "sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==",
"version": "4.2.31",
"resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.31.tgz",
"integrity": "sha512-JTvoApUXA5kbpceI2vuqQzRjeTbLpx1eoa5R/YEZbTgtxvIB7AQZxFJ0SEyfCpgPCyVV9IT7we+ytSeIB3CyWA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/config-resolver": "^4.4.6",
"@smithy/credential-provider-imds": "^4.2.8",
"@smithy/node-config-provider": "^4.3.8",
"@smithy/property-provider": "^4.2.8",
"@smithy/smithy-client": "^4.11.5",
"@smithy/smithy-client": "^4.11.1",
"@smithy/types": "^4.12.0",
"tslib": "^2.6.2"
},
@@ -3877,13 +3674,13 @@
}
},
"node_modules/@smithy/util-stream": {
"version": "4.5.12",
"resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz",
"integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==",
"version": "4.5.10",
"resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz",
"integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/fetch-http-handler": "^5.3.9",
"@smithy/node-http-handler": "^4.4.10",
"@smithy/node-http-handler": "^4.4.8",
"@smithy/types": "^4.12.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-buffer-from": "^4.2.0",
@@ -7166,15 +6963,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fuse.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz",
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=10"
}
},
"node_modules/gaxios": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
@@ -9403,12 +9191,6 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9547,24 +9329,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/phone": {
"version": "3.1.70",
"resolved": "https://registry.npmjs.org/phone/-/phone-3.1.70.tgz",

View File

@@ -23,8 +23,6 @@
"@aws-sdk/client-s3": "^3.978.0",
"@aws-sdk/client-secrets-manager": "^3.978.0",
"@aws-sdk/client-ses": "^3.978.0",
"@aws-sdk/client-sqs": "^3.975.0",
"@aws-sdk/client-textract": "^3.975.0",
"@aws-sdk/credential-provider-node": "^3.972.3",
"@aws-sdk/lib-storage": "^3.978.0",
"@aws-sdk/s3-request-presigner": "^3.978.0",
@@ -48,7 +46,6 @@
"express": "^4.21.1",
"fast-xml-parser": "^5.3.4",
"firebase-admin": "^13.6.0",
"fuse.js": "^7.1.0",
"graphql": "^16.12.0",
"graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2",
@@ -63,7 +60,6 @@
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
"pdf-lib": "^1.17.1",
"phone": "^3.1.70",
"query-string": "7.1.3",
"recursive-diff": "^1.0.9",

View File

@@ -127,8 +127,6 @@ const applyRoutes = ({ app }) => {
app.use("/payroll", require("./server/routes/payrollRoutes"));
app.use("/sso", require("./server/routes/ssoRoutes"));
app.use("/integrations", require("./server/routes/intergrationRoutes"));
app.use("/ai", require("./server/routes/aiRoutes"));
app.use("/chatter", require("./server/routes/chatterRoutes"));
// Default route for forbidden access
@@ -427,12 +425,6 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
chatterApiQueue.on("error", (error) => {
logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
// Initialize bill-ocr with Redis client
const { initializeBillOcr, startSQSPolling } = require("./server/ai/bill-ocr/bill-ocr");
initializeBillOcr(pubClient);
// Start SQS polling for Textract notifications
startSQSPolling();
};
/**
@@ -462,7 +454,6 @@ const main = async () => {
try {
await server.listen(port);
logger.log(`Server started on port ${port}`, "INFO", "api");
} catch (error) {
logger.log(`Server failed to start on port ${port}`, "ERROR", "api", null, { error: error.message });
}

View File

@@ -1,792 +0,0 @@
const Fuse = require('fuse.js');
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
const InstanceManager = require("../../utils/instanceMgr").default;
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
const PRICE_QUANTITY_MARGIN_TOLERANCE = 0.03; //Used to make sure that if there is a quantity, the price is likely a unit price.
// Helper function to normalize fields
const normalizePartNumber = (str) => {
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
};
const normalizeText = (str) => {
return str.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, ' ').trim().toUpperCase();
};
const normalizePrice = (str) => {
if (typeof str !== 'string') return str;
let value = str.trim();
// Handle European-style decimal comma like "292,37".
// Only treat the *last* comma as a decimal separator when:
// - there's no '.' anywhere (so we don't fight normal US formatting like "1,234.56")
// - and the suffix after the last comma is 1-2 digits (so "1,234" stays 1234)
if (!value.includes('.') && value.includes(',')) {
const lastCommaIndex = value.lastIndexOf(',');
const decimalSuffix = value.slice(lastCommaIndex + 1).trim();
if (/^\d{1,2}$/.test(decimalSuffix)) {
const before = value.slice(0, lastCommaIndex).replace(/,/g, '');
value = `${before}.${decimalSuffix}`;
} else {
// Treat commas as thousands separators (or noise) and drop them.
value = value.replace(/,/g, '');
}
}
return value.replace(/[^0-9.-]+/g, "");
};
const roundToIncrement = (value, increment) => {
if (typeof value !== 'number' || !isFinite(value) || typeof increment !== 'number' || !isFinite(increment) || increment <= 0) {
return value;
}
const rounded = Math.round((value + Number.EPSILON) / increment) * increment;
// Prevent float artifacts (e.g. 0.20500000000000002)
const decimals = Math.max(0, Math.ceil(-Math.log10(increment)));
return parseFloat(rounded.toFixed(decimals));
};
//More complex function. Not necessary at the moment, keeping for reference.
// const normalizePriceFinal = (str) => {
// if (typeof str !== 'string') {
// // If it's already a number, format to 2 decimals
// const num = parseFloat(str);
// return isNaN(num) ? 0 : num;
// }
// // First, try to extract valid decimal number patterns (e.g., "123.45")
// const decimalPattern = /\d+\.\d{1,2}/g;
// const decimalMatches = str.match(decimalPattern);
// if (decimalMatches && decimalMatches.length > 0) {
// // Found valid decimal number(s)
// const numbers = decimalMatches.map(m => parseFloat(m)).filter(n => !isNaN(n) && n > 0);
// if (numbers.length === 1) {
// return numbers[0];
// }
// if (numbers.length > 1) {
// // Check if all numbers are the same (e.g., "47.57.47.57" -> [47.57, 47.57])
// const uniqueNumbers = [...new Set(numbers)];
// if (uniqueNumbers.length === 1) {
// return uniqueNumbers[0];
// }
// // Check if numbers are very close (within 1% tolerance)
// const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
// const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01);
// if (allClose) {
// return avg;
// }
// // Return the first number (most likely correct)
// return numbers[0];
// }
// }
// // Fallback: Split on common delimiters and extract all potential numbers
// const parts = str.split(/[\/|\\,;]/).map(part => part.trim()).filter(part => part.length > 0);
// if (parts.length > 1) {
// // Multiple values detected - extract and parse all valid numbers
// const numbers = parts
// .map(part => {
// const cleaned = part.replace(/[^0-9.-]+/g, "");
// const parsed = parseFloat(cleaned);
// return isNaN(parsed) ? null : parsed;
// })
// .filter(num => num !== null && num > 0);
// if (numbers.length === 0) {
// // No valid numbers found, try fallback to basic cleaning
// const cleaned = str.replace(/[^0-9.-]+/g, "");
// const parsed = parseFloat(cleaned);
// return isNaN(parsed) ? 0 : parsed;
// }
// if (numbers.length === 1) {
// return numbers[0];
// }
// // Multiple valid numbers
// const uniqueNumbers = [...new Set(numbers)];
// if (uniqueNumbers.length === 1) {
// return uniqueNumbers[0];
// }
// // Check if numbers are very close (within 1% tolerance)
// const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
// const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01);
// if (allClose) {
// return avg;
// }
// // Return the first valid number
// return numbers[0];
// }
// // Single value or no delimiters, clean normally
// const cleaned = str.replace(/[^0-9.-]+/g, "");
// const parsed = parseFloat(cleaned);
// return isNaN(parsed) ? 0 : parsed;
// };
// Helper function to calculate Textract OCR confidence (0-100%)
const calculateTextractConfidence = (textractLineItem) => {
if (!textractLineItem || Object.keys(textractLineItem).length === 0) {
return 0;
}
const confidenceValues = [];
// Collect confidence from all fields in the line item
Object.values(textractLineItem).forEach(field => {
if (field.confidence && typeof field.confidence === 'number') {
confidenceValues.push(field.confidence);
}
});
if (confidenceValues.length === 0) {
return 0;
}
// Check if critical normalized labels are present
const hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
const hasLineDesc = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.line_desc);
const hasQuantity = textractLineItem?.QUANTITY?.value; //We don't normalize quantity, we just use what textract gives us.
// Calculate weighted average, giving more weight to important fields
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
let totalWeight = 0;
let weightedSum = 0;
Object.entries(textractLineItem).forEach(([key, field]) => {
if (field.confidence && typeof field.confidence === 'number') {
// Weight important fields higher
let weight = 1;
if (field.normalizedLabel === standardizedFieldsnames.actual_cost || field.normalizedLabel === standardizedFieldsnames.actual_price) {
weight = 4;
}
else if (field.normalizedLabel === standardizedFieldsnames.part_no || field.normalizedLabel === standardizedFieldsnames.line_desc) {
weight = 3.5;
}
else if (field.normalizedLabel === standardizedFieldsnames.quantity) {
weight = 3.5;
}
// We generally ignore the key from textract. Keeping for future reference.
// else if (key === 'ITEM' || key === 'PRODUCT_CODE') {
// weight = 3; // Description and part number are most important
// } else if (key === 'PRICE' || key === 'UNIT_PRICE' || key === 'QUANTITY') {
// weight = 2; // Price and quantity moderately important
// }
weightedSum += field.confidence * weight;
totalWeight += weight;
}
});
let avgConfidence = totalWeight > 0 ? weightedSum / totalWeight : 0;
// Apply penalty if critical normalized labels are missing
let missingFieldsPenalty = 1.0;
let missingCount = 0;
if (!hasActualCost) missingCount++;
if (!hasActualPrice) missingCount++;
if (!hasLineDesc) missingCount++;
if (!hasQuantity) missingCount++;
// Each missing field reduces confidence by 20%
if (missingCount > 0) {
missingFieldsPenalty = 1.0 - (missingCount * 0.20);
}
avgConfidence = avgConfidence * missingFieldsPenalty;
return Math.round(avgConfidence * 100) / 100; // Round to 2 decimal places
};
const calculateMatchConfidence = (matches, bestMatch) => {
if (!matches || matches.length === 0 || !bestMatch) {
return 0; // No match = 0% confidence
}
// Base confidence from the match score
// finalScore is already weighted and higher is better
// Normalize it to a 0-100 scale
const baseScore = Math.min(bestMatch.finalScore * 10, 100); // Scale factor of 10, cap at 100
// Bonus for multiple field matches (up to +15%)
const fieldMatchBonus = Math.min(bestMatch.fieldMatches.length * 5, 15);
// Bonus for having price data (+10%)
const priceDataBonus = bestMatch.hasPriceData ? 10 : 0;
// Bonus for clear winner (gap between 1st and 2nd match)
let confidenceMarginBonus = 0;
if (matches.length > 1) {
const scoreDiff = bestMatch.finalScore - matches[1].finalScore;
// If the best match is significantly better than the second best, add bonus
confidenceMarginBonus = Math.min(scoreDiff * 5, 10); // Up to +10%
} else {
// Only one match found, add small bonus
confidenceMarginBonus = 5;
}
// Calculate total match confidence
let matchConfidence = baseScore + fieldMatchBonus + priceDataBonus + confidenceMarginBonus;
// Cap at 100% and round to 2 decimal places
matchConfidence = Math.min(Math.round(matchConfidence * 100) / 100, 100);
// Ensure minimum of 1% if there's any match at all
return Math.max(matchConfidence, 1);
};
const calculateOverallConfidence = (ocrConfidence, matchConfidence) => {
// If there's no match, OCR confidence doesn't matter much
if (matchConfidence === 0) {
return 0;
}
// Overall confidence is affected by both how well Textract read the data
// and how well we matched it to existing joblines
// Use a weighted average: 60% OCR confidence, 40% match confidence
// OCR confidence is more important because even perfect match is useless without good OCR
const overall = (ocrConfidence * 0.6) + (matchConfidence * 0.4);
return Math.round(overall * 100) / 100;
};
// Helper function to merge and deduplicate results with weighted scoring
const mergeResults = (resultsArray, weights = []) => {
const scoreMap = new Map();
resultsArray.forEach((results, index) => {
const weight = weights[index] || 1;
results.forEach(result => {
const id = result.item.id;
const weightedScore = result.score * weight;
if (!scoreMap.has(id)) {
scoreMap.set(id, { item: result.item, score: weightedScore, count: 1 });
} else {
const existing = scoreMap.get(id);
// Lower score is better in Fuse.js, so take the minimum
existing.score = Math.min(existing.score, weightedScore);
existing.count++;
}
});
});
// Convert back to array and sort by score (lower is better)
return Array.from(scoreMap.values())
.sort((a, b) => {
// Prioritize items found in multiple searches
if (a.count !== b.count) return b.count - a.count;
return a.score - b.score;
})
.slice(0, 5); // Return top 5 results
};
async function generateBillFormData({ processedData, jobid: jobidFromProps, bodyshopid, partsorderid, req }) {
const client = req.userGraphQLClient;
let jobid = jobidFromProps;
//If no jobid, fetch it, and funnel it back.
if (!jobid || jobid === null || jobid === undefined || jobid === "" || jobid === "null" || jobid === "undefined") {
const ro_number = processedData.summary?.PO_NUMBER?.value || Object.values(processedData.summary).find(value => value.normalizedLabel === 'ro_number')?.value;
if (!ro_number) {
throw new Error("Could not find RO number in the extracted data to associate with the bill. Select an RO and try again.");
}
const { jobs } = await client.request(`
query QUERY_BILL_OCR_JOB_BY_RO($ro_number: String!) {
jobs(where: {ro_number: {_eq: $ro_number}}) {
id
}
}`, { ro_number });
if (jobs.length === 0) {
throw new Error("No job found for the detected RO/PO number.");
}
jobid = jobs[0].id;
}
const jobData = await client.request(`
query QUERY_BILL_OCR_DATA($jobid: uuid!) {
vendors {
id
name
}
jobs_by_pk(id: $jobid) {
id
bodyshop {
id
md_responsibility_centers
cdk_dealerid
pbs_serialnumber
rr_dealerid
}
joblines {
id
line_desc
removed
act_price
db_price
oem_partno
alt_partno
part_type
}
}
}
`, {
jobid, // TODO: Parts order IDs are currently ignore. If receving a parts order, it could be used to more precisely match to joblines.
});
//Create fuses of line descriptions for matching.
const jobLineDescFuse = new Fuse(
jobData.jobs_by_pk.joblines.map(jl => ({ ...jl, line_desc_normalized: normalizeText(jl.line_desc || ""), oem_partno_normalized: normalizePartNumber(jl.oem_partno || ""), alt_partno_normalized: normalizePartNumber(jl.alt_partno || "") })),
{
keys: [{
name: 'line_desc',
weight: 6
}, {
name: 'oem_partno',
weight: 8
}, {
name: 'alt_partno',
weight: 5
},
{
name: 'act_price',
weight: 1
},
{
name: 'line_desc_normalized',
weight: 4
},
{
name: 'oem_partno_normalized',
weight: 6
},
{
name: 'alt_partno_normalized',
weight: 3
}],
threshold: 0.4, //Adjust as needed for matching sensitivity,
includeScore: true,
}
);
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
const vendorFuse = new Fuse(
jobData.vendors.map(v => ({ ...v, name_normalized: normalizeText(v.name) })),
{
keys: [{ name: "name", weight: 3 }, { name: 'name_normalized', weight: 2 }],
threshold: 0.4,
includeScore: true,
},
);
const vendorMatches = vendorFuse.search(normalizeText(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value));
let vendorid;
if (vendorMatches.length > 0) {
vendorid = vendorMatches[0].item.id;
}
const { jobs_by_pk: job } = jobData;
if (!job) {
throw new Error('Job not found for bill form data generation.');
}
//Is there a subtotal level discount? If there is, we need to figure out what the percentage is, and apply that to the actual cost as a reduction
const subtotalDiscountValueRaw = processedData.summary?.DISCOUNT?.value || processedData.summary?.SUBTOTAL_DISCOUNT?.value || 0;
let discountPercentageDecimal = 0;
if (subtotalDiscountValueRaw) {
const subtotal = parseFloat(normalizePrice(processedData.summary?.SUBTOTAL?.value || 0)) || 0;
const subtotalDiscountValue = parseFloat(normalizePrice(subtotalDiscountValueRaw)) || 0;
if (subtotal > 0 && subtotalDiscountValue) {
// Store discount percentage as a decimal (e.g. 20.5% => 0.205),
// but only allow half-percent increments (0.005 steps).
discountPercentageDecimal = Math.abs(subtotalDiscountValue / subtotal);
discountPercentageDecimal = roundToIncrement(discountPercentageDecimal, 0.005);
}
}
//TODO: How do we handle freight lines and core charges?
//Create the form data structure for the bill posting screen.
const billFormData = {
"jobid": jobid,
"vendorid": vendorid,
"invoice_number": processedData.summary?.INVOICE_RECEIPT_ID?.value,
"date": processedData.summary?.INVOICE_RECEIPT_DATE?.value,
"is_credit_memo": false,
"total": normalizePrice(processedData.summary?.INVOICE_TOTAL?.value || processedData.summary?.TOTAL?.value),
"billlines": joblineMatches.map(jlMatchLine => {
const { matches, textractLineItem, } = jlMatchLine
//Matches should be pre-sorted, take the first one.
const matchToUse = matches.length > 0 ? matches[0] : null;
// Calculate confidence scores
const ocrConfidence = calculateTextractConfidence(textractLineItem);
const matchConfidence = calculateMatchConfidence(matches, matchToUse);
const overallConfidence = calculateOverallConfidence(ocrConfidence, matchConfidence);
//TODO: Should be using the textract if there is an exact match on the normalized label.
//if there isn't then we can do the below.
let actualPrice, actualCost;
//TODO: What is several match on the normalized name? We need to pick the most likely one.
const hasNormalizedActualPrice = Object.keys(textractLineItem).find(key => textractLineItem[key].normalizedLabel === 'actual_price');
const hasNormalizedActualCost = Object.keys(textractLineItem).find(key => textractLineItem[key].normalizedLabel === 'actual_cost');
if (hasNormalizedActualPrice) {
actualPrice = textractLineItem[hasNormalizedActualPrice].value;
}
if (hasNormalizedActualCost) {
actualCost = textractLineItem[hasNormalizedActualCost].value;
}
if (!hasNormalizedActualPrice || !hasNormalizedActualCost) {
//This is if there was no match found for normalized labels.
//Check all prices, and generally the higher one will be the actual price and the lower one will be the cost.
//Need to make sure that other random items are excluded. This should be within a reasonable range of the matched jobline at matchToUse.item.act_price
//Iterate over all of the text values, and check out which of them are currencies.
//They'll be in the format starting with a $ sign usually.
const currencyTextractLineItems = [] // {key, value}
Object.keys(textractLineItem).forEach(key => {
const currencyValue = textractLineItem[key].value?.startsWith('$') ? textractLineItem[key].value : null;
if (currencyValue) {
//Clean it and parse it
const cleanValue = parseFloat(currencyValue.replace(/[^0-9.-]/g, '')) || 0;
currencyTextractLineItems.push({ key, value: cleanValue })
}
})
//Sort them descending
currencyTextractLineItems.sort((a, b) => b.value - a.value);
//Most expensive should be the actual price, second most expensive should be the cost.
if (!actualPrice) actualPrice = currencyTextractLineItems.length > 0 ? currencyTextractLineItems[0].value : 0;
if (!actualCost) actualCost = currencyTextractLineItems.length > 1 ? currencyTextractLineItems[1].value : 0;
if (matchToUse) {
//Double check that they're within 50% of the matched jobline price if there is one.
const joblinePrice = parseFloat(matchToUse.item.act_price) || 0;
if (!hasNormalizedActualPrice && actualPrice > 0 && (actualPrice < joblinePrice * (1 - PRICE_PERCENT_MARGIN_TOLERANCE) || actualPrice > joblinePrice * (1 + PRICE_PERCENT_MARGIN_TOLERANCE))) {
actualPrice = joblinePrice; //Set to the jobline as a fallback.
}
if (!hasNormalizedActualCost && actualCost > 0 && (actualCost < joblinePrice * (1 - PRICE_PERCENT_MARGIN_TOLERANCE) || actualCost > joblinePrice * (1 + PRICE_PERCENT_MARGIN_TOLERANCE))) {
actualCost = null //Blank it out if it's not likely.
}
}
}
//If there's nothing, just fall back to seeing if there's a price object from textract.
if (!actualPrice && textractLineItem.PRICE) {
actualPrice = textractLineItem.PRICE.value;
}
if (!actualCost && textractLineItem.PRICE) {
actualCost = textractLineItem.PRICE.value;
}
//If quantity greater than 1, check if the actual cost is a multiple of the actual price, if so, divide it out to get the unit price.
const quantity = parseInt(textractLineItem?.QUANTITY?.value);
if (quantity && quantity > 1) {
if (actualPrice && quantity && Math.abs((actualPrice / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualPrice = actualPrice / quantity;
}
if (actualCost && quantity && Math.abs((actualCost / quantity) - (parseFloat(matchToUse?.item?.act_price) || 0)) / ((parseFloat(matchToUse?.item?.act_price) || 1)) < PRICE_QUANTITY_MARGIN_TOLERANCE) {
actualCost = actualCost / quantity;
}
}
if (discountPercentageDecimal > 0) {
actualCost = actualCost * (1 - discountPercentageDecimal);
}
const responsibilityCenters = job.bodyshop.md_responsibility_centers
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price)
const lineObject = {
"line_desc": matchToUse?.item?.line_desc || textractLineItem.ITEM?.value || "NO DESCRIPTION",
"quantity": textractLineItem.QUANTITY?.value,
"actual_price": normalizePrice(actualPrice),
"actual_cost": normalizePrice(actualCost),
"cost_center": matchToUse?.item?.part_type
? bodyshopHasDmsKey(job.bodyshop)
? matchToUse?.item?.part_type !== "PAE"
? matchToUse?.item?.part_type
: null
: responsibilityCenters.defaults &&
(responsibilityCenters.defaults.costs[matchToUse?.item?.part_type] || null)
: null,
"applicable_taxes": {
"federal": InstanceManager({ imex: true, rome: false }),
"state": false,
"local": false
},
"joblineid": matchToUse?.item?.id || "noline",
"confidence": `T${overallConfidence} - O${ocrConfidence} - J${matchConfidence}`
}
return lineObject
})
}
return billFormData
}
function joblineFuzzySearch({ fuseToSearch, processedData }) {
const matches = []
const searchStats = []; // Track search statistics
processedData.lineItems.forEach((lineItem, lineIndex) => {
const lineStats = {
lineNumber: lineIndex + 1,
searches: []
};
// Refined ITEM search (multi-word description)
const refinedItemResults = (() => {
if (!lineItem.ITEM?.value) return [];
const itemValue = lineItem.ITEM.value;
const normalized = normalizeText(itemValue);
// 1: Full string search
const fullSearch = fuseToSearch.search(normalized);
lineStats.searches.push({ type: 'ITEM - Full String', term: normalized, results: fullSearch.length });
// 2: Search individual significant words (3+ chars)
const words = normalized.split(' ').filter(w => w.length >= 3);
const wordSearches = words.map(word => {
const results = fuseToSearch.search(word);
lineStats.searches.push({ type: 'ITEM - Individual Word', term: word, results: results.length });
return results;
});
// 3: Search without spaces entirely
const noSpaceSearch = fuseToSearch.search(normalized.replace(/\s+/g, ''));
lineStats.searches.push({ type: 'ITEM - No Spaces', term: normalized.replace(/\s+/g, ''), results: noSpaceSearch.length });
// Merge results with weights (full search weighted higher)
return mergeResults(
[fullSearch, ...wordSearches, noSpaceSearch],
[1.0, ...words.map(() => 1.5), 1.2] // Full search best, individual words penalized slightly
);
})();
// Refined PRODUCT_CODE search (part numbers)
const refinedProductCodeResults = (() => {
if (!lineItem.PRODUCT_CODE?.value) return [];
const productCode = lineItem.PRODUCT_CODE.value;
const normalized = normalizePartNumber(productCode);
// 1: Normalized search (no spaces/special chars)
const normalizedSearch = fuseToSearch.search(normalized);
lineStats.searches.push({ type: 'PRODUCT_CODE - Normalized', term: normalized, results: normalizedSearch.length });
// 2: Original with minimal cleaning
const minimalClean = productCode.replace(/\s+/g, '').toUpperCase();
const minimalSearch = fuseToSearch.search(minimalClean);
lineStats.searches.push({ type: 'PRODUCT_CODE - Minimal Clean', term: minimalClean, results: minimalSearch.length });
// 3: Search with dashes (common in part numbers)
const withDashes = productCode.replace(/[^a-zA-Z0-9-]/g, '').toUpperCase();
const dashSearch = fuseToSearch.search(withDashes);
lineStats.searches.push({ type: 'PRODUCT_CODE - With Dashes', term: withDashes, results: dashSearch.length });
// 4: Special chars to spaces (preserve word boundaries)
const specialCharsToSpaces = productCode.replace(/[^a-zA-Z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim().toUpperCase();
const specialCharsSearch = fuseToSearch.search(specialCharsToSpaces);
lineStats.searches.push({ type: 'PRODUCT_CODE - Special Chars to Spaces', term: specialCharsToSpaces, results: specialCharsSearch.length });
return mergeResults(
[normalizedSearch, minimalSearch, dashSearch, specialCharsSearch],
[1.0, 1.1, 1.2, 1.15] // Prefer fully normalized, special chars to spaces slightly weighted
);
})();
// Refined PRICE search
const refinedPriceResults = (() => {
if (!lineItem.PRICE?.value) return [];
const price = normalizePrice(lineItem.PRICE.value);
// 1: Exact price match
const exactSearch = fuseToSearch.search(price);
lineStats.searches.push({ type: 'PRICE - Exact', term: price, results: exactSearch.length });
// 2: Price with 2 decimal places
const priceFloat = parseFloat(price);
if (!isNaN(priceFloat)) {
const formattedPrice = priceFloat.toFixed(2);
const formattedSearch = fuseToSearch.search(formattedPrice);
lineStats.searches.push({ type: 'PRICE - Formatted (2 decimals)', term: formattedPrice, results: formattedSearch.length });
return mergeResults([exactSearch, formattedSearch], [1.0, 1.1]);
}
return exactSearch;
})();
// Refined UNIT_PRICE search
const refinedUnitPriceResults = (() => {
if (!lineItem.UNIT_PRICE?.value) return [];
const unitPrice = normalizePrice(lineItem.UNIT_PRICE.value);
// 1: Exact price match
const exactSearch = fuseToSearch.search(unitPrice);
lineStats.searches.push({ type: 'UNIT_PRICE - Exact', term: unitPrice, results: exactSearch.length });
// 2: Price with 2 decimal places
const priceFloat = parseFloat(unitPrice);
if (!isNaN(priceFloat)) {
const formattedPrice = priceFloat.toFixed(2);
const formattedSearch = fuseToSearch.search(formattedPrice);
lineStats.searches.push({ type: 'UNIT_PRICE - Formatted (2 decimals)', term: formattedPrice, results: formattedSearch.length });
return mergeResults([exactSearch, formattedSearch], [1.0, 1.1]);
}
return exactSearch;
})();
//Merge them all together and sort by the highest scores.
const combinedScoreMap = new Map();
// Weight different field types differently
const fieldWeights = {
productCode: 5.0, // Most important - part numbers should match
item: 3.0, // Second most important - description
price: 1.0, // Less important - prices can vary
unitPrice: 0.8 // Least important - similar to price
};
[
{ results: refinedProductCodeResults, weight: fieldWeights.productCode, field: 'productCode' },
{ results: refinedItemResults, weight: fieldWeights.item, field: 'item' },
{ results: refinedPriceResults, weight: fieldWeights.price, field: 'price' },
{ results: refinedUnitPriceResults, weight: fieldWeights.unitPrice, field: 'unitPrice' }
].forEach(({ results, weight, field }) => {
results.forEach((result, index) => {
const id = result.item.id;
// Position bonus (first result is better than fifth)
const positionBonus = (5 - index) / 5;
// Lower score is better in Fuse.js, so invert it and apply weights
const normalizedScore = (1 - result.score) * weight * positionBonus;
if (!combinedScoreMap.has(id)) {
combinedScoreMap.set(id, {
item: result.item,
score: normalizedScore,
fieldMatches: [field],
matchCount: result.count || 1
});
} else {
const existing = combinedScoreMap.get(id);
existing.score += normalizedScore;
existing.fieldMatches.push(field);
existing.matchCount += (result.count || 1);
}
});
});
// Convert to array and sort by best combined score
const finalMatches = Array.from(combinedScoreMap.values())
.map(entry => {
// Apply penalty if item has no act_price or it's 0
const hasPriceData = entry.item.act_price && parseFloat(entry.item.act_price) > 0;
const priceDataPenalty = hasPriceData ? 1.0 : 0.5; // 50% penalty if no price
return {
...entry,
// Boost score for items that matched in multiple fields, penalize for missing price
finalScore: entry.score * (1 + (entry.fieldMatches.length * 0.2)) * priceDataPenalty,
hasPriceData
};
})
.sort((a, b) => b.finalScore - a.finalScore)
.slice(0, 5);
// Always push the textract line item, even if no matches found
// This ensures all invoice lines are processed
matches.push({
matches: finalMatches,
textractLineItem: lineItem,
hasMatch: finalMatches.length > 0
});
searchStats.push(lineStats);
})
// // Output search statistics table
// console.log('\n═══════════════════════════════════════════════════════════════════════');
// console.log(' FUSE.JS SEARCH STATISTICS');
// console.log('═══════════════════════════════════════════════════════════════════════\n');
// searchStats.forEach(lineStat => {
// console.log(`📄 Line Item #${lineStat.lineNumber}:`);
// console.log('─'.repeat(75));
// if (lineStat.searches.length > 0) {
// const tableData = lineStat.searches.map(search => ({
// 'Search Type': search.type,
// 'Search Term': search.term.substring(0, 40) + (search.term.length > 40 ? '...' : ''),
// 'Results': search.results
// }));
// console.table(tableData);
// } else {
// console.log(' No searches performed for this line item.\n');
// }
// });
// // Summary statistics
// const totalSearches = searchStats.reduce((sum, stat) => sum + stat.searches.length, 0);
// const totalResults = searchStats.reduce((sum, stat) =>
// sum + stat.searches.reduce((s, search) => s + search.results, 0), 0);
// const avgResultsPerSearch = totalSearches > 0 ? (totalResults / totalSearches).toFixed(2) : 0;
// console.log('═══════════════════════════════════════════════════════════════════════');
// console.log(' SUMMARY');
// console.log('═══════════════════════════════════════════════════════════════════════');
// console.table({
// 'Total Line Items': processedData.lineItems.length,
// 'Total Searches Performed': totalSearches,
// 'Total Results Found': totalResults,
// 'Average Results per Search': avgResultsPerSearch
// });
// console.log('═══════════════════════════════════════════════════════════════════════\n');
return matches
}
const bodyshopHasDmsKey = (bodyshop) =>
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid;
module.exports = {
generateBillFormData,
normalizePrice
}

View File

@@ -1,159 +0,0 @@
const PDFDocument = require('pdf-lib').PDFDocument;
const logger = require("../../utils/logger");
const TEXTRACT_REDIS_PREFIX = `textract:${process.env?.NODE_ENV}`
const TEXTRACT_JOB_TTL = 10 * 60;
/**
* Generate Redis key for Textract job using textract job ID
* @param {string} textractJobId
* @returns {string}
*/
function getTextractJobKey(textractJobId) {
return `${TEXTRACT_REDIS_PREFIX}:${textractJobId}`;
}
/**
* Store Textract job data in Redis
* @param {string} textractJobId
* @param {Object} redisPubClient
* @param {Object} jobData
*/
async function setTextractJob({ redisPubClient, textractJobId, jobData }) {
if (!redisPubClient) {
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
}
const key = getTextractJobKey(textractJobId);
await redisPubClient.set(key, JSON.stringify(jobData));
await redisPubClient.expire(key, TEXTRACT_JOB_TTL);
}
/**
* Retrieve Textract job data from Redis
* @param {string} textractJobId
* @param {Object} redisPubClient
* @returns {Promise<Object|null>}
*/
async function getTextractJob({ redisPubClient, textractJobId }) {
if (!redisPubClient) {
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
}
const key = getTextractJobKey(textractJobId);
const data = await redisPubClient.get(key);
return data ? JSON.parse(data) : null;
}
/**
* Detect file type based on MIME type and file signature
* @param {Object} file - Multer file object
* @returns {string} 'pdf', 'image', or 'unknown'
*/
function getFileType(file) {
// Check MIME type first
const mimeType = file.mimetype?.toLowerCase();
if (mimeType === 'application/pdf') {
return 'pdf';
}
if (mimeType && mimeType.startsWith('image/')) {
return 'image';
}
// Fallback: Check file signature (magic bytes)
const buffer = file.buffer;
if (buffer && buffer.length > 4) {
// PDF signature: %PDF
if (buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46) {
return 'pdf';
}
// JPEG signature: FF D8 FF
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return 'image';
}
// PNG signature: 89 50 4E 47
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return 'image';
}
// HEIC/HEIF: Check for ftyp followed by heic/heix/hevc/hevx
if (buffer.length > 12) {
const ftypIndex = buffer.indexOf(Buffer.from('ftyp'));
if (ftypIndex > 0 && ftypIndex < 12) {
const brand = buffer.slice(ftypIndex + 4, ftypIndex + 8).toString('ascii');
if (brand.startsWith('heic') || brand.startsWith('heix') ||
brand.startsWith('hevc') || brand.startsWith('hevx') ||
brand.startsWith('mif1')) {
return 'image';
}
}
}
}
return 'unknown';
}
/**
* Get the number of pages in a PDF buffer
* @param {Buffer} pdfBuffer
* @returns {Promise<number>}
*/
async function getPdfPageCount(pdfBuffer) {
try {
const pdfDoc = await PDFDocument.load(pdfBuffer);
return pdfDoc.getPageCount();
} catch (error) {
console.error('Error reading PDF page count:', error);
throw new Error('Failed to read PDF: ' + error.message);
}
}
/**
* Check if there are any jobs in IN_PROGRESS status
* @returns {Promise<boolean>}
*/
async function hasActiveJobs({ redisPubClient }) {
if (!redisPubClient) {
throw new Error('Redis client not initialized.');
}
try {
// Get all textract job keys
const pattern = `${TEXTRACT_REDIS_PREFIX}:*`;
const keys = await redisPubClient.keys(pattern);
if (!keys || keys.length === 0) {
return false;
}
//TODO: Is there a better way to do this that supports clusters?
// Check if any job has IN_PROGRESS status
for (const key of keys) {
const data = await redisPubClient.get(key);
if (data) {
const jobData = JSON.parse(data);
if (jobData.status === 'IN_PROGRESS') {
return true;
}
}
}
return false;
} catch (error) {
logger.log("bill-ocr-job-check-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
return false;
}
}
module.exports = {
getTextractJobKey,
setTextractJob,
getTextractJob,
getFileType,
getPdfPageCount,
hasActiveJobs,
TEXTRACT_REDIS_PREFIX
}

View File

@@ -1,202 +0,0 @@
const MIN_CONFIDENCE_VALUE = 50
function normalizeFieldName(fieldType) {
//Placeholder normalization for now.
return fieldType;
}
const standardizedFieldsnames = {
actual_cost: "actual_cost",
actual_price: "actual_price",
line_desc: "line_desc",
quantity: "quantity",
part_no: "part_no",
ro_number: "ro_number",
}
function normalizeLabelName(labelText) {
if (!labelText) return '';
// Convert to lowercase and trim whitespace
let normalized = labelText.toLowerCase().trim();
// Remove special characters and replace spaces with underscores
normalized = normalized.replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, '_');
// Common label normalizations
const labelMap = {
'qty': standardizedFieldsnames.quantity,
'qnty': standardizedFieldsnames.quantity,
'sale_qty': standardizedFieldsnames.quantity,
'invoiced_qty': standardizedFieldsnames.quantity,
'qty_shipped': standardizedFieldsnames.quantity,
'quantity': standardizedFieldsnames.quantity,
'filled': standardizedFieldsnames.quantity,
'count': standardizedFieldsnames.quantity,
'quant': standardizedFieldsnames.quantity,
'desc': standardizedFieldsnames.line_desc,
'description': standardizedFieldsnames.line_desc,
'item': standardizedFieldsnames.line_desc,
'part': standardizedFieldsnames.part_no,
'part_no': standardizedFieldsnames.part_no,
'part_num': standardizedFieldsnames.part_no,
'part_number': standardizedFieldsnames.part_no,
'item_no': standardizedFieldsnames.part_no,
'price': standardizedFieldsnames.actual_price,
//'amount': standardizedFieldsnames.actual_price,
'list_price': standardizedFieldsnames.actual_price,
'unit_price': standardizedFieldsnames.actual_price,
'list': standardizedFieldsnames.actual_price,
'retail_price': standardizedFieldsnames.actual_price,
'retail': standardizedFieldsnames.actual_price,
'net': standardizedFieldsnames.actual_cost,
'selling_price': standardizedFieldsnames.actual_cost,
'net_price': standardizedFieldsnames.actual_cost,
'net_cost': standardizedFieldsnames.actual_cost,
'total': standardizedFieldsnames.actual_cost,
'po_no': standardizedFieldsnames.ro_number,
'customer_po_no': standardizedFieldsnames.ro_number,
'customer_po_no_': standardizedFieldsnames.ro_number
};
return labelMap[normalized] || `NOT_MAPPED => ${normalized}`; // TODO: Should we monitor unmapped labels?
}
function processScanData(invoiceData) {
// Process and clean the extracted data
const processed = {
summary: {},
lineItems: []
};
// Clean summary fields
for (const [key, value] of Object.entries(invoiceData.summary)) {
if (value.confidence > MIN_CONFIDENCE_VALUE) { // Only include fields with > 50% confidence
processed.summary[key] = {
value: value.value,
label: value.label,
normalizedLabel: value.normalizedLabel,
confidence: value.confidence
};
}
}
// Process line items
processed.lineItems = invoiceData.lineItems
.map(item => {
const processedItem = {};
for (const [key, value] of Object.entries(item)) {
if (value.confidence > MIN_CONFIDENCE_VALUE) { // Only include fields with > 50% confidence
let cleanValue = value.value;
// Parse numbers for quantity and price fields
if (key === 'quantity') {
cleanValue = parseFloat(cleanValue) || 0;
} else if (key === 'retail_price' || key === 'actual_price') {
// Remove currency symbols and parse
cleanValue = parseFloat(cleanValue.replace(/[^0-9.-]/g, '')) || 0;
}
processedItem[key] = {
value: cleanValue,
label: value.label,
normalizedLabel: value.normalizedLabel,
confidence: value.confidence
};
}
}
return processedItem;
})
return processed;
}
function extractInvoiceData(textractResponse) {
const invoiceData = {
summary: {},
lineItems: []
};
if (!textractResponse.ExpenseDocuments || textractResponse.ExpenseDocuments.length === 0) {
return invoiceData;
}
// Process each page of the invoice
textractResponse.ExpenseDocuments.forEach(expenseDoc => {
// Extract summary fields (vendor, invoice number, date, total, etc.)
if (expenseDoc.SummaryFields) {
expenseDoc.SummaryFields.forEach(field => {
const fieldType = field.Type?.Text || '';
const fieldValue = field.ValueDetection?.Text || '';
const fieldLabel = field.LabelDetection?.Text || '';
const confidence = field.ValueDetection?.Confidence || 0;
// Map common invoice fields
if (fieldType && fieldValue) {
invoiceData.summary[fieldType] = {
value: fieldValue,
label: fieldLabel,
normalizedLabel: normalizeLabelName(fieldLabel),
confidence: confidence
};
}
});
}
// Extract line items
if (expenseDoc.LineItemGroups) {
expenseDoc.LineItemGroups.forEach(lineItemGroup => {
if (lineItemGroup.LineItems) {
lineItemGroup.LineItems.forEach(lineItem => {
const item = {};
const fieldNameCounts = {}; // Track field name occurrences
if (lineItem.LineItemExpenseFields) {
lineItem.LineItemExpenseFields.forEach(field => {
const fieldType = field.Type?.Text || '';
const fieldValue = field.ValueDetection?.Text || '';
const fieldLabel = field.LabelDetection?.Text || '';
const confidence = field.ValueDetection?.Confidence || 0;
if (fieldType && fieldValue) {
// Normalize field names
let normalizedField = normalizeFieldName(fieldType);
// Ensure uniqueness by appending a counter if the field already exists
if (Object.prototype.hasOwnProperty.call(item, normalizedField)) {
fieldNameCounts[normalizedField] = (fieldNameCounts[normalizedField] || 1) + 1;
normalizedField = `${normalizedField}_${fieldNameCounts[normalizedField]}`;
}
item[normalizedField] = {
value: fieldValue,
label: fieldLabel,
normalizedLabel: normalizeLabelName(fieldLabel),
confidence: confidence
};
}
});
}
if (Object.keys(item).length > 0) {
invoiceData.lineItems.push(item);
}
});
}
});
}
});
return invoiceData;
}
module.exports = {
extractInvoiceData,
processScanData,
standardizedFieldsnames
}

View File

@@ -1,8 +0,0 @@
Required Infrastructure setup
1. Create an AI user that has access to the required S3 buckets and textract permissions.
2. Had to create a queue and SNS topic. had to also create the role that had `sns:Publish`. Had to add `sqs:ReceiveMessage` and `sqs:DeleteMessage` to the profile.
3. Created 2 roles for SNS. The textract role is the right one, the other was created manually based on incorrect instructions.
TODO:
* Create a rome bucket for uploads, or move to the regular spot.
* Add environment variables.

View File

@@ -1,465 +0,0 @@
const { TextractClient, StartExpenseAnalysisCommand, GetExpenseAnalysisCommand, AnalyzeExpenseCommand } = require("@aws-sdk/client-textract");
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
const { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } = require("@aws-sdk/client-sqs");
const { v4: uuidv4 } = require('uuid');
const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPageCount, hasActiveJobs } = require("./bill-ocr-helpers");
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
const { generateBillFormData } = require("./bill-ocr-generator");
const logger = require("../../utils/logger");
const _ = require("lodash");
// Initialize AWS clients
const awsConfig = {
region: process.env.AWS_AI_REGION || "ca-central-1",
credentials: {
accessKeyId: process.env.AWS_AI_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_AI_SECRET_ACCESS_KEY,
}
};
const textractClient = new TextractClient(awsConfig);
const s3Client = new S3Client(awsConfig);
const sqsClient = new SQSClient(awsConfig);
let redisPubClient = null;
/**
* Initialize the bill-ocr module with Redis client
* @param {Object} pubClient - Redis cluster client
*/
function initializeBillOcr(pubClient) {
redisPubClient = pubClient;
}
/**
* Check if job exists by Textract job ID
* @param {string} textractJobId
* @returns {Promise<boolean>}
*/
async function jobExists(textractJobId) {
if (!redisPubClient) {
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
}
const key = getTextractJobKey(textractJobId);
const exists = await redisPubClient.exists(key);
if (exists) {
return true;
}
return false;
}
async function handleBillOcr(req, res) {
// Check if file was uploaded
if (!req.file) {
return res.status(400).send({ error: 'No file uploaded.' });
}
// The uploaded file is available in request file
const uploadedFile = req.file;
const { jobid, bodyshopid, partsorderid } = req.body;
logger.log("bill-ocr-start", "DEBUG", req.user.email, jobid, null);
try {
const fileType = getFileType(uploadedFile);
// Images are always processed synchronously (single page)
if (fileType === 'image') {
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
status: 'COMPLETED',
data: { ...processedData, billForm },
message: 'Invoice processing completed'
});
} else if (fileType === 'pdf') {
// Check the number of pages in the PDF
const pageCount = await getPdfPageCount(uploadedFile.buffer);
if (pageCount === 1) {
// Process synchronously for single-page documents
const processedData = await processSinglePageDocument(uploadedFile.buffer);
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ..._.omit(processedData, "originalTextractResponse"), billForm });
return res.status(200).json({
success: true,
status: 'COMPLETED',
data: { ...processedData, billForm },
message: 'Invoice processing completed'
});
}
// Start the Textract job (non-blocking) for multi-page documents
const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid });
logger.log("bill-ocr-multipage-start", "DEBUG", req.user.email, jobid, jobInfo);
return res.status(202).json({
success: true,
textractJobId: jobInfo.jobId,
message: 'Invoice processing started',
statusUrl: `/ai/bill-ocr/status/${jobInfo.jobId}`
});
} else {
logger.log("bill-ocr-unsupported-filetype", "WARN", req.user.email, jobid, { fileType });
return res.status(400).json({
error: 'Unsupported file type',
message: 'Please upload a PDF or supported image file (JPEG, PNG, TIFF)'
});
}
} catch (error) {
logger.log("bill-ocr-error", "ERROR", req.user.email, jobid, { error: error.message, stack: error.stack });
return res.status(500).json({
error: 'Failed to start invoice processing',
message: error.message
});
}
}
async function handleBillOcrStatus(req, res) {
const { textractJobId } = req.params;
if (!textractJobId) {
logger.log("bill-ocr-status-error", "WARN", req.user.email, null, { error: 'No textractJobId found in params' });
return res.status(400).json({ error: 'Job ID is required' });
}
const jobStatus = await getTextractJob({ redisPubClient, textractJobId });
if (!jobStatus) {
return res.status(404).json({ error: 'Job not found' });
}
if (jobStatus.status === 'COMPLETED') {
// Generate billForm on-demand if not already generated
let billForm = jobStatus.data?.billForm;
if (!billForm && jobStatus.context) {
try {
billForm = await generateBillFormData({
processedData: jobStatus.data,
jobid: jobStatus.context.jobid,
bodyshopid: jobStatus.context.bodyshopid,
partsorderid: jobStatus.context.partsorderid,
req: req // Now we have request context!
});
logger.log("bill-ocr-multipage-complete", "DEBUG", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, billForm });
// Cache the billForm back to Redis for future requests
await setTextractJob({
redisPubClient,
textractJobId,
jobData: {
...jobStatus,
data: {
...jobStatus.data,
billForm
}
}
});
} catch (error) {
logger.log("bill-ocr-multipage-error", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: error.message, stack: error.stack });
return res.status(500).send({
status: 'COMPLETED',
error: 'Data processed but failed to generate bill form',
message: error.message,
data: jobStatus.data // Still return the raw processed data
});
}
}
return res.status(200).send({
status: 'COMPLETED',
data: {
...jobStatus.data,
billForm
}
});
} else if (jobStatus.status === 'FAILED') {
logger.log("bill-ocr-multipage-failed", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: jobStatus.error, });
return res.status(500).json({
status: 'FAILED',
error: jobStatus.error
});
} else {
return res.status(200).json({
status: jobStatus.status
});
}
}
/**
* Process a single-page document synchronously using AnalyzeExpenseCommand
* @param {Buffer} pdfBuffer
* @returns {Promise<Object>}
*/
async function processSinglePageDocument(pdfBuffer) {
const analyzeCommand = new AnalyzeExpenseCommand({
Document: {
Bytes: pdfBuffer
}
});
const result = await textractClient.send(analyzeCommand);
const invoiceData = extractInvoiceData(result);
const processedData = processScanData(invoiceData);
return {
...processedData,
originalTextractResponse: result
};
}
async function startTextractJob(pdfBuffer, context = {}) {
// Upload PDF to S3 temporarily for Textract async processing
const { bodyshopid, jobid } = context;
const s3Bucket = process.env.AWS_AI_BUCKET;
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
const snsRoleArn = process.env.AWS_TEXTRACT_SNS_ROLE_ARN;
if (!s3Bucket) {
throw new Error('AWS_AI_BUCKET environment variable is required');
}
if (!snsTopicArn) {
throw new Error('AWS_TEXTRACT_SNS_TOPIC_ARN environment variable is required');
}
if (!snsRoleArn) {
throw new Error('AWS_TEXTRACT_SNS_ROLE_ARN environment variable is required');
}
const uploadId = uuidv4();
const s3Key = `textract-temp/${bodyshopid}/${jobid}/${uploadId}.pdf`; //TODO Update Keys structure to something better.
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: s3Bucket,
Key: s3Key,
Body: pdfBuffer,
ContentType: 'application/pdf' //Hard coded - we only support PDFs for multi-page
});
await s3Client.send(uploadCommand);
// Start async Textract expense analysis with SNS notification
const startCommand = new StartExpenseAnalysisCommand({
DocumentLocation: {
S3Object: {
Bucket: s3Bucket,
Name: s3Key
}
},
NotificationChannel: {
SNSTopicArn: snsTopicArn,
RoleArn: snsRoleArn
},
ClientRequestToken: uploadId
});
const startResult = await textractClient.send(startCommand);
const textractJobId = startResult.JobId;
// Store job info in Redis using textractJobId as the key
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
status: 'IN_PROGRESS',
s3Key: s3Key,
uploadId: uploadId,
startedAt: new Date().toISOString(),
context: context // Store the context for later use
}
}
);
return {
jobId: textractJobId
};
}
// Process SQS messages from Textract completion notifications
async function processSQSMessages() {
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
if (!queueUrl) {
logger.log("bill-ocr-error", "ERROR", "api", null, { message: "AWS_TEXTRACT_SQS_QUEUE_URL not configured" });
return;
}
// Only poll if there are active mutli page jobs in progress
const hasActive = await hasActiveJobs({ redisPubClient });
if (!hasActive) {
return;
}
try {
const receiveCommand = new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20,
MessageAttributeNames: ['All']
});
const result = await sqsClient.send(receiveCommand);
if (result.Messages && result.Messages.length > 0) {
logger.log("bill-ocr-sqs-processing", "DEBUG", "api", null, { message: `Processing ${result.Messages.length} messages from SQS` });
for (const message of result.Messages) {
try {
// Environment-level filtering: check if this message belongs to this environment
const shouldProcess = await shouldProcessMessage(message);
if (shouldProcess) {
await handleTextractNotification(message);
// Delete message after successful processing
const deleteCommand = new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: message.ReceiptHandle
});
await sqsClient.send(deleteCommand);
}
} catch (error) {
logger.log("bill-ocr-sqs-processing-error", "ERROR", "api", null, { message, error: error.message, stack: error.stack });
}
}
}
} catch (error) {
logger.log("bill-ocr-sqs-receiving-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
}
/**
* Check if a message should be processed by this environment
* @param {Object} message - SQS message
* @returns {Promise<boolean>}
*/
async function shouldProcessMessage(message) {
try {
const body = JSON.parse(message.Body);
const snsMessage = JSON.parse(body.Message);
const textractJobId = snsMessage.JobId;
// Check if job exists in Redis for this environment (using environment-specific prefix)
const exists = await jobExists(textractJobId);
return exists;
} catch (error) {
logger.log("bill-ocr-message-check-error", "DEBUG", "api", null, { message: "Error checking if message should be processed", error: error.message, stack: error.stack });
// If we can't parse the message, don't process it
return false;
}
}
async function handleTextractNotification(message) {
const body = JSON.parse(message.Body);
let snsMessage
try {
snsMessage = JSON.parse(body.Message);
} catch (error) {
logger.log("bill-ocr-handle-textract-error", "DEBUG", "api", null, { message: "Error parsing SNS message - invalid message format.", error: error.message, stack: error.stack, body });
return;
}
const textractJobId = snsMessage.JobId;
const status = snsMessage.Status;
// Get job info from Redis
const jobInfo = await getTextractJob({ redisPubClient, textractJobId });
if (!jobInfo) {
logger.log("bill-ocr-job-not-found", "DEBUG", "api", null, { message: `Job info not found in Redis for Textract job ID: ${textractJobId}`, textractJobId, snsMessage });
return;
}
if (status === 'SUCCEEDED') {
// Retrieve the results
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
// Store the processed data - billForm will be generated on-demand in the status endpoint
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: 'COMPLETED',
data: {
...processedData,
originalTextractResponse: originalResponse
},
completedAt: new Date().toISOString()
}
}
);
} else if (status === 'FAILED') {
await setTextractJob(
{
redisPubClient,
textractJobId,
jobData: {
...jobInfo,
status: 'FAILED',
error: snsMessage.StatusMessage || 'Textract job failed',
completedAt: new Date().toISOString()
}
}
);
}
}
async function retrieveTextractResults(textractJobId) {
// Handle pagination if there are multiple pages of results
let allExpenseDocuments = [];
let nextToken = null;
do {
const getCommand = new GetExpenseAnalysisCommand({
JobId: textractJobId,
NextToken: nextToken
});
const result = await textractClient.send(getCommand);
if (result.ExpenseDocuments) {
allExpenseDocuments = allExpenseDocuments.concat(result.ExpenseDocuments);
}
nextToken = result.NextToken;
} while (nextToken);
// Store the complete original response
const fullTextractResponse = { ExpenseDocuments: allExpenseDocuments };
// Extract invoice data from Textract response
const invoiceData = extractInvoiceData(fullTextractResponse);
return {
processedData: processScanData(invoiceData),
originalResponse: fullTextractResponse
};
}
// Start SQS polling (call this when server starts)
function startSQSPolling() {
const pollInterval = setInterval(() => {
processSQSMessages().catch(error => {
logger.log("bill-ocr-sqs-poll-error", "ERROR", "api", null, { message: error.message, stack: error.stack });
});
}, 10000); // Poll every 10 seconds
return pollInterval;
}
module.exports = {
initializeBillOcr,
handleBillOcr,
handleBillOcrStatus,
startSQSPolling
};

View File

@@ -8,5 +8,6 @@ exports.podium = require("./podium").default;
exports.emsUpload = require("./emsUpload").default;
exports.carfax = require("./carfax").default;
exports.carfaxRps = require("./carfax-rps").default;
exports.vehicletype = require("./vehicletype/vehicletype").default;
exports.documentAnalytics = require("./analytics/documents").default;
exports.chatterApi = require("./chatter-api").default;

View File

@@ -264,29 +264,30 @@ const CreateRepairOrderTag = (job, errorCallback) => {
}${job.est_ct_fn ? job.est_ct_fn : ""}`
},
Dates: {
DateEstimated: (job.date_estimated && moment(job.date_estimated).format(DateFormat)) || "",
DateOpened: (job.date_opened && moment(job.date_opened).format(DateFormat)) || "",
DateScheduled:
(job.scheduled_in && moment(job.scheduled_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateArrived: (job.actual_in && moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateEstimated: job.date_estimated ? moment(job.date_estimated).format(DateFormat) : "",
DateOpened: job.date_open ? moment(job.date_open).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateScheduled: job.scheduled_in ? moment(job.scheduled_in).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateArrived: job.actual_in ? moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateStart: job.date_repairstarted
? (job.date_repairstarted && moment(job.date_repairstarted).tz(job.bodyshop.timezone).format(DateFormat)) ||
""
: (job.actual_in && moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateScheduledCompletion:
(job.scheduled_completion && moment(job.scheduled_completion).tz(job.bodyshop.timezone).format(DateFormat)) ||
"",
DateCompleted:
(job.actual_completion && moment(job.actual_completion).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateScheduledDelivery:
(job.scheduled_delivery && moment(job.scheduled_delivery).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateDelivered:
(job.actual_delivery && moment(job.actual_delivery).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateInvoiced:
(job.date_invoiced && moment(job.date_invoiced).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateExported:
(job.date_exported && moment(job.date_exported).tz(job.bodyshop.timezone).format(DateFormat)) || "",
DateVoid: (job.date_void && moment(job.date_void).tz(job.bodyshop.timezone).format(DateFormat)) || ""
? moment(job.date_repairstarted).tz(job.bodyshop.timezone).format(DateFormat)
: job.actual_in
? moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateScheduledCompletion: job.scheduled_completion
? moment(job.scheduled_completion).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateCompleted: job.actual_completion
? moment(job.actual_completion).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateScheduledDelivery: job.scheduled_delivery
? moment(job.scheduled_delivery).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateDelivered: job.actual_delivery
? moment(job.actual_delivery).tz(job.bodyshop.timezone).format(DateFormat)
: "",
DateInvoiced: job.date_invoiced ? moment(job.date_invoiced).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateExported: job.date_exported ? moment(job.date_exported).tz(job.bodyshop.timezone).format(DateFormat) : "",
DateVoid: job.date_void ? moment(job.date_void).tz(job.bodyshop.timezone).format(DateFormat) : ""
},
JobLineDetails: (function () {
const joblineSource = Array.isArray(job.joblines) ? job.joblines : job.joblines ? [job.joblines] : [];

View File

@@ -0,0 +1,126 @@
[
"PROMASTER 1500",
"PROMASTER 2500",
"PROMASTER CITY",
"NV 1500",
"NV 200",
"NV 2500",
"NV 3500",
"NV1500",
"NV200",
"NV2500",
"NV3500",
"SPRINTER",
"E150 ECONOLINE CARGO VAN",
"E150 ECONOLINE XL",
"E250 ECONOLINE CARGO",
"E250 ECONOLINE CARGO (AMALGAM)",
"E250 ECONOLINE CARGO (INSPECT)",
"E250 ECONOLINE CARGO VAN EXT",
"E250 ECONOLINE SUPER CARGO VAN",
"E350 CUTAWAY VAN",
"E350 ECONO SD CARGO VAN EXT",
"E350 ECONOLINE CARGO VAN",
"E350 ECONOLINE CUTAWAY",
"E350 ECONOLINE SD CARGO VAN",
"E350 ECONOLINE SD XL",
"E350 ECONOLINE SD XL EXT",
"E350 ECONOLINE SD XLT",
"E350 ECONOLINE SD XLT EXT",
"E350 SD CUTAWAY",
"E450",
"E450 ECONOLINE",
"E450 ECONOLINE SD",
"E450 ECONOLINE SD CUTAWAY",
"TRANSIT 150 WB 130 CARGO VAN",
"TRANSIT 150 WB 130 XLT",
"TRANSIT 150 WB 148 CARGO VAN",
"TRANSIT 250 WB 130 CARGO VAN",
"TRANSIT 250 WB 148 CARGO VAN",
"TRANSIT 250 WB 148 EL CARGO",
"TRANSIT 350 WB 148 CARGO VAN",
"TRANSIT 350 WB 148 EL CARGO",
"TRANSIT CONNECT XL CARGO VAN",
"TRANSIT CONNECT XLT CARGO VAN",
"250 TRANSIT",
"CITY EXPRESS LS CARGO VAN",
"CITY EXPRESS LT CARGO VAN",
"EXPRESS 1500",
"EXPRESS 1500 CARGO VAN",
"EXPRESS 1500 LS",
"EXPRESS 1500 LT",
"EXPRESS 2500 CARGO VAN",
"EXPRESS 2500 CARGO VAN EXT",
"EXPRESS 2500 LS",
"EXPRESS 2500 LT",
"EXPRESS 3500",
"EXPRESS 3500 CARGO VAN",
"EXPRESS 3500 CARGO VAN EXT",
"EXPRESS 3500 EXT",
"EXPRESS 3500 LS",
"EXPRESS 3500 LS EXT",
"EXPRESS 3500 LT",
"EXPRESS 3500 LT EXT",
"G3500 EXPRESS CUTAWAY",
"SAVANA 1500 CARGO VAN",
"SAVANA 1500 SL",
"SAVANA 1500 SLE",
"SAVANA 2500",
"2500 SAVANA",
"SAVANA 2500 CARGO VAN",
"SAVANA 2500 CARGO VAN EXT",
"SAVANA 2500 LT",
"SAVANA 2500 SLE",
"SAVANA 3500",
"SAVANA 3500 CARGO VAN",
"SAVANA 3500 CARGO VAN EXT",
"SAVANA 3500 EXT",
"SAVANA 3500 LT EXT",
"SAVANA 3500 SLE EXT",
"SAVANA G3500 CUTAWAY",
"SAVANA G4500 CUTAWAY",
"EXPRESS 1500 LS CARGO VAN",
"G20 SPORTVAN",
"NV 3500 S V8 CARGO VAN",
"E-150",
"E-250",
"E-350",
"E-450",
"E150",
"E250",
"E350",
"TRANSIT",
"CITY",
"CITY EXPRESS",
"EXPRESS",
"EXPRESS 2500",
"G3500",
"SAVANA",
"SAVANA 1500",
"CHEVY EXPRESS G2500",
"CLUBWAGON E350",
"TRANSIT CONNECT",
"SPRINTER 2500",
"TRANSIT 150",
"ECONOLINE E250",
"TRANSIT 250",
"ECONOLINE E350",
"NV3500 HD",
"TRANSIT 350HD",
"ECONOLINE E150",
"E250 ECONOLINE",
"C/V",
"E350 CHSCAB",
"G1500 CHEVY EXPRESS",
"2500 SPRINTER",
"E150 ECONOLINE",
"350 TRANSIT",
"E450 CUTAWAY",
"PROMASTER 3500",
"CHEVY EXPRESS G3500",
"SAVANA G3500",
"1500 PROMASTER",
"2500 EXPRESS",
"3500 EXPRESS",
"3500 SPRINTER"
]

View File

@@ -0,0 +1,33 @@
[
"GRAND CARAVAN",
"GRANDCARAVAN",
"GRAND CARAVAN CREW",
"GRAND CARAVAN CV",
"GRAND CARAVAN CVP",
"GRAND CARAVAN SE",
"GRAND CARAVAN SXT",
"CARAVAN CV",
"SIENNA CE V6",
"SIENNA LE V6",
"SIENNA XLE V6",
"SIENNA",
"ODYSSEY",
"SEDONA",
"PACIFICA (NEW)",
"QUEST",
"CARAVAN",
"MONTANA SV6",
"FREESTAR",
"UPLANDER",
"MONTANA",
"VOYAGER",
"ENTOURAGE",
"PACIFICA",
"CARNIVAL",
"VENTURE",
"SAFARI",
"VANAGON",
"WINDSTAR",
"TOWN&COUNTRY",
"ROUTAN"
]

View File

@@ -0,0 +1,485 @@
[
"EDGE SEL",
"ESCAPE",
"ESCAPE SE",
"ESCAPE SEL",
"ESCAPE XLT V6",
"EXPEDITION",
"EXPEDITION LIMITED",
"EXPEDITION MAX",
"EXPEDITION MAX LIMITED",
"EXPLORER",
"EXCURSION",
"EXPLORER LIMITED",
"EXPLORER PLATINUM ECOBOOST",
"EXPLORER XLT",
"FLEX",
"FLEX SE",
"ECOSPORT",
"ESCAPE HYBRID",
"MUSTANG MACH-E",
"BRONCO",
"BRONCO SPORT",
"TRAILBLAZER",
"BLAZER LT",
"CHEROKEE",
"CHEROKEE CLASSIC",
"CHEROKEE COUNTRY",
"CHEROKEE LIMITED",
"CHEROKEE NORTH",
"CHEROKEE OVERLAND",
"CHEROKEE SPORT",
"CHEROKEE TRAILHAWK",
"CJ",
"CJ7",
"CJ7 RENEGADE",
"COMMANDER",
"COMMANDER LIMITED",
"COMMANDER SPORT",
"COMPASS",
"COMPASS HIGH ALTITUDE",
"COMPASS LATITUDE",
"COMPASS LIMITED",
"COMPASS NORTH",
"COMPASS SPORT",
"COMPASS TRAILHAWK",
"GLADIATOR OVERLAND",
"GLADIATOR RUBICON",
"GRAND CHEROKEE LAREDO",
"GRAND CHEROKEE LIMITED",
"GRAND CHEROKEE OVERLAND",
"GRAND CHEROKEE SE",
"GRAND CHEROKEE SRT",
"GRAND CHEROKEE SRT8",
"GRAND CHEROKEE SUMMIT",
"GRAND CHEROKEE TRACKHAWK",
"GRAND CHEROKEE TRAILHAWK",
"GRAND CHEROKEE",
"GRANDCHEROKEE",
"LIBERTY LIMITED",
"LIBERTY RENEGADE",
"LIBERTY SPORT",
"LIBERTY",
"PATRIOT",
"PATRIOT HIGH ALTITUDE",
"PATRIOT LATITUDE",
"PATRIOT LIMITED",
"PATRIOT NORTH",
"PATRIOT SPORT",
"RENEGADE LIMITED",
"RENEGADE NORTH",
"RENEGADE SPORT",
"RENEGADE TRAILHAWK",
"TJ",
"TJ RUBICON",
"TJ SAHARA",
"TJ SPORT",
"TJ UNLIMITED",
"WRANGLER",
"WRANGLER RUBICON",
"WRANGLER SAHARA",
"WRANGLER SPORT",
"WRANGLER UNLIMITED",
"WRANGLER UNLIMITED 70TH ANNIV",
"WRANGLER UNLIMITED RUBICON",
"WRANGLER UNLIMITED SAHARA",
"WRANGLER UNLIMITED SPORT",
"WRANGLER UNLIMITED X",
"WRANGLER X",
"YJ WRANGLER",
"AVIATOR",
"AVIATOR RESERVE",
"MKC",
"MKC RESERVE",
"MKC SELECT",
"MKT",
"MKT ECOBOOST",
"MKX",
"MKX RESERVE",
"NAUTILUS RESERVE",
"NAUTILUS RESERVE V6",
"NAVIGATOR",
"NAVIGATOR L",
"NAVIGATOR L RESERVE",
"NAVIGATOR L SELECT",
"NAVIGATOR RESERVE",
"PILOT",
"PILOT BLACK EDITION",
"PILOT ELITE",
"PILOT EX",
"PILOT EX-L",
"PILOT GRANITE",
"PILOT LX",
"PILOT SE",
"PILOT SE-L",
"PILOT TOURING",
"DURANGO R/T",
"DURANGO SLT PLUS",
"DURANGO SRT",
"DURANGO",
"JOURNEY",
"JOURNEY CROSSROAD",
"JOURNEY CVP",
"JOURNEY LIMITED",
"JOURNEY R/T",
"JOURNEY SXT",
"NITRO SE",
"NITRO",
"K1500 SUBURBAN",
"SUBURBAN 1500 LT",
"SUBURBAN 1500 LTZ",
"SUBURBAN 1500 PREMIER",
"SUBURBAN 2500 LS",
"TAHOE LT",
"TRAVERSE LS",
"TRAVERSE LT",
"TRAVERSE PREMIER",
"TRAX LT",
"TRAX PREMIER",
"UPLANDER LT EXT",
"SUBURBAN",
"TAHOE",
"TRAVERSE",
"TRAX",
"UPLANDER",
"YUKON",
"YUKON DENALI",
"YUKON XL",
"YUKON XL DENALI",
"EQUINOX LS",
"EQUINOX LT",
"EQUINOX PREMIER",
"EQUINOX",
"RAV4 LE",
"RAV4 XLE",
"HIGHLANDER SPORT V6",
"4RUNNER SR5 V6",
"RAV4",
"RAV4 HYBRID",
"RAV4 XLE HYBRID",
"HIGHLANDER",
"4RUNNER",
"SEQUOIA",
"PATHFINDER SE",
"PATHFINDER SL",
"PATHFINDER",
"MURANO PLATINUM",
"MURANO SV",
"MURANO",
"TUCSON",
"TERRAIN",
"SORENTO",
"EDGE",
"KICKS",
"QASHQAI",
"SANTA FE",
"ARMADA",
"TELLURIDE",
"PALISADE",
"SELTOS",
"TORRENT",
"C-HR",
"SPORTAGE",
"VENZA",
"ACADIA",
"CR-V",
"HR-V",
"CX-5",
"CX-50",
"CX-7",
"CX-9",
"CX-3",
"Q3",
"Q5",
"Q7",
"Q8",
"JUKE SV",
"JUKE",
"ROGUE",
"ROGUE SV",
"XTERRA",
"COROLLA CROSS",
"ACADIA DENALI",
"TAURUS X",
"MACAN",
"FJ CRUISER",
"BRONCO SPORT BADLANDS",
"ESCALADE",
"RX 350",
"KONA",
"MDX",
"RDX",
"COOPER COUNTRYMAN",
"V70",
"OUTLANDER",
"RIO5",
"GLC300 COUPE",
"ENCORE",
"SRX",
"SANTA FE SPORT",
"NX 300",
"WRANGLER UNLIMITE",
"WRANGLER JK UNLIM",
"RANGEROVER EVOQUE",
"CROSSTREK",
"FORESTER",
"TIGUAN",
"XV CROSSTREK",
"ENDEAVOR",
"RX 330",
"ATLAS",
"XC90",
"TOUAREG",
"STELVIO",
"RANGE ROVER SPORT",
"GLE350D",
"EX35",
"RVR",
"MONTERO",
"X-TRAIL",
"GRAND VITARA",
"TRIBUTE",
"X3",
"XC60",
"GLK250 BLUETEC",
"ENVOY",
"ML350 BLUETEC",
"ENVISION",
"FX35",
"X1",
"VENUE",
"TAOS",
"KONA ELECTRIC",
"OUTLANDER PHEV",
"PASSPORT",
"H3",
"EXPLORERSPORTTRAC",
"F-PACE",
"ML320 BLUETEC",
"REGAL SPORTBACK",
"DISCOVERY SPORT",
"RENDEZVOUS",
"XC70",
"COMPASS (NEW)",
"CUBE",
"V60 CROSS COUNTRY",
"QX70",
"X6",
"ELEMENT",
"RX 400H",
"VUE",
"RANGE ROVER VELAR",
"E-PACE",
"RAV4 PRIME",
"LX 570",
"GX 470",
"EX37",
"GLE43",
"NAUTILUS",
"XT6",
"RX 450H",
"ESCALADE ESV",
"OUTLOOK",
"CAYENNE",
"XC90 PLUG-IN",
"MODEL X",
"MODEL Y",
"GLC300",
"SANTA FE HYBRID",
"G63",
"XV CROSSTREK HYBR",
"JX35",
"JIMMY",
"TUCSON HYBRID",
"XC40 ELECTRIC",
"RX 300",
"ML320",
"WRANGLER JK UNLIMITED",
"POLICE INTERCEPTOR UTILITY",
"WRANGLER JK",
"TRIBECA",
"E-TRON SPORTBACK",
"500X",
"RX 350H",
"GL350 BLUETEC",
"WRANGLER UNLIMITED 4XE",
"GV80",
"GL550",
"Q5 E",
"H2 SUV",
"Q5 HYBRID",
"IONIQ 5",
"SQ5 SPORTBACK",
"LEVANTE",
"TONALE",
"GLE43 COUPE",
"GRAND CHEROKEE WK",
"DEFENDER",
"NX 450H+",
"ML400",
"LX 600",
"RX 450HL",
"SORENTO HYBRID",
"NX 350",
"TRACKER",
"GLE450",
"Q5 SPORTBACK",
"CR-V HYBRID",
"LX 470",
"EQS580 SUV",
"H2",
"EV9",
"SORENTO PLUG-IN",
"LYRIQ",
"GLE550",
"RX 500H",
"X1 SAV",
"E-TRON S SPORTBACK",
"ML500",
"GRAND HIGHLANDER HYBRID",
"RS Q8",
"GLS550",
"GLS580",
"IX",
"CAYENNE COUPE",
"SOLTERRA",
"PATHFINDER HYBRID",
"Q8 E-TRON",
"TX 350",
"TX 500H",
"EQUINOX EV",
"NAUTILUS HYBRID",
"TRAVERSE LIMITED",
"CX-70",
"SANTA FE XL",
"RENEGADE",
"QX50",
"ECLIPSE CROSS",
"QX80",
"X5",
"X3",
"X1",
"X4",
"ENCLAVE",
"ENCORE GX",
"CAYENNE HYBRID",
"SOUL",
"GX 460",
"UX 250H",
"XT5",
"GLE53",
"XT4",
"SQ7",
"NX 350H",
"GLK350",
"GLE350",
"NX 300H",
"NX 200T",
"RANGE ROVER EVOQUE",
"GLS450",
"TERRAIN DENALI",
"GRAND CHEROKEE L",
"GLE400",
"TUCSON PLUG-IN",
"BLAZER",
"ASCENT",
"HIGHLANDER HYBRID",
"ATLAS CROSS SPORT",
"XC40",
"VENZA HYBRID",
"GLA45",
"GLB250",
"GRAND HIGHLANDER",
"GV70",
"NIRO",
"NIRO EV",
"GLA250",
"ESCAPE PLUG-IN",
"WAGONEER",
"CX-30",
"QX60",
"GRAND CHEROKEE 4XE",
"SPORTAGE HYBRID",
"EV6",
"TONALE PLUG-IN",
"GLC43 COUPE",
"X2",
"RX 350L",
"HORNET",
"ENVISTA",
"LEVANTE S",
"SPORTAGE PLUG-IN",
"ORLANDO",
"X5 M",
"EXPLORER HYBRID",
"FREESTYLE",
"CORSAIR",
"K1500 YUKON XL",
"RANGE ROVER",
"SUV W/O LABOR",
"ID.4",
"CX-90",
"X7",
"CORSAIR PLUG-IN",
"ESCALADE EXT",
"QX55",
"DISCOVERY",
"BOLT EUV",
"C40 ELECTRIC",
"LR4",
"GRAND WAGONEER",
"XC60 PLUG-IN",
"LR2",
"EQE350 SUV",
"COROLLA CROSS HYBRID",
"SOUL EV",
"GRECALE",
"SUV W/O LABOR",
"QX30",
"SQ5",
"NIRO PLUG-IN",
"BORREGO",
"CX-90 PLUG-IN",
"XL-7",
"SUV W/O LABOR",
"SUV W/O LABOR",
"I-PACE",
"HORNET PLUG-IN",
"UX 300H",
"ML320 CDI",
"VERACRUZ",
"SQ8",
"GLE53 COUPE",
"ZDX",
"9-7X",
"ARIYA",
"ASPEN",
"AVIATOR PLUG-IN",
"B9 TRIBECA",
"BRAVADA",
"ENVOY XL",
"EQB350",
"EQB350 SUV",
"ESCALADE-V",
"E-TRON",
"FX37",
"GL320 CDI",
"GLADIATOR",
"GLC43",
"GLE450 COUPE",
"GLE63",
"GV60",
"MKT TOWN CAR",
"ML350",
"ML550",
"ML63",
"NX 250",
"Q4 E-TRON",
"Q8 E-TRON SPORTBACK",
"QX4",
"QX56",
"SANTA FE PLUG-IN",
"UX 200",
"WAGONEER L",
"XB"
]

View File

@@ -0,0 +1,567 @@
[
"MARK LT",
"F-150",
"F-250",
"F-350",
"F-450",
"F-550",
"F-650",
"F100 PICKUP",
"F150 FX2 SUPERCAB",
"F150 FX4 PICKUP",
"F150 FX4 SUPERCAB",
"F150 FX4 SUPERCREW",
"F150 HARLEY DAVIDSON SUPERCAB",
"F150 HARLEY DAVIDSON SUPERCREW",
"F150 KING RANCH SUPERCREW",
"F150 LARIAT FX4 SUPERCREW",
"F150 LARIAT HARLEY DAVIDSON SC",
"F150 LARIAT KING RANCH SUPCREW",
"F150 LARIAT LIMITED SUPERCREW",
"F150 LARIAT PICKUP",
"F150 LARIAT SUPERCAB",
"F150 LARIAT SUPERCAB (AMALGAM)",
"F150 LARIAT SUPERCREW",
"F150 LARIAT SUPERCREW (AMALGA)",
"F150 LIMITED SUPERCREW",
"F150 PICKUP",
"F150 PLATINUM SUPERCREW",
"F150 RAPTOR SUPERCAB",
"F150 RAPTOR SUPERCREW",
"F150 STX PICKUP",
"F150 STX SUPERCAB",
"F150 SUPERCAB",
"F150 SUPERCREW",
"F150 SUPERCREW (AMALGAMATED)",
"F150 SVT RAPTOR SUPERCAB",
"F150 XL PICKUP",
"F150 XL SUPERCAB",
"F150 XL SUPERCREW",
"F150 XLT LARIAT SUPERCAB",
"F150 XLT PICKUP",
"F150 XLT SUPERCAB",
"F150 XLT SUPERCREW",
"F150 XLT SUPERCREW (AMALGAMAT)",
"F150 XTR SUPERCAB",
"F250 PICKUP",
"F250 SD CREW CAB",
"F250 SD FX4 CREW CAB",
"F250 SD FX4 SUPERCAB",
"F250 SD KING RANCH CREW CAB",
"F250 SD LARIAT CREW CAB",
"F250 SD LARIAT CREW CAB (AMAL)",
"F250 SD LARIAT PICKUP",
"F250 SD LARIAT SUPERCAB",
"F250 SD LIMITED CREW CAB",
"F250 SD PLATINUM CREW CAB",
"F250 SD SUPERCAB",
"F250 SD XL CREW CAB",
"F250 SD XL PICKUP",
"F250 SD XL SUPERCAB",
"F250 SD XLT CREW CAB",
"F250 SD XLT PICKUP",
"F250 SD XLT SUPERCAB",
"F250 SUPERCAB",
"F250 XL CREW CAB",
"F350 CREW CAB",
"F350 PICKUP",
"F350 PICKUP 2WD",
"F350 SD CABELAS CREW CAB",
"F350 SD CREW CAB",
"F350 SD FX4 CREW CAB",
"F350 SD FX4 SUPERCAB",
"F350 SD HARLEY DAVIDSON",
"F350 SD KING RANCH CREW CAB",
"F350 SD LARIAT CREW CAB",
"F350 SD LARIAT CREW CAB (AMAL)",
"F350 SD LARIAT KING RANCH",
"F350 SD LARIAT SUPERCAB",
"F350 SD LIMITED CREW CAB",
"F350 SD PICKUP",
"F350 SD PLATINUM CREW CAB",
"F350 SD SUPERCAB",
"F350 SD XL CREW CAB",
"F350 SD XL PICKUP",
"F350 SD XL SUPERCAB",
"F350 SD XLT CREW CAB",
"F350 SD XLT SUPERCAB",
"F350 SUPER DUTY",
"F350 SUPER DUTY XL",
"F350 XL PICKUP",
"F450",
"F450 Pickup",
"F450 SD KING RANCH CREW CAB",
"F450 SD LARIAT CREW CAB",
"F450 SD PICKUP",
"F450 SD PLATINUM CREW CAB",
"F450 SD XL",
"F450 SD XL CREW CAB",
"F450 SD XL PICKUP",
"F450 SD XLT CREW CAB",
"F450 SUPER DUTY XLT",
"F550",
"F550 SD",
"F550 SD XL",
"F550 SD XL PICKUP",
"F550 SD XLT CREW CAB",
"F550 SD XLT SUPERCAB",
"F550 SUPER DUTY",
"F550 SUPER DUTY XL",
"F550 SUPER DUTY XLT",
"F550 SUPER DUTY XLT CREW CAB",
"F550 XL",
"F650 SD XLT SUPERCAB",
"F68",
"F750 XL",
"RANGER",
"RANGER EDGE SUPERCAB",
"RANGER FX4 SUPERCAB",
"RANGER LARIAT SUPERCREW",
"RANGER SPORT SUPERCAB",
"RANGER STX SUPERCAB",
"RANGER SUPERCAB",
"RANGER XL",
"RANGER XL SUPERCAB",
"RANGER XLT",
"RANGER XLT SUPERCAB",
"RANGER XLT SUPERCREW",
"FRONTIER LE CREW CAB V6",
"FRONTIER NISMO CREW CAB V6",
"FRONTIER NISMO KING CAB V6",
"FRONTIER PRO-4X CREW CAB V6",
"FRONTIER PRO-4X KING CAB V6",
"FRONTIER S KING CAB",
"FRONTIER SC CREW CAB V6",
"FRONTIER SC V6",
"FRONTIER SE CREW CAB V6",
"FRONTIER SE KING CAB V6",
"FRONTIER SL CREW CAB V6",
"FRONTIER SV CREW CAB V6",
"FRONTIER SV KING CAB V6",
"FRONTIER XE KING CAB",
"FRONTIER XE KING CAB V6",
"KING CAB",
"TITAN 5.6 LE CREW CAB",
"TITAN 5.6 LE KING CAB",
"TITAN 5.6 MIDNIGHT CREW CAB",
"TITAN 5.6 PLATINUM RESERVE CC",
"TITAN 5.6 PRO-4X CREW CAB",
"TITAN 5.6 PRO-4X KING CAB",
"TITAN 5.6 S CREW CAB",
"TITAN 5.6 SE CREW CAB",
"TITAN 5.6 SE KING CAB",
"TITAN 5.6 SL CREW CAB",
"TITAN 5.6 SV CREW CAB",
"TITAN 5.6 SV KING CAB",
"TITAN 5.6 XE CREW CAB",
"TITAN 5.6 XE KING CAB",
"TITAN XD PLATINUM CREW CAB",
"TITAN XD PRO-4X CREW CAB",
"TITAN XD S CREW CAB",
"TITAN XD SL CREW CAB",
"TITAN XD SV CREW CAB",
"PICKUP SR5",
"TACOMA",
"TACOMA ACCESS CAB",
"TACOMA DOUBLE CAB V6",
"TACOMA LIMITED DOUBLE CAB V6",
"TACOMA PRERUNNER DOUBLE CAB V6",
"TACOMA PRERUNNER V6 ACCESS CAB",
"TACOMA PRERUNNER XTRACAB",
"TACOMA PRERUNNER XTRACAB V6",
"TACOMA SR5 DOUBLE CAB V6",
"TACOMA SR5 V6 ACCESS CAB",
"TACOMA SR5 V6 XTRACAB",
"TACOMA V6 ACCESS CAB",
"TACOMA XTRACAB",
"TACOMA XTRACAB V6",
"TUNDRA ACCESS CAB V8",
"TUNDRA DOUBLE CAB V8",
"TUNDRA LIMITED ACCESS CAB V8",
"TUNDRA LIMITED SR5 DBLCAB V8",
"TUNDRA LIMITED V8",
"TUNDRA LIMITED V8 CREWMAX",
"TUNDRA LIMITED V8 DOUBLE CAB",
"TUNDRA PLATINUM V8 CREWMAX",
"TUNDRA SR DOUBLE CAB V8",
"TUNDRA SR V8",
"TUNDRA SR5 DOUBLE CAB V8",
"TUNDRA SR5 TRD DOUBLE CAB V8",
"TUNDRA SR5 V8 CREWMAX",
"TUNDRA V8",
"TUNDRA V8 CREWMAX",
"XTRACAB LONG BOX",
"AVALANCHE 1500",
"AVALANCHE 1500 LS",
"AVALANCHE 1500 LS Z71",
"AVALANCHE 1500 LT",
"AVALANCHE 1500 LT Z71",
"AVALANCHE 1500 LTZ",
"C/R 10/1500 4+CAB",
"C/R 10/1500 PICKUP",
"C/R 20/2500 4+CAB",
"C/R 20/2500 PICKUP",
"C3500",
"COLORADO",
"COLORADO EXT CAB",
"COLORADO LS",
"COLORADO LS CREW CAB",
"COLORADO LS EXT CAB",
"COLORADO LT",
"COLORADO LT CREW CAB",
"COLORADO LT EXT CAB",
"COLORADO WT CREW CAB",
"COLORADO WT EXT CAB",
"COLORADO Z71 CREW CAB",
"COLORADO Z71 EXT CAB",
"COLORADO ZR2 CREW CAB",
"COLORADO ZR2 EXT CAB",
"HHR LS PANEL",
"K/V 10/1500 4+CAB",
"K/V 10/1500 PICKUP",
"K/V 20/2500 4+CAB",
"K/V 20/2500 PICKUP",
"K/V 30/3500 4+CAB",
"Pickup K3500",
"Pickup Silverado C2500 HD",
"S10 4+CAB",
"S10 LS 4+CAB",
"SILVERADO 1500",
"SILVERADO 1500 CHEYENNE CREW",
"SILVERADO 1500 CREW CAB",
"SILVERADO 1500 CREW CAB (AMAL)",
"SILVERADO 1500 CUST TRAIL DC",
"SILVERADO 1500 CUSTOM CREW CAB",
"SILVERADO 1500 CUSTOM DC",
"SILVERADO 1500 CUSTOM TRAIL CC",
"SILVERADO 1500 DOUBLE (AMALGA)",
"SILVERADO 1500 EXT CAB",
"SILVERADO 1500 HD LS CREW CAB",
"SILVERADO 1500 HD LT CREW CAB",
"SILVERADO 1500 HIGH COUNTRY CC",
"SILVERADO 1500 HYBRID CREW CAB",
"SILVERADO 1500 LS",
"SILVERADO 1500 LS CREW CAB",
"SILVERADO 1500 LS DOUBLE CAB",
"SILVERADO 1500 LS EXT CAB",
"SILVERADO 1500 LT",
"SILVERADO 1500 LT CC (AMALGAM)",
"SILVERADO 1500 LT CREW CAB",
"SILVERADO 1500 LT DOUBLE CAB",
"SILVERADO 1500 LT EXT CAB",
"SILVERADO 1500 LT TRAIL CC",
"SILVERADO 1500 LT TRAIL DC",
"SILVERADO 1500 LTZ CREW CAB",
"SILVERADO 1500 LTZ DOUBLE CAB",
"SILVERADO 1500 LTZ EXT CAB",
"SILVERADO 1500 RST CREW CAB",
"SILVERADO 1500 RST DOUBLE CAB",
"SILVERADO 1500 SS EXT CAB",
"SILVERADO 1500 WT",
"SILVERADO 1500 WT CREW CAB",
"SILVERADO 1500 WT DOUBLE CAB",
"SILVERADO 1500 WT EXT CAB",
"SILVERADO 2500 EXT CAB",
"SILVERADO 2500 HD",
"SILVERADO 2500 HD CREW CAB",
"SILVERADO 2500 HD EXT CAB",
"SILVERADO 2500 HD HC CREW CAB",
"SILVERADO 2500 HD LS CREW CAB",
"SILVERADO 2500 HD LS EXT CAB",
"SILVERADO 2500 HD LT",
"SILVERADO 2500 HD LT CREW CAB",
"SILVERADO 2500 HD LT DBL CAB",
"SILVERADO 2500 HD LT EXT CAB",
"SILVERADO 2500 HD LTZ CREW CAB",
"SILVERADO 2500 HD LTZ DBL CAB",
"SILVERADO 2500 HD LTZ EXT CAB",
"SILVERADO 2500 HD WT",
"SILVERADO 2500 HD WT CREW CAB",
"SILVERADO 2500 HD WT DBL CAB",
"SILVERADO 2500 HD WT EXT CAB",
"SILVERADO 3500",
"SILVERADO 3500 CREW CAB",
"SILVERADO 3500 CREW CAB (AMAL)",
"SILVERADO 3500 EXT CAB",
"SILVERADO 3500 HC CREW CAB",
"SILVERADO 3500 HD (AMALGAMATE)",
"SILVERADO 3500 LS",
"SILVERADO 3500 LS CREW CAB",
"SILVERADO 3500 LS EXT CAB",
"SILVERADO 3500 LT CREW CAB",
"SILVERADO 3500 LT DOUBLE CAB",
"SILVERADO 3500 LT EXT CAB",
"SILVERADO 3500 LTZ CREW CAB",
"SILVERADO 3500 LTZ EXT CAB",
"SILVERADO 3500 WT CREW CAB",
"Silverado 3500HD",
"B250 SPORTSMAN",
"DAKOTA CLUB CAB",
"DAKOTA LARAMIE V8 CLUB CAB",
"DAKOTA LARAMIE V8 QUAD CAB",
"DAKOTA QUAD CAB",
"DAKOTA SLT CREW CAB",
"DAKOTA SLT EXT CAB",
"DAKOTA SLT PLUS QUAD CAB",
"DAKOTA SLT PLUS V8 CLUB CAB",
"DAKOTA SLT PLUS V8 QUAD CAB",
"DAKOTA SLT QUAD CAB",
"DAKOTA SLT V8 CLUB CAB",
"DAKOTA SLT V8 CREW CAB",
"DAKOTA SLT V8 EXT CAB",
"DAKOTA SLT V8 QUAD CAB",
"DAKOTA SPORT V8",
"DAKOTA SPORT V8 CLUB CAB",
"DAKOTA SPORT V8 QUAD CAB",
"DAKOTA ST CLUB CAB",
"DAKOTA ST QUAD CAB",
"DAKOTA ST V8 QUAD CAB",
"DAKOTA SXT CREW CAB",
"DAKOTA SXT EXT CAB",
"DAKOTA SXT V8 CREW CAB",
"DAKOTA SXT V8 EXT CAB",
"DAKOTA V8 CLUB CAB",
"DAKOTA V8 QUAD CAB",
"RAM 1500",
"RAM 1500 BIG HORN CREW CAB",
"RAM 1500 BIG HORN QUAD CAB",
"RAM 1500 CLUB CAB",
"RAM 1500 CREW CAB (AMALGAMATE)",
"RAM 1500 EXPRESS",
"RAM 1500 LARAMIE CREW (AMALGA)",
"RAM 1500 LARAMIE CREW CAB",
"RAM 1500 LARAMIE LONGHORN CREW",
"RAM 1500 LARAMIE MEGA CAB",
"RAM 1500 LARAMIE QUAD CAB",
"RAM 1500 LARAMIE SLT QUAD CAB",
"RAM 1500 LIMITED CREW CAB",
"RAM 1500 LONGHORN CREW CAB",
"RAM 1500 OUTDOORSMAN CREW CAB",
"RAM 1500 OUTDOORSMAN QC (AMAL)",
"RAM 1500 OUTDOORSMAN QUAD CAB",
"RAM 1500 QUAD CAB",
"RAM 1500 R/T",
"RAM 1500 REBEL CREW CAB",
"RAM 1500 REBEL QUAD CAB",
"RAM 1500 SLT",
"RAM 1500 SLT CREW (AMALGAMATE)",
"RAM 1500 SLT CREW CAB",
"RAM 1500 SLT MEGA CAB",
"RAM 1500 SLT QUAD (AMALGAMATE)",
"RAM 1500 SLT QUAD CAB",
"RAM 1500 SPORT",
"RAM 1500 SPORT CLUB CAB",
"RAM 1500 SPORT CREW CAB",
"RAM 1500 SPORT CREW CAB (AMAL)",
"RAM 1500 SPORT QUAD CAB",
"RAM 1500 ST",
"RAM 1500 ST CREW CAB",
"RAM 1500 ST QUAD CAB",
"RAM 1500 SXT CREW CAB",
"RAM 1500 SXT QUAD CAB",
"RAM 1500 TRADESMAN CREW CAB",
"RAM 1500 TRADESMAN QUAD CAB",
"RAM 1500 TRX QUAD CAB",
"RAM 2500",
"RAM 2500 BIG HORN CREW CAB",
"RAM 2500 BIG HORN MEGA CAB",
"RAM 2500 CLUB CAB",
"RAM 2500 LARAMIE CREW CAB",
"RAM 2500 LARAMIE LONGHORN CREW",
"RAM 2500 LARAMIE LONGHORN MEGA",
"RAM 2500 LARAMIE MEGA CAB",
"RAM 2500 LARAMIE QUAD CAB",
"RAM 2500 LARAMIE SLT",
"RAM 2500 LARAMIE SLT QUAD CAB",
"RAM 2500 LIMITED CREW CAB",
"RAM 2500 OUTDOORSMAN CREW CAB",
"RAM 2500 POWER WAGON CREW CAB",
"RAM 2500 QUAD CAB",
"RAM 2500 SLT",
"RAM 2500 SLT CREW CAB",
"RAM 2500 SLT MEGA CAB",
"RAM 2500 SLT QUAD CAB",
"RAM 2500 SLT QUAD CAB (AMALGA)",
"RAM 2500 SPORT QUAD CAB",
"RAM 2500 ST",
"RAM 2500 ST CREW CAB",
"RAM 2500 ST QUAD CAB",
"RAM 2500 SXT QUAD CAB",
"RAM 2500 TRADESMAN",
"RAM 2500 TRADESMAN CREW CAB",
"RAM 2500 TRX CREW CAB",
"RAM 2500 TRX QUAD CAB",
"RAM 3500",
"RAM 3500 4WD",
"RAM 3500 BIG HORN CREW CAB",
"RAM 3500 CREW CAB",
"RAM 3500 CREW CAB (AMALGAMATE)",
"RAM 3500 LARAMIE CREW CAB",
"RAM 3500 LARAMIE LONGHORN CREW",
"RAM 3500 LARAMIE LONGHORN MEGA",
"RAM 3500 LARAMIE MEGA CAB",
"RAM 3500 LARAMIE QUAD CAB",
"RAM 3500 LARAMIE SLT",
"RAM 3500 LARAMIE SLT QUAD CAB",
"RAM 3500 LIMITED MEGA CAB",
"RAM 3500 LONGHORN CREW CAB",
"RAM 3500 QUAD CAB",
"RAM 3500 SLT",
"RAM 3500 SLT CREW CAB",
"RAM 3500 SLT MEGA CAB",
"RAM 3500 SLT QUAD CAB",
"RAM 3500 SPORT QUAD CAB",
"RAM 3500 ST",
"RAM 3500 ST CREW CAB",
"RAM 3500 ST QUAD CAB",
"RAM 3500 TRX QUAD CAB",
"RAM 4500",
"RAM 4500 CREW CAB",
"RAM 5500",
"RAM 5500 CREW CAB",
"W250 TURBO DIESEL",
"C Series 5500",
"C/R 1500 4+CAB",
"C/R 1500 PICKUP",
"C/R 1500 SIERRA SL EXT CAB",
"C/R 3500",
"C/R 3500 PICKUP",
"CANYON ALL TERRAIN CREW CAB",
"CANYON CREW CAB",
"CANYON DENALI CREW CAB",
"CANYON EXT CAB",
"CANYON SL",
"CANYON SL EXT CAB",
"CANYON SLE",
"CANYON SLE CREW CAB",
"CANYON SLE EXT CAB",
"CANYON SLT CREW CAB",
"CANYON SLT CREW CAB (AMALGAMA)",
"K/V 1500 4+CAB",
"K/V 1500 PICKUP",
"K/V 2500 4+CAB",
"K/V 2500 PICKUP",
"K/V 3500 SIERRA SL CREW CAB",
"K/V 3500 SIERRA SLE CREW CAB",
"SIERRA 1500 AT4 CREW CAB",
"SIERRA 1500 AT4 DOUBLE CAB",
"SIERRA 1500 CREW CAB",
"SIERRA 1500 CREW CAB (AMALGAM)",
"SIERRA 1500 DENALI CREW CAB",
"SIERRA 1500 DENALI EXT CAB",
"SIERRA 1500 DOUBLE CAB",
"SIERRA 1500 ELEVATION CREW CAB",
"SIERRA 1500 ELEVATION DC",
"SIERRA 1500 EXT CAB",
"SIERRA 1500 HD CREW CAB",
"SIERRA 1500 HD SLE CREW CAB",
"SIERRA 1500 HD SLT CREW CAB",
"SIERRA 1500 NEVADA EDITION",
"SIERRA 1500 PICKUP",
"SIERRA 1500 SL CREW CAB",
"SIERRA 1500 SL EXT CAB",
"SIERRA 1500 SL PICKUP",
"SIERRA 1500 SLE CREW CAB",
"SIERRA 1500 SLE DC (AMALGAMAT)",
"SIERRA 1500 SLE DOUBLE CAB",
"SIERRA 1500 SLE EXT CAB",
"SIERRA 1500 SLE EXT CAB (AMAL)",
"SIERRA 1500 SLE PICKUP",
"SIERRA 1500 SLT CREW (AMALGAM)",
"SIERRA 1500 SLT CREW CAB",
"SIERRA 1500 SLT DOUBLE CAB",
"SIERRA 1500 SLT EXT CAB",
"SIERRA 1500 WT CREW CAB",
"SIERRA 1500 WT EXT CAB",
"SIERRA 1500 WT PICKUP",
"SIERRA 2500 EXT CAB",
"SIERRA 2500 HD AT4 CREW CAB",
"SIERRA 2500 HD CREW CAB",
"SIERRA 2500 HD DENALI CREW CAB",
"SIERRA 2500 HD DOUBLE CAB",
"SIERRA 2500 HD EXT CAB",
"SIERRA 2500 HD PICKUP",
"SIERRA 2500 HD SL EXT CAB",
"SIERRA 2500 HD SL PICKUP",
"SIERRA 2500 HD SLE CREW CAB",
"SIERRA 2500 HD SLE DOUBLE CAB",
"SIERRA 2500 HD SLE EXT CAB",
"SIERRA 2500 HD SLE PICKUP",
"SIERRA 2500 HD SLT CREW CAB",
"SIERRA 2500 HD SLT DOUBLE CAB",
"SIERRA 2500 HD SLT EXT CAB",
"SIERRA 2500 HD WT CREW CAB",
"SIERRA 2500 HD WT DOUBLE CAB",
"SIERRA 2500 HD WT EXT CAB",
"SIERRA 2500 HD WT PICKUP",
"SIERRA 2500 SLE EXT CAB",
"SIERRA 3500 AT4 CREW CAB",
"SIERRA 3500 CREW CAB",
"SIERRA 3500 DENALI CREW CAB",
"SIERRA 3500 EXT CAB",
"SIERRA 3500 PICKUP",
"SIERRA 3500 SL CREW CAB",
"SIERRA 3500 SLE",
"SIERRA 3500 SLE CREW CAB",
"SIERRA 3500 SLE EXT CAB",
"SIERRA 3500 SLT CREW CAB",
"SIERRA 3500 WT CREW CAB",
"SONOMA",
"SONOMA CREW CAB",
"SONOMA EXT CAB",
"1500",
"1500 Classic",
"Pickup 1500",
"Pickup 3500",
"ProMaster 1500",
"RIDGELINE",
"RIDGELINE BLACK EDITION",
"RIDGELINE DX",
"RIDGELINE EX-L",
"RIDGELINE LX",
"RIDGELINE RT",
"RIDGELINE RTL",
"RIDGELINE RTS",
"RIDGELINE RTX",
"RIDGELINE SE",
"RIDGELINE SPORT",
"RIDGELINE TOURING",
"RIDGELINE VP",
"TITAN",
"TACOMA",
"TUNDRA",
"AVALANCE",
"COLORADO",
"SILVERADO",
"SILVERADO 1500",
"SILVERADO 2500",
"SILVERADO 3500",
"DAKOTA",
"RAM 1500",
"RAM 2500",
"RAM 3500",
"RAM 4500",
"RAM 5500",
"CANYON",
"SIERRA 1500",
"SIERRA 2500",
"SIERRA 3500",
"SONOMA",
"1500"
]

View File

@@ -0,0 +1,39 @@
const logger = require("../../utils/logger");
const TrucksList = require("./trucks.json");
const CargoVanList = require("./cargovans.json");
const PassengerVanList = require("./passengervans.json");
const SuvList = require("./suvs.json");
const vehicletype = async (req, res) => {
try {
const { model } = req.body;
if (!model || model.trim() === "") {
res.status(400).json({ success: false, error: "Please provide a model" });
} else {
vehicle
const type = getVehicleType(model.trim())
res.status(200).json({ success: true, ...type });
}
} catch (error) {
logger.log("vehicletype-error", "ERROR", req?.user?.email, null, {
error: error.message,
stack: error.stack
});
res.status(500).json({ error: error.message, stack: error.stack });
}
};
function getVehicleType(model) {
const inTrucks = TrucksList.includes(model.toUpperCase());
const inPV = PassengerVanList.includes(model.toUpperCase());
const inSuv = SuvList.includes(model.toUpperCase());
const inCv = CargoVanList.includes(model.toUpperCase());
if (inTrucks) return { type: "TK", match: true };
else if (inPV) return { type: "PC", match: true };
else if (inSuv) return { type: "SUV", match: true };
else if (inCv) return { type: "VN", match: true };
else return { type: "PC", match: false };
}
exports.default = vehicletype;

View File

@@ -86,6 +86,9 @@ async function FetchSubscriptions({ redisHelpers, socket, jobid, SubscriptionObj
logRequest: false
});
const SubscriptionMeta = subscriptions.data.subscriptions.find((s) => s.subscriptionId === SubscriptionID);
if (!SubscriptionMeta) {
throw new Error(`Subscription metadata not found for SubscriptionID: ${SubscriptionID}`);
}
if (setSessionTransactionData) {
await setSessionTransactionData(
socket.id,
@@ -102,11 +105,15 @@ async function FetchSubscriptions({ redisHelpers, socket, jobid, SubscriptionObj
error: error.message,
stack: error.stack
});
throw error;
}
}
async function GetDepartmentId({ apiName, debug = false, SubscriptionMeta, overrideDepartmentId }) {
if (!apiName) throw new Error("apiName not provided. Unable to get department without apiName.");
if (!SubscriptionMeta || !Array.isArray(SubscriptionMeta.apiDmsInfo)) {
throw new Error("Subscription metadata missing apiDmsInfo.");
}
if (debug) {
console.log("API Names & Departments ");
console.log("===========");
@@ -118,9 +125,8 @@ async function GetDepartmentId({ apiName, debug = false, SubscriptionMeta, overr
.find((info) => info.name === apiName)?.departments; //Departments are categorized by API name and have an array of departments.
if (overrideDepartmentId) {
return departmentIds && departmentIds.find(d => d.id === overrideDepartmentId)?.id
return departmentIds && departmentIds.find((d) => d.id === overrideDepartmentId)?.id;
} else {
return departmentIds && departmentIds[0] && departmentIds[0].id; //TODO: This makes the assumption that there is only 1 department.
}
}
@@ -235,18 +241,6 @@ async function MakeFortellisCall({
// jobid: socket?.recordid
// });
if (result.data.checkStatusAfterSeconds) {
return DelayedCallback({
delayMeta: result.data,
access_token,
SubscriptionID: SubscriptionMeta.subscriptionId,
ReqId,
departmentIds: DepartmentId
});
}
logger.log(
"fortellis-log-event-json",
"DEBUG",
@@ -261,6 +255,18 @@ async function MakeFortellisCall({
},
);
if (result.data.checkStatusAfterSeconds) {
return DelayedCallback({
delayMeta: result.data,
access_token,
SubscriptionID: SubscriptionMeta.subscriptionId,
ReqId,
departmentIds: DepartmentId,
jobid,
socket
});
}
return result.data;
} catch (error) {
const errorDetails = {
@@ -310,7 +316,7 @@ async function MakeFortellisCall({
//Some Fortellis calls return a batch result that isn't ready immediately.
//This function will check the status of the call and wait until it is ready.
//It will try 5 times before giving up.
async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId, departmentIds }) {
async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId, departmentIds, jobid, socket }) {
for (let index = 0; index < 5; index++) {
await sleep(delayMeta.checkStatusAfterSeconds * 1000);
//Check to see if the call is ready.
@@ -334,6 +340,19 @@ async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId,
//"Department-Id": departmentIds[0].id
}
});
logger.log(
"fortellis-log-event-json-DelayedCallback",
"DEBUG",
socket?.user?.email,
jobid,
{
requestcurl: batchResult.config.curlCommand,
reqid: batchResult.config.headers["Request-Id"] || null,
subscriptionId: batchResult.config.headers["Subscription-Id"] || null,
resultdata: batchResult.data,
resultStatus: batchResult.status
},
);
// await writeFortellisLogToFile({
// timestamp: new Date().toISOString(),
// reqId: ReqId,

View File

@@ -14,7 +14,7 @@ const {
const _ = require("lodash");
const moment = require("moment-timezone");
const replaceSpecialRegex = /[^a-zA-Z0-9 .,\n #]+/g;
const replaceSpecialRegex = /[^a-zA-Z0-9 ]+/g;
// Helper function to handle FortellisApiError logging
function handleFortellisApiError(socket, error, functionName, additionalDetails = {}) {
@@ -180,25 +180,55 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
getTransactionType(jobid),
FortellisCacheEnums.txEnvelope
);
const DMSVid = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
FortellisCacheEnums.DMSVid
);
if (!JobData || !txEnvelope) {
const friendlyMessage =
"Fortellis export context was lost after reconnect. Click Post again to restart the Fortellis flow.";
CreateFortellisLogEvent(socket, "WARN", friendlyMessage, {
jobid,
hasJobData: !!JobData,
hasTxEnvelope: !!txEnvelope
});
socket.emit("export-failed", {
title: "Fortellis",
severity: "warning",
errorCode: "FORTELLIS_CONTEXT_MISSING",
friendlyMessage
});
return;
}
try {
const DMSVid = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
FortellisCacheEnums.DMSVid
);
if (!DMSVid) {
const friendlyMessage =
"Fortellis vehicle context is missing after reconnect. Click Post again to restart the Fortellis flow.";
CreateFortellisLogEvent(socket, "WARN", friendlyMessage, {
jobid,
hasDMSVid: !!DMSVid
});
socket.emit("export-failed", {
title: "Fortellis",
severity: "warning",
errorCode: "FORTELLIS_CONTEXT_MISSING",
friendlyMessage
});
return;
}
let DMSCust;
if (selectedCustomerId) {
CreateFortellisLogEvent(socket, "DEBUG", `{3.1} Querying the Customer using Customer ID: ${selectedCustomerId}`);
//Get cust list from Redis. Return the item
const DMSCustList = await getSessionTransactionData(
socket.id,
getTransactionType(jobid),
FortellisCacheEnums.DMSCustList
);
const DMSCustList =
(await getSessionTransactionData(socket.id, getTransactionType(jobid), FortellisCacheEnums.DMSCustList)) || [];
const existingCustomerInDMSCustList = DMSCustList.find((c) => c.customerId === selectedCustomerId);
DMSCust = existingCustomerInDMSCustList || {
customerId: selectedCustomerId //This is the fall back in case it is the generic customer.
customerId: selectedCustomerId //This is the fall back in case it is the generic customer.
};
await setSessionTransactionData(
socket.id,
@@ -207,8 +237,6 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
DMSCust,
defaultFortellisTTL
);
} else {
CreateFortellisLogEvent(socket, "DEBUG", `{3.2} Creating new customer.`);
const DMSCustomerInsertResponse = await InsertDmsCustomer({ socket, redisHelpers, JobData });
@@ -227,14 +255,10 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
CreateFortellisLogEvent(socket, "DEBUG", `{4.1} Inserting new vehicle with ID: ID ${DMSVid.vehiclesVehId}`);
DMSVeh = await InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMSVid, DMSCust });
} else {
DMSVeh = await getSessionTransactionData(
socket.id,
getTransactionType(jobid),
FortellisCacheEnums.DMSVeh
)
DMSVeh = await getSessionTransactionData(socket.id, getTransactionType(jobid), FortellisCacheEnums.DMSVeh);
CreateFortellisLogEvent(socket, "DEBUG", `{4.3} Updating Existing Vehicle to associate to owner.`);
//Check to see if the vehicle needs to be updated - i.e. the owner is not the selected customer.
//Check to see if the vehicle needs to be updated - i.e. the owner is not the selected customer.
if (!DMSVeh?.owners.find((o) => o.id.value === DMSCust.customerId && o.id.assigningPartyId === "CURRENT")) {
DMSVeh = await UpdateDmsVehicle({
socket,
@@ -271,7 +295,11 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
if (DMSTransHeader.rtnCode === "0") {
try {
CreateFortellisLogEvent(socket, "DEBUG", `{6} Attempting to post Transaction with ID ${DMSTransHeader.transID}`);
CreateFortellisLogEvent(
socket,
"DEBUG",
`{6} Attempting to post Transaction with ID ${DMSTransHeader.transID}`
);
const DmsBatchTxnPost = await PostDmsBatchWip({ socket, redisHelpers, JobData }); // 2 in 1 call that includes a post and the transactions.
await setSessionTransactionData(
@@ -282,16 +310,14 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
defaultFortellisTTL
);
if (DmsBatchTxnPost.rtnCode === "0") {
//TODO: Validate this is a string and not #
//something
CreateFortellisLogEvent(socket, "DEBUG", `{6} Successfully posted transaction to DMS.`);
await MarkJobExported({ socket, jobid: JobData.id, JobData });
await MarkJobExported({ socket, jobid: JobData.id, JobData, redisHelpers });
try {
CreateFortellisLogEvent(socket, "DEBUG", `{7} Updating Service Vehicle History.`);
const DMSVehHistory = await InsertServiceVehicleHistory({ socket, redisHelpers, JobData });
await setSessionTransactionData(
@@ -302,24 +328,19 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
defaultFortellisTTL
);
} catch (error) {
CreateFortellisLogEvent(socket, "ERROR", `{7.1} Error posting vehicle service history. ${error.message}`);
}
socket.emit("export-success", JobData.id);
} else {
//There was something wrong. Throw an error to trigger clean up.
//There was something wrong. Throw an error to trigger clean up.
//throw new Error("Error posting DMS Batch Transaction");
}
} catch (error) {
} catch {
//Clean up the transaction and insert a faild error code
// //Get the error code
CreateFortellisLogEvent(socket, "DEBUG", `{6.1} Getting errors for Transaction ID ${DMSTransHeader.transID}`);
const DmsError = await QueryDmsErrWip({ socket, redisHelpers, JobData });
// //Delete the transaction
CreateFortellisLogEvent(socket, "DEBUG", `{6.2} Deleting Transaction ID ${DMSTransHeader.transID}`);
@@ -345,7 +366,17 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
stack: error.stack,
data: error.errorData
});
await InsertFailedExportLog({ socket, JobData, error: error.errorData?.issues || [JSON.stringify(error.errorData)] });
socket.emit("export-failed", {
title: "Fortellis",
severity: "error",
error: error.message,
friendlyMessage: "Fortellis export failed. Please click Post again to retry."
});
await InsertFailedExportLog({
socket,
JobData,
error: error.errorData?.issues || [JSON.stringify(error.errorData)]
});
} finally {
//Ensure we always insert logEvents
//GQL to insert logevents.
@@ -374,7 +405,7 @@ async function CalculateDmsVid({ socket, JobData, redisHelpers }) {
jobid: JobData.id,
body: {}
});
return result;
return Array.isArray(result) ? result.filter((v) => v.vehiclesVehId !== null && v.vehiclesVehId !== "") : [];
} catch (error) {
handleFortellisApiError(socket, error, "CalculateDmsVid", {
vin: JobData.v_vin,
@@ -429,12 +460,12 @@ async function QueryDmsCustomerById({ socket, redisHelpers, JobData, CustomerId
async function QueryDmsCustomerByName({ socket, redisHelpers, JobData }) {
const ownerName =
JobData.ownr_co_nm && JobData.ownr_co_nm.trim() !== ""
//? [["firstName", JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()]] // Commented out until we receive direction.
? [["phone", JobData.ownr_ph1?.replace(replaceSpecialRegex, "")]]
? //? [["firstName", JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()]] // Commented out until we receive direction.
[["phone", JobData.ownr_ph1?.replace(/[^0-9]/g, "")]]
: [
["firstName", JobData.ownr_fn?.replace(/[^a-zA-Z-]/g, "").toUpperCase()],
["lastName", JobData.ownr_ln?.replace(/[^a-zA-Z-]/g, "").toUpperCase()]
];
["firstName", JobData.ownr_fn?.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()],
["lastName", JobData.ownr_ln?.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()]
];
try {
const result = await MakeFortellisCall({
...FortellisActions.QueryCustomerByName,
@@ -457,7 +488,7 @@ async function QueryDmsCustomerByName({ socket, redisHelpers, JobData }) {
async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
try {
const isBusiness = (JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").trim() !== "")
const isBusiness = JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").trim() !== "";
const result = await MakeFortellisCall({
...FortellisActions.CreateCustomer,
headers: {},
@@ -466,21 +497,23 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
jobid: JobData.id,
body: {
customerType: isBusiness ? "BUSINESS" : "INDIVIDUAL",
...isBusiness ? {
companyName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase(),
secondaryCustomerName: {
//lastName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()
}
} : {
customerName: {
//"suffix": "Mr.",
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(/[^a-zA-Z-]/g, "").toUpperCase(),
//"middleName": "",
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(/[^a-zA-Z-]/g, "").toUpperCase()
//"title": "",
//"nickName": ""
}
},
...(isBusiness
? {
companyName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase(),
secondaryCustomerName: {
//lastName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()
}
}
: {
customerName: {
//"suffix": "Mr.",
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(),
//"middleName": "",
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()
//"title": "",
//"nickName": ""
}
}),
postalAddress: {
addressLine1: JobData.ownr_addr1?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
addressLine2: JobData.ownr_addr2?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
@@ -490,7 +523,7 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
rome: JobData.ownr_zip
}),
state: JobData.ownr_st?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
country: JobData.ownr_ctry?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
country: JobData.ownr_ctry?.replace(replaceSpecialRegex, "").trim().toUpperCase()
//"territory": ""
},
// "birthDate": {
@@ -504,7 +537,7 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
phones: [
{
//"uuid": "",
number: JobData.ownr_ph1?.replace(replaceSpecialRegex, ""),
number: JobData.ownr_ph1?.replace(/[^0-9]/g, ""),
type: "HOME"
// "doNotCallIndicator": true,
// "doNotCallIndicatorDate": `null,
@@ -540,18 +573,18 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
emailAddresses: [
...(!_.isEmpty(JobData.ownr_ea)
? [
{
//"uuid": "",
address: JobData.ownr_ea.toUpperCase(),
type: "PERSONAL"
// "doNotEmailSource": "",
// "doNotEmail": false,
// "isPreferred": true,
// "transactionEmailNotificationOptIn": false,
// "optInRequestDate": null,
// "optInDate": null
}
]
{
//"uuid": "",
address: JobData.ownr_ea.toUpperCase(),
type: "PERSONAL"
// "doNotEmailSource": "",
// "doNotEmail": false,
// "isPreferred": true,
// "transactionEmailNotificationOptIn": false,
// "optInRequestDate": null,
// "optInDate": null
}
]
: [])
// {
// "uuid": "",
@@ -691,9 +724,9 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
txEnvelope.dms_unsold === true
? ""
: moment(txEnvelope.inservicedate)
//.tz(JobData.bodyshop.timezone)
.startOf("day")
.toISOString()
//.tz(JobData.bodyshop.timezone)
.startOf("day")
.toISOString()
}),
//"lastServiceDate": "2011-11-23",
vehicleId: DMSVid.vehiclesVehId
@@ -735,8 +768,8 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
txEnvelope.dms_unsold === true
? ""
: moment()
// .tz(JobData.bodyshop.timezone)
.format("YYYY-MM-DD"),
// .tz(JobData.bodyshop.timezone)
.format("YYYY-MM-DD"),
// "deliveryMileage": 4,
// "doorsQuantity": 4,
// "engineNumber": "",
@@ -753,8 +786,8 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
: String(JobData.plate_no).replace(/([^\w]|_)/g, "").length === 0
? null
: String(JobData.plate_no)
.replace(/([^\w]|_)/g, "")
.toUpperCase(),
.replace(/([^\w]|_)/g, "")
.toUpperCase(),
make: txEnvelope.dms_make,
// "model": "CC10753",
modelAbrev: txEnvelope.dms_model,
@@ -900,13 +933,13 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
},
...(oldOwner
? [
{
id: {
assigningPartyId: "PREVIOUS",
value: oldOwner.id.value
{
id: {
assigningPartyId: "PREVIOUS",
value: oldOwner.id.value
}
}
}
]
]
: [])
];
}
@@ -936,24 +969,24 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
txEnvelope.dms_unsold === true
? ""
: moment(DMSVehToSend.dealer.inServiceDate || txEnvelope.inservicedate)
// .tz(JobData.bodyshop.timezone)
.toISOString()
// .tz(JobData.bodyshop.timezone)
.toISOString()
})
},
vehicle: {
...DMSVehToSend.vehicle,
...(txEnvelope.dms_model_override
? {
make: txEnvelope.dms_make,
modelAbrev: txEnvelope.dms_model
}
make: txEnvelope.dms_make,
modelAbrev: txEnvelope.dms_model
}
: {}),
deliveryDate:
txEnvelope.dms_unsold === true
? ""
: moment(DMSVehToSend.vehicle.deliveryDate)
//.tz(JobData.bodyshop.timezone)
.toISOString()
//.tz(JobData.bodyshop.timezone)
.toISOString()
},
owners: ids
}
@@ -1061,7 +1094,7 @@ async function InsertDmsStartWip({ socket, redisHelpers, JobData }) {
// transID: "",
// userID: "partprgm",
// userName: "PROGRAM, PARTNER"
},
}
});
return result;
} catch (error) {
@@ -1091,9 +1124,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.profitCenter.dms_acctnumber,
cntl:
alloc.profitCenter.dms_control_override &&
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
? alloc.profitCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1114,9 +1147,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.costCenter.dms_acctnumber,
cntl:
alloc.costCenter.dms_control_override &&
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
? alloc.costCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1134,9 +1167,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.costCenter.dms_wip_acctnumber,
cntl:
alloc.costCenter.dms_control_override &&
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
alloc.costCenter.dms_control_override !== null &&
alloc.costCenter.dms_control_override !== undefined &&
alloc.costCenter.dms_control_override?.trim() !== ""
? alloc.costCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1158,9 +1191,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
acct: alloc.profitCenter.dms_acctnumber,
cntl:
alloc.profitCenter.dms_control_override &&
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
alloc.profitCenter.dms_control_override !== null &&
alloc.profitCenter.dms_control_override !== undefined &&
alloc.profitCenter.dms_control_override?.trim() !== ""
? alloc.profitCenter.dms_control_override
: JobData.ro_number,
cntl2: null,
@@ -1228,7 +1261,7 @@ async function PostDmsBatchWip({ socket, redisHelpers, JobData }) {
opCode: "P",
transID: DMSTransHeader.transID,
transWipReqList: await GenerateTransWips({ socket, redisHelpers, JobData })
},
}
});
return result;
} catch (error) {
@@ -1256,7 +1289,7 @@ async function QueryDmsErrWip({ socket, redisHelpers, JobData }) {
socket,
jobid: JobData.id,
requestPathParams: DMSTransHeader.transID,
body: {},
body: {}
});
return result;
} catch (error) {
@@ -1286,7 +1319,7 @@ async function DeleteDmsWip({ socket, redisHelpers, JobData }) {
body: {
opCode: "D",
transID: DMSTransHeader.transID
},
}
});
return result;
} catch (error) {
@@ -1298,9 +1331,15 @@ async function DeleteDmsWip({ socket, redisHelpers, JobData }) {
}
}
async function MarkJobExported({ socket, jobid, JobData }) {
async function MarkJobExported({ socket, jobid, JobData, redisHelpers }) {
CreateFortellisLogEvent(socket, "DEBUG", `Marking job as exported for id ${jobid}`);
const transwips = await redisHelpers.getSessionTransactionData(
socket.id,
getTransactionType(JobData.id),
FortellisCacheEnums.transWips
);
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
const currentToken =
(socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
@@ -1318,7 +1357,7 @@ async function MarkJobExported({ socket, jobid, JobData }) {
jobid: jobid,
successful: true,
useremail: socket.user.email,
metadata: socket.transWips
metadata: transwips
},
bill: {
exported: true,
@@ -1338,13 +1377,15 @@ async function InsertFailedExportLog({ socket, JobData, error }) {
const result = await client
.setHeaders({ Authorization: `Bearer ${currentToken}` })
.request(queries.INSERT_EXPORT_LOG, {
logs: [{
bodyshopid: JobData.bodyshop.id,
jobid: JobData.id,
successful: false,
message: JSON.stringify(error),
useremail: socket.user.email
}]
logs: [
{
bodyshopid: JobData.bodyshop.id,
jobid: JobData.id,
successful: false,
message: JSON.stringify(error),
useremail: socket.user.email
}
]
});
return result;

View File

@@ -1,17 +0,0 @@
const express = require("express");
const router = express.Router();
const multer = require("multer");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
const { handleBillOcr, handleBillOcrStatus } = require("../ai/bill-ocr/bill-ocr");
// Configure multer for form data parsing
const upload = multer();
router.use(validateFirebaseIdTokenMiddleware);
router.use(withUserGraphQLClientMiddleware);
router.post("/bill-ocr", upload.single('billScan'), handleBillOcr);
router.get("/bill-ocr/status/:textractJobId", handleBillOcrStatus);
module.exports = router;

View File

@@ -144,5 +144,20 @@ router.post("/emsupload", validateFirebaseIdTokenMiddleware, data.emsUpload);
// Redis Cache Routes
router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache);
// Estimate Scrubber Vehicle Type
router.post("/es/vehicletype", data.vehicletype);
router.post("/analytics/documents", data.documentAnalytics);
// Health Check for docker-compose-cluster load balancer, only available in development
if (process.env.NODE_ENV === "development") {
router.get("/health", (req, res) => {
const healthStatus = {
status: "healthy",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV || "unknown",
uptime: process.uptime()
};
res.status(200).json(healthStatus);
});
}
module.exports = router;

View File

@@ -262,7 +262,7 @@ const updateRRRepairOrderWithFullData = async (args) => {
CreateRRLogEvent(socket, "INFO", "RR allocations resolved for update", {
hasAllocations: allocations.length > 0,
count: allocations.length,
allocationsPreview: allocations.slice(0, 2).map(a => ({
allocationsPreview: allocations.slice(0, 2).map((a) => ({
type: a?.type,
code: a?.code,
laborSale: a?.laborSale,
@@ -322,11 +322,11 @@ const updateRRRepairOrderWithFullData = async (args) => {
// Add roNo for linking to existing RO
payload.roNo = String(roNo);
payload.outsdRoNo = job?.ro_number || job?.id || undefined;
// Keep rolabor - it's needed to register the job/OpCode accounts in Reynolds
// Without this, Reynolds won't recognize the OpCode when we send rogg operations
// The rolabor section tells Reynolds "these jobs exist" even with minimal data
CreateRRLogEvent(socket, "INFO", "Sending full data for early RO (using create with roNo)", {
roNo: String(roNo),
hasRolabor: !!payload.rolabor,

View File

@@ -52,6 +52,122 @@ const asN2 = (dineroLike) => {
return amount.toFixed(2);
};
/**
* Normalize various "money-like" shapes to integer cents.
* Supports:
* - Dinero instances (getAmount / toUnit)
* - { cents }
* - { amount, precision }
* - plain numbers (treated as units, e.g. dollars)
* - numeric strings (treated as units, e.g. "123.45")
* @param value
* @returns {number}
*/
const toMoneyCents = (value) => {
if (value == null || value === "") return 0;
if (typeof value.getAmount === "function") {
return value.getAmount();
}
if (typeof value.toUnit === "function") {
const unit = value.toUnit();
return Number.isFinite(unit) ? Math.round(unit * 100) : 0;
}
if (typeof value.cents === "number") {
return value.cents;
}
if (typeof value.amount === "number") {
const precision = typeof value.precision === "number" ? value.precision : 2;
if (precision === 2) return value.amount;
const factor = Math.pow(10, 2 - precision);
return Math.round(value.amount * factor);
}
if (typeof value === "number") {
return Math.round(value * 100);
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
}
return 0;
};
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 });
/**
* Build RR estimate block from allocation totals.
* @param {Array} allocations
* @returns {{parts: string, labor: string, total: string}|null}
*/
const buildEstimateFromAllocations = (allocations) => {
if (!Array.isArray(allocations) || allocations.length === 0) return null;
const totals = allocations.reduce(
(acc, alloc) => {
acc.parts += toMoneyCents(alloc?.partsSale);
acc.labor += toMoneyCents(alloc?.laborTaxableSale);
acc.labor += toMoneyCents(alloc?.laborNonTaxableSale);
acc.total += toMoneyCents(alloc?.totalSale);
return acc;
},
{ parts: 0, labor: 0, total: 0 }
);
// If totalSale wasn't provided, keep total coherent with parts + labor.
if (!totals.total) {
totals.total = totals.parts + totals.labor;
}
return {
parts: asN2FromCents(totals.parts),
labor: asN2FromCents(totals.labor),
total: asN2FromCents(totals.total)
};
};
/**
* Build RR estimate block from precomputed job totals.
* @param job
* @returns {{parts: string, labor: string, total: string}|null}
*/
const buildEstimateFromJobTotals = (job) => {
const totals = job?.job_totals;
if (!totals) return null;
const partsCents = toMoneyCents(totals?.parts?.parts?.total) + toMoneyCents(totals?.parts?.sublets?.total);
const laborCents = toMoneyCents(totals?.rates?.rates_subtotal ?? totals?.rates?.subtotal);
let totalCents = toMoneyCents(totals?.totals?.subtotal);
if (!totalCents) {
totalCents = partsCents + laborCents;
}
// If we truly have no numbers from totals, omit estimate entirely.
if (!partsCents && !laborCents && !totalCents) return null;
return {
parts: asN2FromCents(partsCents),
labor: asN2FromCents(laborCents),
total: asN2FromCents(totalCents)
};
};
/**
* Build RR estimate block from the best available source.
* @param job
* @param allocations
* @returns {{parts: string, labor: string, total: string}|null}
*/
const buildRREstimate = ({ job, allocations } = {}) => {
return buildEstimateFromAllocations(allocations) || buildEstimateFromJobTotals(job);
};
/**
* Build RO.GOG structure for the reynolds-rome-client `createRepairOrder` payload
* from allocations.
@@ -103,44 +219,6 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
const ops = [];
/**
* Normalize various "money-like" shapes to integer cents.
* Supports:
* - Dinero instances (getAmount / toUnit)
* - { cents }
* - { amount, precision }
* - plain numbers (treated as units, e.g. dollars)
*/
const toCents = (value) => {
if (!value) return 0;
if (typeof value.getAmount === "function") {
return value.getAmount();
}
if (typeof value.toUnit === "function") {
const unit = value.toUnit();
return Number.isFinite(unit) ? Math.round(unit * 100) : 0;
}
if (typeof value.cents === "number") {
return value.cents;
}
if (typeof value.amount === "number") {
const precision = typeof value.precision === "number" ? value.precision : 2;
if (precision === 2) return value.amount;
const factor = Math.pow(10, 2 - precision);
return Math.round(value.amount * factor);
}
if (typeof value === "number") {
return Math.round(value * 100);
}
return 0;
};
const asMoneyLike = (amountCents) => ({
amount: amountCents || 0,
precision: 2
@@ -154,13 +232,13 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
// Only centers configured for RR GOG are included
if (!breakOut || !itemType) continue;
const partsTaxableCents = toCents(alloc.partsTaxableSale);
const partsNonTaxableCents = toCents(alloc.partsNonTaxableSale);
const extrasTaxableCents = toCents(alloc.extrasTaxableSale);
const extrasNonTaxableCents = toCents(alloc.extrasNonTaxableSale);
const laborTaxableCents = toCents(alloc.laborTaxableSale);
const laborNonTaxableCents = toCents(alloc.laborNonTaxableSale);
const costCents = toCents(alloc.cost);
const partsTaxableCents = toMoneyCents(alloc.partsTaxableSale);
const partsNonTaxableCents = toMoneyCents(alloc.partsNonTaxableSale);
const extrasTaxableCents = toMoneyCents(alloc.extrasTaxableSale);
const extrasNonTaxableCents = toMoneyCents(alloc.extrasNonTaxableSale);
const laborTaxableCents = toMoneyCents(alloc.laborTaxableSale);
const laborNonTaxableCents = toMoneyCents(alloc.laborNonTaxableSale);
const costCents = toMoneyCents(alloc.cost);
const segments = [];
@@ -418,6 +496,11 @@ const buildRRRepairOrderPayload = ({
mileageIn: job.kmin
};
const estimate = buildRREstimate({ job, allocations });
if (estimate) {
payload.estimate = estimate;
}
if (story) {
payload.roComment = String(story).trim();
}

View File

@@ -68,12 +68,33 @@ const fetchBodyshopFromDB = async (bodyshopId, logger) => {
* @param logger
*/
const applyRedisHelpers = ({ pubClient, app, logger }) => {
const toRedisJson = (value) => JSON.stringify(value === undefined ? null : value);
// Store session data in Redis
const setSessionData = async (socketId, key, value, ttl) => {
try {
await pubClient.hset(`socket:${socketId}`, key, JSON.stringify(value)); // Use Redis pubClient
const sessionKey = `socket:${socketId}`;
// Supports both forms:
// 1) setSessionData(socketId, "field", value, ttl)
// 2) setSessionData(socketId, { fieldA: valueA, fieldB: valueB }, ttl)
if (key && typeof key === "object" && !Array.isArray(key)) {
const entries = Object.entries(key).flatMap(([field, fieldValue]) => [field, toRedisJson(fieldValue)]);
if (entries.length > 0) {
await pubClient.hset(sessionKey, ...entries);
}
const objectTtl = typeof value === "number" ? value : typeof ttl === "number" ? ttl : null;
if (objectTtl) {
await pubClient.expire(sessionKey, objectTtl);
}
return;
}
await pubClient.hset(sessionKey, key, toRedisJson(value)); // Use Redis pubClient
if (ttl && typeof ttl === "number") {
await pubClient.expire(`socket:${socketId}`, ttl);
await pubClient.expire(sessionKey, ttl);
}
} catch (error) {
logger.log(`Error Setting Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
@@ -88,7 +109,26 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
*/
const getSessionData = async (socketId, key) => {
try {
const data = await pubClient.hget(`socket:${socketId}`, key);
const sessionKey = `socket:${socketId}`;
// Supports:
// 1) getSessionData(socketId, "field") -> parsed field value
// 2) getSessionData(socketId) -> parsed object of all fields
if (typeof key === "undefined") {
const raw = await pubClient.hgetall(sessionKey);
if (!raw || Object.keys(raw).length === 0) return null;
return Object.entries(raw).reduce((acc, [field, rawValue]) => {
try {
acc[field] = JSON.parse(rawValue);
} catch {
acc[field] = rawValue;
}
return acc;
}, {});
}
const data = await pubClient.hget(sessionKey, key);
return data ? JSON.parse(data) : null;
} catch (error) {
logger.log(`Error Getting Session Data for socket ${socketId}: ${error}`, "ERROR", "redis");
@@ -106,7 +146,7 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
*/
const setSessionTransactionData = async (socketId, transactionType, key, value, ttl) => {
try {
await pubClient.hset(getSocketTransactionkey({ socketId, transactionType }), key, JSON.stringify(value)); // Use Redis pubClient
await pubClient.hset(getSocketTransactionkey({ socketId, transactionType }), key, toRedisJson(value)); // Use Redis pubClient
if (ttl && typeof ttl === "number") {
await pubClient.expire(getSocketTransactionkey({ socketId, transactionType }), ttl);
}
@@ -160,7 +200,17 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
*/
const clearSessionTransactionData = async (socketId, transactionType) => {
try {
await pubClient.del(getSocketTransactionkey({ socketId, transactionType }));
if (transactionType) {
await pubClient.del(getSocketTransactionkey({ socketId, transactionType }));
return;
}
// If no transactionType is provided, clear all transaction namespaces for this socket.
const pattern = getSocketTransactionkey({ socketId, transactionType: "*" });
const keys = await pubClient.keys(pattern);
if (Array.isArray(keys) && keys.length > 0) {
await pubClient.del(...keys);
}
} catch (error) {
logger.log(
`Error Clearing Session Transaction Data for socket ${socketId}:${transactionType}: ${error}`,

View File

@@ -4,11 +4,14 @@ const { FortellisJobExport, FortellisSelectedCustomer } = require("../fortellis/
const CdkCalculateAllocations = require("../cdk/cdk-calculate-allocations").default;
const registerRREvents = require("../rr/rr-register-socket-events");
const SOCKET_SESSION_TTL_SECONDS = 60 * 60 * 24;
const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => {
// Destructure helpers locally, but keep full objects available for downstream modules
const {
setSessionData,
getSessionData,
clearSessionData,
addUserSocketMapping,
removeUserSocketMapping,
refreshUserSocketTTL,
@@ -51,12 +54,16 @@ const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => {
}
// NEW: seed a base session for this socket so downstream handlers can read it
await setSessionData(socket.id, {
bodyshopId,
email: user.email,
uid: user.user_id || user.uid,
seededAt: Date.now()
});
await setSessionData(
socket.id,
{
bodyshopId,
email: user.email,
uid: user.user_id || user.uid,
seededAt: Date.now()
},
SOCKET_SESSION_TTL_SECONDS
);
await addUserSocketMapping(user.email, socket.id, bodyshopId);
next();
@@ -126,14 +133,18 @@ const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => {
}
// NEW: refresh (or create) the base session with the latest info
await setSessionData(socket.id, {
bodyshopId,
email: user.email,
uid: user.user_id || user.uid,
refreshedAt: Date.now()
});
await setSessionData(
socket.id,
{
bodyshopId,
email: user.email,
uid: user.user_id || user.uid,
refreshedAt: Date.now()
},
SOCKET_SESSION_TTL_SECONDS
);
await refreshUserSocketTTL(user.email, bodyshopId);
await refreshUserSocketTTL(user.email);
socket.emit("token-updated", { success: true });
} catch (error) {
if (error.code === "auth/id-token-expired") {
@@ -189,6 +200,11 @@ const redisSocketEvents = ({ io, redisHelpers, ioHelpers, logger }) => {
if (socket.user?.email) {
await removeUserSocketMapping(socket.user.email, socket.id);
}
try {
await clearSessionData(socket.id);
} catch {
//
}
// Optional: clear transactional session
try {
await clearSessionTransactionData(socket.id);