Compare commits
118 Commits
bugfix/IO-
...
hotfix/202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51af6f084d | ||
|
|
e03546d989 | ||
|
|
1dd74bf029 | ||
|
|
e90e0b9be9 | ||
|
|
e3f49ebca4 | ||
|
|
d2d9be433c | ||
|
|
f0c0b5dc45 | ||
|
|
a6a621e73f | ||
|
|
ee0f2c3293 | ||
|
|
83a30f1fcd | ||
|
|
ba3e831503 | ||
|
|
6b87b15e97 | ||
|
|
425cdac26e | ||
|
|
ade8461851 | ||
|
|
f6c5f85a87 | ||
|
|
532fa3fb18 | ||
|
|
c7875c7be3 | ||
|
|
78b9b8d260 | ||
|
|
38fc3285b4 | ||
|
|
9d14ad3167 | ||
|
|
2e53fe8606 | ||
|
|
6317606ce1 | ||
|
|
e599c2b2d6 | ||
|
|
2b35090359 | ||
|
|
fa2c729ac2 | ||
|
|
95bb5b03c2 | ||
|
|
318482c195 | ||
|
|
eea9e8e2cc | ||
|
|
cde12f9970 | ||
|
|
48def2b74d | ||
|
|
dde7a99956 | ||
|
|
df964aa14e | ||
|
|
7619360f37 | ||
|
|
f15f371e86 | ||
|
|
34fe0cc3bf | ||
|
|
7acaefb5c5 | ||
|
|
ab02da47a2 | ||
|
|
2a7dec90d5 | ||
|
|
6e0b1f65a7 | ||
|
|
8671d1254d | ||
|
|
0ea254ed4e | ||
|
|
331dcfc063 | ||
|
|
c46804cfdf | ||
|
|
484d09d635 | ||
|
|
188a7b47b1 | ||
|
|
a6ca93f482 | ||
|
|
d08bfc61cd | ||
|
|
9b4de1645e | ||
|
|
503c217c99 | ||
|
|
2333067e02 | ||
|
|
953172493e | ||
|
|
b444639fca | ||
|
|
6ee7e56b9b | ||
|
|
ffd5acb21a | ||
|
|
0340ca5fcc | ||
|
|
1b2fc8b114 | ||
|
|
3745d7a414 | ||
|
|
a0efac9bd8 | ||
|
|
17a772563c | ||
|
|
b1ce356bd8 | ||
|
|
9818cac30e | ||
|
|
171277630e | ||
|
|
d8b400cb8c | ||
|
|
fe7bf684aa | ||
|
|
7e6c97b3cf | ||
|
|
9c6fe1905d | ||
|
|
2126cccff1 | ||
|
|
56559dd3ff | ||
|
|
fde137d7f7 | ||
|
|
b797bf7dc9 | ||
|
|
37c3be5cde | ||
|
|
b87d1a65fe | ||
|
|
35c832dbc3 | ||
|
|
019b3cf4da | ||
|
|
27f4385539 | ||
|
|
ad520ab23e | ||
|
|
b3716521ec | ||
|
|
05ae0801e5 | ||
|
|
332ade96e5 | ||
|
|
3acec55c0e | ||
|
|
da0462f14c | ||
|
|
2cc9fa961e | ||
|
|
2646e85863 | ||
|
|
1b6fe4d18e | ||
|
|
22aae0a7f1 | ||
|
|
cfbd6f93c3 | ||
|
|
db1b701a96 | ||
|
|
2746421c09 | ||
|
|
5217120994 | ||
|
|
77f72a2a12 | ||
|
|
a84ad4ee32 | ||
|
|
2cacd75822 | ||
|
|
217a0b84ac | ||
|
|
f53ed8c427 | ||
|
|
f8b7588a04 | ||
|
|
ee3cb4456d | ||
|
|
ae05692c46 | ||
|
|
e01a2af5a4 | ||
|
|
9c0cb5f80b | ||
|
|
1f726aca4d | ||
|
|
b9f398cf2d | ||
|
|
ff73a14610 | ||
|
|
1e44d4fe42 | ||
|
|
0f42875d1b | ||
|
|
a0f1299006 | ||
|
|
87d8a5d746 | ||
|
|
268851902a | ||
|
|
68bb7d2529 | ||
|
|
d50db12330 | ||
|
|
1438986c18 | ||
|
|
c047699fbb | ||
|
|
e5b7fcb919 | ||
|
|
cadcfc9b0d | ||
|
|
55023ceaca | ||
|
|
45e143578c | ||
|
|
28a41f7637 | ||
|
|
2a2edeadb9 | ||
|
|
52c9b9a290 |
@@ -13,4 +13,5 @@
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
bodyshop_translations.babel
|
||||
.env.localstack.docker
|
||||
bodyshop_translations.babel
|
||||
|
||||
@@ -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** you’ll 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.
|
||||
|
||||
---
|
||||
|
||||
## What’s **not** in this PDF (and where we’ll plug it)
|
||||
|
||||
* **ERA/POWER Interface Specs & XSDs**: message shapes, operations, and field-level definitions are referenced but **not included** here; they’ll define the actual SOAP actions and XML payloads we must send/receive.
|
||||
* Once you provide those PDFs/XSDs, I’ll:
|
||||
|
||||
* 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 isn’t applicable yet because the file doesn’t 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, I’ll generate the concrete XML/XSDs, sample envelopes, and the typed client helpers.*
|
||||
@@ -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 Rome’s middleware.
|
||||
|
||||
---
|
||||
|
||||
Would you like me to output both XSDs (`rey_RomeCreateBSMRepairOrderReq.xsd` and `rey_RomeCreateBSMRepairOrderResp.xsd`) as ready-to-save files next?
|
||||
@@ -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.*
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 — it’s 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 RCI’s `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).
|
||||
|
||||
---
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
@@ -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 vehicle’s 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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
106
client/package-lock.json
generated
106
client/package-lock.json
generated
@@ -12,6 +12,10 @@
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
@@ -60,7 +64,6 @@
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
@@ -2494,6 +2497,73 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/modifiers": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
|
||||
@@ -8396,16 +8466,6 @@
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-runtime": {
|
||||
"version": "6.26.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
|
||||
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^2.4.0",
|
||||
"regenerator-runtime": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
@@ -9192,14 +9252,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "2.6.12",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
|
||||
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
|
||||
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
|
||||
@@ -15376,16 +15428,6 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-drag-listview": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-2.0.0.tgz",
|
||||
"integrity": "sha512-7Apx/1Xt4qu+JHHP0rH6aLgZgS7c2MX8ocHVGCi03KfeIWEu0t14MhT3boQKM33l5eJrE/IWfExFTvoYq22fsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"babel-runtime": "^6.26.0",
|
||||
"prop-types": "^15.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||
@@ -15972,12 +16014,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
|
||||
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
@@ -59,7 +63,6 @@
|
||||
"react-color": "^2.19.3",
|
||||
"react-cookie": "^8.0.1",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-drag-listview": "^2.0.0",
|
||||
"react-grid-gallery": "^1.0.1",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-i18next": "^16.5.4",
|
||||
|
||||
@@ -446,3 +446,32 @@
|
||||
//.rbc-time-header-gutter {
|
||||
// padding: 0;
|
||||
//}
|
||||
|
||||
/* globally allow shrink inside table cells */
|
||||
.prod-list-table .ant-table-cell,
|
||||
.prod-list-table .ant-table-cell > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* common AntD offenders */
|
||||
.prod-list-table > .ant-table-cell .ant-space,
|
||||
.ant-table-cell .ant-space-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Keep your custom header content on the left, push AntD sorter to the far right */
|
||||
.prod-list-table .ant-table-column-sorters {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prod-list-table .ant-table-column-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0; /* allows ellipsis to work */
|
||||
}
|
||||
|
||||
.prod-list-table .ant-table-column-sorter {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit }}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
onChange={handleTableChange}
|
||||
|
||||
@@ -195,7 +195,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit }}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
onChange={handleTableChange}
|
||||
|
||||
@@ -212,7 +212,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
|
||||
<Table
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit }}
|
||||
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
onChange={handleTableChange}
|
||||
|
||||
@@ -52,6 +52,9 @@ export default function BillCmdReturnsTableComponent({ form, returnLoading, retu
|
||||
{fields.map((field, index) => (
|
||||
<tr key={field.key}>
|
||||
<td>
|
||||
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.line_desc")}
|
||||
key={`${index}line_desc`}
|
||||
|
||||
@@ -373,9 +373,11 @@ export function BillFormComponent({
|
||||
"local_tax_rate"
|
||||
]);
|
||||
let totals;
|
||||
if (!!values.total && !!values.billlines && values.billlines.length > 0)
|
||||
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
|
||||
totals = CalculateBillTotal(values);
|
||||
if (totals)
|
||||
}
|
||||
|
||||
if (totals) {
|
||||
return (
|
||||
// TODO: Align is not correct
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
@@ -414,7 +416,7 @@ export function BillFormComponent({
|
||||
<Statistic
|
||||
title={t("bills.labels.discrepancy")}
|
||||
styles={{
|
||||
value: {
|
||||
content: {
|
||||
color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
|
||||
}
|
||||
}}
|
||||
@@ -427,6 +429,7 @@ export function BillFormComponent({
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -32,6 +33,7 @@ export function BillEnterModalLinesComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
||||
const firstFieldRefs = useRef({});
|
||||
|
||||
const CONTROL_HEIGHT = 32;
|
||||
|
||||
@@ -90,6 +92,7 @@ export function BillEnterModalLinesComponent({
|
||||
});
|
||||
};
|
||||
|
||||
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
|
||||
const autofillActualCost = (index) => {
|
||||
Promise.resolve().then(() => {
|
||||
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
|
||||
@@ -154,6 +157,9 @@ export function BillEnterModalLinesComponent({
|
||||
),
|
||||
formInput: (record, index) => (
|
||||
<BillLineSearchSelect
|
||||
ref={(el) => {
|
||||
firstFieldRefs.current[index] = el;
|
||||
}}
|
||||
disabled={disabled}
|
||||
options={lineData}
|
||||
style={{
|
||||
@@ -164,10 +170,9 @@ export function BillEnterModalLinesComponent({
|
||||
}}
|
||||
allowRemoved={form.getFieldValue("is_credit_memo") || false}
|
||||
onSelect={(value, opt) => {
|
||||
const d = normalizeDiscount(discount);
|
||||
const retail = Number(opt.cost);
|
||||
const computedActual = Number.isFinite(retail) ? round2(retail * (1 - d)) : null;
|
||||
|
||||
// IMPORTANT:
|
||||
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
|
||||
// from Retail (actual_price) -> Actual Cost (actual_cost).
|
||||
setFieldsValue({
|
||||
billlines: (getFieldValue("billlines") || []).map((item, idx) => {
|
||||
if (idx !== index) return item;
|
||||
@@ -178,7 +183,7 @@ export function BillEnterModalLinesComponent({
|
||||
quantity: opt.part_qty || 1,
|
||||
actual_price: opt.cost,
|
||||
original_actual_price: opt.cost,
|
||||
actual_cost: isBlank(item.actual_cost) ? computedActual : item.actual_cost,
|
||||
// actual_cost intentionally untouched here
|
||||
cost_center: opt.part_type
|
||||
? bodyshopHasDmsKey(bodyshop)
|
||||
? opt.part_type !== "PAE"
|
||||
@@ -205,7 +210,7 @@ export function BillEnterModalLinesComponent({
|
||||
label: t("billlines.fields.line_desc"),
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize />
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
|
||||
},
|
||||
{
|
||||
title: t("billlines.fields.quantity"),
|
||||
@@ -234,7 +239,7 @@ export function BillEnterModalLinesComponent({
|
||||
})
|
||||
]
|
||||
}),
|
||||
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} />
|
||||
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} tabIndex={0} />
|
||||
},
|
||||
{
|
||||
title: t("billlines.fields.actual_price"),
|
||||
@@ -251,9 +256,10 @@ export function BillEnterModalLinesComponent({
|
||||
<CurrencyInput
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
onBlur={() => autofillActualCost(index)}
|
||||
tabIndex={0}
|
||||
// NOTE: Autofill should only happen on forward Tab out of Retail
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Tab") autofillActualCost(index);
|
||||
if (e.key === "Tab" && !e.shiftKey) autofillActualCost(index);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@@ -328,6 +334,7 @@ export function BillEnterModalLinesComponent({
|
||||
min={0}
|
||||
disabled={disabled}
|
||||
controls={false}
|
||||
tabIndex={0}
|
||||
style={{ width: "100%", height: CONTROL_HEIGHT }}
|
||||
onFocus={() => autofillActualCost(index)}
|
||||
/>
|
||||
@@ -392,7 +399,7 @@ export function BillEnterModalLinesComponent({
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
|
||||
{bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
|
||||
@@ -412,7 +419,7 @@ export function BillEnterModalLinesComponent({
|
||||
name: [field.name, "location"]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select disabled={disabled}>
|
||||
<Select disabled={disabled} tabIndex={0}>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
@@ -432,7 +439,7 @@ export function BillEnterModalLinesComponent({
|
||||
key: `${field.name}deductedfromlbr`,
|
||||
name: [field.name, "deductedfromlbr"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />,
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />,
|
||||
additional: (record, index) => (
|
||||
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
|
||||
{() => {
|
||||
@@ -517,9 +524,13 @@ export function BillEnterModalLinesComponent({
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.name}fedtax`,
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "federal"]
|
||||
name: [field.name, "applicable_taxes", "federal"],
|
||||
initialValue: InstanceRenderManager({
|
||||
imex: true,
|
||||
rome: false
|
||||
})
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -534,7 +545,7 @@ export function BillEnterModalLinesComponent({
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "state"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />
|
||||
},
|
||||
|
||||
...InstanceRenderManager({
|
||||
@@ -550,7 +561,7 @@ export function BillEnterModalLinesComponent({
|
||||
valuePropName: "checked",
|
||||
name: [field.name, "applicable_taxes", "local"]
|
||||
}),
|
||||
formInput: () => <Switch disabled={disabled} />
|
||||
formInput: () => <Switch disabled={disabled} tabIndex={0} />
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -570,6 +581,7 @@ export function BillEnterModalLinesComponent({
|
||||
icon={<DeleteFilled />}
|
||||
disabled={disabled || invLen > 0}
|
||||
onClick={() => remove(record.name)}
|
||||
tabIndex={0}
|
||||
/>
|
||||
|
||||
{Simple_Inventory.treatment === "on" && (
|
||||
@@ -641,12 +653,19 @@ export function BillEnterModalLinesComponent({
|
||||
<Button
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
const newIndex = fields.length;
|
||||
add(
|
||||
InstanceRenderManager({
|
||||
imex: { applicable_taxes: { federal: true } },
|
||||
rome: { applicable_taxes: { federal: false } }
|
||||
})
|
||||
);
|
||||
setTimeout(() => {
|
||||
const firstField = firstFieldRefs.current[newIndex];
|
||||
if (firstField?.focus) {
|
||||
firstField.focus();
|
||||
}
|
||||
}, 100);
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
|
||||
@@ -57,7 +57,6 @@ const CardPaymentModalComponent = ({
|
||||
QUERY_RO_AND_OWNER_BY_JOB_PKS,
|
||||
{
|
||||
fetchPolicy: "network-only",
|
||||
notifyOnNetworkStatusChange: true
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
|
||||
const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
...(pollInterval > 0 ? { pollInterval } : {})
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export function DashboardTotalProductionHours({ bodyshop, data, ...cardProps })
|
||||
<Statistic
|
||||
title={t("dashboard.labels.prodhrs")}
|
||||
value={hours.total.toFixed(1)}
|
||||
styles={{ value: { color: aboveTargetHours ? "green" : "red" } }}
|
||||
styles={{ content: { color: aboveTargetHours ? "green" : "red" } }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -23,13 +23,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector)
|
||||
* @constructor
|
||||
*/
|
||||
export function DmsCustomerSelector(props) {
|
||||
const { bodyshop, jobid, socket, rrOptions = {} } = props;
|
||||
const { bodyshop, jobid, job, socket, rrOptions = {} } = props;
|
||||
|
||||
// Centralized "mode" (provider + transport)
|
||||
const mode = props.mode;
|
||||
|
||||
// Stable base props for children
|
||||
const base = useMemo(() => ({ bodyshop, jobid, socket }), [bodyshop, jobid, socket]);
|
||||
const base = useMemo(() => ({ bodyshop, jobid, job, socket }), [bodyshop, jobid, job, socket]);
|
||||
|
||||
switch (mode) {
|
||||
case DMS_MAP.reynolds: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd";
|
||||
import { Alert, Button, Checkbox, message, Modal, Space, Table } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { alphaSort } from "../../utils/sorters";
|
||||
@@ -47,6 +47,7 @@ const rrAddressToString = (addr) => {
|
||||
export default function RRCustomerSelector({
|
||||
jobid,
|
||||
socket,
|
||||
job,
|
||||
rrOpenRoLimit = false,
|
||||
onRrOpenRoFinished,
|
||||
rrValidationPending = false,
|
||||
@@ -59,15 +60,26 @@ export default function RRCustomerSelector({
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// Show dialog automatically when validation is pending
|
||||
// BUT: skip this for early RO flow (job already has dms_id)
|
||||
useEffect(() => {
|
||||
if (rrValidationPending) setOpen(true);
|
||||
}, [rrValidationPending]);
|
||||
if (rrValidationPending && !job?.dms_id) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [rrValidationPending, job?.dms_id]);
|
||||
|
||||
// Listen for RR customer selection list
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
const handleRrSelectCustomer = (list) => {
|
||||
const normalized = normalizeRrList(list);
|
||||
|
||||
// If list is empty, it means early RO exists and customer selection should be skipped
|
||||
// Don't open the modal in this case
|
||||
if (normalized.length === 0) {
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
setCustomerList(normalized);
|
||||
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
|
||||
@@ -127,6 +139,10 @@ export default function RRCustomerSelector({
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const refreshRrSearch = () => {
|
||||
setRefreshing(true);
|
||||
const to = setTimeout(() => setRefreshing(false), 12000);
|
||||
@@ -141,8 +157,6 @@ export default function RRCustomerSelector({
|
||||
socket.emit("rr-export-job", { jobId: jobid });
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const columns = [
|
||||
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
|
||||
{
|
||||
@@ -169,8 +183,45 @@ export default function RRCustomerSelector({
|
||||
return !rrOwnerSet.has(String(record.custNo));
|
||||
};
|
||||
|
||||
// For early RO flow: show validation banner even when modal is closed
|
||||
if (!open) {
|
||||
if (rrValidationPending && job?.dms_id) {
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
title="Complete Validation in Reynolds"
|
||||
description={
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div>
|
||||
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
|
||||
click <strong>Finished</strong> to finalize and mark this export as complete.
|
||||
</div>
|
||||
<div>
|
||||
<Space>
|
||||
<Button type="primary" onClick={onValidationFinished}>
|
||||
Finished
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Col span={24}>
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
title={t("dms.selectCustomer")}
|
||||
>
|
||||
<Table
|
||||
title={() => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
@@ -196,8 +247,8 @@ export default function RRCustomerSelector({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Validation step banner */}
|
||||
{rrValidationPending && (
|
||||
{/* Validation step banner - only show for NON-early RO flow (legacy) */}
|
||||
{rrValidationPending && !job?.dms_id && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
@@ -262,6 +313,6 @@ export default function RRCustomerSelector({
|
||||
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function DmsLogEvents({
|
||||
return {
|
||||
key: idx,
|
||||
color: logLevelColor(level),
|
||||
children: (
|
||||
content: (
|
||||
<Space orientation="vertical" size={4} style={{ display: "flex" }}>
|
||||
{/* Row 1: summary + inline "Details" toggle */}
|
||||
<Space wrap align="start">
|
||||
@@ -113,7 +113,7 @@ export function DmsLogEvents({
|
||||
[logs, openSet, colorizeJson, isDarkMode, showDetails]
|
||||
);
|
||||
|
||||
return <Timeline pending reverse items={items} />;
|
||||
return <Timeline reverse items={items} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -404,7 +404,7 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
|
||||
<Typography.Title>=</Typography.Title>
|
||||
<Statistic
|
||||
title={t("jobs.labels.dms.notallocated")}
|
||||
styles={{ value: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
|
||||
styles={{ content: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
|
||||
value={discrep.toFormat()}
|
||||
/>
|
||||
<Button disabled={disablePost} htmlType="submit">
|
||||
|
||||
@@ -208,8 +208,18 @@ export default function RRPostForm({
|
||||
});
|
||||
};
|
||||
|
||||
// Check if early RO was created (job has all early RO fields)
|
||||
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
|
||||
|
||||
return (
|
||||
<Card title={t("jobs.labels.dms.postingform")}>
|
||||
{hasEarlyRO && (
|
||||
<Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}>
|
||||
✅ {t("jobs.labels.dms.earlyro.created")} {job.dms_id}
|
||||
<br />
|
||||
<Typography.Text type="secondary">{t("jobs.labels.dms.earlyro.willupdate")}</Typography.Text>
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
@@ -218,96 +228,96 @@ export default function RRPostForm({
|
||||
initialValues={initialValues}
|
||||
>
|
||||
<Row gutter={[16, 12]} align="bottom">
|
||||
{/* Advisor + inline Refresh */}
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Form.Item label={t("jobs.fields.dms.advisor")} required>
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
name="advisorNo"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t("general.validation.required") }]}
|
||||
>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
loading={advLoading}
|
||||
allowClear
|
||||
placeholder={t("general.actions.select", "Select...")}
|
||||
popupMatchSelectWidth
|
||||
options={advisors
|
||||
.map((a) => {
|
||||
const value = getAdvisorNumber(a);
|
||||
if (value == null) return null;
|
||||
return { value: String(value), label: getAdvisorLabel(a) || String(value) };
|
||||
})
|
||||
.filter(Boolean)}
|
||||
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title={t("general.actions.refresh")}>
|
||||
<Button
|
||||
aria-label={t("general.actions.refresh")}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchRrAdvisors(true)}
|
||||
loading={advLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{/* RR OpCode (prefix / base / suffix) */}
|
||||
<Col xs={24} sm={12} md={12} lg={8}>
|
||||
<Form.Item
|
||||
required
|
||||
label={
|
||||
<Space size="small" align="center">
|
||||
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
|
||||
{isCustomOpCode && (
|
||||
{/* Advisor + inline Refresh - Only show if no early RO */}
|
||||
{!hasEarlyRO && (
|
||||
<Col xs={24} sm={24} md={12} lg={8}>
|
||||
<Form.Item label={t("jobs.fields.dms.advisor")} required>
|
||||
<Space.Compact block>
|
||||
<Form.Item
|
||||
name="advisorNo"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t("general.validation.required") }]}
|
||||
>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
loading={advLoading}
|
||||
allowClear
|
||||
placeholder={t("general.actions.select", "Select...")}
|
||||
popupMatchSelectWidth
|
||||
options={advisors
|
||||
.map((a) => {
|
||||
const value = getAdvisorNumber(a);
|
||||
if (value == null) return null;
|
||||
return { value: String(value), label: getAdvisorLabel(a) || String(value) };
|
||||
})
|
||||
.filter(Boolean)}
|
||||
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title={t("general.actions.refresh")}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RollbackOutlined />}
|
||||
onClick={handleResetOpCode}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space.Compact block>
|
||||
<Form.Item name="opPrefix" noStyle>
|
||||
<Input
|
||||
allowClear
|
||||
maxLength={4}
|
||||
style={{ width: "30%" }}
|
||||
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="opBase"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t("general.validation.required") }]}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
maxLength={10}
|
||||
style={{ width: "40%" }}
|
||||
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="opSuffix" noStyle>
|
||||
<Input
|
||||
allowClear
|
||||
maxLength={4}
|
||||
style={{ width: "30%" }}
|
||||
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
aria-label={t("general.actions.refresh")}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchRrAdvisors(true)}
|
||||
loading={advLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
|
||||
{!hasEarlyRO && (
|
||||
<Col xs={24} sm={12} md={12} lg={8}>
|
||||
<Form.Item
|
||||
required
|
||||
label={
|
||||
<Space size="small" align="center">
|
||||
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
|
||||
{isCustomOpCode && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<RollbackOutlined />}
|
||||
onClick={handleResetOpCode}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{t("jobs.fields.dms.rr_opcode_reset", "Reset")}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space.Compact block>
|
||||
<Form.Item name="opPrefix" noStyle>
|
||||
<Input
|
||||
allowClear
|
||||
maxLength={4}
|
||||
style={{ width: "30%" }}
|
||||
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="opBase" noStyle rules={[{ required: true }]}>
|
||||
<Input
|
||||
allowClear
|
||||
maxLength={10}
|
||||
style={{ width: "40%" }}
|
||||
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="opSuffix" noStyle>
|
||||
<Input
|
||||
allowClear
|
||||
maxLength={4}
|
||||
style={{ width: "30%" }}
|
||||
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
<Col xs={12} sm={8} md={6} lg={4}>
|
||||
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
|
||||
@@ -355,13 +365,14 @@ export default function RRPostForm({
|
||||
{/* Validation */}
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
const advisorOk = !!form.getFieldValue("advisorNo");
|
||||
// When early RO exists, advisor is already set, so we don't need to validate it
|
||||
const advisorOk = hasEarlyRO ? true : !!form.getFieldValue("advisorNo");
|
||||
return (
|
||||
<Space size="large" wrap align="center">
|
||||
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
|
||||
<Typography.Title>=</Typography.Title>
|
||||
<Button disabled={!advisorOk} htmlType="submit">
|
||||
{t("jobs.actions.dms.post")}
|
||||
<Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
|
||||
{hasEarlyRO ? t("jobs.actions.dms.update_ro") : t("jobs.actions.dms.post")}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
367
client/src/components/dms-post-form/rr-early-ro-form.jsx
Normal file
367
client/src/components/dms-post-form/rr-early-ro-form.jsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from "antd";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
// Simple customer selector table
|
||||
function CustomerSelectorTable({ customers, onSelect, isSubmitting }) {
|
||||
const [selectedCustNo, setSelectedCustNo] = useState(null);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Select",
|
||||
key: "select",
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Radio checked={selectedCustNo === record.custNo} onChange={() => setSelectedCustNo(record.custNo)} />
|
||||
)
|
||||
},
|
||||
{ title: "Customer ID", dataIndex: "custNo", key: "custNo" },
|
||||
{ title: "Name", dataIndex: "name", key: "name" },
|
||||
{
|
||||
title: "VIN Owner",
|
||||
key: "vinOwner",
|
||||
render: (_, record) => (record.vinOwner || record.isVehicleOwner ? "Yes" : "No")
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table columns={columns} dataSource={customers} rowKey="custNo" pagination={false} size="small" />
|
||||
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => onSelect(selectedCustNo, false)}
|
||||
disabled={!selectedCustNo || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Use Selected Customer
|
||||
</Button>
|
||||
<Button onClick={() => onSelect(null, true)} disabled={isSubmitting} loading={isSubmitting}>
|
||||
Create New Customer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RR Early RO Creation Form
|
||||
* Used from convert button or admin page to create minimal RO before full export
|
||||
* @param bodyshop
|
||||
* @param socket
|
||||
* @param job
|
||||
* @param onSuccess - callback when RO is created successfully
|
||||
* @param onCancel - callback to close modal
|
||||
* @param showCancelButton - whether to show cancel button
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export default function RREarlyROForm({ bodyshop, socket, job, onSuccess, onCancel, showCancelButton = true }) {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Advisors
|
||||
const [advisors, setAdvisors] = useState([]);
|
||||
const [advLoading, setAdvLoading] = useState(false);
|
||||
|
||||
// Customer selection
|
||||
const [customerCandidates, setCustomerCandidates] = useState([]);
|
||||
const [showCustomerSelector, setShowCustomerSelector] = useState(false);
|
||||
|
||||
// Loading and success states
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id);
|
||||
const [createdRoNumber, setCreatedRoNumber] = useState(job?.dms_id || null);
|
||||
|
||||
// Derive default OpCode parts from bodyshop config (matching dms.container.jsx logic)
|
||||
const initialValues = useMemo(() => {
|
||||
const cfg = bodyshop?.rr_configuration || {};
|
||||
const defaults =
|
||||
cfg.opCodeDefault ||
|
||||
cfg.op_code_default ||
|
||||
cfg.op_codes?.default ||
|
||||
cfg.defaults?.opCode ||
|
||||
cfg.defaults ||
|
||||
cfg.default ||
|
||||
{};
|
||||
|
||||
const prefix = defaults.prefix ?? defaults.opCodePrefix ?? "";
|
||||
const base = defaults.base ?? defaults.opCodeBase ?? "";
|
||||
const suffix = defaults.suffix ?? defaults.opCodeSuffix ?? "";
|
||||
|
||||
return {
|
||||
kmin: job?.kmin || 0,
|
||||
opPrefix: prefix,
|
||||
opBase: base,
|
||||
opSuffix: suffix
|
||||
};
|
||||
}, [bodyshop, job]);
|
||||
|
||||
const getAdvisorNumber = (a) => a?.advisorId;
|
||||
const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim();
|
||||
|
||||
const fetchRrAdvisors = (refresh = false) => {
|
||||
if (!socket) return;
|
||||
setAdvLoading(true);
|
||||
|
||||
const onResult = (payload) => {
|
||||
try {
|
||||
const list = payload?.result ?? payload ?? [];
|
||||
setAdvisors(Array.isArray(list) ? list : []);
|
||||
} finally {
|
||||
setAdvLoading(false);
|
||||
socket.off("rr-get-advisors:result", onResult);
|
||||
}
|
||||
};
|
||||
|
||||
socket.once("rr-get-advisors:result", onResult);
|
||||
socket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => {
|
||||
if (ack?.ok) {
|
||||
const list = ack.result ?? [];
|
||||
setAdvisors(Array.isArray(list) ? list : []);
|
||||
} else if (ack) {
|
||||
console.error("Error fetching RR Advisors:", ack.error);
|
||||
}
|
||||
setAdvLoading(false);
|
||||
socket.off("rr-get-advisors:result", onResult);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRrAdvisors(false);
|
||||
}, [bodyshop?.id, socket]);
|
||||
|
||||
const handleStartEarlyRO = async (values) => {
|
||||
if (!socket) {
|
||||
console.error("Socket not available");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const txEnvelope = {
|
||||
advisorNo: values.advisorNo,
|
||||
story: values.story || "",
|
||||
kmin: values.kmin || job?.kmin || 0,
|
||||
opPrefix: values.opPrefix || "",
|
||||
opBase: values.opBase || "",
|
||||
opSuffix: values.opSuffix || ""
|
||||
};
|
||||
|
||||
// Emit the early RO creation request
|
||||
socket.emit("rr-create-early-ro", {
|
||||
jobId: job.id,
|
||||
txEnvelope
|
||||
});
|
||||
|
||||
// Wait for customer selection
|
||||
const customerListener = (candidates) => {
|
||||
console.log("Received rr-select-customer event with candidates:", candidates);
|
||||
setCustomerCandidates(candidates || []);
|
||||
setShowCustomerSelector(true);
|
||||
setIsSubmitting(false);
|
||||
socket.off("rr-select-customer", customerListener);
|
||||
};
|
||||
|
||||
socket.once("rr-select-customer", customerListener);
|
||||
|
||||
// Handle failures
|
||||
const failureListener = (payload) => {
|
||||
if (payload?.jobId === job.id) {
|
||||
console.error("Early RO creation failed:", payload.error);
|
||||
alert(`Failed to create early RO: ${payload.error}`);
|
||||
setIsSubmitting(false);
|
||||
setShowCustomerSelector(false);
|
||||
socket.off("export-failed", failureListener);
|
||||
socket.off("rr-select-customer", customerListener);
|
||||
}
|
||||
};
|
||||
|
||||
socket.once("export-failed", failureListener);
|
||||
};
|
||||
|
||||
const handleCustomerSelected = (custNo, createNew = false) => {
|
||||
if (!socket) return;
|
||||
|
||||
console.log("handleCustomerSelected called:", { custNo, createNew, custNoType: typeof custNo });
|
||||
|
||||
setIsSubmitting(true);
|
||||
setShowCustomerSelector(false);
|
||||
|
||||
const payload = {
|
||||
jobId: job.id,
|
||||
custNo: createNew ? null : custNo,
|
||||
create: createNew
|
||||
};
|
||||
|
||||
console.log("Emitting rr-early-customer-selected:", payload);
|
||||
|
||||
// Emit customer selection
|
||||
socket.emit("rr-early-customer-selected", payload, (ack) => {
|
||||
console.log("Received ack from rr-early-customer-selected:", ack);
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (ack?.ok) {
|
||||
const roNumber = ack.dmsRoNo || ack.outsdRoNo;
|
||||
setEarlyRoCreated(true);
|
||||
setCreatedRoNumber(roNumber);
|
||||
onSuccess?.({ roNumber, ...ack });
|
||||
} else {
|
||||
alert(`Failed to create early RO: ${ack?.error || "Unknown error"}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Also listen for socket events
|
||||
const successListener = (payload) => {
|
||||
if (payload?.jobId === job.id) {
|
||||
const roNumber = payload.dmsRoNo || payload.outsdRoNo;
|
||||
console.log("Early RO created:", roNumber);
|
||||
socket.off("rr-early-ro-created", successListener);
|
||||
socket.off("export-failed", failureListener);
|
||||
}
|
||||
};
|
||||
|
||||
const failureListener = (payload) => {
|
||||
if (payload?.jobId === job.id) {
|
||||
console.error("Early RO creation failed:", payload.error);
|
||||
setIsSubmitting(false);
|
||||
setEarlyRoCreated(false);
|
||||
socket.off("rr-early-ro-created", successListener);
|
||||
socket.off("export-failed", failureListener);
|
||||
}
|
||||
};
|
||||
|
||||
socket.once("rr-early-ro-created", successListener);
|
||||
socket.once("export-failed", failureListener);
|
||||
};
|
||||
|
||||
// If early RO already created, show success message
|
||||
if (earlyRoCreated) {
|
||||
return (
|
||||
<Alert
|
||||
title="Early Reynolds RO Created"
|
||||
description={`RO Number: ${createdRoNumber || "N/A"} - You can now convert the job.`}
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// If showing customer selector, render modal
|
||||
if (showCustomerSelector) {
|
||||
return (
|
||||
<>
|
||||
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">Waiting for customer selection...</Typography.Paragraph>
|
||||
|
||||
<Modal
|
||||
title="Select Customer for Early RO"
|
||||
open={true}
|
||||
width={800}
|
||||
footer={null}
|
||||
onCancel={() => {
|
||||
setShowCustomerSelector(false);
|
||||
setIsSubmitting(false);
|
||||
}}
|
||||
>
|
||||
<CustomerSelectorTable
|
||||
customers={customerCandidates}
|
||||
onSelect={handleCustomerSelected}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle manual submit (since we can't nest forms)
|
||||
const handleManualSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
handleStartEarlyRO(values);
|
||||
} catch (error) {
|
||||
console.error("Validation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Show the form
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: "12px" }}>
|
||||
Complete this section to create a minimal RO in Reynolds before converting the job.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form form={form} layout="vertical" component={false} initialValues={initialValues}>
|
||||
<Form.Item name="advisorNo" label="Advisor" rules={[{ required: true, message: "Please select an advisor" }]}>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => (option?.children?.toLowerCase() ?? "").includes(input.toLowerCase())
|
||||
}}
|
||||
loading={advLoading}
|
||||
placeholder="Select advisor..."
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchRrAdvisors(true)}
|
||||
style={{ width: "100%", textAlign: "left" }}
|
||||
>
|
||||
Refresh Advisors
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{advisors.map((adv) => (
|
||||
<Select.Option key={getAdvisorNumber(adv)} value={getAdvisorNumber(adv)}>
|
||||
{getAdvisorLabel(adv)}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="kmin"
|
||||
label="Mileage In"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter initial mileage" },
|
||||
{ type: "number", min: 1, message: "Mileage must be greater than 0" }
|
||||
]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
|
||||
{/* RR OpCode (prefix / base / suffix) */}
|
||||
<Form.Item required label="RR OpCode">
|
||||
<Space.Compact block>
|
||||
<Form.Item name="opPrefix" noStyle>
|
||||
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Prefix" />
|
||||
</Form.Item>
|
||||
<Form.Item name="opBase" noStyle rules={[{ required: true, message: "Base Required" }]}>
|
||||
<Input allowClear maxLength={10} style={{ width: "40%" }} placeholder="Base" />
|
||||
</Form.Item>
|
||||
<Form.Item name="opSuffix" noStyle>
|
||||
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Suffix" />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="story" label="Comments / Story (Optional)">
|
||||
<Input.TextArea rows={2} maxLength={240} showCount placeholder="Enter comments or story..." />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleManualSubmit} loading={isSubmitting} disabled={advLoading}>
|
||||
Create Early RO
|
||||
</Button>
|
||||
{showCancelButton && <Button onClick={onCancel}>Cancel</Button>}
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
client/src/components/dms-post-form/rr-early-ro-modal.jsx
Normal file
33
client/src/components/dms-post-form/rr-early-ro-modal.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Modal } from "antd";
|
||||
import RREarlyROForm from "./rr-early-ro-form";
|
||||
|
||||
/**
|
||||
* Modal wrapper for RR Early RO Creation Form
|
||||
* @param open - boolean to control modal visibility
|
||||
* @param onClose - callback when modal is closed
|
||||
* @param onSuccess - callback when RO is created successfully
|
||||
* @param bodyshop - bodyshop object
|
||||
* @param socket - socket.io connection
|
||||
* @param job - job object
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export default function RREarlyROModal({ open, onClose, onSuccess, bodyshop, socket, job }) {
|
||||
const handleSuccess = (result) => {
|
||||
onSuccess?.(result);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={700}
|
||||
destroyOnHidden
|
||||
title="Create Reynolds Repair Order"
|
||||
>
|
||||
<RREarlyROForm bodyshop={bodyshop} socket={socket} job={job} onSuccess={handleSuccess} onCancel={onClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -14,8 +14,11 @@ export default function GlobalSearch() {
|
||||
const [callSearch, { loading, error, data }] = useLazyQuery(GLOBAL_SEARCH_QUERY);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const executeSearch = (v) => {
|
||||
if (v && v.variables.search && v.variables.search !== "" && v.variables.search.length >= 3) callSearch(v);
|
||||
const executeSearch = (variables) => {
|
||||
if (variables?.search !== "" && variables?.search?.length >= 3)
|
||||
callSearch({
|
||||
variables
|
||||
});
|
||||
};
|
||||
const debouncedExecuteSearch = _.debounce(executeSearch, 750);
|
||||
|
||||
@@ -157,7 +160,9 @@ export default function GlobalSearch() {
|
||||
return (
|
||||
<AutoComplete
|
||||
options={options}
|
||||
onSearch={handleSearch}
|
||||
showSearch={{
|
||||
onSearch: handleSearch
|
||||
}}
|
||||
defaultActiveFirstOption
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
@@ -97,7 +97,7 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
|
||||
return (
|
||||
<>
|
||||
<Button onClick={showModal}>{t("printcenter.jobs.3rdpartypayer")}</Button>
|
||||
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
|
||||
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel} getContainer={() => document.body}>
|
||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
||||
<Form.Item label={t("bills.fields.vendor")} name="vendorid">
|
||||
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />
|
||||
|
||||
@@ -1,29 +1,65 @@
|
||||
import { useMemo } from "react";
|
||||
import { Tag, Tooltip } from "antd";
|
||||
import { Tooltip } from "antd";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
const mapDispatchToProps = () => ({
|
||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
||||
});
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
const colorMap = {
|
||||
gray: { bg: "#fafafa", border: "#d9d9d9", text: "#000000" },
|
||||
gold: { bg: "#fffbe6", border: "#ffe58f", text: "#d48806" },
|
||||
red: { bg: "#fff1f0", border: "#ffccc7", text: "#cf1322" },
|
||||
blue: { bg: "#e6f7ff", border: "#91d5ff", text: "#0958d9" },
|
||||
green: { bg: "#f6ffed", border: "#b7eb8f", text: "#389e0d" },
|
||||
orange: { bg: "#fff7e6", border: "#ffd591", text: "#d46b08" }
|
||||
};
|
||||
|
||||
function CompactTag({ color = "gray", children, tooltip = "" }) {
|
||||
const colors = colorMap[color] || colorMap.gray;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 2px",
|
||||
fontSize: "12px",
|
||||
lineHeight: "20px",
|
||||
backgroundColor: colors.bg,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: "2px",
|
||||
color: colors.text,
|
||||
minWidth: "24px",
|
||||
textAlign: "center"
|
||||
}}
|
||||
>
|
||||
<Tooltip title={tooltip}>{children}</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobPartsQueueCount);
|
||||
|
||||
export function JobPartsQueueCount({ bodyshop, parts }) {
|
||||
const { t } = useTranslation();
|
||||
const partsStatus = useMemo(() => {
|
||||
if (!parts) return null;
|
||||
|
||||
const statusKeys = ["default_bo", "default_ordered", "default_received", "default_returned"];
|
||||
|
||||
return parts.reduce(
|
||||
(acc, val) => {
|
||||
if (val.part_type === "PAS" || val.part_type === "PASL") return acc;
|
||||
acc.total = acc.total + val.count;
|
||||
acc[val.status] = acc[val.status] + val.count;
|
||||
|
||||
acc.total += val.count;
|
||||
|
||||
// NOTE: if val.status is null, object key becomes "null"
|
||||
acc[val.status] = (acc[val.status] ?? 0) + val.count;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
@@ -34,45 +70,38 @@ export function JobPartsQueueCount({ bodyshop, parts }) {
|
||||
);
|
||||
}, [bodyshop, parts]);
|
||||
|
||||
if (!parts) return null;
|
||||
if (!parts || !partsStatus) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(40px, 1fr))",
|
||||
gap: "8px",
|
||||
width: "100%",
|
||||
justifyItems: "start"
|
||||
display: "inline-flex", // ✅ shrink-wraps, fixes the “extra box” to the right
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Total">
|
||||
<Tag style={{ minWidth: "40px", textAlign: "center" }}>{partsStatus.total}</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("dashboard.errors.status_normal")}>
|
||||
<Tag color="gold" style={{ minWidth: "40px", textAlign: "center" }}>
|
||||
{partsStatus["null"]}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={bodyshop.md_order_statuses.default_bo}>
|
||||
<Tag color="red" style={{ minWidth: "40px", textAlign: "center" }}>
|
||||
{partsStatus[bodyshop.md_order_statuses.default_bo]}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={bodyshop.md_order_statuses.default_ordered}>
|
||||
<Tag color="blue" style={{ minWidth: "40px", textAlign: "center" }}>
|
||||
{partsStatus[bodyshop.md_order_statuses.default_ordered]}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={bodyshop.md_order_statuses.default_received}>
|
||||
<Tag color="green" style={{ minWidth: "40px", textAlign: "center" }}>
|
||||
{partsStatus[bodyshop.md_order_statuses.default_received]}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Tooltip title={bodyshop.md_order_statuses.default_returned}>
|
||||
<Tag color="orange" style={{ minWidth: "40px", textAlign: "center" }}>
|
||||
{partsStatus[bodyshop.md_order_statuses.default_returned]}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<CompactTag tooltip="Total" color="gray">
|
||||
{partsStatus.total}
|
||||
</CompactTag>
|
||||
|
||||
<CompactTag tooltip="No Status" color="gold">
|
||||
{partsStatus["null"]}
|
||||
</CompactTag>
|
||||
|
||||
<CompactTag tooltip={bodyshop.md_order_statuses.default_bo} color="red">
|
||||
{partsStatus[bodyshop.md_order_statuses.default_bo]}
|
||||
</CompactTag>
|
||||
|
||||
<CompactTag tooltip={bodyshop.md_order_statuses.default_ordered} color="blue">
|
||||
{partsStatus[bodyshop.md_order_statuses.default_ordered]}
|
||||
</CompactTag>
|
||||
<CompactTag tooltip={bodyshop.md_order_statuses.default_received} color="green">
|
||||
{partsStatus[bodyshop.md_order_statuses.default_received]}
|
||||
</CompactTag>
|
||||
<CompactTag tooltip={bodyshop.md_order_statuses.default_returned} color="orange">
|
||||
{partsStatus[bodyshop.md_order_statuses.default_returned]}
|
||||
</CompactTag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,17 @@ const mapStateToProps = createStructuredSelector({
|
||||
* @param parts
|
||||
* @param displayMode
|
||||
* @param popoverPlacement
|
||||
* @param countsOnly
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popoverPlacement = "top" }) {
|
||||
export function JobPartsReceived({
|
||||
bodyshop,
|
||||
parts,
|
||||
displayMode = "full",
|
||||
popoverPlacement = "top",
|
||||
countsOnly = false
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -61,6 +68,8 @@ export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popove
|
||||
[canOpen]
|
||||
);
|
||||
|
||||
if (countsOnly) return <JobPartsQueueCount parts={parts} />;
|
||||
|
||||
const displayText =
|
||||
displayMode === "compact" ? summary.percentLabel : `${summary.percentLabel} (${summary.received}/${summary.total})`;
|
||||
|
||||
@@ -74,7 +83,7 @@ export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popove
|
||||
trigger={["click"]}
|
||||
placement={popoverPlacement}
|
||||
content={
|
||||
<div onClick={stop} style={{ minWidth: 260 }}>
|
||||
<div onClick={stop}>
|
||||
<JobPartsQueueCount parts={parts} />
|
||||
</div>
|
||||
}
|
||||
@@ -99,7 +108,8 @@ JobPartsReceived.propTypes = {
|
||||
bodyshop: PropTypes.object,
|
||||
parts: PropTypes.array,
|
||||
displayMode: PropTypes.oneOf(["full", "compact"]),
|
||||
popoverPlacement: PropTypes.string
|
||||
popoverPlacement: PropTypes.string,
|
||||
countsOnly: PropTypes.bool
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(JobPartsReceived);
|
||||
|
||||
@@ -42,11 +42,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
||||
<tbody>
|
||||
{fields.map((field, index) => (
|
||||
<tr key={field.key}>
|
||||
{/* Hidden field to preserve jobline ID */}
|
||||
<Form.Item hidden name={[field.name, "id"]}>
|
||||
<input />
|
||||
</Form.Item>
|
||||
<td>
|
||||
{/* Hidden field to preserve jobline ID without injecting a div under <tr> */}
|
||||
<Form.Item noStyle name={[field.name, "id"]}>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.line_desc")}
|
||||
key={`${index}line_desc`}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { Button, Form, Input, Popover, Select, Space, Switch } from "antd";
|
||||
import { Button, Divider, Form, Input, Modal, Select, Space, Switch } from "antd";
|
||||
import axios from "axios";
|
||||
import { some } from "lodash";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectJobReadOnly } from "../../redux/application/application.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
import RREarlyROForm from "../dms-post-form/rr-early-ro-form";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
@@ -33,11 +37,27 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id); // Track early RO creation state
|
||||
const [earlyRoCreatedThisSession, setEarlyRoCreatedThisSession] = useState(false); // Track if created in THIS modal session
|
||||
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const notification = useNotification();
|
||||
const allFormValues = Form.useWatch([], form);
|
||||
const { socket } = useSocket(); // Extract socket from context
|
||||
|
||||
// Get Fortellis treatment for proper DMS mode detection
|
||||
const {
|
||||
treatments: { Fortellis }
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Fortellis"],
|
||||
splitKey: bodyshop?.imexshopid
|
||||
});
|
||||
|
||||
// Check if bodyshop has Reynolds integration using the proper getDmsMode function
|
||||
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
|
||||
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||
|
||||
const handleConvert = async ({ employee_csr, category, ...values }) => {
|
||||
if (parentFormIsFieldsTouched()) {
|
||||
@@ -82,177 +102,227 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
|
||||
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
|
||||
|
||||
const popMenu = (
|
||||
<div>
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onFinish={handleConvert}
|
||||
initialValues={{
|
||||
driveable: true,
|
||||
towin: job.towin,
|
||||
ca_gst_registrant: job.ca_gst_registrant,
|
||||
employee_csr: job.employee_csr,
|
||||
category: job.category,
|
||||
referral_source: job.referral_source,
|
||||
referral_source_extra: job.referral_source_extra ?? ""
|
||||
const handleEarlyROSuccess = (result) => {
|
||||
setEarlyRoCreated(true); // Mark early RO as created
|
||||
setEarlyRoCreatedThisSession(true); // Mark as created in this session
|
||||
notification.success({
|
||||
title: t("jobs.successes.early_ro_created"),
|
||||
description: `RO Number: ${result.roNumber || "N/A"}`
|
||||
});
|
||||
// Delay refetch to keep success message visible for 2 seconds
|
||||
setTimeout(() => {
|
||||
refetch?.();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
if (job.converted) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
key="convert"
|
||||
type="primary"
|
||||
danger
|
||||
disabled={job.converted || jobRO}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setEarlyRoCreated(!!job?.dms_id); // Initialize state based on current job
|
||||
setEarlyRoCreatedThisSession(false); // Reset session state when opening modal
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name={["ins_co_nm"]}
|
||||
label={t("jobs.fields.ins_co_nm")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
{t("jobs.actions.convert")}
|
||||
</Button>
|
||||
|
||||
{/* Convert Job Modal */}
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleModalClose}
|
||||
closable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
|
||||
maskClosable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
|
||||
title={t("jobs.actions.convert")}
|
||||
footer={null}
|
||||
width={700}
|
||||
destroyOnHidden
|
||||
>
|
||||
{/* Standard Convert Form */}
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onFinish={handleConvert}
|
||||
initialValues={{
|
||||
driveable: true,
|
||||
towin: job.towin,
|
||||
ca_gst_registrant: job.ca_gst_registrant,
|
||||
employee_csr: job.employee_csr,
|
||||
category: job.category,
|
||||
referral_source: job.referral_source,
|
||||
referral_source_extra: job.referral_source_extra ?? ""
|
||||
}}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_ins_cos.map((s, i) => (
|
||||
<Select.Option key={i} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{bodyshop.enforce_class && (
|
||||
{/* Show Reynolds Early RO section at the top if applicable */}
|
||||
{isReynoldsMode && !job.dms_id && !earlyRoCreated && (
|
||||
<>
|
||||
<RREarlyROForm
|
||||
bodyshop={bodyshop}
|
||||
socket={socket}
|
||||
job={job}
|
||||
onSuccess={handleEarlyROSuccess}
|
||||
showCancelButton={false}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name={"class"}
|
||||
label={t("jobs.fields.class")}
|
||||
name={["ins_co_nm"]}
|
||||
label={t("jobs.fields.ins_co_nm")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_class
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_classes.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
<Select showSearch>
|
||||
{bodyshop.md_ins_cos.map((s, i) => (
|
||||
<Select.Option key={i} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.enforce_referral && (
|
||||
<>
|
||||
{bodyshop.enforce_class && (
|
||||
<Form.Item
|
||||
name={"referral_source"}
|
||||
label={t("jobs.fields.referralsource")}
|
||||
name={"class"}
|
||||
label={t("jobs.fields.class")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_referral
|
||||
required: bodyshop.enforce_class
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
{bodyshop.md_classes.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{bodyshop.enforce_conversion_csr && (
|
||||
<Form.Item
|
||||
name={"employee_csr"}
|
||||
label={t(
|
||||
InstanceRenderManager({
|
||||
imex: "jobs.fields.employee_csr",
|
||||
rome: "jobs.fields.employee_csr_writer"
|
||||
})
|
||||
)}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_csr
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
)}
|
||||
{bodyshop.enforce_referral && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={"referral_source"}
|
||||
label={t("jobs.fields.referralsource")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_referral
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{bodyshop.enforce_conversion_csr && (
|
||||
<Form.Item
|
||||
name={"employee_csr"}
|
||||
label={t(
|
||||
InstanceRenderManager({
|
||||
imex: "jobs.fields.employee_csr",
|
||||
rome: "jobs.fields.employee_csr_writer"
|
||||
})
|
||||
)}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_csr
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
{bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
{bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.enforce_conversion_category && (
|
||||
<Form.Item
|
||||
name={"category"}
|
||||
label={t("jobs.fields.category")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_category
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select allowClear>
|
||||
{bodyshop.md_categories.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.enforce_conversion_category && (
|
||||
<Form.Item
|
||||
name={"category"}
|
||||
label={t("jobs.fields.category")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_category
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select allowClear>
|
||||
{bodyshop.md_categories.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
|
||||
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
|
||||
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={loading}>
|
||||
{t("jobs.actions.convert")}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>{t("general.actions.close")}</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (job.converted) return <></>;
|
||||
|
||||
return (
|
||||
<Popover open={open} content={popMenu}>
|
||||
<Button
|
||||
key="convert"
|
||||
type="primary"
|
||||
danger
|
||||
// style={{ display: job.converted ? "none" : "" }}
|
||||
disabled={job.converted || jobRO}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("jobs.actions.convert")}
|
||||
</Button>
|
||||
</Popover>
|
||||
<Space wrap style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
disabled={submitDisabled() || (isReynoldsMode && !job.dms_id && !earlyRoCreated)}
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => form.submit()}
|
||||
loading={loading}
|
||||
>
|
||||
{t("jobs.actions.convert")}
|
||||
</Button>
|
||||
<Button onClick={handleModalClose} disabled={earlyRoCreatedThisSession && !job.converted}>
|
||||
{t("general.actions.close")}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +251,6 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
@@ -267,6 +266,21 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
<Form.Item label={t("jobs.fields.lost_sale_reason")} name="lost_sale_reason">
|
||||
<Input disabled={jobRO} allowClear />
|
||||
</Form.Item>
|
||||
{bodyshop.rr_dealerid && (
|
||||
<Form.Item label={t("jobs.fields.dms.id")} name="dms_id">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.rr_dealerid && (
|
||||
<Form.Item label={t("jobs.fields.dms.advisor")} name="dms_advisor_id">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop.rr_dealerid && (
|
||||
<Form.Item label={t("jobs.fields.dms.customer")} name="dms_customer_id">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
</FormRow>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -157,7 +157,6 @@ export function JobsDetailHeaderActions({
|
||||
variables: watcherVars,
|
||||
skip: !jobId,
|
||||
fetchPolicy: "cache-first",
|
||||
notifyOnNetworkStatusChange: true
|
||||
});
|
||||
|
||||
const jobWatchersCount = jobWatchersData?.job_watchers?.length ?? job?.job_watchers?.length ?? 0;
|
||||
|
||||
@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(vis) => setOpen(vis)} content={overlay} trigger="click">
|
||||
<Popover
|
||||
getPopupContainer={(trigger) => trigger?.parentElement || document.body}
|
||||
open={open}
|
||||
onOpenChange={(vis) => setOpen(vis)}
|
||||
content={overlay}
|
||||
trigger="click"
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,6 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
|
||||
where: whereClause
|
||||
},
|
||||
fetchPolicy: "cache-and-network",
|
||||
notifyOnNetworkStatusChange: true,
|
||||
errorPolicy: "all",
|
||||
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
|
||||
skip: skipQuery
|
||||
|
||||
@@ -2,6 +2,7 @@ import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { store } from "../../redux/store";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { Tooltip } from "antd";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
@@ -11,15 +12,27 @@ const mapDispatchToProps = () => ({
|
||||
});
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(OwnerNameDisplay);
|
||||
|
||||
export function OwnerNameDisplay({ bodyshop, ownerObject }) {
|
||||
export function OwnerNameDisplay({ bodyshop, ownerObject, withToolTip = false }) {
|
||||
const emptyTest = ownerObject?.ownr_fn + ownerObject?.ownr_ln + ownerObject?.ownr_co_nm;
|
||||
|
||||
if (!emptyTest || emptyTest === "null" || emptyTest.trim() === "") return "N/A";
|
||||
|
||||
if (bodyshop.last_name_first)
|
||||
return `${ownerObject?.ownr_ln || ""}, ${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_co_nm || ""}`.trim();
|
||||
|
||||
return `${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_ln || ""} ${ownerObject.ownr_co_nm || ""}`.trim();
|
||||
let returnString;
|
||||
if (bodyshop.last_name_first) {
|
||||
returnString =
|
||||
`${ownerObject?.ownr_ln || ""}, ${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_co_nm || ""}`.trim();
|
||||
} else {
|
||||
returnString = `${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_ln || ""} ${ownerObject.ownr_co_nm || ""}`.trim();
|
||||
}
|
||||
if (withToolTip) {
|
||||
return (
|
||||
<Tooltip title={returnString} mouseEnterDelay={0.5}>
|
||||
{returnString}
|
||||
</Tooltip>
|
||||
);
|
||||
} else {
|
||||
return returnString;
|
||||
}
|
||||
}
|
||||
|
||||
export function OwnerNameDisplayFunction(ownerObject, forceFirstLast = false) {
|
||||
|
||||
@@ -16,9 +16,10 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE
|
||||
);
|
||||
|
||||
const executeSearch = (v) => {
|
||||
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
|
||||
const executeSearch = (variables) => {
|
||||
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
|
||||
};
|
||||
|
||||
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
|
||||
|
||||
const handleSearch = (value) => {
|
||||
|
||||
@@ -1,94 +1,121 @@
|
||||
import { DownOutlined } from "@ant-design/icons";
|
||||
import { Dropdown, InputNumber, Space } from "antd";
|
||||
import { Button, Divider, Dropdown, InputNumber, Space, theme } from "antd";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
|
||||
const DISCOUNT_PRESETS = [5, 10, 15, 20, 25, 40];
|
||||
|
||||
export default function PartsOrderModalPriceChange({ form, field }) {
|
||||
const { t } = useTranslation();
|
||||
const menu = {
|
||||
items: [
|
||||
{
|
||||
key: "5",
|
||||
label: t("parts_orders.labels.discount", { percent: "5%" })
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
label: t("parts_orders.labels.discount", { percent: "10%" })
|
||||
},
|
||||
{
|
||||
key: "15",
|
||||
label: t("parts_orders.labels.discount", { percent: "15%" })
|
||||
},
|
||||
{
|
||||
key: "20",
|
||||
label: t("parts_orders.labels.discount", { percent: "20%" })
|
||||
},
|
||||
{
|
||||
key: "25",
|
||||
label: t("parts_orders.labels.discount", { percent: "25%" })
|
||||
},
|
||||
{
|
||||
key: "40",
|
||||
label: t("parts_orders.labels.discount", { percent: "40%" })
|
||||
},
|
||||
{
|
||||
key: "custom",
|
||||
label: (
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const values = form.getFieldsValue();
|
||||
const { parts_order_lines } = values;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
form.setFieldsValue({
|
||||
parts_order_lines: {
|
||||
data: parts_order_lines.data.map((p, idx) => {
|
||||
if (idx !== field.name) return p;
|
||||
console.log(p, e.target.value, (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100));
|
||||
return {
|
||||
...p,
|
||||
act_price: (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100)
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
e.target.value = 0;
|
||||
}
|
||||
}}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0 }}>%</span>
|
||||
</Space.Compact>
|
||||
)
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customPercent, setCustomPercent] = useState(0);
|
||||
|
||||
const applyDiscountPercent = (percent) => {
|
||||
const pct = Number(percent) || 0;
|
||||
|
||||
const values = form.getFieldsValue();
|
||||
const parts_order_lines = values?.parts_order_lines;
|
||||
const data = Array.isArray(parts_order_lines?.data) ? parts_order_lines.data : [];
|
||||
if (!data.length) return;
|
||||
|
||||
form.setFieldsValue({
|
||||
parts_order_lines: {
|
||||
data: data.map((p, idx) => {
|
||||
if (idx !== field.name) return p;
|
||||
return {
|
||||
...p,
|
||||
act_price: (p.act_price || 0) * ((100 - pct) / 100)
|
||||
};
|
||||
})
|
||||
}
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const applyCustom = () => {
|
||||
logImEXEvent("parts_order_manual_discount", {});
|
||||
applyDiscountPercent(customPercent);
|
||||
setCustomPercent(0);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const menu = {
|
||||
// Kill the menu “card” styling so our wrapper becomes the single card.
|
||||
style: {
|
||||
background: "transparent",
|
||||
boxShadow: "none"
|
||||
},
|
||||
items: DISCOUNT_PRESETS.map((pct) => ({
|
||||
key: String(pct),
|
||||
label: t("parts_orders.labels.discount", { percent: `${pct}%` })
|
||||
})),
|
||||
onClick: ({ key }) => {
|
||||
logImEXEvent("parts_order_manual_discount", {});
|
||||
if (key === "custom") return;
|
||||
const values = form.getFieldsValue();
|
||||
const { parts_order_lines } = values;
|
||||
form.setFieldsValue({
|
||||
parts_order_lines: {
|
||||
data: parts_order_lines.data.map((p, idx) => {
|
||||
if (idx !== field.name) return p;
|
||||
return {
|
||||
...p,
|
||||
act_price: (p.act_price || 0) * ((100 - key) / 100)
|
||||
};
|
||||
})
|
||||
}
|
||||
});
|
||||
applyDiscountPercent(key);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={menu} trigger="click">
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={["click"]}
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => setOpen(nextOpen)}
|
||||
getPopupContainer={(triggerNode) => triggerNode?.parentElement ?? document.body}
|
||||
popupRender={(menus) => (
|
||||
<div
|
||||
// This makes the whole dropdown (menu + footer) look like one panel in both light/dark.
|
||||
style={{
|
||||
background: token.colorBgElevated,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
overflow: "hidden",
|
||||
minWidth: 180
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menus}
|
||||
|
||||
<Divider style={{ margin: 0 }} />
|
||||
|
||||
<div style={{ padding: token.paddingXS }}>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<InputNumber
|
||||
value={customPercent}
|
||||
min={0}
|
||||
max={100}
|
||||
precision={0}
|
||||
controls={false}
|
||||
style={{ width: "100%" }}
|
||||
formatter={(v) => (v === null || v === undefined ? "" : `${v}%`)}
|
||||
parser={(v) =>
|
||||
String(v ?? "")
|
||||
.replace("%", "")
|
||||
.trim()
|
||||
}
|
||||
onChange={(v) => setCustomPercent(v ?? 0)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyCustom();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" onClick={applyCustom}>
|
||||
{t("general.labels.apply")}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Space>
|
||||
%
|
||||
<DownOutlined />
|
||||
% <DownOutlined />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||
import PartsOrderModalComponent from "./parts-order-modal.component";
|
||||
import axios from "axios";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
@@ -66,7 +65,7 @@ export function PartsOrderModalContainer({
|
||||
const sendTypeState = useState("e");
|
||||
const sendType = sendTypeState[0];
|
||||
|
||||
const { loading, error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
|
||||
const { error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
|
||||
skip: !open,
|
||||
variables: { jobId: jobId },
|
||||
fetchPolicy: "network-only",
|
||||
@@ -94,16 +93,6 @@ export function PartsOrderModalContainer({
|
||||
};
|
||||
});
|
||||
|
||||
const missingIdx = forcedLines.findIndex((l) => !l?.job_line_id);
|
||||
if (missingIdx !== -1) {
|
||||
notification.error({
|
||||
title: t("parts_orders.errors.creating"),
|
||||
description: `Missing job_line_id for parts line #${missingIdx + 1}`
|
||||
});
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let insertResult;
|
||||
try {
|
||||
insertResult = await insertPartOrder({
|
||||
@@ -372,6 +361,7 @@ export function PartsOrderModalContainer({
|
||||
}
|
||||
}, [open, linesToOrder, form]);
|
||||
|
||||
//This used to have a loading component spinner for the vendor data. With Apollo 4, the NetworkState isn't emitting correctly, so loading just gets set to true the second time, and no longer works as expected.
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -390,18 +380,14 @@ export function PartsOrderModalContainer({
|
||||
>
|
||||
{error ? <AlertComponent title={error.message} type="error" /> : null}
|
||||
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish} initialValues={initialValues}>
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<PartsOrderModalComponent
|
||||
form={form}
|
||||
vendorList={data?.vendors || []}
|
||||
sendTypeState={sendTypeState}
|
||||
isReturn={isReturn}
|
||||
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
|
||||
job={job}
|
||||
/>
|
||||
)}
|
||||
<PartsOrderModalComponent
|
||||
form={form}
|
||||
vendorList={data?.vendors || []}
|
||||
sendTypeState={sendTypeState}
|
||||
isReturn={isReturn}
|
||||
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
|
||||
job={job}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { Button, Card, Input, Space, Table } from "antd";
|
||||
import { Button, Card, Checkbox, Input, Space, Table } from "antd";
|
||||
import _ from "lodash";
|
||||
import queryString from "query-string";
|
||||
import { useState } from "react";
|
||||
@@ -31,6 +31,8 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
const { selected, sortcolumn, sortorder, statusFilters } = searchParams;
|
||||
const history = useNavigate();
|
||||
const [filter, setFilter] = useLocalStorage("filter_parts_queue", null);
|
||||
const [viewTimeStamp, setViewTimeStamp] = useLocalStorage("parts_queue_timestamps", false);
|
||||
const [countsOnly, setCountsOnly] = useLocalStorage("parts_queue_counts_only", false);
|
||||
|
||||
const { loading, error, data, refetch } = useQuery(QUERY_PARTS_QUEUE, {
|
||||
fetchPolicy: "network-only",
|
||||
@@ -92,6 +94,7 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
title: t("jobs.fields.ro_number"),
|
||||
dataIndex: "ro_number",
|
||||
key: "ro_number",
|
||||
width: "110px",
|
||||
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
|
||||
sortOrder: sortcolumn === "ro_number" && sortorder,
|
||||
|
||||
@@ -103,16 +106,20 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
title: t("jobs.fields.owner"),
|
||||
dataIndex: "ownr_ln",
|
||||
key: "ownr_ln",
|
||||
width: "8%",
|
||||
ellipsis: {
|
||||
showTitle: true
|
||||
},
|
||||
sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)),
|
||||
sortOrder: sortcolumn === "ownr_ln" && sortorder,
|
||||
render: (text, record) => {
|
||||
return record.ownerid ? (
|
||||
<Link to={"/manage/owners/" + record.ownerid}>
|
||||
<OwnerNameDisplay ownerObject={record} />
|
||||
<OwnerNameDisplay ownerObject={record} withToolTip />
|
||||
</Link>
|
||||
) : (
|
||||
<span>
|
||||
<OwnerNameDisplay ownerObject={record} />
|
||||
<OwnerNameDisplay ownerObject={record} withToolTip />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -187,7 +194,7 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
ellipsis: true,
|
||||
sorter: (a, b) => dateSort(a.scheduled_in, b.scheduled_in),
|
||||
sortOrder: sortcolumn === "scheduled_in" && sortorder,
|
||||
render: (text, record) => <DateTimeFormatter>{record.scheduled_in}</DateTimeFormatter>
|
||||
render: (text, record) => <DateTimeFormatter hideTime={!viewTimeStamp}>{record.scheduled_in}</DateTimeFormatter>
|
||||
},
|
||||
{
|
||||
title: t("jobs.fields.scheduled_completion"),
|
||||
@@ -196,7 +203,9 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
ellipsis: true,
|
||||
sorter: (a, b) => dateSort(a.scheduled_completion, b.scheduled_completion),
|
||||
sortOrder: sortcolumn === "scheduled_completion" && sortorder,
|
||||
render: (text, record) => <DateTimeFormatter>{record.scheduled_completion}</DateTimeFormatter>
|
||||
render: (text, record) => (
|
||||
<DateTimeFormatter hideTime={!viewTimeStamp}>{record.scheduled_completion}</DateTimeFormatter>
|
||||
)
|
||||
},
|
||||
// {
|
||||
// title: t("vehicles.fields.plate_no"),
|
||||
@@ -227,16 +236,23 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
title: t("jobs.fields.updated_at"),
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: "110px",
|
||||
sorter: (a, b) => dateSort(a.updated_at, b.updated_at),
|
||||
sortOrder: sortcolumn === "updated_at" && sortorder,
|
||||
render: (text, record) => <TimeAgoFormatter>{record.updated_at}</TimeAgoFormatter>
|
||||
render: (text, record) => <TimeAgoFormatter removeAgoString>{record.updated_at}</TimeAgoFormatter>
|
||||
},
|
||||
{
|
||||
title: t("jobs.fields.partsstatus"),
|
||||
dataIndex: "partsstatus",
|
||||
key: "partsstatus",
|
||||
width: countsOnly ? "180px" : "110px",
|
||||
render: (text, record) => (
|
||||
<JobPartsReceived parts={record.joblines_status} displayMode="full" popoverPlacement="topLeft" />
|
||||
<JobPartsReceived
|
||||
parts={record.joblines_status}
|
||||
displayMode="full"
|
||||
popoverPlacement="middle"
|
||||
countsOnly={countsOnly}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -249,6 +265,7 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
title: t("jobs.fields.queued_for_parts"),
|
||||
dataIndex: "queued_for_parts",
|
||||
key: "queued_for_parts",
|
||||
width: "120px",
|
||||
sorter: (a, b) => a.queued_for_parts - b.queued_for_parts,
|
||||
sortOrder: sortcolumn === "queued_for_parts" && sortorder,
|
||||
filteredValue: filter?.queued_for_parts || null,
|
||||
@@ -275,6 +292,12 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
<Button onClick={() => refetch()}>
|
||||
<SyncOutlined />
|
||||
</Button>
|
||||
<Checkbox checked={countsOnly} onChange={(e) => setCountsOnly(e.target.checked)}>
|
||||
{t("parts.labels.view_counts_only")}
|
||||
</Checkbox>
|
||||
<Checkbox checked={viewTimeStamp} onChange={(e) => setViewTimeStamp(e.target.checked)}>
|
||||
{t("parts.labels.view_timestamps")}
|
||||
</Checkbox>
|
||||
<Input.Search
|
||||
className="imex-table-header__search"
|
||||
placeholder={t("general.labels.search")}
|
||||
@@ -299,7 +322,7 @@ export function PartsQueueListComponent({ bodyshop }) {
|
||||
rowKey="id"
|
||||
dataSource={jobs}
|
||||
style={{ height: "100%" }}
|
||||
scroll={{ x: true }}
|
||||
//scroll={{ x: true }}
|
||||
onChange={handleTableChange}
|
||||
rowSelection={{
|
||||
onSelect: (record) => {
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function PaymentFormTotalPayments({ jobid }) {
|
||||
{balance && (
|
||||
<Statistic
|
||||
title={t("payments.labels.balance")}
|
||||
styles={{ value: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
|
||||
styles={{ content: { color: balance.getAmount() !== 0 ? "red" : "green" } }}
|
||||
value={(balance && balance.toFormat()) || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function PrintCenterJobsLabels({ jobId }) {
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Popover content={content} open={isModalVisible}>
|
||||
<Popover content={content} open={isModalVisible} getPopupContainer={(trigger) => trigger.parentElement}>
|
||||
<Button onClick={() => setIsModalVisible(true)}>{t("printcenter.jobs.labels.labels")}</Button>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -35,8 +35,6 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
const result = await updateJob({
|
||||
variables: { jobId: record.id, job: { [empAssignment]: employeeid } }
|
||||
|
||||
// awaitRefetchQueries: true,
|
||||
});
|
||||
|
||||
insertAuditTrail({
|
||||
@@ -55,6 +53,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
await refetch();
|
||||
|
||||
setAssignment({ operation: null, employeeid: null });
|
||||
setLoading(false);
|
||||
};
|
||||
const handleRemove = async (operation) => {
|
||||
@@ -84,6 +83,7 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
await refetch();
|
||||
|
||||
setAssignment({ operation: null, employeeid: null });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -94,29 +94,31 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
|
||||
const [visibility, setVisibility] = useState(false);
|
||||
const onChange = (e, option) => {
|
||||
setAssignment({ ...assignment, employeeid: e, name: option.name });
|
||||
setAssignment({ ...assignment, employeeid: e, name: option.label });
|
||||
};
|
||||
|
||||
const employeeOptions = bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => ({
|
||||
value: emp.id,
|
||||
label: `${emp.first_name} ${emp.last_name}`,
|
||||
name: `${emp.first_name} ${emp.last_name}`
|
||||
}));
|
||||
|
||||
const popContent = (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Select
|
||||
id="employeeSelector"
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
value={assignment.employeeid}
|
||||
onChange={onChange}
|
||||
>
|
||||
{bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={employeeOptions}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Space wrap>
|
||||
@@ -141,25 +143,25 @@ export function ProductionListEmpAssignment({ insertAuditTrail, bodyshop, record
|
||||
if (record[type]) theEmployee = bodyshop.employees.find((e) => e.id === record[type]);
|
||||
|
||||
return (
|
||||
<Popover destroyOnHidden content={popContent} open={visibility}>
|
||||
<Spin spinning={loading}>
|
||||
{record[type] ? (
|
||||
<div style={{ cursor: "pointer" }}>
|
||||
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
|
||||
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
|
||||
</div>
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
{record[type] ? (
|
||||
<div style={{ cursor: "pointer" }}>
|
||||
<span>{`${theEmployee?.first_name || ""} ${theEmployee?.last_name || ""}`}</span>
|
||||
<DeleteFilled style={iconStyle} onClick={() => handleRemove(type)} />
|
||||
</div>
|
||||
) : (
|
||||
<Popover destroyOnHidden content={popContent} open={visibility} trigger="click">
|
||||
<PlusCircleFilled
|
||||
style={{ ...iconStyle, cursor: "pointer" }}
|
||||
className="muted-button"
|
||||
onClick={() => {
|
||||
setAssignment({ operation: type });
|
||||
setAssignment({ operation: type, employeeid: null });
|
||||
setVisibility(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Popover>
|
||||
</Popover>
|
||||
)}
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { SyncOutlined } from "@ant-design/icons";
|
||||
import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { PageHeader } from "@ant-design/pro-layout";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDragListView from "react-drag-listview";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { closestCenter, DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectTechnician } from "../../redux/tech/tech.selectors";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import { selectDarkMode } from "../../redux/application/application.selectors.js";
|
||||
import Prompt from "../../utils/prompt.js";
|
||||
import AlertComponent from "../alert/alert.component.jsx";
|
||||
import ProductionListColumnsAdd from "../production-list-columns/production-list-columns.add.component";
|
||||
@@ -23,12 +27,81 @@ import { logImEXEvent } from "../../firebase/firebase.utils.js";
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
technician: selectTechnician,
|
||||
currentUser: selectCurrentUser
|
||||
currentUser: selectCurrentUser,
|
||||
isDarkMode: selectDarkMode
|
||||
});
|
||||
|
||||
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser }) {
|
||||
// Draggable header cell component - combines drag and resize
|
||||
function DraggableHeaderCell(props) {
|
||||
const { children, columnKey, onResize, width, ...restProps } = props;
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: columnKey,
|
||||
disabled: !columnKey
|
||||
});
|
||||
|
||||
const style = {
|
||||
...restProps.style,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.3 : 1,
|
||||
userSelect: "none",
|
||||
textAlign: "left"
|
||||
};
|
||||
|
||||
// If no columnKey, render as regular header
|
||||
if (!columnKey) {
|
||||
return <ResizeableTitle {...props} />;
|
||||
}
|
||||
|
||||
// Only apply drag listeners to elements with data-drag-handle attribute
|
||||
const filteredListeners = listeners
|
||||
? {
|
||||
onPointerDown: (e) => {
|
||||
// Only trigger drag if clicking on the drag handle
|
||||
if (e.target.closest('[data-drag-handle="true"]')) {
|
||||
listeners.onPointerDown?.(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
: {};
|
||||
|
||||
// Combine drag functionality with resize
|
||||
return (
|
||||
<ResizeableTitle
|
||||
{...restProps}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
onResize={onResize}
|
||||
width={width}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={filteredListeners}
|
||||
>
|
||||
{children}
|
||||
</ResizeableTitle>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser, isDarkMode }) {
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// NEW: smoother resize
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeRafRef = useRef(null);
|
||||
const pendingResizeRef = useRef(null);
|
||||
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
|
||||
const MIN_COL_WIDTH = 20;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 1
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
treatments: { Production_List_Status_Colors, Enhanced_Payroll }
|
||||
} = useTreatmentsWithConfig({
|
||||
@@ -36,8 +109,10 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
names: ["Production_List_Status_Colors", "Enhanced_Payroll"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const assoc = bodyshop.associations.find((a) => a.useremail === currentUser.email);
|
||||
const defaultView = assoc?.default_prod_list_view;
|
||||
|
||||
const initialStateRef = useRef(
|
||||
(bodyshop.production_config &&
|
||||
bodyshop.production_config.find((p) => p.name === defaultView)?.columns.tableState) ||
|
||||
@@ -46,6 +121,7 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
filteredInfo: { text: "" }
|
||||
}
|
||||
);
|
||||
|
||||
const initialColumnsRef = useRef(
|
||||
(initialStateRef.current &&
|
||||
bodyshop?.production_config
|
||||
@@ -66,14 +142,36 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
})) ||
|
||||
[]
|
||||
);
|
||||
|
||||
const [state, setState] = useState(initialStateRef.current);
|
||||
const [columns, setColumns] = useState(initialColumnsRef.current);
|
||||
|
||||
const scrollX = useMemo(() => {
|
||||
// keep scroll width aligned with the actual column widths so AntD doesn't clamp at a fixed floor
|
||||
const sum = columns.reduce((acc, c) => acc + (c.width ?? 100), 0);
|
||||
return Math.max(sum, 1);
|
||||
}, [columns]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const matchingColumnConfig = useMemo(() => {
|
||||
return bodyshop?.production_config?.find((p) => p.name === defaultView);
|
||||
}, [bodyshop.production_config, defaultView]);
|
||||
|
||||
// NEW: cleanup RAF on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resizeRafRef.current) cancelAnimationFrame(resizeRafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// NEW: while resizing, don’t regenerate columns
|
||||
if (isResizing) return;
|
||||
|
||||
// NEW: bail early BEFORE expensive ProductionListColumns(...) call
|
||||
if (!_.isEqual(initialColumnsRef.current, columns)) return;
|
||||
|
||||
const newColumns =
|
||||
matchingColumnConfig?.columns.columnKeys.map((k) => {
|
||||
return {
|
||||
@@ -89,10 +187,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
width: k.width ?? 100
|
||||
};
|
||||
}) || [];
|
||||
// Only update columns if they haven't been manually changed by the user
|
||||
if (_.isEqual(initialColumnsRef.current, columns)) {
|
||||
setColumns(newColumns);
|
||||
}
|
||||
|
||||
setColumns(newColumns);
|
||||
}, [
|
||||
matchingColumnConfig,
|
||||
bodyshop,
|
||||
@@ -102,7 +198,8 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
Production_List_Status_Colors,
|
||||
refetch,
|
||||
state,
|
||||
columns
|
||||
columns,
|
||||
isResizing
|
||||
]);
|
||||
|
||||
const handleTableChange = (pagination, filters, sorter) => {
|
||||
@@ -118,17 +215,30 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
logImEXEvent("production_list_sort_filter", { pagination, filters, sorter });
|
||||
};
|
||||
|
||||
const onDragEnd = (fromIndex, toIndex) => {
|
||||
if (fromIndex === toIndex) return;
|
||||
const columnsCopy = [...columns];
|
||||
const [movedItem] = columnsCopy.splice(fromIndex, 1);
|
||||
columnsCopy.splice(toIndex, 0, movedItem);
|
||||
if (!_.isEqual(columnsCopy, columns)) {
|
||||
setColumns(columnsCopy);
|
||||
setHasUnsavedChanges(true);
|
||||
const onDragStart = ({ active }) => {
|
||||
setActiveId(active.id);
|
||||
};
|
||||
|
||||
const onDragEnd = ({ active, over }) => {
|
||||
setActiveId(null);
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = columns.findIndex((col) => col.key === active.id);
|
||||
const newIndex = columns.findIndex((col) => col.key === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const newColumns = arrayMove(columns, oldIndex, newIndex);
|
||||
if (!_.isEqual(newColumns, columns)) {
|
||||
setColumns(newColumns);
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onDragCancel = () => {
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const removeColumn = (e) => {
|
||||
const { key } = e;
|
||||
const newColumns = columns.filter((i) => i.key !== key);
|
||||
@@ -139,19 +249,55 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
logImEXEvent("production_list_remove_column", { key });
|
||||
};
|
||||
|
||||
const handleResize =
|
||||
(index) =>
|
||||
(e, { size }) => {
|
||||
const nextColumns = [...columns];
|
||||
nextColumns[index] = {
|
||||
...nextColumns[index],
|
||||
width: size.width
|
||||
};
|
||||
if (!_.isEqual(nextColumns, columns)) {
|
||||
setColumns(nextColumns);
|
||||
// NEW: commit widths via rAF (less jank)
|
||||
const applyColumnWidth = useCallback((columnKey, width) => {
|
||||
const nextWidth = Math.max(MIN_COL_WIDTH, Math.round(width));
|
||||
setColumns((prev) => {
|
||||
const idx = prev.findIndex((c) => c.key === columnKey);
|
||||
if (idx === -1) return prev;
|
||||
|
||||
const currentWidth = prev[idx].width ?? 100;
|
||||
if (currentWidth === nextWidth) return prev;
|
||||
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...next[idx], width: nextWidth };
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(columnKey) =>
|
||||
(e, { size }) => {
|
||||
pendingResizeRef.current = { columnKey, width: size.width };
|
||||
|
||||
if (resizeRafRef.current) return;
|
||||
resizeRafRef.current = requestAnimationFrame(() => {
|
||||
resizeRafRef.current = null;
|
||||
const pending = pendingResizeRef.current;
|
||||
if (!pending) return;
|
||||
applyColumnWidth(pending.columnKey, pending.width);
|
||||
});
|
||||
},
|
||||
[applyColumnWidth]
|
||||
);
|
||||
|
||||
const handleResizeStart = useCallback(() => {
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
const handleResizeStop = useCallback(
|
||||
(columnKey) =>
|
||||
(e, { size }) => {
|
||||
setIsResizing(false);
|
||||
|
||||
// Ensure final width is committed
|
||||
applyColumnWidth(columnKey, size.width);
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
};
|
||||
logImEXEvent("production_list_resize_column", { key: columnKey, width: size.width });
|
||||
},
|
||||
[applyColumnWidth]
|
||||
);
|
||||
|
||||
const addColumn = (newColumn) => {
|
||||
const updatedColumns = [...columns, newColumn];
|
||||
@@ -163,19 +309,53 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
};
|
||||
|
||||
const headerItem = (col) => {
|
||||
const menu = {
|
||||
onClick: removeColumn,
|
||||
items: [
|
||||
{
|
||||
key: col.key,
|
||||
label: t("production.actions.removecolumn")
|
||||
}
|
||||
]
|
||||
};
|
||||
const menu = { onClick: removeColumn, items: [{ key: col.key, label: t("production.actions.removecolumn") }] };
|
||||
|
||||
return (
|
||||
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
|
||||
<span>{col.title}</span>
|
||||
</Dropdown>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "left",
|
||||
width: "100%",
|
||||
userSelect: "none",
|
||||
minWidth: 0 // critical: allow the flex row to shrink
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="drag-handle-trigger"
|
||||
data-drag-handle="true"
|
||||
style={{
|
||||
marginRight: 8,
|
||||
color: "#999",
|
||||
cursor: "grab",
|
||||
padding: 4,
|
||||
display: "inline-flex",
|
||||
alignItems: "left",
|
||||
userSelect: "none",
|
||||
flex: "0 0 auto"
|
||||
}}
|
||||
title="Drag to reorder column"
|
||||
>
|
||||
<HolderOutlined />
|
||||
</span>
|
||||
|
||||
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
|
||||
<span
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0, // critical: allow text to shrink
|
||||
overflow: "hidden", // clip
|
||||
textOverflow: "ellipsis", // show …
|
||||
whiteSpace: "nowrap", // keep single line
|
||||
cursor: "default",
|
||||
userSelect: "none",
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{col.title}
|
||||
</span>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -274,6 +454,9 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
onSave={() => {
|
||||
setHasUnsavedChanges(false);
|
||||
initialStateRef.current = state;
|
||||
|
||||
// NEW: after saving, treat current columns as the baseline
|
||||
initialColumnsRef.current = columns;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
@@ -286,60 +469,104 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
|
||||
}
|
||||
/>
|
||||
<ProductionListDetail jobs={dataSource} />
|
||||
<ReactDragListView.DragColumn onDragEnd={onDragEnd} nodeSelector="th" handleSelector=".prod-header-dropdown">
|
||||
<Table
|
||||
sticky
|
||||
pagination={false}
|
||||
size="small"
|
||||
{...(Production_List_Status_Colors.treatment === "on" && {
|
||||
onRow: (record, index) => {
|
||||
if (!bodyshop.md_ro_statuses.production_colors) return null;
|
||||
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status);
|
||||
if (!color) {
|
||||
if (index % 2 === 0)
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
>
|
||||
<SortableContext items={columns.map((col) => col.key)} strategy={horizontalListSortingStrategy}>
|
||||
<Table
|
||||
sticky
|
||||
tableLayout="fixed"
|
||||
className="prod-list-table"
|
||||
pagination={false}
|
||||
size="small"
|
||||
{...(Production_List_Status_Colors.treatment === "on" &&
|
||||
!isResizing && {
|
||||
onRow: (record, index) => {
|
||||
if (!bodyshop.md_ro_statuses.production_colors) return null;
|
||||
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status);
|
||||
if (!color) {
|
||||
if (index % 2 === 0)
|
||||
return {
|
||||
style: {
|
||||
backgroundColor: "var(--table-row-even-bg)"
|
||||
}
|
||||
};
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
className: "rowWithColor",
|
||||
style: {
|
||||
backgroundColor: "var(--table-row-even-bg)"
|
||||
"--bgColor": color.color
|
||||
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
|
||||
: "var(--status-row-bg-fallback)"
|
||||
}
|
||||
};
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
className: "rowWithColor",
|
||||
style: {
|
||||
"--bgColor": color.color
|
||||
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
|
||||
: "var(--status-row-bg-fallback)"
|
||||
}
|
||||
})}
|
||||
components={{
|
||||
header: {
|
||||
cell: DraggableHeaderCell
|
||||
}
|
||||
}}
|
||||
columns={columns.map((c) => {
|
||||
return {
|
||||
...c,
|
||||
filteredValue: state.filteredInfo[c.key] || null,
|
||||
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
|
||||
title: headerItem(c),
|
||||
ellipsis: true,
|
||||
width: c.width ?? 100,
|
||||
onHeaderCell: (column) => ({
|
||||
columnKey: column.key,
|
||||
width: column.width,
|
||||
onResize: handleResize(column.key),
|
||||
onResizeStart: handleResizeStart,
|
||||
onResizeStop: handleResizeStop(column.key)
|
||||
})
|
||||
};
|
||||
}
|
||||
})}
|
||||
components={{
|
||||
header: {
|
||||
cell: ResizeableTitle
|
||||
}
|
||||
}}
|
||||
columns={columns.map((c, index) => {
|
||||
return {
|
||||
...c,
|
||||
filteredValue: state.filteredInfo[c.key] || null,
|
||||
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
|
||||
title: headerItem(c),
|
||||
ellipsis: true,
|
||||
width: c.width ?? 100,
|
||||
onHeaderCell: (column) => ({
|
||||
width: column.width,
|
||||
onResize: handleResize(index)
|
||||
})
|
||||
};
|
||||
})}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
scroll={{ x: 1000 }}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</ReactDragListView.DragColumn>
|
||||
})}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={dataSource}
|
||||
scroll={{ x: scrollX }}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeId ? (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: isDarkMode ? "#141414" : "white",
|
||||
color: isDarkMode ? "white" : "#000",
|
||||
border: `2px solid ${isDarkMode ? "#177ddc" : "#1890ff"}`,
|
||||
borderRadius: "4px",
|
||||
padding: "12px 16px",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
|
||||
cursor: "grabbing",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontWeight: 500,
|
||||
minWidth: "120px"
|
||||
}}
|
||||
>
|
||||
<HolderOutlined style={{ marginRight: "8px", color: isDarkMode ? "white" : "#000", fontSize: "16px" }} />
|
||||
<span>
|
||||
{(() => {
|
||||
const col = columns.find((c) => c.key === activeId);
|
||||
const title = typeof col?.title === "string" ? col.title : col?.dataIndex || col?.key || "Column";
|
||||
return title;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Resizable } from "react-resizable";
|
||||
import "react-resizable/css/styles.css";
|
||||
|
||||
export default function ResizableComponent(props) {
|
||||
const { onResize, width, ...restProps } = props;
|
||||
const ResizableComponent = forwardRef((props, ref) => {
|
||||
const { onResize, onResizeStart, onResizeStop, width, dragAttributes, dragListeners, ...restProps } = props;
|
||||
|
||||
if (!width) {
|
||||
return <th {...restProps} />;
|
||||
return <th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable
|
||||
width={width || 200}
|
||||
width={width}
|
||||
height={0}
|
||||
onResize={onResize}
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeStop={onResizeStop}
|
||||
draggableOpts={{ enableUserSelectHack: false }}
|
||||
handle={
|
||||
resizeHandles={["e"]}
|
||||
axis="x"
|
||||
handle={(axis, handleRef) => (
|
||||
<span
|
||||
className="react-resizable-handle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
ref={handleRef}
|
||||
className={`react-resizable-handle react-resizable-handle-${axis}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<th {...restProps} />
|
||||
<th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />
|
||||
</Resizable>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ResizableComponent.displayName = "ResizableComponent";
|
||||
|
||||
export default ResizableComponent;
|
||||
|
||||
@@ -18,9 +18,10 @@ const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
SEARCH_VEHICLES_BY_ID_FOR_AUTOCOMPLETE
|
||||
);
|
||||
|
||||
const executeSearch = (v) => {
|
||||
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
|
||||
const executeSearch = (variables) => {
|
||||
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
|
||||
};
|
||||
|
||||
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
|
||||
|
||||
const handleSearch = (value) => {
|
||||
|
||||
@@ -470,6 +470,9 @@ export const GET_JOB_BY_PK = gql`
|
||||
clm_total
|
||||
comment
|
||||
converted
|
||||
dms_id
|
||||
dms_customer_id
|
||||
dms_advisor_id
|
||||
csiinvites {
|
||||
completedon
|
||||
id
|
||||
@@ -491,6 +494,9 @@ export const GET_JOB_BY_PK = gql`
|
||||
ded_status
|
||||
deliverchecklist
|
||||
depreciation_taxes
|
||||
dms_id
|
||||
dms_advisor_id
|
||||
dms_customer_id
|
||||
driveable
|
||||
employee_body
|
||||
employee_body_rel {
|
||||
@@ -1995,6 +2001,9 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
|
||||
qb_multiple_payers
|
||||
lbr_adjustments
|
||||
ownr_ea
|
||||
dms_id
|
||||
dms_customer_id
|
||||
dms_advisor_id
|
||||
payments {
|
||||
amount
|
||||
created_at
|
||||
@@ -2216,6 +2225,9 @@ export const QUERY_JOB_EXPORT_DMS = gql`
|
||||
plate_no
|
||||
plate_st
|
||||
ownr_co_nm
|
||||
dms_id
|
||||
dms_customer_id
|
||||
dms_advisor_id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -426,6 +426,24 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
|
||||
if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
|
||||
|
||||
// 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
|
||||
status="warning"
|
||||
title={t("dms.errors.earlyrorequired")}
|
||||
subTitle={t("dms.errors.earlyrorequired.message")}
|
||||
extra={
|
||||
<Link to={`/manage/jobs/${jobId}/admin`}>
|
||||
<Button type="primary">{t("general.actions.gotoadmin")}</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />
|
||||
@@ -486,6 +504,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
|
||||
<DmsCustomerSelector
|
||||
jobid={jobId}
|
||||
job={data?.jobs_by_pk}
|
||||
bodyshop={bodyshop}
|
||||
socket={activeSocket}
|
||||
mode={mode}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { Card, Col, Result, Row, Space, Typography } from "antd";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { some } from "lodash";
|
||||
import axios from "axios";
|
||||
import AlertComponent from "../../components/alert/alert.component";
|
||||
import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.component";
|
||||
import ScoreboardAddButton from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component";
|
||||
@@ -19,13 +22,26 @@ import JobsAdminRemoveAR from "../../components/jobs-admin-remove-ar/jobs-admin-
|
||||
import LoadingSpinner from "../../components/loading-spinner/loading-spinner.component";
|
||||
import NotFound from "../../components/not-found/not-found.component";
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries";
|
||||
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
|
||||
import { GET_JOB_BY_PK, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
|
||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||
import { insertAuditTrail } from "../../redux/application/application.actions";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { useSocket } from "../../contexts/SocketIO/useSocket";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext";
|
||||
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
|
||||
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||
});
|
||||
|
||||
const colSpan = {
|
||||
@@ -39,14 +55,36 @@ const cardStyle = {
|
||||
height: "100%"
|
||||
};
|
||||
|
||||
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop, insertAuditTrail }) {
|
||||
const { jobId } = useParams();
|
||||
const { loading, error, data } = useQuery(GET_JOB_BY_PK, {
|
||||
const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
|
||||
variables: { id: jobId },
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const { socket } = useSocket(); // Extract socket from context
|
||||
const notification = useNotification();
|
||||
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
|
||||
const [showConvertModal, setShowConvertModal] = useState(false);
|
||||
const [convertLoading, setConvertLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
|
||||
const allFormValues = Form.useWatch([], form);
|
||||
|
||||
// Get Fortellis treatment for proper DMS mode detection
|
||||
const {
|
||||
treatments: { Fortellis }
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Fortellis"],
|
||||
splitKey: bodyshop?.imexshopid
|
||||
});
|
||||
|
||||
// Check if bodyshop has Reynolds integration using the proper getDmsMode function
|
||||
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
|
||||
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||
const job = data?.jobs_by_pk;
|
||||
useEffect(() => {
|
||||
setSelectedHeader("activejobs");
|
||||
document.title = t("titles.jobs-admin", {
|
||||
@@ -75,6 +113,55 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||
]);
|
||||
}, [setBreadcrumbs, t, jobId, data, setSelectedHeader]);
|
||||
|
||||
const handleEarlyROSuccess = (result) => {
|
||||
notification.success({
|
||||
title: t("jobs.successes.early_ro_created"),
|
||||
description: `RO Number: ${result.roNumber || "N/A"}`
|
||||
});
|
||||
setShowEarlyROModal(false);
|
||||
refetch?.();
|
||||
};
|
||||
|
||||
const handleConvert = async ({ employee_csr, category, ...values }) => {
|
||||
if (!job?.id) return;
|
||||
setConvertLoading(true);
|
||||
const res = await mutationConvertJob({
|
||||
variables: {
|
||||
jobId: job.id,
|
||||
job: {
|
||||
converted: true,
|
||||
...(bodyshop?.enforce_conversion_csr ? { employee_csr } : {}),
|
||||
...(bodyshop?.enforce_conversion_category ? { category } : {}),
|
||||
...values
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (values.ca_gst_registrant) {
|
||||
await axios.post("/job/totalsssu", {
|
||||
id: job.id
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.errors) {
|
||||
refetch();
|
||||
notification.success({
|
||||
title: t("jobs.successes.converted")
|
||||
});
|
||||
|
||||
insertAuditTrail({
|
||||
jobid: job.id,
|
||||
operation: AuditTrailMapping.jobconverted(res.data.update_jobs.returning[0].ro_number),
|
||||
type: "jobconverted"
|
||||
});
|
||||
|
||||
setShowConvertModal(false);
|
||||
}
|
||||
setConvertLoading(false);
|
||||
};
|
||||
|
||||
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||
if (!data.jobs_by_pk) return <NotFound />;
|
||||
@@ -99,6 +186,16 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||
<JobsAdminUnvoid job={data ? data.jobs_by_pk : {}} />
|
||||
<JobsAdminStatus job={data ? data.jobs_by_pk : {}} />
|
||||
<JobsAdminRemoveAR job={data ? data.jobs_by_pk : {}} />
|
||||
{isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && (
|
||||
<Button className="ant-btn ant-btn-default" onClick={() => setShowEarlyROModal(true)}>
|
||||
{t("jobs.actions.dms.createearlyro", "Create RR RO")}
|
||||
</Button>
|
||||
)}
|
||||
{isReynoldsMode && !job?.converted && !job?.dms_id && (
|
||||
<Button type="primary" danger onClick={() => setShowConvertModal(true)}>
|
||||
{t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -124,8 +221,173 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Early RO Modal */}
|
||||
<RREarlyROModal
|
||||
open={showEarlyROModal}
|
||||
onClose={() => setShowEarlyROModal(false)}
|
||||
onSuccess={handleEarlyROSuccess}
|
||||
bodyshop={bodyshop}
|
||||
socket={socket}
|
||||
job={job}
|
||||
/>
|
||||
|
||||
{/* Convert without Early RO Modal */}
|
||||
<Modal
|
||||
open={showConvertModal}
|
||||
onCancel={() => setShowConvertModal(false)}
|
||||
title={t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
|
||||
footer={null}
|
||||
width={700}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onFinish={handleConvert}
|
||||
initialValues={{
|
||||
driveable: true,
|
||||
towin: job?.towin,
|
||||
ca_gst_registrant: job?.ca_gst_registrant,
|
||||
employee_csr: job?.employee_csr,
|
||||
category: job?.category,
|
||||
referral_source: job?.referral_source,
|
||||
referral_source_extra: job?.referral_source_extra ?? ""
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name={["ins_co_nm"]}
|
||||
label={t("jobs.fields.ins_co_nm")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop?.md_ins_cos?.map((s, i) => (
|
||||
<Select.Option key={i} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{bodyshop?.enforce_class && (
|
||||
<Form.Item
|
||||
name={"class"}
|
||||
label={t("jobs.fields.class")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_class
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop?.md_classes?.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop?.enforce_referral && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={"referral_source"}
|
||||
label={t("jobs.fields.referralsource")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_referral
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop?.md_referral_sources?.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{bodyshop?.enforce_conversion_csr && (
|
||||
<Form.Item
|
||||
name={"employee_csr"}
|
||||
label={t(
|
||||
InstanceRenderManager({
|
||||
imex: "jobs.fields.employee_csr",
|
||||
rome: "jobs.fields.employee_csr_writer"
|
||||
})
|
||||
)}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_csr
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
{bodyshop?.employees
|
||||
?.filter((emp) => emp.active)
|
||||
?.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop?.enforce_conversion_category && (
|
||||
<Form.Item
|
||||
name={"category"}
|
||||
label={t("jobs.fields.category")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_category
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select allowClear>
|
||||
{bodyshop?.md_categories?.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
{bodyshop?.region_config?.toLowerCase().startsWith("ca") && (
|
||||
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap style={{ marginTop: 16 }}>
|
||||
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
|
||||
{t("jobs.actions.convert")}
|
||||
</Button>
|
||||
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Modal>
|
||||
</RbacWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(null, mapDispatchToProps)(JobsCloseContainer);
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(JobsCloseContainer);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
@@ -42,7 +43,7 @@ import { setModalContext } from "../../redux/modals/modals.actions.js";
|
||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||
import dayjs from "../../utils/day";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -71,6 +72,11 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
const notification = useNotification();
|
||||
|
||||
const hasDMSKey = bodyshopHasDmsKey(bodyshop);
|
||||
const dmsMode = getDmsMode(bodyshop, "off");
|
||||
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
|
||||
const canSendToDMS = !isReynoldsMode || hasEarlyRO;
|
||||
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
|
||||
|
||||
const {
|
||||
treatments: { Qb_Multi_Ar, ClosingPeriod }
|
||||
@@ -82,18 +88,18 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
|
||||
const handleFinish = async ({ removefromproduction, ...values }) => {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
// Validate that all joblines have valid IDs
|
||||
const joblinesWithIds = values.joblines.filter(jl => jl && jl.id);
|
||||
const joblinesWithIds = values.joblines.filter((jl) => jl && jl.id);
|
||||
if (joblinesWithIds.length !== values.joblines.length) {
|
||||
notification.error({
|
||||
title: t("jobs.errors.invalidjoblines"),
|
||||
message: t("jobs.errors.missingjoblineids")
|
||||
description: t("jobs.errors.missingjoblineids")
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const result = await client.mutate({
|
||||
mutation: generateJobLinesUpdatesForInvoicing(values.joblines)
|
||||
});
|
||||
@@ -208,9 +214,17 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
{bodyshopHasDmsKey(bodyshop) && (
|
||||
<Link to={`/manage/dms?jobId=${job.id}`}>
|
||||
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
|
||||
</Link>
|
||||
<>
|
||||
{canSendToDMS ? (
|
||||
<Link to={`/manage/dms?jobId=${job.id}`}>
|
||||
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button disabled={job.date_exported || !jobRO} onClick={() => setShowEarlyROModal(true)}>
|
||||
{t("jobs.actions.sendtodms")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -510,7 +524,7 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
<Statistic
|
||||
title={t("jobs.labels.pimraryamountpayable")}
|
||||
styles={{
|
||||
value: {
|
||||
content: {
|
||||
color: discrep.getAmount() >= 0 ? "green" : "red"
|
||||
}
|
||||
}}
|
||||
@@ -527,6 +541,30 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
<Divider />
|
||||
<JobsCloseLines job={job} />
|
||||
</Form>
|
||||
|
||||
{/* Early RO Required Modal */}
|
||||
<Modal
|
||||
open={showEarlyROModal}
|
||||
onCancel={() => setShowEarlyROModal(false)}
|
||||
footer={null}
|
||||
title={
|
||||
<Space>
|
||||
<Typography.Text type="warning" style={{ fontSize: "1.2em" }}>
|
||||
⚠️
|
||||
</Typography.Text>
|
||||
<span>{t("dms.errors.earlyrorequired")}</span>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Typography.Paragraph>{t("dms.errors.earlyrorequired.message")}</Typography.Paragraph>
|
||||
<Link to={`/manage/jobs/${job.id}/admin`}>
|
||||
<Button type="primary" block onClick={() => setShowEarlyROModal(false)}>
|
||||
{t("general.actions.gotoadmin")}
|
||||
</Button>
|
||||
</Link>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import "./tech.page.styles.scss";
|
||||
import UpsellComponent, { upsellEnum } from "../../components/upsell/upsell.component.jsx";
|
||||
import { lazyDev } from "../../utils/lazyWithPreload.jsx";
|
||||
|
||||
const TimeTicketModalContainer = lazyDev(() => import("../../components/time-ticket-modal/time-ticket-modal.container"));
|
||||
const NoteUpsertModal = lazyDev(() => import("../../components/note-upsert-modal/note-upsert-modal.container.jsx"));
|
||||
const TimeTicketModalContainer = lazyDev(
|
||||
() => import("../../components/time-ticket-modal/time-ticket-modal.container")
|
||||
);
|
||||
const EmailOverlayContainer = lazyDev(() => import("../../components/email-overlay/email-overlay.container.jsx"));
|
||||
const PrintCenterModalContainer = lazyDev(
|
||||
() => import("../../components/print-center-modal/print-center-modal.container")
|
||||
@@ -34,7 +37,9 @@ const TimeTicketModalTask = lazyDev(
|
||||
const TechAssignedProdJobs = lazyDev(() => import("../tech-assigned-prod-jobs/tech-assigned-prod-jobs.component"));
|
||||
const TechDispatchedParts = lazyDev(() => import("../tech-dispatched-parts/tech-dispatched-parts.page"));
|
||||
|
||||
const TaskUpsertModalContainer = lazyDev(() => import("../../components/task-upsert-modal/task-upsert-modal.container"));
|
||||
const TaskUpsertModalContainer = lazyDev(
|
||||
() => import("../../components/task-upsert-modal/task-upsert-modal.container")
|
||||
);
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
@@ -70,6 +75,8 @@ export function TechPage({ technician }) {
|
||||
<TechHeader />
|
||||
|
||||
<TaskUpsertModalContainer />
|
||||
<NoteUpsertModal />
|
||||
|
||||
<Content className="tech-content-container">
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"arrivedon": "Arrived on: ",
|
||||
"arrivingjobs": "Arriving Jobs",
|
||||
"blocked": "Blocked",
|
||||
"bp": "B/P",
|
||||
"cancelledappointment": "Canceled appointment for: ",
|
||||
"completingjobs": "Completing Jobs",
|
||||
"dataconsistency": "<0>{{ro_number}}</0> has a data consistency issue. It may have been excluded for scheduling purposes. CODE: {{code}}.",
|
||||
@@ -59,18 +60,17 @@
|
||||
"noarrivingjobs": "No Jobs are arriving.",
|
||||
"nocompletingjobs": "No Jobs scheduled for completion.",
|
||||
"nodateselected": "No date has been selected.",
|
||||
"owner": "Owner",
|
||||
"priorappointments": "Previous Appointments",
|
||||
"reminder": "This is {{shopname}} reminding you about an appointment on {{date}} at {{time}}. Please let us know if you are not able to make the appointment. We look forward to seeing you soon. ",
|
||||
"ro_number": "RO #",
|
||||
"scheduled_completion": "Scheduled Completion",
|
||||
"scheduledfor": "Scheduled appointment for: ",
|
||||
"severalerrorsfound": "Several Jobs have issues which may prevent accurate smart scheduling. Click to expand.",
|
||||
"smartscheduling": "Smart Scheduling",
|
||||
"smspaymentreminder": "This is {{shopname}} reminding you about your remaining balance of {{amount}}. To pay for the said balance click the link {{payment_link}}.",
|
||||
"suggesteddates": "Suggested Dates",
|
||||
"ro_number": "RO #",
|
||||
"owner": "Owner",
|
||||
"vehicle": "Vehicle",
|
||||
"bp": "B/P",
|
||||
"scheduled_completion": "Scheduled Completion"
|
||||
"vehicle": "Vehicle"
|
||||
},
|
||||
"successes": {
|
||||
"canceled": "Appointment canceled successfully.",
|
||||
@@ -90,6 +90,11 @@
|
||||
"actions": "Actions"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": "Click anywhere to enable the message ding."
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"fields": {
|
||||
"cc": "CC",
|
||||
@@ -149,11 +154,6 @@
|
||||
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": "Click anywhere to enable the message ding."
|
||||
}
|
||||
},
|
||||
"billlines": {
|
||||
"actions": {
|
||||
"newline": "New Line"
|
||||
@@ -281,9 +281,9 @@
|
||||
},
|
||||
"errors": {
|
||||
"creatingdefaultview": "Error creating default view.",
|
||||
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
|
||||
"loading": "Unable to load shop details. Please call technical support.",
|
||||
"saving": "Error encountered while saving. {{message}}",
|
||||
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique"
|
||||
"saving": "Error encountered while saving. {{message}}"
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
|
||||
@@ -564,21 +564,18 @@
|
||||
"responsibilitycenter_tax_tier": "Tax {{typeNum}} Tier {{typeNumIterator}}",
|
||||
"responsibilitycenter_tax_type": "Tax {{typeNum}} Type",
|
||||
"responsibilitycenters": {
|
||||
"gogcode": "GOG Code (BreakOut)",
|
||||
"item_type": "Item Type",
|
||||
"item_type_gog": "GOG",
|
||||
"item_type_paint": "Paint Materials",
|
||||
"item_type_freight": "Freight",
|
||||
"taxable_flag": "Taxable?",
|
||||
"taxable": "Taxable",
|
||||
"nontaxable": "Non-taxable",
|
||||
"ap": "Accounts Payable",
|
||||
"ar": "Accounts Receivable",
|
||||
"ats": "ATS",
|
||||
"federal_tax": "Federal Tax",
|
||||
"federal_tax_itc": "Federal Tax Credit",
|
||||
"gogcode": "GOG Code (BreakOut)",
|
||||
"gst_override": "GST Override Account #",
|
||||
"invoiceexemptcode": "QuickBooks US - Invoice Tax Exempt Code",
|
||||
"item_type": "Item Type",
|
||||
"item_type_freight": "Freight",
|
||||
"item_type_gog": "GOG",
|
||||
"item_type_paint": "Paint Materials",
|
||||
"itemexemptcode": "QuickBooks US - Line Item Tax Exempt Code",
|
||||
"la1": "LA1",
|
||||
"la2": "LA2",
|
||||
@@ -597,6 +594,7 @@
|
||||
"local_tax": "Local Tax",
|
||||
"mapa": "Paint Materials",
|
||||
"mash": "Shop Materials",
|
||||
"nontaxable": "Non-taxable",
|
||||
"paa": "Aftermarket",
|
||||
"pac": "Chrome",
|
||||
"pag": "Glass",
|
||||
@@ -617,6 +615,8 @@
|
||||
"state": "State Tax Applies"
|
||||
},
|
||||
"state_tax": "State Tax",
|
||||
"taxable": "Taxable",
|
||||
"taxable_flag": "Taxable?",
|
||||
"tow": "Towing"
|
||||
},
|
||||
"schedule_end_time": "Schedule Ending Time",
|
||||
@@ -678,8 +678,6 @@
|
||||
"zip_post": "Zip/Postal Code"
|
||||
},
|
||||
"labels": {
|
||||
"parts_shop_management": "Shop Management",
|
||||
"parts_vendor_management": "Vendor Management",
|
||||
"2tiername": "Name => RO",
|
||||
"2tiersetup": "2 Tier Setup",
|
||||
"2tiersource": "Source => RO",
|
||||
@@ -702,11 +700,11 @@
|
||||
"payers": "Payers"
|
||||
},
|
||||
"cdk_dealerid": "CDK Dealer ID",
|
||||
"rr_dealerid": "Reynolds Store Number",
|
||||
"costsmapping": "Costs Mapping",
|
||||
"dms_allocations": "DMS Allocations",
|
||||
"pbs_serialnumber": "PBS Serial Number",
|
||||
"profitsmapping": "Profits Mapping",
|
||||
"rr_dealerid": "Reynolds Store Number",
|
||||
"title": "DMS"
|
||||
},
|
||||
"emaillater": "Email Later",
|
||||
@@ -733,6 +731,8 @@
|
||||
"followers": "Notifications"
|
||||
},
|
||||
"orderstatuses": "Order Statuses",
|
||||
"parts_shop_management": "Shop Management",
|
||||
"parts_vendor_management": "Vendor Management",
|
||||
"partslocations": "Parts Locations",
|
||||
"partsscan": "Parts Scanning",
|
||||
"printlater": "Print Later",
|
||||
@@ -1047,7 +1047,9 @@
|
||||
},
|
||||
"dms": {
|
||||
"errors": {
|
||||
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export."
|
||||
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export.",
|
||||
"earlyrorequired": "Early RO Required",
|
||||
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": "Refresh to see DMS Allocations."
|
||||
@@ -1228,8 +1230,6 @@
|
||||
},
|
||||
"general": {
|
||||
"actions": {
|
||||
"select": "Select",
|
||||
"optional": "Optional",
|
||||
"add": "Add",
|
||||
"autoupdate": "{{app}} will automatically update in {{time}} seconds. Please save all changes.",
|
||||
"calculate": "Calculate",
|
||||
@@ -1246,9 +1246,11 @@
|
||||
"deselectall": "Deselect All",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"gotoadmin": "Go to Admin Panel",
|
||||
"login": "Login",
|
||||
"next": "Next",
|
||||
"ok": "Ok",
|
||||
"optional": "Optional",
|
||||
"previous": "Previous",
|
||||
"print": "Print",
|
||||
"refresh": "Refresh",
|
||||
@@ -1259,6 +1261,7 @@
|
||||
"save": "Save",
|
||||
"saveandnew": "Save and New",
|
||||
"saveas": "Save As",
|
||||
"select": "Select",
|
||||
"selectall": "Select All",
|
||||
"send": "Send",
|
||||
"sendbysms": "Send by SMS",
|
||||
@@ -1288,8 +1291,7 @@
|
||||
"vehicle": "Vehicle"
|
||||
},
|
||||
"labels": {
|
||||
"selected": "Selected",
|
||||
"settings": "Settings",
|
||||
"apply": "Apply",
|
||||
"actions": "Actions",
|
||||
"areyousure": "Are you sure?",
|
||||
"barcode": "Barcode",
|
||||
@@ -1343,8 +1345,10 @@
|
||||
"search": "Search...",
|
||||
"searchresults": "Results for {{search}}",
|
||||
"selectdate": "Select date...",
|
||||
"selected": "Selected",
|
||||
"sendagain": "Send Again",
|
||||
"sendby": "Send By",
|
||||
"settings": "Settings",
|
||||
"signin": "Sign In",
|
||||
"sms": "SMS",
|
||||
"status": "Status",
|
||||
@@ -1587,13 +1591,13 @@
|
||||
"labels": {
|
||||
"adjustmenttobeadded": "Adjustment to be added: {{adjustment}}",
|
||||
"billref": "Latest Bill",
|
||||
"bulk_location_help": "This will set the same location on all selected lines.",
|
||||
"convertedtolabor": "This line has been converted to labor. Ensure you adjust the profit center for the amount accordingly.",
|
||||
"edit": "Edit Line",
|
||||
"ioucreated": "IOU",
|
||||
"new": "New Line",
|
||||
"nostatus": "No Status",
|
||||
"presets": "Jobline Presets",
|
||||
"bulk_location_help": "This will set the same location on all selected lines."
|
||||
"presets": "Jobline Presets"
|
||||
},
|
||||
"successes": {
|
||||
"created": "Job line created successfully.",
|
||||
@@ -1621,11 +1625,13 @@
|
||||
"changestatus": "Change Status",
|
||||
"changestimator": "Change Estimator",
|
||||
"convert": "Convert",
|
||||
"convertwithoutearlyro": "Convert without Early RO",
|
||||
"createiou": "Create IOU",
|
||||
"deliver": "Deliver",
|
||||
"deliver_quick": "Quick Deliver",
|
||||
"dms": {
|
||||
"addpayer": "Add Payer",
|
||||
"createearlyro": "Create RR RO",
|
||||
"createnewcustomer": "Create New Customer",
|
||||
"findmakemodelcode": "Find Make/Model Code",
|
||||
"getmakes": "Get Makes",
|
||||
@@ -1634,6 +1640,7 @@
|
||||
},
|
||||
"post": "Post",
|
||||
"refetchmakesmodels": "Refetch Make and Model Codes",
|
||||
"update_ro": "Update RO",
|
||||
"usegeneric": "Use Generic Customer",
|
||||
"useselected": "Use Selected Customer"
|
||||
},
|
||||
@@ -1701,9 +1708,9 @@
|
||||
"actual_delivery": "Actual Delivery",
|
||||
"actual_in": "Actual In",
|
||||
"acv_amount": "ACV Amount",
|
||||
"admin_clerk": "Admin Clerk",
|
||||
"adjustment_bottom_line": "Adjustments",
|
||||
"adjustmenthours": "Adjustment Hours",
|
||||
"admin_clerk": "Admin Clerk",
|
||||
"alt_transport": "Alt. Trans.",
|
||||
"area_of_damage_impact": {
|
||||
"10": "Left Front Side",
|
||||
@@ -1784,9 +1791,8 @@
|
||||
"ded_status": "Deductible Status",
|
||||
"depreciation_taxes": "Betterment/Depreciation/Taxes",
|
||||
"dms": {
|
||||
"first_name": "First Name",
|
||||
"last_name": "Last Name",
|
||||
"address": "Customer Address",
|
||||
"advisor": "Advisor #",
|
||||
"amount": "Amount",
|
||||
"center": "Center",
|
||||
"control_type": {
|
||||
@@ -1794,17 +1800,19 @@
|
||||
},
|
||||
"cost": "Cost",
|
||||
"cost_dms_acctnumber": "Cost DMS Acct #",
|
||||
"customer": "Customer #",
|
||||
"dms_make": "DMS Make",
|
||||
"dms_model": "DMS Model",
|
||||
"dms_model_override": "Override DMS Make/Model",
|
||||
"dms_unsold": "New, Unsold Vehicle",
|
||||
"dms_wip_acctnumber": "Cost WIP DMS Acct #",
|
||||
"first_name": "First Name",
|
||||
"id": "DMS ID",
|
||||
"inservicedate": "In Service Date",
|
||||
"journal": "Journal #",
|
||||
"make_override": "Make Override",
|
||||
"advisor": "Advisor #",
|
||||
"last_name": "Last Name",
|
||||
"lines": "Posting Lines",
|
||||
"make_override": "Make Override",
|
||||
"name1": "Customer Name",
|
||||
"payer": {
|
||||
"amount": "Amount",
|
||||
@@ -1817,7 +1825,11 @@
|
||||
"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",
|
||||
@@ -1945,7 +1957,7 @@
|
||||
"amount": "Amount",
|
||||
"name": "Name"
|
||||
},
|
||||
"queued_for_parts": "Queued for Parts",
|
||||
"queued_for_parts": "Queued",
|
||||
"rate_ats": "ATS Rate",
|
||||
"rate_ats_flat": "ATS Flat Rate",
|
||||
"rate_la1": "LA1",
|
||||
@@ -2102,6 +2114,11 @@
|
||||
"damageto": "Damage to $t(jobs.fields.area_of_damage_impact.{{area_of_damage}}).",
|
||||
"defaultstory": "B/S RO: {{ro_number}}. Owner: {{ownr_nm}}. Insurance Co: {{ins_co_nm}}. Claim/PO #: {{clm_po}}",
|
||||
"disablebillwip": "Cost and WIP for bills has been ignored per shop configuration.",
|
||||
"earlyro": {
|
||||
"created": "Early RO Created:",
|
||||
"fields": "Required fields:",
|
||||
"willupdate": "This will update the existing RO with full job data."
|
||||
},
|
||||
"invoicedatefuture": "Invoice date must be today or in the future for CDK posting.",
|
||||
"kmoutnotgreaterthankmin": "Mileage out must be greater than mileage in.",
|
||||
"logs": "Logs",
|
||||
@@ -2259,6 +2276,7 @@
|
||||
"delete": "Job deleted successfully.",
|
||||
"deleted": "Job deleted successfully.",
|
||||
"duplicated": "Job duplicated successfully. ",
|
||||
"early_ro_created": "Early RO Created",
|
||||
"exported": "Job(s) exported successfully. ",
|
||||
"invoiced": "Job closed and invoiced successfully.",
|
||||
"ioucreated": "IOU created successfully. Click to see.",
|
||||
@@ -2447,6 +2465,7 @@
|
||||
"labels": {
|
||||
"addlabel": "Add a label to this conversation.",
|
||||
"archive": "Archive",
|
||||
"mark_unread": "Mark as unread",
|
||||
"maxtenimages": "You can only select up to a maximum of 10 images at a time.",
|
||||
"messaging": "Messaging",
|
||||
"no_consent": "Opted-out",
|
||||
@@ -2459,8 +2478,7 @@
|
||||
"selectmedia": "Select Media",
|
||||
"sentby": "Sent by {{by}} at {{time}}",
|
||||
"typeamessage": "Send a message...",
|
||||
"unarchive": "Unarchive",
|
||||
"mark_unread": "Mark as unread"
|
||||
"unarchive": "Unarchive"
|
||||
},
|
||||
"render": {
|
||||
"conversation_list": "Conversation List"
|
||||
@@ -2614,20 +2632,20 @@
|
||||
"name": "Owner Details"
|
||||
},
|
||||
"labels": {
|
||||
"cell": "Cell",
|
||||
"create_new": "Create a new owner record.",
|
||||
"deleteconfirm": "Are you sure you want to delete this owner? This cannot be undone.",
|
||||
"email": "Email",
|
||||
"existing_owners": "Existing Owners",
|
||||
"fromclaim": "Current Claim",
|
||||
"fromowner": "Historical Owner Record",
|
||||
"relatedjobs": "Related Jobs",
|
||||
"updateowner": "Update Owner",
|
||||
"work": "Work",
|
||||
"home": "Home",
|
||||
"cell": "Cell",
|
||||
"other": "Other",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"sms": "SMS"
|
||||
"relatedjobs": "Related Jobs",
|
||||
"sms": "SMS",
|
||||
"updateowner": "Update Owner",
|
||||
"work": "Work"
|
||||
},
|
||||
"successes": {
|
||||
"delete": "Owner deleted successfully.",
|
||||
@@ -2638,6 +2656,10 @@
|
||||
"actions": {
|
||||
"order": "Order Parts",
|
||||
"orderinhouse": "Order as In House"
|
||||
},
|
||||
"labels": {
|
||||
"view_counts_only": "View Parts Counts Only",
|
||||
"view_timestamps": "Show timestamps"
|
||||
}
|
||||
},
|
||||
"parts_dispatch": {
|
||||
@@ -2987,8 +3009,6 @@
|
||||
"settings": "Error saving board settings: {{error}}"
|
||||
},
|
||||
"labels": {
|
||||
"click_for_statuses": "Click to view parts statuses",
|
||||
"partsreceived": "Parts Received",
|
||||
"actual_in": "Actual In",
|
||||
"addnewprofile": "Add New Profile",
|
||||
"alert": "Alert",
|
||||
@@ -3007,6 +3027,7 @@
|
||||
"card_size": "Card Size",
|
||||
"cardcolor": "Colored Cards",
|
||||
"cardsettings": "Card Settings",
|
||||
"click_for_statuses": "Click to view parts statuses",
|
||||
"clm_no": "Claim Number",
|
||||
"comment": "Comment",
|
||||
"compact": "Compact Cards",
|
||||
@@ -3027,6 +3048,7 @@
|
||||
"orientation": "Board Orientation",
|
||||
"ownr_nm": "Customer Name",
|
||||
"paintpriority": "P/P",
|
||||
"partsreceived": "Parts Received",
|
||||
"partsstatus": "Parts Status",
|
||||
"production_note": "Production Note",
|
||||
"refinishhours": "R",
|
||||
@@ -3573,17 +3595,12 @@
|
||||
}
|
||||
},
|
||||
"titles": {
|
||||
"parts_settings": "Parts Management Settings | {{app}}",
|
||||
"simplified-parts-jobs": "Parts Management | {{app}}",
|
||||
"accounting-payables": "Payables | {{app}}",
|
||||
"accounting-payments": "Payments | {{app}}",
|
||||
"accounting-receivables": "Receivables | {{app}}",
|
||||
"all_tasks": "All Tasks | {{app}}",
|
||||
"app": "",
|
||||
"bc": {
|
||||
"simplified-parts-jobs": "Jobs",
|
||||
"parts": "Parts",
|
||||
"parts_settings": "Settings",
|
||||
"accounting-payables": "Payables",
|
||||
"accounting-payments": "Payments",
|
||||
"accounting-receivables": "Receivables",
|
||||
@@ -3615,7 +3632,9 @@
|
||||
"my_tasks": "My Tasks",
|
||||
"owner-detail": "{{name}}",
|
||||
"owners": "Owners",
|
||||
"parts": "Parts",
|
||||
"parts-queue": "Parts Queue",
|
||||
"parts_settings": "Settings",
|
||||
"payments-all": "All Payments",
|
||||
"phonebook": "Phonebook",
|
||||
"productionboard": "Production Board - Visual",
|
||||
@@ -3627,6 +3646,7 @@
|
||||
"shop-csi": "CSI Responses",
|
||||
"shop-templates": "Shop Templates",
|
||||
"shop-vendors": "Vendors",
|
||||
"simplified-parts-jobs": "Jobs",
|
||||
"tasks": "Tasks",
|
||||
"temporarydocs": "Temporary Documents",
|
||||
"timetickets": "Time Tickets",
|
||||
@@ -3662,7 +3682,9 @@
|
||||
"my_tasks": "My Tasks | {{app}}",
|
||||
"owners": "All Owners | {{app}}",
|
||||
"owners-detail": "{{name}} | {{app}}",
|
||||
"parts": "",
|
||||
"parts-queue": "Parts Queue | {{app}}",
|
||||
"parts_settings": "Parts Management Settings | {{app}}",
|
||||
"payments-all": "Payments | {{app}}",
|
||||
"phonebook": "Phonebook | {{app}}",
|
||||
"productionboard": "Production Board - Visual | {{app}}",
|
||||
@@ -3678,6 +3700,7 @@
|
||||
"shop-csi": "CSI Responses | {{app}}",
|
||||
"shop-templates": "Shop Templates | {{app}}",
|
||||
"shop_vendors": "Vendors | {{app}}",
|
||||
"simplified-parts-jobs": "Parts Management | {{app}}",
|
||||
"tasks": "Tasks",
|
||||
"techconsole": "Technician Console | {{app}}",
|
||||
"techjobclock": "Technician Job Clock | {{app}}",
|
||||
@@ -3838,10 +3861,10 @@
|
||||
"user": {
|
||||
"actions": {
|
||||
"changepassword": "Change Password",
|
||||
"signout": "Sign Out",
|
||||
"updateprofile": "Update Profile",
|
||||
"dark_theme": "Switch to Dark Theme",
|
||||
"light_theme": "Switch to Light Theme",
|
||||
"dark_theme": "Switch to Dark Theme"
|
||||
"signout": "Sign Out",
|
||||
"updateprofile": "Update Profile"
|
||||
},
|
||||
"errors": {
|
||||
"updating": "Error updating user or association {{message}}"
|
||||
@@ -3855,14 +3878,14 @@
|
||||
"labels": {
|
||||
"actions": "Actions",
|
||||
"changepassword": "Change Password",
|
||||
"profileinfo": "Profile Info",
|
||||
"user_settings": "User Settings",
|
||||
"play_sound_for_new_messages": "Play a sound for new messages",
|
||||
"notification_sound_on": "Sound is ON",
|
||||
"notification_sound_off": "Sound is OFF",
|
||||
"notification_sound_enabled": "Notification sound enabled",
|
||||
"notification_sound_disabled": "Notification sound disabled",
|
||||
"notification_sound_help": "Toggle the ding for incoming chat messages."
|
||||
"notification_sound_enabled": "Notification sound enabled",
|
||||
"notification_sound_help": "Toggle the ding for incoming chat messages.",
|
||||
"notification_sound_off": "Sound is OFF",
|
||||
"notification_sound_on": "Sound is ON",
|
||||
"play_sound_for_new_messages": "Play a sound for new messages",
|
||||
"profileinfo": "Profile Info",
|
||||
"user_settings": "User Settings"
|
||||
},
|
||||
"successess": {
|
||||
"passwordchanged": "Password changed successfully. "
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"arrivedon": "Llegado el:",
|
||||
"arrivingjobs": "",
|
||||
"blocked": "",
|
||||
"bp": "",
|
||||
"cancelledappointment": "Cita cancelada para:",
|
||||
"completingjobs": "",
|
||||
"dataconsistency": "",
|
||||
@@ -59,18 +60,17 @@
|
||||
"noarrivingjobs": "",
|
||||
"nocompletingjobs": "",
|
||||
"nodateselected": "No se ha seleccionado ninguna fecha.",
|
||||
"owner": "",
|
||||
"priorappointments": "Nombramientos previos",
|
||||
"reminder": "",
|
||||
"ro_number": "",
|
||||
"scheduled_completion": "",
|
||||
"scheduledfor": "Cita programada para:",
|
||||
"severalerrorsfound": "",
|
||||
"smartscheduling": "",
|
||||
"smspaymentreminder": "",
|
||||
"suggesteddates": "",
|
||||
"ro_number": "",
|
||||
"owner": "",
|
||||
"vehicle": "",
|
||||
"bp": "",
|
||||
"scheduled_completion": ""
|
||||
"vehicle": ""
|
||||
},
|
||||
"successes": {
|
||||
"canceled": "Cita cancelada con éxito.",
|
||||
@@ -90,6 +90,11 @@
|
||||
"actions": "Comportamiento"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"fields": {
|
||||
"cc": "",
|
||||
@@ -149,11 +154,6 @@
|
||||
"tasks_updated": ""
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"billlines": {
|
||||
"actions": {
|
||||
"newline": ""
|
||||
@@ -281,9 +281,9 @@
|
||||
},
|
||||
"errors": {
|
||||
"creatingdefaultview": "",
|
||||
"duplicate_insurance_company": "",
|
||||
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
|
||||
"saving": "",
|
||||
"duplicate_insurance_company": ""
|
||||
"saving": ""
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "",
|
||||
@@ -564,21 +564,18 @@
|
||||
"responsibilitycenter_tax_tier": "",
|
||||
"responsibilitycenter_tax_type": "",
|
||||
"responsibilitycenters": {
|
||||
"gogcode": "",
|
||||
"item_type": "Item Type",
|
||||
"item_type_gog": "",
|
||||
"item_type_paint": "",
|
||||
"item_type_freight": "",
|
||||
"taxable_flag": "",
|
||||
"taxable": "",
|
||||
"nontaxable": "",
|
||||
"ap": "",
|
||||
"ar": "",
|
||||
"ats": "",
|
||||
"federal_tax": "",
|
||||
"federal_tax_itc": "",
|
||||
"gogcode": "",
|
||||
"gst_override": "",
|
||||
"invoiceexemptcode": "",
|
||||
"item_type": "Item Type",
|
||||
"item_type_freight": "",
|
||||
"item_type_gog": "",
|
||||
"item_type_paint": "",
|
||||
"itemexemptcode": "",
|
||||
"la1": "",
|
||||
"la2": "",
|
||||
@@ -597,6 +594,7 @@
|
||||
"local_tax": "",
|
||||
"mapa": "",
|
||||
"mash": "",
|
||||
"nontaxable": "",
|
||||
"paa": "",
|
||||
"pac": "",
|
||||
"pag": "",
|
||||
@@ -617,6 +615,8 @@
|
||||
"state": ""
|
||||
},
|
||||
"state_tax": "",
|
||||
"taxable": "",
|
||||
"taxable_flag": "",
|
||||
"tow": ""
|
||||
},
|
||||
"schedule_end_time": "",
|
||||
@@ -678,8 +678,6 @@
|
||||
"zip_post": ""
|
||||
},
|
||||
"labels": {
|
||||
"parts_shop_management": "",
|
||||
"parts_vendor_management": "",
|
||||
"2tiername": "",
|
||||
"2tiersetup": "",
|
||||
"2tiersource": "",
|
||||
@@ -702,11 +700,11 @@
|
||||
"payers": ""
|
||||
},
|
||||
"cdk_dealerid": "",
|
||||
"rr_dealerid": "",
|
||||
"costsmapping": "",
|
||||
"dms_allocations": "",
|
||||
"pbs_serialnumber": "",
|
||||
"profitsmapping": "",
|
||||
"rr_dealerid": "",
|
||||
"title": ""
|
||||
},
|
||||
"emaillater": "",
|
||||
@@ -733,6 +731,8 @@
|
||||
"followers": ""
|
||||
},
|
||||
"orderstatuses": "",
|
||||
"parts_shop_management": "",
|
||||
"parts_vendor_management": "",
|
||||
"partslocations": "",
|
||||
"partsscan": "",
|
||||
"printlater": "",
|
||||
@@ -1047,7 +1047,9 @@
|
||||
},
|
||||
"dms": {
|
||||
"errors": {
|
||||
"alreadyexported": ""
|
||||
"alreadyexported": "",
|
||||
"earlyrorequired": "",
|
||||
"earlyrorequired.message": ""
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": ""
|
||||
@@ -1244,9 +1246,11 @@
|
||||
"deselectall": "",
|
||||
"download": "",
|
||||
"edit": "Editar",
|
||||
"gotoadmin": "",
|
||||
"login": "",
|
||||
"next": "",
|
||||
"ok": "",
|
||||
"optional": "",
|
||||
"previous": "",
|
||||
"print": "",
|
||||
"refresh": "",
|
||||
@@ -1257,6 +1261,7 @@
|
||||
"save": "Salvar",
|
||||
"saveandnew": "",
|
||||
"saveas": "",
|
||||
"select": "",
|
||||
"selectall": "",
|
||||
"send": "",
|
||||
"sendbysms": "",
|
||||
@@ -1286,9 +1291,8 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"selected": "",
|
||||
"apply": "",
|
||||
"actions": "Comportamiento",
|
||||
"settings": "",
|
||||
"areyousure": "",
|
||||
"barcode": "código de barras",
|
||||
"cancel": "",
|
||||
@@ -1341,8 +1345,10 @@
|
||||
"search": "Buscar...",
|
||||
"searchresults": "",
|
||||
"selectdate": "",
|
||||
"selected": "",
|
||||
"sendagain": "",
|
||||
"sendby": "",
|
||||
"settings": "",
|
||||
"signin": "",
|
||||
"sms": "",
|
||||
"status": "",
|
||||
@@ -1585,13 +1591,13 @@
|
||||
"labels": {
|
||||
"adjustmenttobeadded": "",
|
||||
"billref": "",
|
||||
"bulk_location_help": "",
|
||||
"convertedtolabor": "",
|
||||
"edit": "Línea de edición",
|
||||
"ioucreated": "",
|
||||
"new": "Nueva línea",
|
||||
"nostatus": "",
|
||||
"presets": "",
|
||||
"bulk_location_help": ""
|
||||
"presets": ""
|
||||
},
|
||||
"successes": {
|
||||
"created": "",
|
||||
@@ -1619,11 +1625,13 @@
|
||||
"changestatus": "Cambiar Estado",
|
||||
"changestimator": "",
|
||||
"convert": "Convertir",
|
||||
"convertwithoutearlyro": "",
|
||||
"createiou": "",
|
||||
"deliver": "",
|
||||
"deliver_quick": "",
|
||||
"dms": {
|
||||
"addpayer": "",
|
||||
"createearlyro": "",
|
||||
"createnewcustomer": "",
|
||||
"findmakemodelcode": "",
|
||||
"getmakes": "",
|
||||
@@ -1632,6 +1640,7 @@
|
||||
},
|
||||
"post": "",
|
||||
"refetchmakesmodels": "",
|
||||
"update_ro": "",
|
||||
"usegeneric": "",
|
||||
"useselected": ""
|
||||
},
|
||||
@@ -1700,8 +1709,8 @@
|
||||
"actual_in": "Real en",
|
||||
"acv_amount": "",
|
||||
"adjustment_bottom_line": "Ajustes",
|
||||
"admin_clerk": "",
|
||||
"adjustmenthours": "",
|
||||
"admin_clerk": "",
|
||||
"alt_transport": "",
|
||||
"area_of_damage_impact": {
|
||||
"10": "",
|
||||
@@ -1782,9 +1791,8 @@
|
||||
"ded_status": "Estado deducible",
|
||||
"depreciation_taxes": "Depreciación / Impuestos",
|
||||
"dms": {
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"address": "",
|
||||
"advisor": "",
|
||||
"amount": "",
|
||||
"center": "",
|
||||
"control_type": {
|
||||
@@ -1792,29 +1800,36 @@
|
||||
},
|
||||
"cost": "",
|
||||
"cost_dms_acctnumber": "",
|
||||
"customer": "",
|
||||
"dms_make": "",
|
||||
"dms_model": "",
|
||||
"dms_model_override": "",
|
||||
"make_override": "",
|
||||
"advisor": "",
|
||||
"dms_unsold": "",
|
||||
"dms_wip_acctnumber": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"inservicedate": "",
|
||||
"journal": "",
|
||||
"last_name": "",
|
||||
"lines": "",
|
||||
"make_override": "",
|
||||
"name1": "",
|
||||
"payer": {
|
||||
"amount": "",
|
||||
"control_type": "",
|
||||
"controlnumber": "",
|
||||
"dms_acctnumber": "",
|
||||
"name": ""
|
||||
"name": "",
|
||||
"payer_type": ""
|
||||
},
|
||||
"sale": "",
|
||||
"sale_dms_acctnumber": "",
|
||||
"story": "",
|
||||
"vinowner": ""
|
||||
"vinowner": "",
|
||||
"rr_opcode": "",
|
||||
"rr_opcode_prefix": "",
|
||||
"rr_opcode_suffix": "",
|
||||
"rr_opcode_base": ""
|
||||
},
|
||||
"dms_allocation": "",
|
||||
"driveable": "",
|
||||
@@ -2099,6 +2114,11 @@
|
||||
"damageto": "",
|
||||
"defaultstory": "",
|
||||
"disablebillwip": "",
|
||||
"earlyro": {
|
||||
"created": "",
|
||||
"fields": "",
|
||||
"willupdate": ""
|
||||
},
|
||||
"invoicedatefuture": "",
|
||||
"kmoutnotgreaterthankmin": "",
|
||||
"logs": "",
|
||||
@@ -2256,6 +2276,7 @@
|
||||
"delete": "",
|
||||
"deleted": "Trabajo eliminado con éxito.",
|
||||
"duplicated": "",
|
||||
"early_ro_created": "",
|
||||
"exported": "",
|
||||
"invoiced": "",
|
||||
"ioucreated": "",
|
||||
@@ -2444,6 +2465,7 @@
|
||||
"labels": {
|
||||
"addlabel": "",
|
||||
"archive": "",
|
||||
"mark_unread": "",
|
||||
"maxtenimages": "",
|
||||
"messaging": "Mensajería",
|
||||
"no_consent": "",
|
||||
@@ -2456,8 +2478,7 @@
|
||||
"selectmedia": "",
|
||||
"sentby": "",
|
||||
"typeamessage": "Enviar un mensaje...",
|
||||
"unarchive": "",
|
||||
"mark_unread": ""
|
||||
"unarchive": ""
|
||||
},
|
||||
"render": {
|
||||
"conversation_list": ""
|
||||
@@ -2611,20 +2632,20 @@
|
||||
"name": ""
|
||||
},
|
||||
"labels": {
|
||||
"cell": "",
|
||||
"create_new": "Crea un nuevo registro de propietario.",
|
||||
"deleteconfirm": "",
|
||||
"email": "",
|
||||
"existing_owners": "Propietarios existentes",
|
||||
"fromclaim": "",
|
||||
"fromowner": "",
|
||||
"relatedjobs": "",
|
||||
"updateowner": "",
|
||||
"work": "",
|
||||
"home": "",
|
||||
"cell": "",
|
||||
"other": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"sms": ""
|
||||
"relatedjobs": "",
|
||||
"sms": "",
|
||||
"updateowner": "",
|
||||
"work": ""
|
||||
},
|
||||
"successes": {
|
||||
"delete": "",
|
||||
@@ -2635,6 +2656,10 @@
|
||||
"actions": {
|
||||
"order": "Pedido de piezas",
|
||||
"orderinhouse": ""
|
||||
},
|
||||
"labels": {
|
||||
"view_counts_only": "",
|
||||
"view_timestamps": ""
|
||||
}
|
||||
},
|
||||
"parts_dispatch": {
|
||||
@@ -2984,8 +3009,6 @@
|
||||
"settings": ""
|
||||
},
|
||||
"labels": {
|
||||
"click_for_statuses": "",
|
||||
"partsreceived": "",
|
||||
"actual_in": "",
|
||||
"addnewprofile": "",
|
||||
"alert": "",
|
||||
@@ -3004,6 +3027,7 @@
|
||||
"card_size": "",
|
||||
"cardcolor": "",
|
||||
"cardsettings": "",
|
||||
"click_for_statuses": "",
|
||||
"clm_no": "",
|
||||
"comment": "",
|
||||
"compact": "",
|
||||
@@ -3024,6 +3048,7 @@
|
||||
"orientation": "",
|
||||
"ownr_nm": "",
|
||||
"paintpriority": "",
|
||||
"partsreceived": "",
|
||||
"partsstatus": "",
|
||||
"production_note": "",
|
||||
"refinishhours": "",
|
||||
@@ -3570,18 +3595,12 @@
|
||||
}
|
||||
},
|
||||
"titles": {
|
||||
"simplified-parts-jobs": "",
|
||||
"parts": "",
|
||||
"parts_settings": "",
|
||||
"accounting-payables": "",
|
||||
"accounting-payments": "",
|
||||
"accounting-receivables": "",
|
||||
"all_tasks": "",
|
||||
"app": "",
|
||||
"bc": {
|
||||
"simplified-parts-jobs": "",
|
||||
"parts": "",
|
||||
"parts_settings": "",
|
||||
"accounting-payables": "",
|
||||
"accounting-payments": "",
|
||||
"accounting-receivables": "",
|
||||
@@ -3613,7 +3632,9 @@
|
||||
"my_tasks": "",
|
||||
"owner-detail": "",
|
||||
"owners": "",
|
||||
"parts": "",
|
||||
"parts-queue": "",
|
||||
"parts_settings": "",
|
||||
"payments-all": "",
|
||||
"phonebook": "",
|
||||
"productionboard": "",
|
||||
@@ -3625,6 +3646,7 @@
|
||||
"shop-csi": "",
|
||||
"shop-templates": "",
|
||||
"shop-vendors": "",
|
||||
"simplified-parts-jobs": "",
|
||||
"tasks": "",
|
||||
"temporarydocs": "",
|
||||
"timetickets": "",
|
||||
@@ -3660,7 +3682,9 @@
|
||||
"my_tasks": "",
|
||||
"owners": "Todos los propietarios | {{app}}",
|
||||
"owners-detail": "",
|
||||
"parts": "",
|
||||
"parts-queue": "",
|
||||
"parts_settings": "",
|
||||
"payments-all": "",
|
||||
"phonebook": "",
|
||||
"productionboard": "",
|
||||
@@ -3676,6 +3700,7 @@
|
||||
"shop-csi": "",
|
||||
"shop-templates": "",
|
||||
"shop_vendors": "Vendedores | {{app}}",
|
||||
"simplified-parts-jobs": "",
|
||||
"tasks": "",
|
||||
"techconsole": "{{app}}",
|
||||
"techjobclock": "{{app}}",
|
||||
@@ -3836,10 +3861,10 @@
|
||||
"user": {
|
||||
"actions": {
|
||||
"changepassword": "",
|
||||
"signout": "desconectar",
|
||||
"updateprofile": "Actualización del perfil",
|
||||
"dark_theme": "",
|
||||
"light_theme": "",
|
||||
"dark_theme": ""
|
||||
"signout": "desconectar",
|
||||
"updateprofile": "Actualización del perfil"
|
||||
},
|
||||
"errors": {
|
||||
"updating": ""
|
||||
@@ -3853,14 +3878,14 @@
|
||||
"labels": {
|
||||
"actions": "",
|
||||
"changepassword": "",
|
||||
"profileinfo": "",
|
||||
"user_settings": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"notification_sound_on": "",
|
||||
"notification_sound_off": "",
|
||||
"notification_sound_enabled": "",
|
||||
"notification_sound_disabled": "",
|
||||
"notification_sound_help": ""
|
||||
"notification_sound_enabled": "",
|
||||
"notification_sound_help": "",
|
||||
"notification_sound_off": "",
|
||||
"notification_sound_on": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"profileinfo": "",
|
||||
"user_settings": ""
|
||||
},
|
||||
"successess": {
|
||||
"passwordchanged": ""
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"arrivedon": "Arrivé le:",
|
||||
"arrivingjobs": "",
|
||||
"blocked": "",
|
||||
"bp": "",
|
||||
"cancelledappointment": "Rendez-vous annulé pour:",
|
||||
"completingjobs": "",
|
||||
"dataconsistency": "",
|
||||
@@ -59,18 +60,17 @@
|
||||
"noarrivingjobs": "",
|
||||
"nocompletingjobs": "",
|
||||
"nodateselected": "Aucune date n'a été sélectionnée.",
|
||||
"owner": "",
|
||||
"priorappointments": "Rendez-vous précédents",
|
||||
"reminder": "",
|
||||
"ro_number": "",
|
||||
"scheduled_completion": "",
|
||||
"scheduledfor": "Rendez-vous prévu pour:",
|
||||
"severalerrorsfound": "",
|
||||
"smartscheduling": "",
|
||||
"smspaymentreminder": "",
|
||||
"suggesteddates": "",
|
||||
"ro_number": "",
|
||||
"owner": "",
|
||||
"vehicle": "",
|
||||
"bp": "",
|
||||
"scheduled_completion": ""
|
||||
"vehicle": ""
|
||||
},
|
||||
"successes": {
|
||||
"canceled": "Rendez-vous annulé avec succès.",
|
||||
@@ -90,6 +90,11 @@
|
||||
"actions": "actes"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"fields": {
|
||||
"cc": "",
|
||||
@@ -149,11 +154,6 @@
|
||||
"tasks_updated": ""
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"manager": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"billlines": {
|
||||
"actions": {
|
||||
"newline": ""
|
||||
@@ -281,9 +281,9 @@
|
||||
},
|
||||
"errors": {
|
||||
"creatingdefaultview": "",
|
||||
"duplicate_insurance_company": "",
|
||||
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
|
||||
"saving": "",
|
||||
"duplicate_insurance_company": ""
|
||||
"saving": ""
|
||||
},
|
||||
"fields": {
|
||||
"ReceivableCustomField": "",
|
||||
@@ -564,21 +564,18 @@
|
||||
"responsibilitycenter_tax_tier": "",
|
||||
"responsibilitycenter_tax_type": "",
|
||||
"responsibilitycenters": {
|
||||
"gogcode": "",
|
||||
"item_type": "Item Type",
|
||||
"item_type_gog": "",
|
||||
"item_type_paint": "",
|
||||
"item_type_freight": "",
|
||||
"taxable_flag": "",
|
||||
"taxable": "",
|
||||
"nontaxable": "",
|
||||
"ap": "",
|
||||
"ar": "",
|
||||
"ats": "",
|
||||
"federal_tax": "",
|
||||
"federal_tax_itc": "",
|
||||
"gogcode": "",
|
||||
"gst_override": "",
|
||||
"invoiceexemptcode": "",
|
||||
"item_type": "Item Type",
|
||||
"item_type_freight": "",
|
||||
"item_type_gog": "",
|
||||
"item_type_paint": "",
|
||||
"itemexemptcode": "",
|
||||
"la1": "",
|
||||
"la2": "",
|
||||
@@ -597,6 +594,7 @@
|
||||
"local_tax": "",
|
||||
"mapa": "",
|
||||
"mash": "",
|
||||
"nontaxable": "",
|
||||
"paa": "",
|
||||
"pac": "",
|
||||
"pag": "",
|
||||
@@ -617,6 +615,8 @@
|
||||
"state": ""
|
||||
},
|
||||
"state_tax": "",
|
||||
"taxable": "",
|
||||
"taxable_flag": "",
|
||||
"tow": ""
|
||||
},
|
||||
"schedule_end_time": "",
|
||||
@@ -678,8 +678,6 @@
|
||||
"zip_post": ""
|
||||
},
|
||||
"labels": {
|
||||
"parts_shop_management": "",
|
||||
"parts_vendor_management": "",
|
||||
"2tiername": "",
|
||||
"2tiersetup": "",
|
||||
"2tiersource": "",
|
||||
@@ -702,11 +700,11 @@
|
||||
"payers": ""
|
||||
},
|
||||
"cdk_dealerid": "",
|
||||
"rr_dealerid": "",
|
||||
"costsmapping": "",
|
||||
"dms_allocations": "",
|
||||
"pbs_serialnumber": "",
|
||||
"profitsmapping": "",
|
||||
"rr_dealerid": "",
|
||||
"title": ""
|
||||
},
|
||||
"emaillater": "",
|
||||
@@ -733,6 +731,8 @@
|
||||
"followers": ""
|
||||
},
|
||||
"orderstatuses": "",
|
||||
"parts_shop_management": "",
|
||||
"parts_vendor_management": "",
|
||||
"partslocations": "",
|
||||
"partsscan": "",
|
||||
"printlater": "",
|
||||
@@ -1047,7 +1047,9 @@
|
||||
},
|
||||
"dms": {
|
||||
"errors": {
|
||||
"alreadyexported": ""
|
||||
"alreadyexported": "",
|
||||
"earlyrorequired": "",
|
||||
"earlyrorequired.message": ""
|
||||
},
|
||||
"labels": {
|
||||
"refreshallocations": ""
|
||||
@@ -1244,9 +1246,11 @@
|
||||
"deselectall": "",
|
||||
"download": "",
|
||||
"edit": "modifier",
|
||||
"gotoadmin": "",
|
||||
"login": "",
|
||||
"next": "",
|
||||
"ok": "",
|
||||
"optional": "",
|
||||
"previous": "",
|
||||
"print": "",
|
||||
"refresh": "",
|
||||
@@ -1257,6 +1261,7 @@
|
||||
"save": "sauvegarder",
|
||||
"saveandnew": "",
|
||||
"saveas": "",
|
||||
"select": "",
|
||||
"selectall": "",
|
||||
"send": "",
|
||||
"sendbysms": "",
|
||||
@@ -1286,8 +1291,7 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"selected": "",
|
||||
"settings": "",
|
||||
"apply": "",
|
||||
"actions": "actes",
|
||||
"areyousure": "",
|
||||
"barcode": "code à barre",
|
||||
@@ -1341,8 +1345,10 @@
|
||||
"search": "Chercher...",
|
||||
"searchresults": "",
|
||||
"selectdate": "",
|
||||
"selected": "",
|
||||
"sendagain": "",
|
||||
"sendby": "",
|
||||
"settings": "",
|
||||
"signin": "",
|
||||
"sms": "",
|
||||
"status": "",
|
||||
@@ -1585,13 +1591,13 @@
|
||||
"labels": {
|
||||
"adjustmenttobeadded": "",
|
||||
"billref": "",
|
||||
"bulk_location_help": "",
|
||||
"convertedtolabor": "",
|
||||
"edit": "Ligne d'édition",
|
||||
"ioucreated": "",
|
||||
"new": "Nouvelle ligne",
|
||||
"nostatus": "",
|
||||
"presets": "",
|
||||
"bulk_location_help": ""
|
||||
"presets": ""
|
||||
},
|
||||
"successes": {
|
||||
"created": "",
|
||||
@@ -1619,11 +1625,13 @@
|
||||
"changestatus": "Changer le statut",
|
||||
"changestimator": "",
|
||||
"convert": "Convertir",
|
||||
"convertwithoutearlyro": "",
|
||||
"createiou": "",
|
||||
"deliver": "",
|
||||
"deliver_quick": "",
|
||||
"dms": {
|
||||
"addpayer": "",
|
||||
"createearlyro": "",
|
||||
"createnewcustomer": "",
|
||||
"findmakemodelcode": "",
|
||||
"getmakes": "",
|
||||
@@ -1632,6 +1640,7 @@
|
||||
},
|
||||
"post": "",
|
||||
"refetchmakesmodels": "",
|
||||
"update_ro": "",
|
||||
"usegeneric": "",
|
||||
"useselected": ""
|
||||
},
|
||||
@@ -1699,9 +1708,9 @@
|
||||
"actual_delivery": "Livraison réelle",
|
||||
"actual_in": "En réel",
|
||||
"acv_amount": "",
|
||||
"admin_clerk": "",
|
||||
"adjustment_bottom_line": "Ajustements",
|
||||
"adjustmenthours": "",
|
||||
"admin_clerk": "",
|
||||
"alt_transport": "",
|
||||
"area_of_damage_impact": {
|
||||
"10": "",
|
||||
@@ -1782,9 +1791,8 @@
|
||||
"ded_status": "Statut de franchise",
|
||||
"depreciation_taxes": "Amortissement / taxes",
|
||||
"dms": {
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"address": "",
|
||||
"advisor": "",
|
||||
"amount": "",
|
||||
"center": "",
|
||||
"control_type": {
|
||||
@@ -1792,29 +1800,36 @@
|
||||
},
|
||||
"cost": "",
|
||||
"cost_dms_acctnumber": "",
|
||||
"customer": "",
|
||||
"dms_make": "",
|
||||
"dms_model": "",
|
||||
"dms_model_override": "",
|
||||
"make_override": "",
|
||||
"advisor": "",
|
||||
"dms_unsold": "",
|
||||
"dms_wip_acctnumber": "",
|
||||
"first_name": "",
|
||||
"id": "",
|
||||
"inservicedate": "",
|
||||
"journal": "",
|
||||
"last_name": "",
|
||||
"lines": "",
|
||||
"make_override": "",
|
||||
"name1": "",
|
||||
"payer": {
|
||||
"amount": "",
|
||||
"control_type": "",
|
||||
"controlnumber": "",
|
||||
"dms_acctnumber": "",
|
||||
"name": ""
|
||||
"name": "",
|
||||
"payer_type": ""
|
||||
},
|
||||
"sale": "",
|
||||
"sale_dms_acctnumber": "",
|
||||
"story": "",
|
||||
"vinowner": ""
|
||||
"vinowner": "",
|
||||
"rr_opcode": "",
|
||||
"rr_opcode_prefix": "",
|
||||
"rr_opcode_suffix": "",
|
||||
"rr_opcode_base": ""
|
||||
},
|
||||
"dms_allocation": "",
|
||||
"driveable": "",
|
||||
@@ -2099,6 +2114,11 @@
|
||||
"damageto": "",
|
||||
"defaultstory": "",
|
||||
"disablebillwip": "",
|
||||
"earlyro": {
|
||||
"created": "",
|
||||
"fields": "",
|
||||
"willupdate": ""
|
||||
},
|
||||
"invoicedatefuture": "",
|
||||
"kmoutnotgreaterthankmin": "",
|
||||
"logs": "",
|
||||
@@ -2256,6 +2276,7 @@
|
||||
"delete": "",
|
||||
"deleted": "Le travail a bien été supprimé.",
|
||||
"duplicated": "",
|
||||
"early_ro_created": "",
|
||||
"exported": "",
|
||||
"invoiced": "",
|
||||
"ioucreated": "",
|
||||
@@ -2433,7 +2454,6 @@
|
||||
"actions": {
|
||||
"link": "",
|
||||
"new": "",
|
||||
|
||||
"openchat": ""
|
||||
},
|
||||
"errors": {
|
||||
@@ -2445,6 +2465,7 @@
|
||||
"labels": {
|
||||
"addlabel": "",
|
||||
"archive": "",
|
||||
"mark_unread": "",
|
||||
"maxtenimages": "",
|
||||
"messaging": "Messagerie",
|
||||
"no_consent": "",
|
||||
@@ -2457,8 +2478,7 @@
|
||||
"selectmedia": "",
|
||||
"sentby": "",
|
||||
"typeamessage": "Envoyer un message...",
|
||||
"unarchive": "",
|
||||
"mark_unread": ""
|
||||
"unarchive": ""
|
||||
},
|
||||
"render": {
|
||||
"conversation_list": ""
|
||||
@@ -2612,20 +2632,20 @@
|
||||
"name": ""
|
||||
},
|
||||
"labels": {
|
||||
"cell": "",
|
||||
"create_new": "Créez un nouvel enregistrement de propriétaire.",
|
||||
"deleteconfirm": "",
|
||||
"email": "",
|
||||
"existing_owners": "Propriétaires existants",
|
||||
"fromclaim": "",
|
||||
"fromowner": "",
|
||||
"relatedjobs": "",
|
||||
"updateowner": "",
|
||||
"work": "",
|
||||
"home": "",
|
||||
"cell": "",
|
||||
"other": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"sms": ""
|
||||
"relatedjobs": "",
|
||||
"sms": "",
|
||||
"updateowner": "",
|
||||
"work": ""
|
||||
},
|
||||
"successes": {
|
||||
"delete": "",
|
||||
@@ -2636,6 +2656,10 @@
|
||||
"actions": {
|
||||
"order": "Commander des pièces",
|
||||
"orderinhouse": ""
|
||||
},
|
||||
"labels": {
|
||||
"view_counts_only": "",
|
||||
"view_timestamps": ""
|
||||
}
|
||||
},
|
||||
"parts_dispatch": {
|
||||
@@ -2985,8 +3009,6 @@
|
||||
"settings": ""
|
||||
},
|
||||
"labels": {
|
||||
"click_for_statuses": "",
|
||||
"partsreceived": "",
|
||||
"actual_in": "",
|
||||
"addnewprofile": "",
|
||||
"alert": "",
|
||||
@@ -3005,6 +3027,7 @@
|
||||
"card_size": "",
|
||||
"cardcolor": "",
|
||||
"cardsettings": "",
|
||||
"click_for_statuses": "",
|
||||
"clm_no": "",
|
||||
"comment": "",
|
||||
"compact": "",
|
||||
@@ -3025,6 +3048,7 @@
|
||||
"orientation": "",
|
||||
"ownr_nm": "",
|
||||
"paintpriority": "",
|
||||
"partsreceived": "",
|
||||
"partsstatus": "",
|
||||
"production_note": "",
|
||||
"refinishhours": "",
|
||||
@@ -3571,18 +3595,12 @@
|
||||
}
|
||||
},
|
||||
"titles": {
|
||||
"simplified-parts-jobs": "",
|
||||
"parts": "",
|
||||
"parts_settings": "",
|
||||
"accounting-payables": "",
|
||||
"accounting-payments": "",
|
||||
"accounting-receivables": "",
|
||||
"all_tasks": "",
|
||||
"app": "",
|
||||
"bc": {
|
||||
"simplified-parts-jobs": "",
|
||||
"parts": "",
|
||||
"parts_settings": "",
|
||||
"accounting-payables": "",
|
||||
"accounting-payments": "",
|
||||
"accounting-receivables": "",
|
||||
@@ -3614,7 +3632,9 @@
|
||||
"my_tasks": "",
|
||||
"owner-detail": "",
|
||||
"owners": "",
|
||||
"parts": "",
|
||||
"parts-queue": "",
|
||||
"parts_settings": "",
|
||||
"payments-all": "",
|
||||
"phonebook": "",
|
||||
"productionboard": "",
|
||||
@@ -3626,6 +3646,7 @@
|
||||
"shop-csi": "",
|
||||
"shop-templates": "",
|
||||
"shop-vendors": "",
|
||||
"simplified-parts-jobs": "",
|
||||
"tasks": "",
|
||||
"temporarydocs": "",
|
||||
"timetickets": "",
|
||||
@@ -3661,7 +3682,9 @@
|
||||
"my_tasks": "",
|
||||
"owners": "Tous les propriétaires | {{app}}",
|
||||
"owners-detail": "",
|
||||
"parts": "",
|
||||
"parts-queue": "",
|
||||
"parts_settings": "",
|
||||
"payments-all": "",
|
||||
"phonebook": "",
|
||||
"productionboard": "",
|
||||
@@ -3677,6 +3700,7 @@
|
||||
"shop-csi": "",
|
||||
"shop-templates": "",
|
||||
"shop_vendors": "Vendeurs | {{app}}",
|
||||
"simplified-parts-jobs": "",
|
||||
"tasks": "",
|
||||
"techconsole": "{{app}}",
|
||||
"techjobclock": "{{app}}",
|
||||
@@ -3837,10 +3861,10 @@
|
||||
"user": {
|
||||
"actions": {
|
||||
"changepassword": "",
|
||||
"signout": "Déconnexion",
|
||||
"updateprofile": "Mettre à jour le profil",
|
||||
"dark_theme": "",
|
||||
"light_theme": "",
|
||||
"dark_theme": ""
|
||||
"signout": "Déconnexion",
|
||||
"updateprofile": "Mettre à jour le profil"
|
||||
},
|
||||
"errors": {
|
||||
"updating": ""
|
||||
@@ -3854,14 +3878,14 @@
|
||||
"labels": {
|
||||
"actions": "",
|
||||
"changepassword": "",
|
||||
"profileinfo": "",
|
||||
"user_settings": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"notification_sound_on": "",
|
||||
"notification_sound_off": "",
|
||||
"notification_sound_enabled": "",
|
||||
"notification_sound_disabled": "",
|
||||
"notification_sound_help": ""
|
||||
"notification_sound_enabled": "",
|
||||
"notification_sound_help": "",
|
||||
"notification_sound_off": "",
|
||||
"notification_sound_on": "",
|
||||
"play_sound_for_new_messages": "",
|
||||
"profileinfo": "",
|
||||
"user_settings": ""
|
||||
},
|
||||
"successess": {
|
||||
"passwordchanged": ""
|
||||
|
||||
@@ -5,8 +5,10 @@ export function DateFormatter(props) {
|
||||
return props.children ? dayjs(props.children).format(props.includeDay ? "ddd MM/DD/YYYY" : "MM/DD/YYYY") : null;
|
||||
}
|
||||
|
||||
export function DateTimeFormatter(props) {
|
||||
return props.children ? dayjs(props.children).format(props.format ? props.format : "MM/DD/YYYY hh:mm a") : null;
|
||||
export function DateTimeFormatter({ hideTime, ...props }) {
|
||||
return props.children
|
||||
? dayjs(props.children).format(props.format ? props.format : `MM/DD/YYYY${hideTime ? "" : " hh:mm a"}`)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function DateTimeFormatterFunction(date) {
|
||||
@@ -17,11 +19,11 @@ export function TimeFormatter(props) {
|
||||
return props.children ? dayjs(props.children).format(props.format ? props.format : "hh:mm a") : null;
|
||||
}
|
||||
|
||||
export function TimeAgoFormatter(props) {
|
||||
export function TimeAgoFormatter({ removeAgoString = false, ...props }) {
|
||||
const m = dayjs(props.children);
|
||||
return props.children ? (
|
||||
<Tooltip placement="top" title={m.format("MM/DD/YYY hh:mm A")}>
|
||||
{m.fromNow()}
|
||||
<Tooltip placement="top" title={m.format("MM/DD/YYYY hh:mm A")}>
|
||||
{m.fromNow(removeAgoString)}
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -248,7 +248,8 @@ const client = new ApolloClient({
|
||||
watchQuery: {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only",
|
||||
errorPolicy: "ignore"
|
||||
errorPolicy: "ignore",
|
||||
notifyOnNetworkStatusChange: false
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "network-only",
|
||||
|
||||
@@ -146,7 +146,8 @@ export async function generateTemplate(
|
||||
if (templateQueryToExecute) {
|
||||
const { data } = await client.query({
|
||||
query: gql(finalQuery),
|
||||
variables: { ...templateObject.variables }
|
||||
variables: { ...templateObject.variables },
|
||||
fetchPolicy: "no-cache"
|
||||
});
|
||||
contextData = data;
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4001:4000" # Different external port for local access
|
||||
volumes:
|
||||
@@ -65,8 +63,6 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4002:4000" # Different external port for local access
|
||||
volumes:
|
||||
@@ -92,8 +88,6 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "4003:4000" # Different external port for local access
|
||||
volumes:
|
||||
@@ -156,23 +150,18 @@ services:
|
||||
|
||||
# LocalStack
|
||||
localstack:
|
||||
image: localstack/localstack
|
||||
image: localstack/localstack:4.13.1
|
||||
container_name: localstack
|
||||
hostname: localstack
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
|
||||
- ./localstack/init:/etc/localstack/init/ready.d:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
|
||||
- DEBUG=0
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
|
||||
- EXTRA_CORS_ALLOWED_ORIGINS=*
|
||||
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
|
||||
env_file:
|
||||
- .env.localstack.docker
|
||||
ports:
|
||||
- "4566:4566"
|
||||
healthcheck:
|
||||
@@ -182,36 +171,6 @@ services:
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# AWS-CLI
|
||||
aws-cli:
|
||||
image: amazon/aws-cli
|
||||
container_name: aws-cli
|
||||
hostname: aws-cli
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './localstack:/tmp/localstack'
|
||||
- './certs:/tmp/certs'
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
entrypoint: /bin/sh -c
|
||||
command: >
|
||||
"
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
|
||||
networks:
|
||||
redis-cluster-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -68,23 +68,18 @@ services:
|
||||
# LocalStack: Used to emulate AWS services locally, currently setup for SES
|
||||
# Notes: Set the ENV Debug to 1 for additional logging
|
||||
localstack:
|
||||
image: localstack/localstack
|
||||
image: localstack/localstack:4.13.1
|
||||
container_name: localstack
|
||||
hostname: localstack
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./certs:/tmp/certs:ro # only if your script reads /tmp/certs/...
|
||||
- ./localstack/init:/etc/localstack/init/ready.d:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- SERVICES=s3,ses,secretsmanager,cloudwatch,logs
|
||||
- DEBUG=0
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
- EXTRA_CORS_ALLOWED_HEADERS=Authorization,Content-Type
|
||||
- EXTRA_CORS_ALLOWED_ORIGINS=*
|
||||
- EXTRA_CORS_EXPOSE_HEADERS=Authorization,Content-Type
|
||||
env_file:
|
||||
- .env.localstack.docker
|
||||
ports:
|
||||
- "4566:4566"
|
||||
healthcheck:
|
||||
@@ -94,38 +89,6 @@ services:
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
# AWS-CLI - Used in conjunction with LocalStack to set required permission to send emails
|
||||
aws-cli:
|
||||
image: amazon/aws-cli
|
||||
container_name: aws-cli
|
||||
hostname: aws-cli
|
||||
networks:
|
||||
- redis-cluster-net
|
||||
depends_on:
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- './localstack:/tmp/localstack'
|
||||
- './certs:/tmp/certs'
|
||||
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=test
|
||||
- AWS_SECRET_ACCESS_KEY=test
|
||||
- AWS_DEFAULT_REGION=ca-central-1
|
||||
entrypoint: /bin/sh -c
|
||||
command: >
|
||||
"
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-domain-identity --domain imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 ses verify-email-identity --email-address noreply@imex.online --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
|
||||
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
|
||||
"
|
||||
# Node App: The Main IMEX API
|
||||
node-app:
|
||||
build:
|
||||
@@ -145,8 +108,7 @@ services:
|
||||
condition: service_healthy
|
||||
localstack:
|
||||
condition: service_healthy
|
||||
aws-cli:
|
||||
condition: service_completed_successfully
|
||||
|
||||
ports:
|
||||
- "4000:4000"
|
||||
- "9229:9229"
|
||||
|
||||
@@ -947,6 +947,7 @@
|
||||
- carfax_exclude
|
||||
- cdk_configuration
|
||||
- cdk_dealerid
|
||||
- chatter_company_id
|
||||
- chatterid
|
||||
- city
|
||||
- claimscorpid
|
||||
@@ -1063,6 +1064,7 @@
|
||||
- bill_allow_post_to_closed
|
||||
- bill_tax_rates
|
||||
- cdk_configuration
|
||||
- chatter_company_id
|
||||
- city
|
||||
- country
|
||||
- created_at
|
||||
@@ -3702,7 +3704,9 @@
|
||||
- ded_status
|
||||
- deliverchecklist
|
||||
- depreciation_taxes
|
||||
- dms_advisor_id
|
||||
- dms_allocation
|
||||
- dms_customer_id
|
||||
- dms_id
|
||||
- driveable
|
||||
- employee_body
|
||||
@@ -3983,7 +3987,9 @@
|
||||
- ded_status
|
||||
- deliverchecklist
|
||||
- depreciation_taxes
|
||||
- dms_advisor_id
|
||||
- dms_allocation
|
||||
- dms_customer_id
|
||||
- dms_id
|
||||
- driveable
|
||||
- employee_body
|
||||
@@ -4276,7 +4282,9 @@
|
||||
- ded_status
|
||||
- deliverchecklist
|
||||
- depreciation_taxes
|
||||
- dms_advisor_id
|
||||
- dms_allocation
|
||||
- dms_customer_id
|
||||
- dms_id
|
||||
- driveable
|
||||
- employee_body
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."bodyshops" add column "chatter_company_id" text
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."bodyshops" add column "chatter_company_id" text
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."jobs" add column "dms_customer_id" text
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."jobs" add column "dms_customer_id" text
|
||||
null;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Could not auto-generate a down migration.
|
||||
-- Please write an appropriate down migration for the SQL below:
|
||||
-- alter table "public"."jobs" add column "dms_advisor_id" text
|
||||
-- null;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table "public"."jobs" add column "dms_advisor_id" text
|
||||
null;
|
||||
65
localstack/init/10-bootstrap.sh
Normal file
65
localstack/init/10-bootstrap.sh
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
echo "Running LocalStack bootstrap script: 10-bootstrap.sh"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REGION="${AWS_DEFAULT_REGION:-ca-central-1}"
|
||||
|
||||
# awslocal is the LocalStack wrapper so you don't need --endpoint-url
|
||||
# (it targets the LocalStack gateway automatically)
|
||||
# Docs: https://docs.localstack.cloud/.../aws-cli/
|
||||
ensure_bucket() {
|
||||
local b="$1"
|
||||
if ! awslocal s3api head-bucket --bucket "$b" >/dev/null 2>&1; then
|
||||
awslocal s3api create-bucket \
|
||||
--bucket "$b" \
|
||||
--create-bucket-configuration LocationConstraint="$REGION" \
|
||||
--region "$REGION" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_log_group() {
|
||||
local lg="$1"
|
||||
awslocal logs create-log-group --log-group-name "$lg" --region "$REGION" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
ensure_secret_string() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
|
||||
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
|
||||
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "$value" >/dev/null
|
||||
else
|
||||
awslocal secretsmanager create-secret --name "$name" --secret-string "$value" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_secret_file() {
|
||||
local name="$1"
|
||||
local filepath="$2"
|
||||
|
||||
if awslocal secretsmanager describe-secret --secret-id "$name" >/dev/null 2>&1; then
|
||||
awslocal secretsmanager update-secret --secret-id "$name" --secret-string "file://$filepath" >/dev/null
|
||||
else
|
||||
awslocal secretsmanager create-secret --name "$name" --secret-string "file://$filepath" >/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# SES identities (idempotent-ish; ignoring if it already exists)
|
||||
awslocal ses verify-domain-identity --domain imex.online --region "$REGION" >/dev/null || true
|
||||
awslocal ses verify-email-identity --email-address noreply@imex.online --region "$REGION" >/dev/null || true
|
||||
|
||||
# Secrets
|
||||
ensure_secret_file "CHATTER_PRIVATE_KEY" "/tmp/certs/io-ftp-test.key"
|
||||
ensure_secret_string "CHATTER_COMPANY_KEY_6713" "${CHATTER_COMPANY_KEY_6713:-REPLACE_ME}"
|
||||
|
||||
# Logs
|
||||
ensure_log_group "development"
|
||||
|
||||
# Buckets
|
||||
ensure_bucket "imex-job-totals"
|
||||
ensure_bucket "parts-estimate"
|
||||
ensure_bucket "imex-large-log"
|
||||
ensure_bucket "imex-carfax-uploads"
|
||||
ensure_bucket "rome-carfax-uploads"
|
||||
ensure_bucket "rps-carfax-uploads"
|
||||
16
server.js
16
server.js
@@ -40,6 +40,8 @@ const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
|
||||
const { loadAppQueue } = require("./server/notifications/queues/appQueue");
|
||||
const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket");
|
||||
const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue");
|
||||
const { loadChatterApiQueue } = require("./server/data/queues/chatterApiQueue");
|
||||
const { processChatterApiJob } = require("./server/data/chatter-api");
|
||||
|
||||
const CLUSTER_RETRY_BASE_DELAY = 100;
|
||||
const CLUSTER_RETRY_MAX_DELAY = 5000;
|
||||
@@ -125,6 +127,7 @@ 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("/chatter", require("./server/routes/chatterRoutes"));
|
||||
|
||||
// Default route for forbidden access
|
||||
app.get("/", (req, res) => {
|
||||
@@ -390,6 +393,15 @@ const applySocketIO = async ({ server, app }) => {
|
||||
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
|
||||
|
||||
// Load chatterApi queue with processJob function and redis helpers
|
||||
const chatterApiQueue = await loadChatterApiQueue({
|
||||
pubClient,
|
||||
logger,
|
||||
processJob: processChatterApiJob,
|
||||
getChatterToken: redisHelpers.getChatterToken,
|
||||
setChatterToken: redisHelpers.setChatterToken
|
||||
});
|
||||
|
||||
// Assuming loadEmailQueue and loadAppQueue return Promises
|
||||
const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([
|
||||
loadEmailQueue(queueSettings),
|
||||
@@ -409,6 +421,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
notificationsFcmQueue.on("error", (error) => {
|
||||
logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
|
||||
});
|
||||
|
||||
chatterApiQueue.on("error", (error) => {
|
||||
logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
139
server/chatter/chatter-client.js
Normal file
139
server/chatter/chatter-client.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
|
||||
const { isString, isEmpty } = require("lodash");
|
||||
|
||||
const CHATTER_BASE_URL = process.env.CHATTER_API_BASE_URL || "https://api.chatterresearch.com";
|
||||
const AWS_REGION = process.env.AWS_REGION || "ca-central-1";
|
||||
|
||||
// Configure SecretsManager client with localstack support
|
||||
const secretsClientOptions = {
|
||||
region: AWS_REGION,
|
||||
credentials: defaultProvider()
|
||||
};
|
||||
|
||||
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
|
||||
|
||||
if (isLocal) {
|
||||
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
|
||||
}
|
||||
|
||||
const secretsClient = new SecretsManagerClient(secretsClientOptions);
|
||||
|
||||
/**
|
||||
* Chatter API Client for making requests to the Chatter API
|
||||
*/
|
||||
class ChatterApiClient {
|
||||
constructor({ baseUrl, apiToken }) {
|
||||
if (!apiToken) throw new Error("ChatterApiClient requires apiToken");
|
||||
this.baseUrl = String(baseUrl || "").replace(/\/+$/, "");
|
||||
this.apiToken = apiToken;
|
||||
}
|
||||
|
||||
async createLocation(companyId, payload) {
|
||||
return this.request(`/api/v1/companies/${companyId}/locations`, {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
}
|
||||
|
||||
async postInteraction(companyId, payload) {
|
||||
return this.request(`/api/v1/companies/${companyId}/solicitation/interaction`, {
|
||||
method: "POST",
|
||||
body: payload
|
||||
});
|
||||
}
|
||||
|
||||
async request(path, { method = "GET", body } = {}) {
|
||||
const res = await fetch(this.baseUrl + path, {
|
||||
method,
|
||||
headers: {
|
||||
"Api-Token": this.apiToken,
|
||||
Accept: "application/json",
|
||||
...(body ? { "Content-Type": "application/json" } : {})
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const data = text ? safeJson(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(`Chatter API error ${res.status} | ${data?.message}`);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
const retryAfterMs = parseRetryAfterMs(res.headers.get("retry-after"));
|
||||
if (retryAfterMs != null) err.retryAfterMs = retryAfterMs;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse JSON, returning original text if parsing fails
|
||||
*/
|
||||
function safeJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(value) {
|
||||
if (!value) return null;
|
||||
|
||||
const sec = Number(value);
|
||||
if (Number.isFinite(sec) && sec >= 0) return Math.ceil(sec * 1000);
|
||||
|
||||
const dateMs = Date.parse(value);
|
||||
if (!Number.isFinite(dateMs)) return null;
|
||||
return Math.max(0, dateMs - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Chatter API token from AWS Secrets Manager
|
||||
* SecretId format: CHATTER_COMPANY_KEY_<companyId>
|
||||
*
|
||||
* @param {string|number} companyId - The company ID
|
||||
* @returns {Promise<string>} The API token
|
||||
*/
|
||||
async function getChatterApiToken(companyId) {
|
||||
const key = String(companyId ?? "").trim();
|
||||
if (!key) throw new Error("getChatterApiToken: companyId is required");
|
||||
|
||||
// Optional override for development/testing
|
||||
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
|
||||
|
||||
const secretId = `CHATTER_COMPANY_KEY_${key}`;
|
||||
const command = new GetSecretValueCommand({ SecretId: secretId });
|
||||
const { SecretString, SecretBinary } = await secretsClient.send(command);
|
||||
|
||||
const token =
|
||||
(SecretString && SecretString.trim()) ||
|
||||
(SecretBinary && Buffer.from(SecretBinary, "base64").toString("ascii").trim()) ||
|
||||
"";
|
||||
|
||||
if (!token) throw new Error(`Chatter API token secret is empty: ${secretId}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Chatter API client instance
|
||||
*
|
||||
* @param {string|number} companyId - The company ID
|
||||
* @param {string} [baseUrl] - Optional base URL override
|
||||
* @returns {Promise<ChatterApiClient>} Configured API client
|
||||
*/
|
||||
async function createChatterClient(companyId, baseUrl = CHATTER_BASE_URL) {
|
||||
const apiToken = await getChatterApiToken(companyId);
|
||||
return new ChatterApiClient({ baseUrl, apiToken });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ChatterApiClient,
|
||||
getChatterApiToken,
|
||||
createChatterClient,
|
||||
safeJson,
|
||||
CHATTER_BASE_URL
|
||||
};
|
||||
123
server/chatter/createLocation.js
Normal file
123
server/chatter/createLocation.js
Normal file
@@ -0,0 +1,123 @@
|
||||
const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID;
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
const { createChatterClient } = require("./chatter-client");
|
||||
const InstanceManager = require("../utils/instanceMgr").default;
|
||||
|
||||
const GET_BODYSHOP_FOR_CHATTER = `
|
||||
query GET_BODYSHOP_FOR_CHATTER($id: uuid!) {
|
||||
bodyshops_by_pk(id: $id) {
|
||||
id
|
||||
shopname
|
||||
address1
|
||||
city
|
||||
state
|
||||
zip_post
|
||||
imexshopid
|
||||
chatterid
|
||||
chatter_company_id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const UPDATE_BODYSHOP_CHATTER_FIELDS = `
|
||||
mutation UPDATE_BODYSHOP_CHATTER_FIELDS($id: uuid!, $chatter_company_id: String!, $chatterid: String!) {
|
||||
update_bodyshops_by_pk(pk_columns: {id: $id}, _set: {chatter_company_id: $chatter_company_id, chatterid: $chatterid}) {
|
||||
id
|
||||
chatter_company_id
|
||||
chatterid
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const createLocation = async (req, res) => {
|
||||
const { logger } = req;
|
||||
const { bodyshopID, googlePlaceID } = req.body;
|
||||
|
||||
console.dir({ body: req.body });
|
||||
|
||||
if (!DEFAULT_COMPANY_ID) {
|
||||
logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "No default company set" });
|
||||
}
|
||||
|
||||
if (!googlePlaceID) {
|
||||
logger.log("chatter-create-location-no-google-place-id", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "No google place id provided" });
|
||||
}
|
||||
|
||||
if (!bodyshopID) {
|
||||
logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "No bodyshop id" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { bodyshops_by_pk: bodyshop } = await client.request(GET_BODYSHOP_FOR_CHATTER, { id: bodyshopID });
|
||||
|
||||
if (!bodyshop) {
|
||||
logger.log("chatter-create-location-bodyshop-not-found", "warn", null, null, { bodyshopID });
|
||||
return res.json({ success: false, message: "Bodyshop not found" });
|
||||
}
|
||||
|
||||
if (bodyshop.chatter_company_id && bodyshop.chatterid) {
|
||||
logger.log("chatter-create-location-already-exists", "warn", null, null, {
|
||||
bodyshopID
|
||||
});
|
||||
return res.json({ success: false, message: "This Bodyshop already has a location associated with it" });
|
||||
}
|
||||
|
||||
const chatterApi = await createChatterClient(DEFAULT_COMPANY_ID);
|
||||
|
||||
const locationIdentifier = `${DEFAULT_COMPANY_ID}-${bodyshop.id}`;
|
||||
|
||||
const locationPayload = {
|
||||
name: bodyshop.shopname,
|
||||
locationIdentifier: locationIdentifier,
|
||||
address: bodyshop.address1,
|
||||
postalCode: bodyshop.zip_post,
|
||||
state: bodyshop.state,
|
||||
city: bodyshop.city,
|
||||
country: InstanceManager({ imex: "Canada", rome: "US" }),
|
||||
googlePlaceId: googlePlaceID,
|
||||
status: "active"
|
||||
};
|
||||
|
||||
logger.log("chatter-create-location-calling-api", "info", null, null, { bodyshopID, locationIdentifier });
|
||||
|
||||
const response = await chatterApi.createLocation(DEFAULT_COMPANY_ID, locationPayload);
|
||||
|
||||
if (!response.location?.id) {
|
||||
logger.log("chatter-create-location-no-location-id", "error", null, null, { bodyshopID, response });
|
||||
return res.json({ success: false, message: "No location ID in response", data: response });
|
||||
}
|
||||
|
||||
await client.request(UPDATE_BODYSHOP_CHATTER_FIELDS, {
|
||||
id: bodyshopID,
|
||||
chatter_company_id: DEFAULT_COMPANY_ID,
|
||||
chatterid: String(response.location.id)
|
||||
});
|
||||
|
||||
logger.log("chatter-create-location-success", "info", null, null, {
|
||||
bodyshopID,
|
||||
chatter_company_id: DEFAULT_COMPANY_ID,
|
||||
chatterid: response.location.id,
|
||||
locationIdentifier
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: response });
|
||||
} catch (error) {
|
||||
logger.log("chatter-create-location-error", "error", null, null, {
|
||||
bodyshopID,
|
||||
error: error.message,
|
||||
status: error.status,
|
||||
data: error.data
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: false,
|
||||
message: error.message || "Failed to create location",
|
||||
error: error.data
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = createLocation;
|
||||
@@ -221,6 +221,8 @@ const CreateRepairOrderTag = (job, errorCallback) => {
|
||||
|
||||
const repairCosts = CreateCosts(job);
|
||||
|
||||
const LaborDetailLines = generateLaborLines(job.timetickets);
|
||||
|
||||
//Calculate detail only lines.
|
||||
const detailAdjustments = job.joblines
|
||||
.filter((jl) => jl.ah_detail_line && jl.mod_lbr_ty)
|
||||
@@ -606,12 +608,14 @@ const CreateRepairOrderTag = (job, errorCallback) => {
|
||||
// CSIID: null,
|
||||
InsGroupCode: null
|
||||
},
|
||||
|
||||
DetailLines: {
|
||||
DetailLine:
|
||||
job.joblines.length > 0
|
||||
? job.joblines.map((jl) => GenerateDetailLines(job, jl, job.bodyshop.md_order_statuses))
|
||||
: [generateNullDetailLine()]
|
||||
},
|
||||
LaborDetailLines: {
|
||||
LaborDetailLine: LaborDetailLines
|
||||
}
|
||||
};
|
||||
return ret;
|
||||
@@ -787,6 +791,76 @@ const CreateCosts = (job) => {
|
||||
};
|
||||
};
|
||||
|
||||
const generateLaborLines = (timetickets) => {
|
||||
if (!timetickets || timetickets.length === 0) return [];
|
||||
|
||||
const codeToProps = {
|
||||
LAB: { actual: "LaborBodyActualHours", flag: "LaborBodyFlagHours", cost: "LaborBodyCost" },
|
||||
LAM: { actual: "LaborMechanicalActualHours", flag: "LaborMechanicalFlagHours", cost: "LaborMechanicalCost" },
|
||||
LAG: { actual: "LaborGlassActualHours", flag: "LaborGlassFlagHours", cost: "LaborGlassCost" },
|
||||
LAS: { actual: "LaborStructuralActualHours", flag: "LaborStructuralFlagHours", cost: "LaborStructuralCost" },
|
||||
LAE: { actual: "LaborElectricalActualHours", flag: "LaborElectricalFlagHours", cost: "LaborElectricalCost" },
|
||||
LAA: { actual: "LaborAluminumActualHours", flag: "LaborAluminumFlagHours", cost: "LaborAluminumCost" },
|
||||
LAR: { actual: "LaborRefinishActualHours", flag: "LaborRefinishFlagHours", cost: "LaborRefinishCost" },
|
||||
LAU: { actual: "LaborDetailActualHours", flag: "LaborDetailFlagHours", cost: "LaborDetailCost" },
|
||||
LA1: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" },
|
||||
LA2: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" },
|
||||
LA3: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" },
|
||||
LA4: { actual: "LaborOtherActualHours", flag: "LaborOtherFlagHours", cost: "LaborOtherCost" }
|
||||
};
|
||||
|
||||
return timetickets.map((ticket, idx) => {
|
||||
const { ciecacode, employee, actualhrs = 0, productivehrs = 0, rate = 0 } = ticket;
|
||||
const isFlatRate = employee?.flat_rate;
|
||||
const hours = isFlatRate ? productivehrs : actualhrs;
|
||||
const cost = rate * hours;
|
||||
|
||||
const laborDetail = {
|
||||
LaborDetailLineNumber: idx + 1,
|
||||
TechnicianNameFirst: employee?.first_name || "",
|
||||
TechnicianNameLast: employee?.last_name || "",
|
||||
LaborBodyActualHours: 0,
|
||||
LaborMechanicalActualHours: 0,
|
||||
LaborGlassActualHours: 0,
|
||||
LaborStructuralActualHours: 0,
|
||||
LaborElectricalActualHours: 0,
|
||||
LaborAluminumActualHours: 0,
|
||||
LaborRefinishActualHours: 0,
|
||||
LaborDetailActualHours: 0,
|
||||
LaborOtherActualHours: 0,
|
||||
LaborBodyFlagHours: 0,
|
||||
LaborMechanicalFlagHours: 0,
|
||||
LaborGlassFlagHours: 0,
|
||||
LaborStructuralFlagHours: 0,
|
||||
LaborElectricalFlagHours: 0,
|
||||
LaborAluminumFlagHours: 0,
|
||||
LaborRefinishFlagHours: 0,
|
||||
LaborDetailFlagHours: 0,
|
||||
LaborOtherFlagHours: 0,
|
||||
LaborBodyCost: 0,
|
||||
LaborMechanicalCost: 0,
|
||||
LaborGlassCost: 0,
|
||||
LaborStructuralCost: 0,
|
||||
LaborElectricalCost: 0,
|
||||
LaborAluminumCost: 0,
|
||||
LaborRefinishCost: 0,
|
||||
LaborDetailCost: 0,
|
||||
LaborOtherCost: 0
|
||||
};
|
||||
|
||||
const effectiveCiecacode = ciecacode || "LA4";
|
||||
|
||||
if (codeToProps[effectiveCiecacode]) {
|
||||
const { actual, flag, cost: costProp } = codeToProps[effectiveCiecacode];
|
||||
laborDetail[actual] = actualhrs;
|
||||
laborDetail[flag] = productivehrs;
|
||||
laborDetail[costProp] = cost;
|
||||
}
|
||||
|
||||
return laborDetail;
|
||||
});
|
||||
};
|
||||
|
||||
const StatusMapping = (status, md_ro_statuses) => {
|
||||
//Possible return statuses EST, SCH, ARR, IPR, RDY, DEL, CLO, CAN, UNDEFINED.
|
||||
const {
|
||||
|
||||
554
server/data/chatter-api.js
Normal file
554
server/data/chatter-api.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* Environment variables used by this file
|
||||
* Chatter integration
|
||||
* - CHATTER_API_CONCURRENCY
|
||||
* - Maximum number of jobs/interactions posted concurrently *per shop* (within a single shop's batch).
|
||||
* - Default: 5
|
||||
* - Used by: createConcurrencyLimit(MAX_CONCURRENCY)
|
||||
*
|
||||
* - CHATTER_API_REQUESTS_PER_SECOND
|
||||
* - Per-company outbound request rate (token bucket refill rate).
|
||||
* - Default: 3
|
||||
* - Must be a positive number; otherwise falls back to default.
|
||||
* - Used by: createTokenBucketRateLimiter({ refillPerSecond })
|
||||
*
|
||||
* - CHATTER_API_BURST_CAPACITY
|
||||
* - Per-company token bucket capacity (maximum burst size).
|
||||
* - Default: equals CHATTER_API_REQUESTS_PER_SECOND (i.e., 3 unless overridden)
|
||||
* - Must be a positive number; otherwise falls back to default.
|
||||
* - Used by: createTokenBucketRateLimiter({ capacity })
|
||||
*
|
||||
* - CHATTER_API_MAX_RETRIES
|
||||
* - Maximum number of attempts for posting an interaction before giving up.
|
||||
* - Default: 6
|
||||
* - Must be a positive integer; otherwise falls back to default.
|
||||
* - Used by: postInteractionWithPolicy()
|
||||
*
|
||||
* - CHATTER_API_TOKEN
|
||||
* - Optional override token for emergency/dev scenarios.
|
||||
* - If set, bypasses Secrets Manager/Redis token retrieval and uses this value for all companies.
|
||||
* - Used by: getChatterApiTokenCached()
|
||||
*
|
||||
* Notes
|
||||
* - Per-company API tokens are otherwise fetched via getChatterApiToken(companyId) (Secrets Manager)
|
||||
* and may be cached via `sessionUtils.getChatterToken/setChatterToken` (Redis-backed).
|
||||
*/
|
||||
|
||||
const queries = require("../graphql-client/queries");
|
||||
const moment = require("moment-timezone");
|
||||
const logger = require("../utils/logger");
|
||||
const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client");
|
||||
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
|
||||
const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION";
|
||||
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
|
||||
const CHATTER_REQUESTS_PER_SECOND = getPositiveNumber(process.env.CHATTER_API_REQUESTS_PER_SECOND, 3);
|
||||
const CHATTER_BURST_CAPACITY = getPositiveNumber(process.env.CHATTER_API_BURST_CAPACITY, CHATTER_REQUESTS_PER_SECOND);
|
||||
const CHATTER_MAX_RETRIES = getPositiveInteger(process.env.CHATTER_API_MAX_RETRIES, 6);
|
||||
|
||||
// Client caching (in-memory) - tokens are now cached in Redis
|
||||
const clientCache = new Map(); // companyId -> ChatterApiClient
|
||||
const tokenInFlight = new Map(); // companyId -> Promise<string> (for in-flight deduplication)
|
||||
const companyRateLimiters = new Map(); // companyId -> rate limiter
|
||||
|
||||
/**
|
||||
* Core processing function for Chatter API jobs.
|
||||
* This can be called by the HTTP handler or the BullMQ worker.
|
||||
*
|
||||
* @param {Object} options - Processing options
|
||||
* @param {string} options.start - Start date for the delivery window
|
||||
* @param {string} options.end - End date for the delivery window
|
||||
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process
|
||||
* @param {boolean} options.skipUpload - Dry-run flag
|
||||
* @param {Object} options.sessionUtils - Optional session utils for token caching
|
||||
* @returns {Promise<Object>} Result with totals, allShopSummaries, and allErrors
|
||||
*/
|
||||
async function processChatterApiJob({ start, end, bodyshopIds, skipUpload, sessionUtils }) {
|
||||
logger.log("chatter-api-start", "DEBUG", "api", null, null);
|
||||
|
||||
const allErrors = [];
|
||||
const allShopSummaries = [];
|
||||
|
||||
// Shops that DO have chatter_company_id
|
||||
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY);
|
||||
|
||||
const shopsToProcess =
|
||||
bodyshopIds?.length > 0 ? bodyshops.filter((shop) => bodyshopIds.includes(shop.id)) : bodyshops;
|
||||
|
||||
logger.log("chatter-api-shopsToProcess-generated", "DEBUG", "api", null, { count: shopsToProcess.length });
|
||||
|
||||
if (shopsToProcess.length === 0) {
|
||||
logger.log("chatter-api-shopsToProcess-empty", "DEBUG", "api", null, null);
|
||||
return {
|
||||
totals: { shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 },
|
||||
allShopSummaries: [],
|
||||
allErrors: []
|
||||
};
|
||||
}
|
||||
|
||||
await processBatchApi({
|
||||
shopsToProcess,
|
||||
start,
|
||||
end,
|
||||
skipUpload,
|
||||
allShopSummaries,
|
||||
allErrors,
|
||||
sessionUtils
|
||||
});
|
||||
|
||||
const totals = allShopSummaries.reduce(
|
||||
(acc, s) => {
|
||||
acc.shops += 1;
|
||||
acc.jobs += s.jobs || 0;
|
||||
acc.sent += s.sent || 0;
|
||||
acc.duplicates += s.duplicates || 0;
|
||||
acc.failed += s.failed || 0;
|
||||
return acc;
|
||||
},
|
||||
{ shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 }
|
||||
);
|
||||
|
||||
logger.log("chatter-api-end", "DEBUG", "api", null, totals);
|
||||
|
||||
return { totals, allShopSummaries, allErrors };
|
||||
}
|
||||
|
||||
exports.default = async (req, res) => {
|
||||
if (process.env.NODE_ENV !== "production") return res.sendStatus(403);
|
||||
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) return res.sendStatus(401);
|
||||
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
message: "Chatter API job queued for processing",
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const { dispatchChatterApiJob } = require("./queues/chatterApiQueue");
|
||||
const { start, end, bodyshopIds, skipUpload } = req.body;
|
||||
|
||||
await dispatchChatterApiJob({
|
||||
start,
|
||||
end,
|
||||
bodyshopIds,
|
||||
skipUpload
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("chatter-api-queue-dispatch-error", "ERROR", "api", null, {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.processChatterApiJob = processChatterApiJob;
|
||||
|
||||
async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) {
|
||||
for (const bodyshop of shopsToProcess) {
|
||||
const summary = {
|
||||
bodyshopid: bodyshop.id,
|
||||
imexshopid: bodyshop.imexshopid,
|
||||
shopname: bodyshop.shopname,
|
||||
chatter_company_id: bodyshop.chatter_company_id,
|
||||
chatterid: bodyshop.chatterid,
|
||||
jobs: 0,
|
||||
sent: 0,
|
||||
duplicates: 0,
|
||||
failed: 0,
|
||||
ok: true
|
||||
};
|
||||
|
||||
try {
|
||||
logger.log("chatter-api-start-shop", "DEBUG", "api", bodyshop.id, { shopname: bodyshop.shopname });
|
||||
|
||||
const companyId = parseCompanyId(bodyshop.chatter_company_id);
|
||||
if (!companyId) {
|
||||
summary.ok = false;
|
||||
summary.failed = 0;
|
||||
allErrors.push({
|
||||
...pickShop(bodyshop),
|
||||
fatal: true,
|
||||
errors: [`Invalid chatter_company_id: "${bodyshop.chatter_company_id}"`]
|
||||
});
|
||||
allShopSummaries.push(summary);
|
||||
continue;
|
||||
}
|
||||
|
||||
const chatterApi = await getChatterApiClient(companyId, sessionUtils);
|
||||
|
||||
const { jobs } = await client.request(queries.CHATTER_QUERY, {
|
||||
bodyshopid: bodyshop.id,
|
||||
start: start ? moment(start).startOf("day") : moment().subtract(1, "days").startOf("day"),
|
||||
...(end && { end: moment(end).endOf("day") })
|
||||
});
|
||||
|
||||
summary.jobs = jobs.length;
|
||||
|
||||
// concurrency-limited posting
|
||||
const limit = createConcurrencyLimit(MAX_CONCURRENCY);
|
||||
const results = await Promise.all(
|
||||
jobs.map((j) =>
|
||||
limit(async () => {
|
||||
const payload = buildInteractionPayload(bodyshop, j);
|
||||
|
||||
// keep legacy flag name: skipUpload == dry-run
|
||||
if (skipUpload) return { ok: true, dryRun: true };
|
||||
|
||||
const r = await postInteractionWithPolicy(chatterApi, companyId, payload);
|
||||
return r;
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
if (r?.dryRun) continue;
|
||||
if (r?.ok && r?.duplicate) summary.duplicates += 1;
|
||||
else if (r?.ok) summary.sent += 1;
|
||||
else summary.failed += 1;
|
||||
}
|
||||
|
||||
// record failures with some detail (cap to avoid huge emails)
|
||||
const failures = results
|
||||
.filter((r) => r && r.ok === false)
|
||||
.slice(0, 25)
|
||||
.map((r) => ({
|
||||
status: r.status,
|
||||
error: r.error,
|
||||
context: r.context
|
||||
}));
|
||||
|
||||
if (failures.length) {
|
||||
summary.ok = false;
|
||||
allErrors.push({
|
||||
...pickShop(bodyshop),
|
||||
fatal: false,
|
||||
errors: failures
|
||||
});
|
||||
}
|
||||
|
||||
logger.log("chatter-api-end-shop", "DEBUG", "api", bodyshop.id, summary);
|
||||
} catch (error) {
|
||||
summary.ok = false;
|
||||
|
||||
logger.log("chatter-api-error-shop", "ERROR", "api", bodyshop.id, {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
allErrors.push({
|
||||
...pickShop(bodyshop),
|
||||
fatal: true,
|
||||
errors: [error.toString()]
|
||||
});
|
||||
} finally {
|
||||
allShopSummaries.push(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildInteractionPayload(bodyshop, j) {
|
||||
const isCompany = Boolean(j.ownr_co_nm);
|
||||
|
||||
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
|
||||
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
|
||||
|
||||
if (j.actual_delivery && !timestamp) {
|
||||
logger.log("chatter-api-invalid-delivery-timestamp", "WARN", "api", bodyshop.id, {
|
||||
bodyshopId: bodyshop.id,
|
||||
jobId: j.id,
|
||||
timezone: bodyshop.timezone,
|
||||
actualDelivery: j.actual_delivery
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
locationIdentifier: locationIdentifier,
|
||||
event: CHATTER_EVENT,
|
||||
consent: "true",
|
||||
transactionId: j.ro_number != null ? String(j.ro_number) : undefined,
|
||||
timestamp,
|
||||
firstName: isCompany ? null : j.ownr_fn || null,
|
||||
lastName: isCompany ? j.ownr_co_nm : j.ownr_ln || null,
|
||||
emailAddress: j.ownr_ea || undefined,
|
||||
phoneNumber: j.ownr_ph1 || undefined,
|
||||
metadata: {
|
||||
imexShopId: bodyshop.imexshopid,
|
||||
bodyshopId: bodyshop.id,
|
||||
jobId: j.id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function postInteractionWithPolicy(chatterApi, companyId, payload) {
|
||||
const limiter = getCompanyRateLimiter(companyId);
|
||||
const requestContext = {
|
||||
companyId,
|
||||
locationIdentifier: payload?.locationIdentifier,
|
||||
transactionId: payload?.transactionId,
|
||||
timestamp: payload?.timestamp ?? null,
|
||||
bodyshopId: payload?.metadata?.bodyshopId ?? null,
|
||||
jobId: payload?.metadata?.jobId ?? null
|
||||
};
|
||||
|
||||
for (let attempt = 0; attempt < CHATTER_MAX_RETRIES; attempt++) {
|
||||
await limiter.acquire();
|
||||
|
||||
try {
|
||||
await chatterApi.postInteraction(companyId, payload);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
// duplicate -> treat as successful idempotency outcome
|
||||
if (e.status === 409) return { ok: true, duplicate: true, error: e.data };
|
||||
|
||||
// rate limited -> backoff + retry
|
||||
if (e.status === 429) {
|
||||
const retryDelayMs = retryDelayMsForError(e, attempt);
|
||||
limiter.pause(retryDelayMs);
|
||||
logger.log("chatter-api-request-rate-limited", "WARN", "api", requestContext.bodyshopId, {
|
||||
...requestContext,
|
||||
attempt: attempt + 1,
|
||||
maxAttempts: CHATTER_MAX_RETRIES,
|
||||
status: e.status,
|
||||
retryAfterMs: e.retryAfterMs,
|
||||
retryDelayMs,
|
||||
error: e.data ?? e.message
|
||||
});
|
||||
await sleep(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
|
||||
...requestContext,
|
||||
attempt: attempt + 1,
|
||||
maxAttempts: CHATTER_MAX_RETRIES,
|
||||
status: e.status,
|
||||
error: e.data ?? e.message
|
||||
});
|
||||
return { ok: false, status: e.status, error: e.data ?? e.message, context: requestContext };
|
||||
}
|
||||
}
|
||||
|
||||
logger.log("chatter-api-request-failed", "ERROR", "api", requestContext.bodyshopId, {
|
||||
...requestContext,
|
||||
maxAttempts: CHATTER_MAX_RETRIES,
|
||||
status: 429,
|
||||
error: "rate limit retry exhausted"
|
||||
});
|
||||
|
||||
return { ok: false, status: 429, error: "rate limit retry exhausted", context: requestContext };
|
||||
}
|
||||
|
||||
function parseCompanyId(val) {
|
||||
const s = String(val ?? "").trim();
|
||||
if (!s) return null;
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
function pickShop(bodyshop) {
|
||||
return {
|
||||
bodyshopid: bodyshop.id,
|
||||
imexshopid: bodyshop.imexshopid,
|
||||
shopname: bodyshop.shopname,
|
||||
chatter_company_id: bodyshop.chatter_company_id,
|
||||
chatterid: bodyshop.chatterid
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function backoffMs(attempt) {
|
||||
const base = Math.min(30_000, 500 * 2 ** attempt);
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
return base + jitter;
|
||||
}
|
||||
|
||||
function retryDelayMsForError(error, attempt) {
|
||||
const retryAfterMs = Number(error?.retryAfterMs);
|
||||
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
|
||||
const jitter = Math.floor(Math.random() * 250);
|
||||
return Math.min(60_000, retryAfterMs + jitter);
|
||||
}
|
||||
return backoffMs(attempt);
|
||||
}
|
||||
|
||||
function formatChatterTimestamp(value, timezone) {
|
||||
if (!value) return undefined;
|
||||
|
||||
const hasValidTimezone = Boolean(timezone && moment.tz.zone(timezone));
|
||||
const parsed = hasValidTimezone ? moment(value).tz(timezone) : moment(value);
|
||||
if (!parsed.isValid()) return undefined;
|
||||
|
||||
// Keep a strict, Chatter-friendly timestamp without fractional seconds.
|
||||
return parsed.utc().format("YYYY-MM-DD HH:mm:ss[Z]");
|
||||
}
|
||||
|
||||
function createConcurrencyLimit(max) {
|
||||
let active = 0;
|
||||
const queue = [];
|
||||
|
||||
const next = () => {
|
||||
if (active >= max) return;
|
||||
const fn = queue.shift();
|
||||
if (!fn) return;
|
||||
active++;
|
||||
fn()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
active--;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
return (fn) =>
|
||||
new Promise((resolve, reject) => {
|
||||
queue.push(async () => {
|
||||
try {
|
||||
resolve(await fn());
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function getCompanyRateLimiter(companyId) {
|
||||
const key = String(companyId);
|
||||
const existing = companyRateLimiters.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const limiter = createTokenBucketRateLimiter({
|
||||
refillPerSecond: CHATTER_REQUESTS_PER_SECOND,
|
||||
capacity: CHATTER_BURST_CAPACITY
|
||||
});
|
||||
|
||||
companyRateLimiters.set(key, limiter);
|
||||
return limiter;
|
||||
}
|
||||
|
||||
function createTokenBucketRateLimiter({ refillPerSecond, capacity }) {
|
||||
let tokens = capacity;
|
||||
let lastRefillAt = Date.now();
|
||||
let pauseUntil = 0;
|
||||
let chain = Promise.resolve();
|
||||
|
||||
const refill = () => {
|
||||
const now = Date.now();
|
||||
const elapsedSec = (now - lastRefillAt) / 1000;
|
||||
if (elapsedSec <= 0) return;
|
||||
tokens = Math.min(capacity, tokens + elapsedSec * refillPerSecond);
|
||||
lastRefillAt = now;
|
||||
};
|
||||
|
||||
const waitForPermit = async () => {
|
||||
for (;;) {
|
||||
const now = Date.now();
|
||||
if (pauseUntil > now) {
|
||||
await sleep(pauseUntil - now);
|
||||
continue;
|
||||
}
|
||||
|
||||
refill();
|
||||
if (tokens >= 1) {
|
||||
tokens -= 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = 1 - tokens;
|
||||
const waitMs = Math.max(25, Math.ceil((missing / refillPerSecond) * 1000));
|
||||
await sleep(waitMs);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
acquire() {
|
||||
chain = chain.then(waitForPermit, waitForPermit);
|
||||
return chain;
|
||||
},
|
||||
pause(ms) {
|
||||
const n = Number(ms);
|
||||
if (!Number.isFinite(n) || n <= 0) return;
|
||||
pauseUntil = Math.max(pauseUntil, Date.now() + n);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getPositiveNumber(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
function getPositiveInteger(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a per-company Chatter API client, caching both the token and the client.
|
||||
*/
|
||||
async function getChatterApiClient(companyId, sessionUtils) {
|
||||
const key = String(companyId);
|
||||
|
||||
const existing = clientCache.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const apiToken = await getChatterApiTokenCached(companyId, sessionUtils);
|
||||
const chatterApi = new ChatterApiClient({ baseUrl: CHATTER_BASE_URL, apiToken });
|
||||
|
||||
clientCache.set(key, chatterApi);
|
||||
return chatterApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the per-company token from AWS Secrets Manager with Redis caching
|
||||
* SecretId: CHATTER_COMPANY_KEY_<companyId>
|
||||
*
|
||||
* Uses Redis caching + in-flight dedupe to avoid hammering Secrets Manager.
|
||||
*/
|
||||
async function getChatterApiTokenCached(companyId, sessionUtils) {
|
||||
const key = String(companyId ?? "").trim();
|
||||
if (!key) throw new Error("getChatterApiToken: companyId is required");
|
||||
|
||||
// Optional override for emergency/dev
|
||||
if (process.env.CHATTER_API_TOKEN) return process.env.CHATTER_API_TOKEN;
|
||||
|
||||
// Check Redis cache if sessionUtils is available
|
||||
if (sessionUtils?.getChatterToken) {
|
||||
const cachedToken = await sessionUtils.getChatterToken(key);
|
||||
if (cachedToken) {
|
||||
logger.log("chatter-api-get-token-cache-hit", "DEBUG", "api", null, { companyId: key });
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for in-flight requests
|
||||
const inflight = tokenInFlight.get(key);
|
||||
if (inflight) return inflight;
|
||||
|
||||
const p = (async () => {
|
||||
logger.log("chatter-api-get-token-cache-miss", "DEBUG", "api", null, { companyId: key });
|
||||
|
||||
// Fetch token from Secrets Manager using shared function
|
||||
const token = await getChatterApiToken(companyId);
|
||||
|
||||
// Store in Redis cache if sessionUtils is available
|
||||
if (sessionUtils?.setChatterToken) {
|
||||
await sessionUtils.setChatterToken(key, token);
|
||||
}
|
||||
|
||||
return token;
|
||||
})();
|
||||
|
||||
tokenInFlight.set(key, p);
|
||||
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
tokenInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ const converter = require("json-2-csv");
|
||||
const logger = require("../utils/logger");
|
||||
const fs = require("fs");
|
||||
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
|
||||
const { defaultProvider } = require("@aws-sdk/credential-provider-node");
|
||||
const { isString, isEmpty } = require("lodash");
|
||||
let Client = require("ssh2-sftp-client");
|
||||
|
||||
const client = require("../graphql-client/graphql-client").client;
|
||||
@@ -144,7 +146,18 @@ async function processBatch(shopsToProcess, start, end, allChatterObjects, allEr
|
||||
|
||||
async function getPrivateKey() {
|
||||
// Connect to AWS Secrets Manager
|
||||
const client = new SecretsManagerClient({ region: "ca-central-1" });
|
||||
const secretsClientOptions = {
|
||||
region: "ca-central-1",
|
||||
credentials: defaultProvider()
|
||||
};
|
||||
|
||||
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
|
||||
|
||||
if (isLocal) {
|
||||
secretsClientOptions.endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:4566`;
|
||||
}
|
||||
|
||||
const client = new SecretsManagerClient(secretsClientOptions);
|
||||
const command = new GetSecretValueCommand({ SecretId: "CHATTER_PRIVATE_KEY" });
|
||||
|
||||
logger.log("chatter-get-private-key", "DEBUG", "api", null, null);
|
||||
|
||||
@@ -9,4 +9,5 @@ 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.documentAnalytics = require("./analytics/documents").default;
|
||||
exports.chatterApi = require("./chatter-api").default;
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
178
server/data/queues/chatterApiQueue.js
Normal file
178
server/data/queues/chatterApiQueue.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const { Queue, Worker } = require("bullmq");
|
||||
const { registerCleanupTask } = require("../../utils/cleanupManager");
|
||||
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
|
||||
const devDebugLogger = require("../../utils/devDebugLogger");
|
||||
const moment = require("moment-timezone");
|
||||
const { sendServerEmail } = require("../../email/sendemail");
|
||||
|
||||
let chatterApiQueue;
|
||||
let chatterApiWorker;
|
||||
|
||||
/**
|
||||
* Initializes the Chatter API queue and worker.
|
||||
*
|
||||
* @param {Object} options - Configuration options for queue initialization.
|
||||
* @param {Object} options.pubClient - Redis client instance for queue communication.
|
||||
* @param {Object} options.logger - Logger instance for logging events and debugging.
|
||||
* @param {Function} options.processJob - Function to process the Chatter API job.
|
||||
* @param {Function} options.getChatterToken - Function to get Chatter token from Redis.
|
||||
* @param {Function} options.setChatterToken - Function to set Chatter token in Redis.
|
||||
* @returns {Queue} The initialized `chatterApiQueue` instance.
|
||||
*/
|
||||
const loadChatterApiQueue = async ({ pubClient, logger, processJob, getChatterToken, setChatterToken }) => {
|
||||
if (!chatterApiQueue) {
|
||||
const prefix = getBullMQPrefix();
|
||||
|
||||
devDebugLogger(`Initializing Chatter API Queue with prefix: ${prefix}`);
|
||||
|
||||
chatterApiQueue = new Queue("chatterApi", {
|
||||
prefix,
|
||||
connection: pubClient,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: false,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 60000 // 1 minute base delay
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chatterApiWorker = new Worker(
|
||||
"chatterApi",
|
||||
async (job) => {
|
||||
const { start, end, bodyshopIds, skipUpload } = job.data;
|
||||
|
||||
logger.log("chatter-api-queue-job-start", "INFO", "api", null, {
|
||||
jobId: job.id,
|
||||
start,
|
||||
end,
|
||||
bodyshopIds,
|
||||
skipUpload
|
||||
});
|
||||
|
||||
try {
|
||||
// Provide sessionUtils-like object with token caching functions
|
||||
const sessionUtils = {
|
||||
getChatterToken,
|
||||
setChatterToken
|
||||
};
|
||||
|
||||
const result = await processJob({
|
||||
start,
|
||||
end,
|
||||
bodyshopIds,
|
||||
skipUpload,
|
||||
sessionUtils
|
||||
});
|
||||
|
||||
logger.log("chatter-api-queue-job-complete", "INFO", "api", null, {
|
||||
jobId: job.id,
|
||||
totals: result.totals
|
||||
});
|
||||
|
||||
// Send email summary
|
||||
await sendServerEmail({
|
||||
subject: `Chatter API Report ${moment().format("MM-DD-YY")}`,
|
||||
text:
|
||||
`Totals:\n${JSON.stringify(result.totals, null, 2)}\n\n` +
|
||||
`Shop summaries:\n${JSON.stringify(result.allShopSummaries, null, 2)}\n\n` +
|
||||
`Errors:\n${JSON.stringify(result.allErrors, null, 2)}\n`
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.log("chatter-api-queue-job-error", "ERROR", "api", null, {
|
||||
jobId: job.id,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// Send error email
|
||||
await sendServerEmail({
|
||||
subject: `Chatter API Error ${moment().format("MM-DD-YY")}`,
|
||||
text: `Job failed:\n${error.message}\n\n${error.stack}`
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
prefix,
|
||||
connection: pubClient,
|
||||
concurrency: 1, // Process one job at a time
|
||||
lockDuration: 14400000 // 4 hours - allow long-running jobs
|
||||
}
|
||||
);
|
||||
|
||||
// Event handlers
|
||||
chatterApiWorker.on("completed", (job) => {
|
||||
devDebugLogger(`Chatter API job ${job.id} completed`);
|
||||
});
|
||||
|
||||
chatterApiWorker.on("failed", (job, err) => {
|
||||
logger.log("chatter-api-queue-job-failed", "ERROR", "api", null, {
|
||||
jobId: job?.id,
|
||||
message: err?.message,
|
||||
stack: err?.stack
|
||||
});
|
||||
});
|
||||
|
||||
chatterApiWorker.on("progress", (job, progress) => {
|
||||
devDebugLogger(`Chatter API job ${job.id} progress: ${progress}%`);
|
||||
});
|
||||
|
||||
// Register cleanup task
|
||||
const shutdown = async () => {
|
||||
devDebugLogger("Closing Chatter API queue worker...");
|
||||
await chatterApiWorker.close();
|
||||
devDebugLogger("Chatter API queue worker closed");
|
||||
};
|
||||
registerCleanupTask(shutdown);
|
||||
}
|
||||
|
||||
return chatterApiQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the initialized `chatterApiQueue` instance.
|
||||
*
|
||||
* @returns {Queue} The `chatterApiQueue` instance.
|
||||
* @throws {Error} If `chatterApiQueue` is not initialized.
|
||||
*/
|
||||
const getQueue = () => {
|
||||
if (!chatterApiQueue) {
|
||||
throw new Error("Chatter API queue not initialized. Ensure loadChatterApiQueue is called during bootstrap.");
|
||||
}
|
||||
return chatterApiQueue;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches a Chatter API job to the queue.
|
||||
*
|
||||
* @param {Object} options - Options for the job.
|
||||
* @param {string} options.start - Start date for the delivery window.
|
||||
* @param {string} options.end - End date for the delivery window.
|
||||
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process.
|
||||
* @param {boolean} options.skipUpload - Dry-run flag.
|
||||
* @returns {Promise<void>} Resolves when the job is added to the queue.
|
||||
*/
|
||||
const dispatchChatterApiJob = async ({ start, end, bodyshopIds, skipUpload }) => {
|
||||
const queue = getQueue();
|
||||
|
||||
const jobData = {
|
||||
start: start || moment().subtract(1, "days").startOf("day").toISOString(),
|
||||
end: end || moment().endOf("day").toISOString(),
|
||||
bodyshopIds: bodyshopIds || [],
|
||||
skipUpload: skipUpload || false
|
||||
};
|
||||
|
||||
await queue.add("process-chatter-api", jobData, {
|
||||
jobId: `chatter-api-${moment().format("YYYY-MM-DD-HHmmss")}`
|
||||
});
|
||||
|
||||
devDebugLogger(`Added Chatter API job to queue: ${JSON.stringify(jobData)}`);
|
||||
};
|
||||
|
||||
module.exports = { loadChatterApiQueue, getQueue, dispatchChatterApiJob };
|
||||
@@ -235,18 +235,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 +249,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 +310,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 +334,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,
|
||||
|
||||
@@ -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 = {}) {
|
||||
@@ -198,7 +198,7 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
);
|
||||
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 +207,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 +225,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 +265,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 +280,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 +298,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) {
|
||||
//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 +336,11 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
stack: error.stack,
|
||||
data: error.errorData
|
||||
});
|
||||
await InsertFailedExportLog({ socket, JobData, error: error.errorData?.issues || [JSON.stringify(error.errorData)] });
|
||||
await InsertFailedExportLog({
|
||||
socket,
|
||||
JobData,
|
||||
error: error.errorData?.issues || [JSON.stringify(error.errorData)]
|
||||
});
|
||||
} finally {
|
||||
//Ensure we always insert logEvents
|
||||
//GQL to insert logevents.
|
||||
@@ -374,7 +369,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 +424,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(replaceSpecialRegex, "").toUpperCase()],
|
||||
["lastName", JobData.ownr_ln?.replace(replaceSpecialRegex, "").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 +452,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 +461,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(replaceSpecialRegex, "").toUpperCase(),
|
||||
//"middleName": "",
|
||||
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(replaceSpecialRegex, "").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 +487,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 +501,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 +537,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 +688,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 +732,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 +750,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 +897,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 +933,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 +1058,7 @@ async function InsertDmsStartWip({ socket, redisHelpers, JobData }) {
|
||||
// transID: "",
|
||||
// userID: "partprgm",
|
||||
// userName: "PROGRAM, PARTNER"
|
||||
},
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1091,9 +1088,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 +1111,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 +1131,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 +1155,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 +1225,7 @@ async function PostDmsBatchWip({ socket, redisHelpers, JobData }) {
|
||||
opCode: "P",
|
||||
transID: DMSTransHeader.transID,
|
||||
transWipReqList: await GenerateTransWips({ socket, redisHelpers, JobData })
|
||||
},
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1256,7 +1253,7 @@ async function QueryDmsErrWip({ socket, redisHelpers, JobData }) {
|
||||
socket,
|
||||
jobid: JobData.id,
|
||||
requestPathParams: DMSTransHeader.transID,
|
||||
body: {},
|
||||
body: {}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1286,7 +1283,7 @@ async function DeleteDmsWip({ socket, redisHelpers, JobData }) {
|
||||
body: {
|
||||
opCode: "D",
|
||||
transID: DMSTransHeader.transID
|
||||
},
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1298,9 +1295,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 +1321,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 +1341,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;
|
||||
|
||||
@@ -827,13 +827,21 @@ exports.AUTOHOUSE_QUERY = `query AUTOHOUSE_EXPORT($start: timestamptz, $bodyshop
|
||||
quantity
|
||||
}
|
||||
}
|
||||
timetickets {
|
||||
timetickets(where: {cost_center: {_neq: "timetickets.labels.shift"}}) {
|
||||
id
|
||||
rate
|
||||
ciecacode
|
||||
cost_center
|
||||
actualhrs
|
||||
productivehrs
|
||||
flat_rate
|
||||
employeeid
|
||||
employee {
|
||||
employee_number
|
||||
flat_rate
|
||||
first_name
|
||||
last_name
|
||||
}
|
||||
}
|
||||
area_of_damage
|
||||
employee_prep_rel {
|
||||
@@ -1612,6 +1620,9 @@ exports.GET_JOB_BY_PK = `query GET_JOB_BY_PK($id: uuid!) {
|
||||
rate_ats
|
||||
flat_rate_ats
|
||||
rate_ats_flat
|
||||
dms_id
|
||||
dms_customer_id
|
||||
dms_advisor_id
|
||||
joblines(where: { removed: { _eq: false } }){
|
||||
id
|
||||
line_no
|
||||
@@ -1911,10 +1922,35 @@ exports.GET_AUTOHOUSE_SHOPS = `query GET_AUTOHOUSE_SHOPS {
|
||||
}`;
|
||||
|
||||
exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS {
|
||||
bodyshops(where: {chatterid: {_is_null: false}, _or: {chatterid: {_neq: ""}}}){
|
||||
bodyshops(
|
||||
where: {
|
||||
chatterid: { _is_null: false, _neq: "" }
|
||||
_or: [
|
||||
{ chatter_company_id: { _is_null: true } }
|
||||
{ chatter_company_id: { _eq: "" } }
|
||||
]
|
||||
}
|
||||
) {
|
||||
id
|
||||
shopname
|
||||
chatterid
|
||||
chatter_company_id
|
||||
imexshopid
|
||||
timezone
|
||||
}
|
||||
}`;
|
||||
|
||||
exports.GET_CHATTER_SHOPS_WITH_COMPANY = `query GET_CHATTER_SHOPS_WITH_COMPANY {
|
||||
bodyshops(
|
||||
where: {
|
||||
chatterid: { _is_null: false, _neq: "" }
|
||||
chatter_company_id: { _is_null: false, _neq: "" }
|
||||
}
|
||||
) {
|
||||
id
|
||||
shopname
|
||||
chatterid
|
||||
chatter_company_id
|
||||
imexshopid
|
||||
timezone
|
||||
}
|
||||
@@ -3203,9 +3239,12 @@ exports.UPDATE_USER_FCM_TOKENS_BY_EMAIL = /* GraphQL */ `
|
||||
}
|
||||
`;
|
||||
|
||||
exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!) {
|
||||
update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id }) {
|
||||
exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!, $dms_customer_id: String, $dms_advisor_id: String, $kmin: Int) {
|
||||
update_jobs_by_pk(pk_columns: { id: $id }, _set: { dms_id: $dms_id, dms_customer_id: $dms_customer_id, dms_advisor_id: $dms_advisor_id, kmin: $kmin }) {
|
||||
id
|
||||
dms_id
|
||||
dms_customer_id
|
||||
dms_advisor_id
|
||||
kmin
|
||||
}
|
||||
}`;
|
||||
|
||||
@@ -13,6 +13,9 @@ const { DiscountNotAlreadyCounted } = InstanceManager({
|
||||
// Dinero.globalLocale = "en-CA";
|
||||
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||
|
||||
const isImEX = InstanceManager({ imex: true, rome: false });
|
||||
const isRome = InstanceManager({ imex: false, rome: true });
|
||||
|
||||
async function JobCosting(req, res) {
|
||||
const { jobid } = req.body;
|
||||
|
||||
@@ -266,9 +269,7 @@ function GenerateCostingData(job) {
|
||||
);
|
||||
|
||||
const materialsHours = { mapaHrs: 0, mashHrs: 0 };
|
||||
let mashOpCodes = InstanceManager({
|
||||
rome: ParseCalopCode(job.materials["MASH"]?.cal_opcode)
|
||||
});
|
||||
let mashOpCodes = isRome && ParseCalopCode(job.materials["MASH"]?.cal_opcode);
|
||||
let hasMapaLine = false;
|
||||
let hasMashLine = false;
|
||||
|
||||
@@ -355,7 +356,7 @@ function GenerateCostingData(job) {
|
||||
if (val.mod_lbr_ty === "LAR") {
|
||||
materialsHours.mapaHrs += val.mod_lb_hrs || 0;
|
||||
}
|
||||
if (InstanceManager({ imex: true, rome: false })) {
|
||||
if (isImEX) {
|
||||
if (val.mod_lbr_ty !== "LAR") {
|
||||
materialsHours.mashHrs += val.mod_lb_hrs || 0;
|
||||
}
|
||||
@@ -363,7 +364,7 @@ function GenerateCostingData(job) {
|
||||
if (val.mod_lbr_ty !== "LAR" && mashOpCodes.includes(val.lbr_op)) {
|
||||
materialsHours.mashHrs += val.mod_lb_hrs || 0;
|
||||
}
|
||||
if (val.manual_line === true && !mashOpCodes.includes(val.lbr_op) && val.mod_lbr_ty !== "LAR" ) {
|
||||
if (val.manual_line === true && !mashOpCodes.includes(val.lbr_op) && val.mod_lbr_ty !== "LAR") {
|
||||
materialsHours.mashHrs += val.mod_lb_hrs || 0;
|
||||
}
|
||||
}
|
||||
@@ -525,14 +526,15 @@ function GenerateCostingData(job) {
|
||||
}
|
||||
}
|
||||
|
||||
if (InstanceManager({ rome: true })) {
|
||||
if (isRome) {
|
||||
if (convertedKey) {
|
||||
const correspondingCiecaStlTotalLine = job.cieca_stl?.data.find(
|
||||
(c) => c.ttl_typecd === convertedKey.toUpperCase()
|
||||
);
|
||||
if (
|
||||
correspondingCiecaStlTotalLine &&
|
||||
Math.abs(jobLineTotalsByProfitCenter.parts[key].getAmount() - correspondingCiecaStlTotalLine.ttl_amt * 100) > 1
|
||||
Math.abs(jobLineTotalsByProfitCenter.parts[key].getAmount() - correspondingCiecaStlTotalLine.ttl_amt * 100) >
|
||||
1
|
||||
) {
|
||||
jobLineTotalsByProfitCenter.parts[key] = jobLineTotalsByProfitCenter.parts[key].add(disc).add(markup);
|
||||
}
|
||||
@@ -545,7 +547,7 @@ function GenerateCostingData(job) {
|
||||
if (
|
||||
job.materials["MAPA"] &&
|
||||
job.materials["MAPA"].cal_maxdlr !== undefined &&
|
||||
job.materials["MAPA"].cal_maxdlr >= 0
|
||||
(isRome ? job.materials["MAPA"].cal_maxdlr >= 0 : job.materials["MAPA"].cal_maxdlr > 0)
|
||||
) {
|
||||
//It has an upper threshhold.
|
||||
threshold = Dinero({
|
||||
@@ -595,7 +597,7 @@ function GenerateCostingData(job) {
|
||||
if (
|
||||
job.materials["MASH"] &&
|
||||
job.materials["MASH"].cal_maxdlr !== undefined &&
|
||||
job.materials["MASH"].cal_maxdlr >= 0
|
||||
(isRome ? job.materials["MASH"].cal_maxdlr >= 0 : job.materials["MASH"].cal_maxdlr > 0)
|
||||
) {
|
||||
//It has an upper threshhold.
|
||||
threshold = Dinero({
|
||||
@@ -641,7 +643,7 @@ function GenerateCostingData(job) {
|
||||
}
|
||||
}
|
||||
|
||||
if (InstanceManager({ imex: false, rome: true })) {
|
||||
if (isRome) {
|
||||
const stlTowing = job.cieca_stl?.data.find((c) => c.ttl_type === "OTTW");
|
||||
const stlStorage = job.cieca_stl?.data.find((c) => c.ttl_type === "OTST");
|
||||
|
||||
|
||||
12
server/routes/chatterRoutes.js
Normal file
12
server/routes/chatterRoutes.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const express = require("express");
|
||||
const createLocation = require("../chatter/createLocation");
|
||||
const router = express.Router();
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const validateAdminMiddleware = require("../middleware/validateAdminMiddleware");
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
router.use(validateAdminMiddleware);
|
||||
|
||||
router.post("/create-location", createLocation);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,10 +1,21 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax, carfaxRps } = require("../data/data");
|
||||
const {
|
||||
autohouse,
|
||||
claimscorp,
|
||||
chatter,
|
||||
kaizen,
|
||||
usageReport,
|
||||
podium,
|
||||
carfax,
|
||||
carfaxRps,
|
||||
chatterApi
|
||||
} = require("../data/data");
|
||||
|
||||
router.post("/ah", autohouse);
|
||||
router.post("/cc", claimscorp);
|
||||
router.post("/chatter", chatter);
|
||||
router.post("/chatter-api", chatterApi);
|
||||
router.post("/kaizen", kaizen);
|
||||
router.post("/usagereport", usageReport);
|
||||
router.post("/podium", podium);
|
||||
|
||||
@@ -86,8 +86,9 @@ const buildMessageJSONString = ({ error, classification, result, fallback }) =>
|
||||
/**
|
||||
* Success: mark job exported + (optionally) insert a success log.
|
||||
* Uses queries.MARK_JOB_EXPORTED (same shape as Fortellis/PBS).
|
||||
* @param {boolean} isEarlyRo - If true, only logs success but does NOT change job status (for early RO creation)
|
||||
*/
|
||||
const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaExtra = {} }) => {
|
||||
const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaExtra = {}, isEarlyRo = false }) => {
|
||||
const endpoint = process.env.GRAPHQL_ENDPOINT;
|
||||
if (!endpoint) throw new Error("GRAPHQL_ENDPOINT not configured");
|
||||
const token = getAuthToken(socket);
|
||||
@@ -96,11 +97,40 @@ const markRRExportSuccess = async ({ socket, jobId, job, bodyshop, result, metaE
|
||||
const client = new GraphQLClient(endpoint, {});
|
||||
client.setHeaders({ Authorization: `Bearer ${token}` });
|
||||
|
||||
const meta = buildRRExportMeta({ result, extra: metaExtra });
|
||||
|
||||
// For early RO, we only insert a log but do NOT change job status or mark as exported
|
||||
if (isEarlyRo) {
|
||||
try {
|
||||
await client.request(queries.INSERT_EXPORT_LOG, {
|
||||
logs: [
|
||||
{
|
||||
bodyshopid: bodyshop?.id || job?.bodyshop?.id,
|
||||
jobid: jobId,
|
||||
successful: true,
|
||||
useremail: socket?.user?.email || null,
|
||||
metadata: meta,
|
||||
message: buildMessageJSONString({ result, fallback: "RR early RO created" })
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", "RR early RO: success log inserted (job status unchanged)", {
|
||||
jobId
|
||||
});
|
||||
} catch (e) {
|
||||
CreateRRLogEvent(socket, "ERROR", "RR early RO: failed to insert success log", {
|
||||
jobId,
|
||||
error: e?.message
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Full export: mark job as exported and insert success log
|
||||
const exportedStatus =
|
||||
job?.bodyshop?.md_ro_statuses?.default_exported || bodyshop?.md_ro_statuses?.default_exported || "Exported*";
|
||||
|
||||
const meta = buildRRExportMeta({ result, extra: metaExtra });
|
||||
|
||||
try {
|
||||
await client.request(queries.MARK_JOB_EXPORTED, {
|
||||
jobId,
|
||||
|
||||
@@ -56,7 +56,324 @@ const deriveRRStatus = (rrRes = {}) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Step 1: Export a job to RR as a new Repair Order.
|
||||
* Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story).
|
||||
* Used when creating RO from convert button or admin page before full job export.
|
||||
* @param args
|
||||
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
|
||||
*/
|
||||
const createMinimalRRRepairOrder = async (args) => {
|
||||
const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId } = args || {};
|
||||
|
||||
if (!bodyshop) throw new Error("createMinimalRRRepairOrder: bodyshop is required");
|
||||
if (!job) throw new Error("createMinimalRRRepairOrder: job is required");
|
||||
if (advisorNo == null || String(advisorNo).trim() === "") {
|
||||
throw new Error("createMinimalRRRepairOrder: advisorNo is required for RR");
|
||||
}
|
||||
|
||||
// Resolve customer number (accept multiple shapes)
|
||||
const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo;
|
||||
if (!selected) throw new Error("createMinimalRRRepairOrder: selectedCustomer.custNo/customerNo is required");
|
||||
|
||||
const { client, opts } = buildClientAndOpts(bodyshop);
|
||||
|
||||
// For early RO creation we always "Insert" (create minimal RO)
|
||||
const finalOpts = {
|
||||
...opts,
|
||||
envelope: {
|
||||
...(opts?.envelope || {}),
|
||||
sender: {
|
||||
...(opts?.envelope?.sender || {}),
|
||||
task: "BSMRO",
|
||||
referenceId: "Insert"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
||||
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
|
||||
|
||||
// Build minimal RO payload - just header, no allocations/parts/labor
|
||||
const cleanVin =
|
||||
(job?.v_vin || "")
|
||||
.toString()
|
||||
.replace(/[^A-Za-z0-9]/g, "")
|
||||
.toUpperCase()
|
||||
.slice(0, 17) || undefined;
|
||||
|
||||
// Resolve mileage - must be a positive number
|
||||
let mileageIn = txEnvelope?.kmin ?? job?.kmin ?? null;
|
||||
if (mileageIn != null) {
|
||||
mileageIn = parseInt(mileageIn, 10);
|
||||
if (isNaN(mileageIn) || mileageIn < 0) {
|
||||
mileageIn = null;
|
||||
}
|
||||
}
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", "Resolved mileage for early RO", {
|
||||
txEnvelopeKmin: txEnvelope?.kmin,
|
||||
jobKmin: job?.kmin,
|
||||
resolvedMileageIn: mileageIn
|
||||
});
|
||||
|
||||
const payload = {
|
||||
customerNo: String(selected),
|
||||
advisorNo: String(advisorNo),
|
||||
vin: cleanVin,
|
||||
departmentType: "B",
|
||||
outsdRoNo: job?.ro_number || job?.id || undefined,
|
||||
estimate: {
|
||||
parts: "0",
|
||||
labor: "0",
|
||||
total: "0.00"
|
||||
}
|
||||
};
|
||||
|
||||
// Only add mileageIn if we have a valid value
|
||||
if (mileageIn != null && mileageIn >= 0) {
|
||||
payload.mileageIn = mileageIn;
|
||||
}
|
||||
|
||||
// Add optional fields if present
|
||||
if (story) {
|
||||
payload.roComment = story;
|
||||
}
|
||||
if (makeOverride) {
|
||||
payload.makeOverride = makeOverride;
|
||||
}
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
|
||||
payload
|
||||
});
|
||||
|
||||
const response = await client.createRepairOrder(payload, finalOpts);
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", "RR minimal Repair Order created", {
|
||||
payload,
|
||||
response
|
||||
});
|
||||
|
||||
const data = response?.data || null;
|
||||
const statusBlocks = response?.statusBlocks || {};
|
||||
const roStatus = deriveRRStatus(response);
|
||||
|
||||
const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
|
||||
|
||||
let success = false;
|
||||
|
||||
if (statusUpper) {
|
||||
// Treat explicit FAILURE / ERROR as hard failures
|
||||
success = !["FAILURE", "ERROR"].includes(statusUpper);
|
||||
} else if (typeof response?.success === "boolean") {
|
||||
// Fallback to library boolean if no explicit status
|
||||
success = response.success;
|
||||
} else if (roStatus?.status) {
|
||||
success = String(roStatus.status).toUpperCase() === "SUCCESS";
|
||||
}
|
||||
|
||||
// Extract canonical roNo for later updates
|
||||
const roNo = data?.dmsRoNo ?? data?.outsdRoNo ?? roStatus?.dmsRoNo ?? null;
|
||||
|
||||
return {
|
||||
success,
|
||||
data,
|
||||
roStatus,
|
||||
statusBlocks,
|
||||
customerNo: String(selected),
|
||||
svId,
|
||||
roNo,
|
||||
xml: response?.xml // expose XML for logging/diagnostics
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Full Data Update: Update an existing RR Repair Order with complete job data (allocations, parts, labor).
|
||||
* Used during DMS post form when an early RO was already created.
|
||||
* @param args
|
||||
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
|
||||
*/
|
||||
const updateRRRepairOrderWithFullData = async (args) => {
|
||||
const { bodyshop, job, advisorNo, selectedCustomer, txEnvelope, socket, svId, roNo } = args || {};
|
||||
|
||||
if (!bodyshop) throw new Error("updateRRRepairOrderWithFullData: bodyshop is required");
|
||||
if (!job) throw new Error("updateRRRepairOrderWithFullData: job is required");
|
||||
if (advisorNo == null || String(advisorNo).trim() === "") {
|
||||
throw new Error("updateRRRepairOrderWithFullData: advisorNo is required for RR");
|
||||
}
|
||||
if (!roNo) throw new Error("updateRRRepairOrderWithFullData: roNo is required for update");
|
||||
|
||||
// Resolve customer number (accept multiple shapes)
|
||||
const selected = selectedCustomer?.customerNo || selectedCustomer?.custNo;
|
||||
if (!selected) throw new Error("updateRRRepairOrderWithFullData: selectedCustomer.custNo/customerNo is required");
|
||||
|
||||
const { client, opts } = buildClientAndOpts(bodyshop);
|
||||
|
||||
// For full data update after early RO, we still use "Insert" referenceId
|
||||
// because we're inserting the job operations for the first time
|
||||
const finalOpts = {
|
||||
...opts,
|
||||
envelope: {
|
||||
...(opts?.envelope || {}),
|
||||
sender: {
|
||||
...(opts?.envelope?.sender || {}),
|
||||
task: "BSMRO",
|
||||
referenceId: "Insert"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
||||
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
|
||||
|
||||
// Optional RR OpCode segments coming from the FE (RRPostForm)
|
||||
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
|
||||
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
|
||||
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
|
||||
|
||||
// RR-only extras
|
||||
let rrCentersConfig = null;
|
||||
let allocations = null;
|
||||
let opCode = null;
|
||||
|
||||
// 1) Responsibility center config (for visibility / debugging)
|
||||
try {
|
||||
rrCentersConfig = extractRrResponsibilityCenters(bodyshop);
|
||||
|
||||
CreateRRLogEvent(socket, "SILLY", "RR responsibility centers resolved", {
|
||||
hasCenters: !!bodyshop.md_responsibility_centers,
|
||||
profitCenters: Object.keys(rrCentersConfig?.profitsByName || {}),
|
||||
costCenters: Object.keys(rrCentersConfig?.costsByName || {}),
|
||||
dmsCostDefaults: rrCentersConfig?.dmsCostDefaults || {},
|
||||
dmsProfitDefaults: rrCentersConfig?.dmsProfitDefaults || {}
|
||||
});
|
||||
} catch (e) {
|
||||
CreateRRLogEvent(socket, "ERROR", "Failed to resolve RR responsibility centers", {
|
||||
message: e?.message,
|
||||
stack: e?.stack
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Allocations (sales + cost by center, with rr_* metadata already attached)
|
||||
try {
|
||||
const allocResult = await CdkCalculateAllocations(socket, job.id);
|
||||
|
||||
// We only need the per-center job allocations for RO.GOG / ROLABOR.
|
||||
allocations = Array.isArray(allocResult?.jobAllocations) ? allocResult.jobAllocations : [];
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", "RR allocations resolved for update", {
|
||||
hasAllocations: allocations.length > 0,
|
||||
count: allocations.length,
|
||||
allocationsPreview: allocations.slice(0, 2).map((a) => ({
|
||||
type: a?.type,
|
||||
code: a?.code,
|
||||
laborSale: a?.laborSale,
|
||||
laborCost: a?.laborCost,
|
||||
partsSale: a?.partsSale,
|
||||
partsCost: a?.partsCost
|
||||
})),
|
||||
taxAllocCount: Array.isArray(allocResult?.taxAllocArray) ? allocResult.taxAllocArray.length : 0,
|
||||
ttlAdjCount: Array.isArray(allocResult?.ttlAdjArray) ? allocResult.ttlAdjArray.length : 0,
|
||||
ttlTaxAdjCount: Array.isArray(allocResult?.ttlTaxAdjArray) ? allocResult.ttlTaxAdjArray.length : 0
|
||||
});
|
||||
} catch (e) {
|
||||
CreateRRLogEvent(socket, "ERROR", "Failed to calculate RR allocations", {
|
||||
message: e?.message,
|
||||
stack: e?.stack
|
||||
});
|
||||
// Proceed with a header-only update if allocations fail.
|
||||
allocations = [];
|
||||
}
|
||||
|
||||
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
|
||||
|
||||
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
|
||||
|
||||
// If the FE only sends segments, combine them here.
|
||||
if (!opCodeOverride && (opPrefix || opBase || opSuffix)) {
|
||||
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
|
||||
if (combined) {
|
||||
opCodeOverride = combined;
|
||||
}
|
||||
}
|
||||
|
||||
if (opCodeOverride || resolvedBaseOpCode) {
|
||||
opCode = String(opCodeOverride || resolvedBaseOpCode).trim() || null;
|
||||
}
|
||||
|
||||
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
|
||||
opCode,
|
||||
baseFromConfig: resolvedBaseOpCode,
|
||||
opPrefix,
|
||||
opBase,
|
||||
opSuffix
|
||||
});
|
||||
|
||||
// Build full RO payload for update with allocations
|
||||
const payload = buildRRRepairOrderPayload({
|
||||
bodyshop,
|
||||
job,
|
||||
selectedCustomer: { customerNo: String(selected), custNo: String(selected) },
|
||||
advisorNo: String(advisorNo),
|
||||
story,
|
||||
makeOverride,
|
||||
allocations,
|
||||
opCode
|
||||
});
|
||||
|
||||
// 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,
|
||||
hasRogg: !!payload.rogg,
|
||||
payload
|
||||
});
|
||||
|
||||
// Use createRepairOrder (not update) with the roNo to link to the existing early RO
|
||||
// Reynolds will merge this with the existing RO header
|
||||
const response = await client.createRepairOrder(payload, finalOpts);
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", "RR Repair Order full data sent", {
|
||||
payload,
|
||||
response
|
||||
});
|
||||
|
||||
const data = response?.data || null;
|
||||
const statusBlocks = response?.statusBlocks || {};
|
||||
const roStatus = deriveRRStatus(response);
|
||||
|
||||
const statusUpper = roStatus?.status ? String(roStatus.status).toUpperCase() : null;
|
||||
|
||||
let success = false;
|
||||
|
||||
if (statusUpper) {
|
||||
success = !["FAILURE", "ERROR"].includes(statusUpper);
|
||||
} else if (typeof response?.success === "boolean") {
|
||||
success = response.success;
|
||||
} else if (roStatus?.status) {
|
||||
success = String(roStatus.status).toUpperCase() === "SUCCESS";
|
||||
}
|
||||
|
||||
return {
|
||||
success,
|
||||
data,
|
||||
roStatus,
|
||||
statusBlocks,
|
||||
customerNo: String(selected),
|
||||
svId,
|
||||
roNo: String(roNo),
|
||||
xml: response?.xml
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* LEGACY: Step 1: Export a job to RR as a new Repair Order with full data.
|
||||
* This is the original function - kept for backward compatibility if shops don't use early RO creation.
|
||||
* @param args
|
||||
* @returns {Promise<{success: boolean, data: *, roStatus: {status: *, statusCode: *|undefined, message}, statusBlocks: *|{}, customerNo: string, svId: *, roNo: *, xml: *}>}
|
||||
*/
|
||||
@@ -315,4 +632,10 @@ const finalizeRRRepairOrder = async (args) => {
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { exportJobToRR, finalizeRRRepairOrder, deriveRRStatus };
|
||||
module.exports = {
|
||||
exportJobToRR,
|
||||
createMinimalRRRepairOrder,
|
||||
updateRRRepairOrderWithFullData,
|
||||
finalizeRRRepairOrder,
|
||||
deriveRRStatus
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
const CreateRRLogEvent = require("./rr-logger-event");
|
||||
const { rrCombinedSearch, rrGetAdvisors, buildClientAndOpts } = require("./rr-lookup");
|
||||
const { QueryJobData, buildRogogFromAllocations, buildRolaborFromRogog } = require("./rr-job-helpers");
|
||||
const { exportJobToRR, finalizeRRRepairOrder } = require("./rr-job-export");
|
||||
const {
|
||||
exportJobToRR,
|
||||
createMinimalRRRepairOrder,
|
||||
updateRRRepairOrderWithFullData,
|
||||
finalizeRRRepairOrder
|
||||
} = require("./rr-job-export");
|
||||
const RRCalculateAllocations = require("./rr-calculate-allocations").default;
|
||||
const { createRRCustomer } = require("./rr-customers");
|
||||
const { ensureRRServiceVehicle } = require("./rr-service-vehicles");
|
||||
@@ -124,13 +129,15 @@ const getBodyshopForSocket = async ({ bodyshopId, socket }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* GraphQL mutation to set job.dms_id
|
||||
* GraphQL mutation to set job.dms_id, dms_customer_id, and dms_advisor_id
|
||||
* @param socket
|
||||
* @param jobId
|
||||
* @param dmsId
|
||||
* @param dmsCustomerId
|
||||
* @param dmsAdvisorId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
|
||||
const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId, mileageIn }) => {
|
||||
if (!jobId || !dmsId) {
|
||||
CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", {
|
||||
jobId,
|
||||
@@ -149,16 +156,28 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => {
|
||||
const client = new GraphQLClient(endpoint, {});
|
||||
await client
|
||||
.setHeaders({ Authorization: `Bearer ${token}` })
|
||||
.request(queries.SET_JOB_DMS_ID, { id: jobId, dms_id: String(dmsId) });
|
||||
.request(queries.SET_JOB_DMS_ID, {
|
||||
id: jobId,
|
||||
dms_id: String(dmsId),
|
||||
dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null,
|
||||
dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : null,
|
||||
kmin: mileageIn != null && mileageIn > 0 ? parseInt(mileageIn, 10) : null
|
||||
});
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", {
|
||||
jobId,
|
||||
dmsId: String(dmsId)
|
||||
dmsId: String(dmsId),
|
||||
dmsCustomerId,
|
||||
dmsAdvisorId,
|
||||
mileageIn
|
||||
});
|
||||
} catch (err) {
|
||||
CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", {
|
||||
jobId,
|
||||
dmsId,
|
||||
dmsCustomerId,
|
||||
dmsAdvisorId,
|
||||
mileageIn,
|
||||
message: err?.message || String(err),
|
||||
stack: err?.stack
|
||||
});
|
||||
@@ -373,7 +392,504 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}) => {
|
||||
/**
|
||||
* NEW: Early RO Creation Event
|
||||
* Creates a minimal RO from convert button or admin page with customer selection,
|
||||
* advisor, mileage, and optional story/overrides.
|
||||
*/
|
||||
socket.on("rr-create-early-ro", async ({ jobid, jobId, txEnvelope } = {}) => {
|
||||
const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
|
||||
|
||||
try {
|
||||
if (!rid) throw new Error("RR early create: jobid required");
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1} Received RR early RO creation request`, { jobid: rid });
|
||||
|
||||
// Cache txEnvelope (contains advisor, mileage, story, overrides)
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(rid),
|
||||
RRCacheEnums.txEnvelope,
|
||||
txEnvelope || {},
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.1} Cached txEnvelope`, { hasTxEnvelope: !!txEnvelope });
|
||||
|
||||
const job = await QueryJobData({ redisHelpers }, rid);
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(rid),
|
||||
RRCacheEnums.JobData,
|
||||
job,
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.2} Cached JobData`, { vin: job?.v_vin, ro: job?.ro_number });
|
||||
|
||||
const adv = readAdvisorNo(
|
||||
{ txEnvelope },
|
||||
await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo)
|
||||
);
|
||||
|
||||
if (adv) {
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(rid),
|
||||
RRCacheEnums.AdvisorNo,
|
||||
String(adv),
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-1.3} Cached advisorNo`, { advisorNo: String(adv) });
|
||||
}
|
||||
|
||||
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
|
||||
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-2} Running multi-search (Full Name + VIN)`);
|
||||
|
||||
const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
|
||||
const decorated = candidates.map((c) => (c.vinOwner != null ? c : { ...c, vinOwner: !!c.isVehicleOwner }));
|
||||
|
||||
socket.emit("rr-select-customer", decorated);
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-2.1} Emitted rr-select-customer for early RO`, {
|
||||
count: decorated.length,
|
||||
anyOwner: decorated.some((c) => c.vinOwner || c.isVehicleOwner)
|
||||
});
|
||||
} catch (error) {
|
||||
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (prepare)`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
jobid: rid
|
||||
});
|
||||
|
||||
try {
|
||||
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: error.message });
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* NEW: Early RO Customer Selected Event
|
||||
* Handles customer selection for early RO creation and creates minimal RO.
|
||||
*/
|
||||
socket.on("rr-early-customer-selected", async ({ jobid, jobId, selectedCustomerId, custNo, create } = {}, ack) => {
|
||||
const rid = resolveJobId(jobid || jobId, { jobid, jobId }, null);
|
||||
let bodyshop = null;
|
||||
let job = null;
|
||||
let createdCustomer = false;
|
||||
|
||||
try {
|
||||
if (!rid) throw new Error("jobid required");
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3} rr-early-customer-selected`, {
|
||||
jobid: rid,
|
||||
custNo,
|
||||
selectedCustomerId,
|
||||
create: !!create
|
||||
});
|
||||
|
||||
const ns = getTransactionType(rid);
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0a} Raw parameters received`, {
|
||||
custNo: custNo,
|
||||
custNoType: typeof custNo,
|
||||
selectedCustomerId: selectedCustomerId,
|
||||
create: create
|
||||
});
|
||||
|
||||
let selectedCustNo =
|
||||
(custNo && String(custNo)) ||
|
||||
(selectedCustomerId && String(selectedCustomerId)) ||
|
||||
(await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.SelectedCustomer));
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0b} After initial resolution`, {
|
||||
selectedCustNo,
|
||||
selectedCustNoType: typeof selectedCustNo
|
||||
});
|
||||
|
||||
// Filter out invalid values
|
||||
if (selectedCustNo === "undefined" || selectedCustNo === "null" || (selectedCustNo && selectedCustNo.trim() === "")) {
|
||||
selectedCustNo = null;
|
||||
}
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.0} Resolved customer selection`, {
|
||||
selectedCustNo,
|
||||
willCreateNew: create === true || !selectedCustNo
|
||||
});
|
||||
|
||||
job = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.JobData);
|
||||
|
||||
const txEnvelope = (await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.txEnvelope)) || {};
|
||||
|
||||
if (!job) throw new Error("Staged JobData not found (run rr-create-early-ro first).");
|
||||
|
||||
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
|
||||
|
||||
bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
|
||||
|
||||
// Create customer (if requested or none chosen)
|
||||
if (create === true || !selectedCustNo) {
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.1} Creating RR customer`);
|
||||
|
||||
const created = await createRRCustomer({ bodyshop, job, socket });
|
||||
selectedCustNo = String(created?.customerNo || "");
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.2} Created customer`, {
|
||||
custNo: selectedCustNo,
|
||||
createdCustomerNo: created?.customerNo
|
||||
});
|
||||
|
||||
if (!selectedCustNo || selectedCustNo === "undefined" || selectedCustNo.trim() === "") {
|
||||
throw new Error("RR create customer returned no valid custNo");
|
||||
}
|
||||
|
||||
createdCustomer = true;
|
||||
}
|
||||
|
||||
// VIN owner pre-check
|
||||
try {
|
||||
const vehQ = makeVehicleSearchPayloadFromJob(job);
|
||||
if (vehQ && vehQ.kind === "vin" && job?.v_vin) {
|
||||
const vinResponse = await rrCombinedSearch(bodyshop, vehQ);
|
||||
|
||||
CreateRRLogEvent(socket, "SILLY", `VIN owner pre-check response (early RO)`, { response: vinResponse });
|
||||
|
||||
const vinBlocks = Array.isArray(vinResponse?.data) ? vinResponse.data : [];
|
||||
|
||||
try {
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
ns,
|
||||
RRCacheEnums.VINCandidates,
|
||||
vinBlocks,
|
||||
defaultRRTTL
|
||||
);
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
const ownersSet = ownersFromVinBlocks(vinBlocks, job.v_vin);
|
||||
|
||||
if (ownersSet?.size) {
|
||||
const sel = String(selectedCustNo);
|
||||
|
||||
if (!ownersSet.has(sel)) {
|
||||
const [existingOwner] = Array.from(ownersSet).map(String);
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.2a} VIN exists; switching to VIN owner`, {
|
||||
vin: job.v_vin,
|
||||
selected: sel,
|
||||
existingOwner
|
||||
});
|
||||
selectedCustNo = existingOwner;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
CreateRRLogEvent(socket, "WARN", `VIN owner pre-check failed; continuing with selected customer (early RO)`, {
|
||||
error: e?.message
|
||||
});
|
||||
}
|
||||
|
||||
// Cache final/effective customer selection
|
||||
const effectiveCustNo = String(selectedCustNo);
|
||||
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
ns,
|
||||
RRCacheEnums.SelectedCustomer,
|
||||
effectiveCustNo,
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-3.3} Cached selected customer`, { custNo: effectiveCustNo });
|
||||
|
||||
// Build client & routing
|
||||
const { client, opts } = await buildClientAndOpts(bodyshop);
|
||||
const routing = opts?.routing || client?.opts?.routing || null;
|
||||
if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required");
|
||||
|
||||
// Reconstruct a lightweight tx object
|
||||
const tx = {
|
||||
jobData: {
|
||||
...job,
|
||||
vin: job?.v_vin
|
||||
},
|
||||
txEnvelope
|
||||
};
|
||||
|
||||
const vin = resolveVin({ tx, job });
|
||||
|
||||
if (!vin) {
|
||||
CreateRRLogEvent(socket, "ERROR", "{EARLY-3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid });
|
||||
throw new Error("ensureRRServiceVehicle: vin required");
|
||||
}
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", "{EARLY-3.4} ensureRRServiceVehicle: starting", {
|
||||
jobid: rid,
|
||||
selectedCustomerNo: effectiveCustNo,
|
||||
vin,
|
||||
dealerNumber: routing.dealerNumber,
|
||||
storeNumber: routing.storeNumber,
|
||||
areaNumber: routing.areaNumber
|
||||
});
|
||||
|
||||
const ensured = await ensureRRServiceVehicle({
|
||||
client,
|
||||
routing,
|
||||
bodyshop,
|
||||
selectedCustomerNo: effectiveCustNo,
|
||||
custNo: effectiveCustNo,
|
||||
customerNo: effectiveCustNo,
|
||||
vin,
|
||||
job,
|
||||
socket,
|
||||
redisHelpers
|
||||
});
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", "{EARLY-3.5} ensureRRServiceVehicle: done", ensured);
|
||||
|
||||
const cachedAdvisor = await redisHelpers.getSessionTransactionData(socket.id, ns, RRCacheEnums.AdvisorNo);
|
||||
const advisorNo = readAdvisorNo({ txEnvelope }, cachedAdvisor);
|
||||
|
||||
if (!advisorNo) {
|
||||
CreateRRLogEvent(socket, "ERROR", `Advisor is required (advisorNo) for early RO`);
|
||||
await insertRRFailedExportLog({
|
||||
socket,
|
||||
jobId: rid,
|
||||
job,
|
||||
bodyshop,
|
||||
error: new Error("Advisor is required (advisorNo)."),
|
||||
classification: { errorCode: "RR_MISSING_ADVISOR", friendlyMessage: "Advisor is required." }
|
||||
});
|
||||
socket.emit("export-failed", { vendor: "rr", jobId: rid, error: "Advisor is required (advisorNo)." });
|
||||
return ack?.({ ok: false, error: "Advisor is required (advisorNo)." });
|
||||
}
|
||||
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
ns,
|
||||
RRCacheEnums.AdvisorNo,
|
||||
String(advisorNo),
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
// CREATE MINIMAL RO (early creation)
|
||||
CreateRRLogEvent(socket, "DEBUG", `{EARLY-4} Creating minimal RR RO`);
|
||||
const result = await createMinimalRRRepairOrder({
|
||||
bodyshop,
|
||||
job,
|
||||
selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo },
|
||||
advisorNo: String(advisorNo),
|
||||
txEnvelope,
|
||||
socket,
|
||||
svId: ensured?.svId || null
|
||||
});
|
||||
|
||||
// Cache raw export result + pending RO number
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
ns,
|
||||
RRCacheEnums.ExportResult,
|
||||
result || {},
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
if (result?.success) {
|
||||
const data = result?.data || {};
|
||||
|
||||
// Prefer explicit return from export function; then fall back to fields
|
||||
const dmsRoNo = result?.roNo ?? data?.dmsRoNo ?? null;
|
||||
|
||||
const outsdRoNo = data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null;
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", "Early RO created - checking dmsRoNo", {
|
||||
dmsRoNo,
|
||||
resultRoNo: result?.roNo,
|
||||
dataRoNo: data?.dmsRoNo,
|
||||
jobId: rid
|
||||
});
|
||||
|
||||
// ✅ Persist DMS RO number, customer ID, advisor ID, and mileage on the job
|
||||
if (dmsRoNo) {
|
||||
const mileageIn = txEnvelope?.kmin ?? null;
|
||||
CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", {
|
||||
jobId: rid,
|
||||
dmsId: dmsRoNo,
|
||||
customerId: effectiveCustNo,
|
||||
advisorId: String(advisorNo),
|
||||
mileageIn
|
||||
});
|
||||
await setJobDmsIdForSocket({
|
||||
socket,
|
||||
jobId: rid,
|
||||
dmsId: dmsRoNo,
|
||||
dmsCustomerId: effectiveCustNo,
|
||||
dmsAdvisorId: String(advisorNo),
|
||||
mileageIn
|
||||
});
|
||||
} else {
|
||||
CreateRRLogEvent(socket, "WARN", "RR early RO creation succeeded but no DMS RO number was returned", {
|
||||
jobId: rid,
|
||||
resultPreview: {
|
||||
roNo: result?.roNo,
|
||||
data: {
|
||||
dmsRoNo: data?.dmsRoNo,
|
||||
outsdRoNo: data?.outsdRoNo
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
ns,
|
||||
RRCacheEnums.PendingRO,
|
||||
{
|
||||
outsdRoNo,
|
||||
dmsRoNo,
|
||||
customerNo: String(effectiveCustNo),
|
||||
advisorNo: String(advisorNo),
|
||||
vin: job?.v_vin || null,
|
||||
earlyRoCreated: true // Flag to indicate this was an early RO
|
||||
},
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", `{EARLY-5} Minimal RO created successfully`, {
|
||||
dmsRoNo: dmsRoNo || null,
|
||||
outsdRoNo: outsdRoNo || null
|
||||
});
|
||||
|
||||
// Mark success in export logs
|
||||
await markRRExportSuccess({
|
||||
socket,
|
||||
jobId: rid,
|
||||
job,
|
||||
bodyshop,
|
||||
result,
|
||||
isEarlyRo: true
|
||||
});
|
||||
|
||||
// Tell FE that early RO was created
|
||||
socket.emit("rr-early-ro-created", { jobId: rid, dmsRoNo, outsdRoNo });
|
||||
|
||||
// Emit result
|
||||
socket.emit("rr-create-early-ro:result", { jobId: rid, bodyshopId: bodyshop?.id, result });
|
||||
|
||||
// ACK with RO details
|
||||
ack?.({
|
||||
ok: true,
|
||||
dmsRoNo,
|
||||
outsdRoNo,
|
||||
result,
|
||||
custNo: String(effectiveCustNo),
|
||||
createdCustomer,
|
||||
earlyRoCreated: true
|
||||
});
|
||||
} else {
|
||||
// classify & fail
|
||||
const tx = result?.statusBlocks?.transaction;
|
||||
|
||||
const vendorStatusCode = Number(
|
||||
result?.roStatus?.statusCode ?? result?.roStatus?.StatusCode ?? tx?.statusCode ?? tx?.StatusCode
|
||||
);
|
||||
|
||||
const vendorMessage =
|
||||
result?.roStatus?.message ??
|
||||
result?.roStatus?.Message ??
|
||||
tx?.message ??
|
||||
tx?.Message ??
|
||||
result?.error ??
|
||||
"RR early RO creation failed";
|
||||
|
||||
const cls = classifyRRVendorError({
|
||||
code: vendorStatusCode,
|
||||
message: vendorMessage
|
||||
});
|
||||
|
||||
CreateRRLogEvent(socket, "ERROR", `Early RO creation failed`, {
|
||||
roStatus: result?.roStatus,
|
||||
statusBlocks: result?.statusBlocks,
|
||||
classification: cls
|
||||
});
|
||||
|
||||
await insertRRFailedExportLog({
|
||||
socket,
|
||||
jobId: rid,
|
||||
job,
|
||||
bodyshop,
|
||||
error: new Error(cls.friendlyMessage || result?.error || "RR early RO creation failed"),
|
||||
classification: cls,
|
||||
result
|
||||
});
|
||||
|
||||
socket.emit("export-failed", {
|
||||
vendor: "rr",
|
||||
jobId: rid,
|
||||
error: cls?.friendlyMessage || result?.error || "RR early RO creation failed",
|
||||
...cls
|
||||
});
|
||||
|
||||
ack?.({
|
||||
ok: false,
|
||||
error: cls.friendlyMessage || result?.error || "RR early RO creation failed",
|
||||
result,
|
||||
classification: cls
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const cls = classifyRRVendorError(error);
|
||||
|
||||
CreateRRLogEvent(socket, "ERROR", `Error during RR early RO creation (customer-selected)`, {
|
||||
error: error.message,
|
||||
vendorStatusCode: cls.vendorStatusCode,
|
||||
code: cls.errorCode,
|
||||
friendly: cls.friendlyMessage,
|
||||
stack: error.stack,
|
||||
jobid: rid
|
||||
});
|
||||
|
||||
try {
|
||||
if (!bodyshop || !job) {
|
||||
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
|
||||
bodyshop = bodyshop || (await getBodyshopForSocket({ bodyshopId, socket }));
|
||||
job =
|
||||
job ||
|
||||
(await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.JobData));
|
||||
}
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
await insertRRFailedExportLog({
|
||||
socket,
|
||||
jobId: rid,
|
||||
job,
|
||||
bodyshop,
|
||||
error,
|
||||
classification: cls
|
||||
});
|
||||
|
||||
try {
|
||||
socket.emit("export-failed", {
|
||||
vendor: "rr",
|
||||
jobId: rid,
|
||||
error: error.message,
|
||||
...cls
|
||||
});
|
||||
socket.emit("rr-user-notice", { jobId: rid, ...cls });
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
ack?.({ ok: false, error: cls.friendlyMessage || error.message, classification: cls });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("rr-export-job", async ({ jobid, jobId, txEnvelope } = {}, ack) => {
|
||||
const rid = resolveJobId(jobid || jobId, { jobId, jobid }, null);
|
||||
|
||||
try {
|
||||
@@ -422,6 +938,139 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
||||
const { bodyshopId } = await getSessionOrSocket(redisHelpers, socket);
|
||||
const bodyshop = await getBodyshopForSocket({ bodyshopId, socket });
|
||||
|
||||
// Check if this job already has an early RO - if so, use stored IDs and skip customer search
|
||||
const hasEarlyRO = !!job?.dms_id;
|
||||
|
||||
if (hasEarlyRO) {
|
||||
CreateRRLogEvent(socket, "DEBUG", `{2} Early RO exists - using stored customer/advisor`, {
|
||||
dms_id: job.dms_id,
|
||||
dms_customer_id: job.dms_customer_id,
|
||||
dms_advisor_id: job.dms_advisor_id
|
||||
});
|
||||
|
||||
// Cache the stored customer/advisor IDs for the next step
|
||||
if (job.dms_customer_id) {
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(rid),
|
||||
RRCacheEnums.SelectedCustomer,
|
||||
String(job.dms_customer_id),
|
||||
defaultRRTTL
|
||||
);
|
||||
}
|
||||
if (job.dms_advisor_id) {
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(rid),
|
||||
RRCacheEnums.AdvisorNo,
|
||||
String(job.dms_advisor_id),
|
||||
defaultRRTTL
|
||||
);
|
||||
}
|
||||
|
||||
// Emit empty customer list to frontend (won't show modal)
|
||||
socket.emit("rr-select-customer", []);
|
||||
|
||||
// Continue directly with the export by calling the selected customer handler logic inline
|
||||
// This is essentially the same as if user selected the stored customer
|
||||
const selectedCustNo = job.dms_customer_id;
|
||||
|
||||
if (!selectedCustNo) {
|
||||
throw new Error("Early RO exists but no customer ID stored");
|
||||
}
|
||||
|
||||
// Continue with ensureRRServiceVehicle and export (same as rr-selected-customer handler)
|
||||
const { client, opts } = await buildClientAndOpts(bodyshop);
|
||||
const routing = opts?.routing || client?.opts?.routing || null;
|
||||
if (!routing?.dealerNumber) throw new Error("ensureRRServiceVehicle: routing.dealerNumber required");
|
||||
|
||||
const tx = {
|
||||
jobData: {
|
||||
...job,
|
||||
vin: job?.v_vin
|
||||
},
|
||||
txEnvelope
|
||||
};
|
||||
|
||||
const vin = resolveVin({ tx, job });
|
||||
if (!vin) {
|
||||
CreateRRLogEvent(socket, "ERROR", "{3.x} No VIN found for ensureRRServiceVehicle", { jobid: rid });
|
||||
throw new Error("ensureRRServiceVehicle: vin required");
|
||||
}
|
||||
|
||||
const ensured = await ensureRRServiceVehicle({
|
||||
client,
|
||||
routing,
|
||||
bodyshop,
|
||||
selectedCustomerNo: String(selectedCustNo),
|
||||
custNo: String(selectedCustNo),
|
||||
customerNo: String(selectedCustNo),
|
||||
vin,
|
||||
job,
|
||||
socket,
|
||||
redisHelpers
|
||||
});
|
||||
|
||||
const advisorNo = job.dms_advisor_id || readAdvisorNo({ txEnvelope }, await redisHelpers.getSessionTransactionData(socket.id, getTransactionType(rid), RRCacheEnums.AdvisorNo));
|
||||
|
||||
if (!advisorNo) {
|
||||
throw new Error("Advisor is required (advisorNo).");
|
||||
}
|
||||
|
||||
// UPDATE existing RO with full data
|
||||
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: job.dms_id });
|
||||
const result = await updateRRRepairOrderWithFullData({
|
||||
bodyshop,
|
||||
job,
|
||||
selectedCustomer: { customerNo: String(selectedCustNo), custNo: String(selectedCustNo) },
|
||||
advisorNo: String(advisorNo),
|
||||
txEnvelope,
|
||||
socket,
|
||||
svId: ensured?.svId || null,
|
||||
roNo: job.dms_id
|
||||
});
|
||||
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.roStatus?.message || "Failed to update RR Repair Order");
|
||||
}
|
||||
|
||||
const dmsRoNo = result?.roNo ?? result?.data?.dmsRoNo ?? job.dms_id;
|
||||
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(rid),
|
||||
RRCacheEnums.ExportResult,
|
||||
result || {},
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(rid),
|
||||
RRCacheEnums.PendingRO,
|
||||
{
|
||||
outsdRoNo: result?.data?.outsdRoNo ?? job?.ro_number ?? job?.id ?? null,
|
||||
dmsRoNo,
|
||||
customerNo: String(selectedCustNo),
|
||||
advisorNo: String(advisorNo),
|
||||
vin: job?.v_vin || null,
|
||||
isUpdate: true
|
||||
},
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
CreateRRLogEvent(socket, "INFO", `RR Repair Order updated successfully`, {
|
||||
dmsRoNo,
|
||||
jobId: rid
|
||||
});
|
||||
|
||||
// For early RO flow, only emit validation-required (not export-job:result)
|
||||
// since the export is not complete yet - we're just waiting for validation
|
||||
socket.emit("rr-validation-required", { dmsRoNo, jobId: rid });
|
||||
|
||||
return ack?.({ ok: true, skipCustomerSelection: true, dmsRoNo });
|
||||
}
|
||||
|
||||
CreateRRLogEvent(socket, "DEBUG", `{2} Running multi-search (Full Name + VIN)`);
|
||||
|
||||
const candidates = await rrMultiCustomerSearch({ bodyshop, job, socket, redisHelpers });
|
||||
@@ -620,17 +1269,59 @@ const registerRREvents = ({ socket, redisHelpers }) => {
|
||||
defaultRRTTL
|
||||
);
|
||||
|
||||
// CREATE/UPDATE (first step only)
|
||||
CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create/update (step 1)`);
|
||||
const result = await exportJobToRR({
|
||||
bodyshop,
|
||||
job,
|
||||
selectedCustomer: { customerNo: effectiveCustNo, custNo: effectiveCustNo },
|
||||
advisorNo: String(advisorNo),
|
||||
txEnvelope,
|
||||
socket,
|
||||
svId: ensured?.svId || null
|
||||
});
|
||||
// Check if this job already has an early RO created (check job.dms_id)
|
||||
// If so, we'll use stored customer/advisor IDs and do a full data UPDATE instead of CREATE
|
||||
const existingDmsId = job?.dms_id || null;
|
||||
const shouldUpdate = !!existingDmsId;
|
||||
|
||||
// When updating an early RO, use stored customer/advisor IDs
|
||||
let finalEffectiveCustNo = effectiveCustNo;
|
||||
let finalAdvisorNo = advisorNo;
|
||||
|
||||
if (shouldUpdate && job?.dms_customer_id) {
|
||||
CreateRRLogEvent(socket, "DEBUG", `Using stored customer ID from early RO`, {
|
||||
storedCustomerId: job.dms_customer_id,
|
||||
originalCustomerId: effectiveCustNo
|
||||
});
|
||||
finalEffectiveCustNo = String(job.dms_customer_id);
|
||||
}
|
||||
|
||||
if (shouldUpdate && job?.dms_advisor_id) {
|
||||
CreateRRLogEvent(socket, "DEBUG", `Using stored advisor ID from early RO`, {
|
||||
storedAdvisorId: job.dms_advisor_id,
|
||||
originalAdvisorId: advisorNo
|
||||
});
|
||||
finalAdvisorNo = String(job.dms_advisor_id);
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
if (shouldUpdate) {
|
||||
// UPDATE existing RO with full data
|
||||
CreateRRLogEvent(socket, "DEBUG", `{4} Updating existing RR RO with full data`, { dmsRoNo: existingDmsId });
|
||||
result = await updateRRRepairOrderWithFullData({
|
||||
bodyshop,
|
||||
job,
|
||||
selectedCustomer: { customerNo: finalEffectiveCustNo, custNo: finalEffectiveCustNo },
|
||||
advisorNo: String(finalAdvisorNo),
|
||||
txEnvelope,
|
||||
socket,
|
||||
svId: ensured?.svId || null,
|
||||
roNo: existingDmsId
|
||||
});
|
||||
} else {
|
||||
// CREATE new RO (legacy flow - full data on first create)
|
||||
CreateRRLogEvent(socket, "DEBUG", `{4} Performing RR create (step 1 - full data)`);
|
||||
result = await exportJobToRR({
|
||||
bodyshop,
|
||||
job,
|
||||
selectedCustomer: { customerNo: finalEffectiveCustNo, custNo: finalEffectiveCustNo },
|
||||
advisorNo: String(finalAdvisorNo),
|
||||
txEnvelope,
|
||||
socket,
|
||||
svId: ensured?.svId || null
|
||||
});
|
||||
}
|
||||
|
||||
// Cache raw export result + pending RO number for finalize
|
||||
await redisHelpers.setSessionTransactionData(
|
||||
|
||||
@@ -8,6 +8,12 @@ const client = require("../graphql-client/graphql-client").client;
|
||||
*/
|
||||
const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Chatter API token cache TTL in seconds
|
||||
* @type {number}
|
||||
*/
|
||||
const CHATTER_TOKEN_CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
/**
|
||||
* Generate a cache key for a bodyshop
|
||||
* @param bodyshopId
|
||||
@@ -15,6 +21,13 @@ const BODYSHOP_CACHE_TTL = 3600; // 1 hour
|
||||
*/
|
||||
const getBodyshopCacheKey = (bodyshopId) => `bodyshop-cache:${bodyshopId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a Chatter API token
|
||||
* @param companyId
|
||||
* @returns {`chatter-token:${string}`}
|
||||
*/
|
||||
const getChatterTokenCacheKey = (companyId) => `chatter-token:${companyId}`;
|
||||
|
||||
/**
|
||||
* Generate a cache key for a user socket mapping
|
||||
* @param email
|
||||
@@ -373,9 +386,53 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
*/
|
||||
const getProviderCache = (ns, field) => getSessionData(`${ns}:provider`, field);
|
||||
|
||||
/**
|
||||
* Get Chatter API token from Redis cache
|
||||
* @param companyId
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
const getChatterToken = async (companyId) => {
|
||||
const key = getChatterTokenCacheKey(companyId);
|
||||
try {
|
||||
const token = await pubClient.get(key);
|
||||
return token;
|
||||
} catch (error) {
|
||||
logger.log("get-chatter-token-from-redis", "ERROR", "redis", null, {
|
||||
companyId,
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set Chatter API token in Redis cache
|
||||
* @param companyId
|
||||
* @param token
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const setChatterToken = async (companyId, token) => {
|
||||
const key = getChatterTokenCacheKey(companyId);
|
||||
try {
|
||||
await pubClient.set(key, token);
|
||||
await pubClient.expire(key, CHATTER_TOKEN_CACHE_TTL);
|
||||
devDebugLogger("chatter-token-cache-set", {
|
||||
companyId,
|
||||
action: "Token cached"
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("set-chatter-token-in-redis", "ERROR", "redis", null, {
|
||||
companyId,
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const api = {
|
||||
getUserSocketMappingKey,
|
||||
getBodyshopCacheKey,
|
||||
getChatterTokenCacheKey,
|
||||
setSessionData,
|
||||
getSessionData,
|
||||
clearSessionData,
|
||||
@@ -390,7 +447,9 @@ const applyRedisHelpers = ({ pubClient, app, logger }) => {
|
||||
getSessionTransactionData,
|
||||
clearSessionTransactionData,
|
||||
setProviderCache,
|
||||
getProviderCache
|
||||
getProviderCache,
|
||||
getChatterToken,
|
||||
setChatterToken
|
||||
};
|
||||
|
||||
Object.assign(module.exports, api);
|
||||
|
||||
Reference in New Issue
Block a user