Compare commits

...

118 Commits

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

Approved-by: Allan Carr
2026-02-18 00:45:08 +00:00
Dave Richer
95bb5b03c2 Merged in feature/IO-3523-Fortellis-Corrections-2 (pull request #3006)
feature/IO-3523-Fortellis-Corrections-2 - Fix
2026-02-17 21:06:45 +00:00
Dave
318482c195 feature/IO-3523-Fortellis-Corrections-2 - Fix 2026-02-17 16:06:12 -05:00
Dave Richer
eea9e8e2cc Merged in release/2026-02-13 (pull request #3003)
feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line

Approved-by: Allan Carr
2026-02-13 16:48:27 +00:00
Dave Richer
cde12f9970 Merged in feature/IO-3558-Reynolds-Part-2 (pull request #3002)
feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line
2026-02-13 16:14:39 +00:00
Dave
48def2b74d feature/IO-3558-Reynolds-Part-2 - Send zeroed out estimate line 2026-02-13 11:13:42 -05:00
Dave Richer
dde7a99956 Merged in release/2026-02-13 (pull request #2999)
Release/2026 02 13 into master-AIO - IO-3503, IO-3510, IO-3521, IO-3533, IO-3551, IO-3556, IO-3557, IO-3558
2026-02-13 00:33:37 +00:00
Dave Richer
df964aa14e Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2997)
feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel
2026-02-12 22:13:38 +00:00
Dave
7619360f37 feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel 2026-02-12 17:13:06 -05:00
Dave Richer
f15f371e86 Merged in feature/IO-3556-Chattr-Integration (pull request #2995)
6feature/IO-3556-Chattr-Integration - Move to BULLMQ stack
2026-02-12 18:00:27 +00:00
Dave
34fe0cc3bf 6feature/IO-3556-Chattr-Integration - Move to BULLMQ stack 2026-02-12 12:56:17 -05:00
Allan Carr
7acaefb5c5 Merged in feature/IO-3557-Reynold-DMS-Info (pull request #2993)
IO-3557 Reynolds DMS Info

Approved-by: Dave Richer
2026-02-12 14:26:38 +00:00
Allan Carr
ab02da47a2 IO-3557 Reynolds DMS Info
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-11 18:39:36 -08:00
Dave Richer
2a7dec90d5 Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2990)
feature/IO-3558-Reynolds-Part-2 - Admin Panel
2026-02-11 23:13:22 +00:00
Dave
6e0b1f65a7 feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 18:12:56 -05:00
Dave Richer
8671d1254d Merged in feature/IO-3558-Reynolds-Part-2 (pull request #2987)
Feature/IO-3558 Reynolds Part 2
2026-02-11 22:25:29 +00:00
Dave
0ea254ed4e feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 16:57:13 -05:00
Dave
331dcfc063 feature/IO-3558-Reynolds-Part-2 - Admin Panel 2026-02-11 15:47:46 -05:00
Dave
c46804cfdf feature/IO-3558-Reynolds-Part-2 - Initial 2026-02-11 15:33:59 -05:00
Dave Richer
484d09d635 Merged in feature/IO-3556-Chattr-Integration (pull request #2986)
feature/IO-3556-Chattr-Integration - Switch Consent to true
2026-02-11 16:59:51 +00:00
Dave
188a7b47b1 feature/IO-3556-Chattr-Integration - Switch Consent to true 2026-02-11 11:59:16 -05:00
Dave Richer
a6ca93f482 Merged in feature/IO-3556-Chattr-Integration (pull request #2984)
feature/IO-3556-Chattr-Integration - Retry beef up / tweeks
2026-02-11 16:40:26 +00:00
Dave
d08bfc61cd feature/IO-3556-Chattr-Integration - Retry beef up / tweeks 2026-02-11 11:37:47 -05:00
Dave Richer
9b4de1645e Merged in feature/IO-3556-Chattr-Integration (pull request #2982)
fix
2026-02-11 14:54:33 +00:00
Dave
503c217c99 fix 2026-02-11 09:52:22 -05:00
Dave Richer
2333067e02 Merged in hotfix/2026-02-10-backend (pull request #2981)
hotfix/2026-02-10-backend - Move chatter DB stuff over
2026-02-10 23:13:50 +00:00
Dave Richer
953172493e Merged in feature/IO-3556-Chattr-Integration (pull request #2979)
hotfix/2026-02-10-backend - Move chatter DB stuff over
2026-02-10 23:05:13 +00:00
Dave
b444639fca Merge branch 'hotfix/2026-02-10-backend' into feature/IO-3556-Chattr-Integration 2026-02-10 18:03:37 -05:00
Dave
6ee7e56b9b hotfix/2026-02-10-backend - Move chatter DB stuff over 2026-02-10 17:58:26 -05:00
Dave Richer
ffd5acb21a Merged in feature/IO-3556-Chattr-Integration (pull request #2976)
Feature/IO-3556 Chattr Integration
2026-02-10 22:28:54 +00:00
Dave
0340ca5fcc feature/IO-3556-Chattr-Integration - Add in Redis caching for Chatter 2026-02-10 17:25:59 -05:00
Dave
1b2fc8b114 feature/IO-3556-Chattr-Integration 2026-02-10 17:17:44 -05:00
Dave
3745d7a414 feature/IO-3556-Chattr-Integration 2026-02-10 12:48:48 -05:00
Allan Carr
a0efac9bd8 Merged in feature/IO-3551-Export-Reports-Return-Data (pull request #2974)
IO-3551 Export Reports Return Data

Approved-by: Dave Richer
2026-02-09 15:34:22 +00:00
Allan Carr
17a772563c Merged in feature/IO-3521-Pagination-Export-Screens (pull request #2973)
IO-3521 Pagination Disable Show Size Changer

Approved-by: Dave Richer
2026-02-09 15:34:01 +00:00
Allan Carr
b1ce356bd8 IO-3551 Export Reports Return Data
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 20:45:44 -08:00
Allan Carr
9818cac30e IO-3521 Pagination Disable Show Size Changer
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 16:10:03 -08:00
Allan Carr
171277630e Merged in feature/IO-3503-Job-Costing-MASH-MAPA-Fix (pull request #2971)
IO-3503 Job Costing Fix

Approved-by: Dave Richer
2026-02-06 23:14:23 +00:00
Allan Carr
d8b400cb8c IO-3503 InstanceManager change
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 15:15:11 -08:00
Allan Carr
fe7bf684aa IO-3503 Job Costing Fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 15:04:00 -08:00
Dave Richer
7e6c97b3cf Merged in hotfix/2026-02-03 (pull request #2970)
Hotfix/2026 02 03
2026-02-06 22:20:58 +00:00
Allan Carr
9c6fe1905d Merged in feature/IO-3533-Actual-Cost-Click-to-Focus (pull request #2967)
IO-3533 Actual Cost Click to Focus

Approved-by: Dave Richer
2026-02-06 19:53:32 +00:00
Allan Carr
2126cccff1 IO-3533 Actual Cost Click to Focus
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-02-06 11:23:34 -08:00
Dave Richer
56559dd3ff Merged in hotfix/2026-02-03 (pull request #2965)
Hotfix/2026 02 03

Approved-by: Allan Carr
2026-02-03 21:49:28 +00:00
Dave
fde137d7f7 Merge branch 'feature/IO-3550-Labor-Adjustment-Popover' into hotfix/2026-02-03 2026-02-03 16:00:28 -05:00
Dave
b797bf7dc9 Merge branch 'feature/IO-3548-Bill-Modal-TabOrder' into hotfix/2026-02-03 2026-02-03 16:00:08 -05:00
Dave Richer
37c3be5cde Merged in feature/IO-3550-Labor-Adjustment-Popover (pull request #2963)
feature/IO-3550-Labor-Adjustment-Popover - Fix
2026-02-03 20:58:01 +00:00
Dave
b87d1a65fe feature/IO-3550-Labor-Adjustment-Popover - Fix 2026-02-03 15:57:24 -05:00
Dave Richer
35c832dbc3 Merged in feature/IO-3545-Production-Board-List-DND (pull request #2961)
feature/IO-3548-Bill-Modal-TabOrder
2026-02-03 20:51:19 +00:00
Dave
019b3cf4da feature/IO-3548-Bill-Modal-TabOrder 2026-02-03 15:50:48 -05:00
Dave Richer
27f4385539 Merged in feature/IO-3548-Bill-Modal-TabOrder (pull request #2959)
feature/IO-3548-Bill-Modal-TabOrder
2026-02-03 20:43:39 +00:00
Dave
ad520ab23e feature/IO-3548-Bill-Modal-TabOrder 2026-02-03 15:42:10 -05:00
Dave Richer
b3716521ec Merged in feature/IO-3545-Production-Board-List-DND (pull request #2957)
Feature/IO-3545 Production Board List DND
2026-02-03 20:30:42 +00:00
Dave
05ae0801e5 feature/IO-3545-Production-Board-List-DND - EMP assignment selector fix 2026-02-03 15:29:03 -05:00
Dave
332ade96e5 feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 15:17:20 -05:00
Dave
3acec55c0e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 15:01:10 -05:00
Dave
da0462f14c feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 14:56:04 -05:00
Dave
2cc9fa961e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 14:34:42 -05:00
Allan Carr
2646e85863 Merged in feature/IO-3510-Autohouse-Datapump-Enhancements (pull request #2956)
IO-3510 Autohouse Datapump Enhancements

Approved-by: Dave Richer
2026-02-03 19:20:30 +00:00
Dave
1b6fe4d18e feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 13:26:17 -05:00
Dave
22aae0a7f1 feature/IO-3545-Production-Board-List-DND - Checkpoint 2026-02-03 13:21:32 -05:00
Dave Richer
cfbd6f93c3 Merged in master-AIO (pull request #2954)
Master AIO
2026-02-03 15:52:51 +00:00
Patrick Fic
db1b701a96 Merged in hotfix/2026-02-02 (pull request #2951)
Hotfix/2026 02 02

Approved-by: Dave Richer
2026-02-02 22:49:14 +00:00
Dave
2746421c09 hotfix/2026-02-02 - 2026-02-02 17:48:03 -05:00
Dave
5217120994 hotfix/2026-02-02 - Parts order manual discounting box 2026-02-02 17:39:47 -05:00
Dave
77f72a2a12 Merge branch 'hotfix/2026-02-02' of bitbucket.org:snaptsoft/bodyshop into hotfix/2026-02-02 2026-02-02 17:11:06 -05:00
Dave
a84ad4ee32 hotfix/2026-02-02 - remove check on missing line ids 2026-02-02 17:10:56 -05:00
Dave Richer
2cacd75822 Merged in bugfix/IO-3533 (pull request #2948)
bugfix/IO-3533 - Fix
2026-02-02 22:05:56 +00:00
Dave
217a0b84ac bugfix/IO-3533 - Fix 2026-02-02 17:04:06 -05:00
Dave Richer
f53ed8c427 Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2944)
feature/IO-3532-parts-queue-ui-adjustments

Approved-by: Patrick Fic
2026-02-02 21:51:31 +00:00
Dave Richer
f8b7588a04 Merged in feature/IO-3542-fix-searches (pull request #2945)
feature/IO-3542-fix-searches

Approved-by: Patrick Fic
2026-02-02 21:46:24 +00:00
Patrick Fic
ee3cb4456d Merged in feature/IO-3531-apollo-rerender (pull request #2946)
IO-3531 remove loading on parts order page.
2026-02-02 21:45:59 +00:00
Patrick Fic
ae05692c46 IO-3531 remove loading on parts order page. 2026-02-02 13:45:25 -08:00
Dave
e01a2af5a4 feature/IO-3542-fix-searches 2026-02-02 16:44:49 -05:00
Dave
9c0cb5f80b Merge branch 'feature/IO-3532-parts-queue-ui-adjustments' of bitbucket.org:snaptsoft/bodyshop into feature/IO-3532-parts-queue-ui-adjustments 2026-02-02 16:15:23 -05:00
Dave
1f726aca4d feature/IO-3532-parts-queue-ui-adjustments 2026-02-02 16:14:44 -05:00
Patrick Fic
b9f398cf2d Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2943)
IO-3532 resolve tooltip on owner name.
2026-02-02 20:57:05 +00:00
Patrick Fic
ff73a14610 IO-3532 resolve tooltip on owner name. 2026-02-02 12:55:29 -08:00
Patrick Fic
1e44d4fe42 Merged in feature/IO-3539-print-center-popovers (pull request #2941)
IO-3539 resolve print center popoves.
2026-02-02 20:38:49 +00:00
Patrick Fic
0f42875d1b IO-3539 resolve print center popoves. 2026-02-02 12:38:29 -08:00
Patrick Fic
a0f1299006 Merged in feature/IO-3538-receivec-cm-on-parts-order (pull request #2940)
IO-3538 Resolve missing id on receive return.
2026-02-02 20:23:20 +00:00
Patrick Fic
87d8a5d746 IO-3538 Resolve missing id on receive return. 2026-02-02 12:22:58 -08:00
Patrick Fic
268851902a Merged in feature/IO-3535-fed-tax-toggle-bill-posting (pull request #2935)
IO-3535 Resolve federal tax default off on received parts order.
2026-02-02 20:07:26 +00:00
Dave Richer
68bb7d2529 Merged in bugfix/IO-3533 (pull request #2937)
bugfix/IO-3533 - Disable on blurr and on focus handlers in bill entry modal

Approved-by: Patrick Fic
2026-02-02 20:06:13 +00:00
Patrick Fic
d50db12330 Merged in feature/IO-3534-bill-discrep-coloring (pull request #2936)
IO-3534 Adjust value prop to content for antd prop change to fix color display.
2026-02-02 20:05:54 +00:00
Patrick Fic
1438986c18 Merged in feature/IO-3532-parts-queue-ui-adjustments (pull request #2938)
IO-3532 Resolve parts queue pages.
2026-02-02 20:05:15 +00:00
Patrick Fic
c047699fbb Merged in feature/IO-3531-apollo-rerender (pull request #2939)
IO-3531 Change global apollo config setting to prevent rerenders.
2026-02-02 20:03:29 +00:00
Patrick Fic
e5b7fcb919 IO-3531 Change global apollo config setting to prevent rerenders. 2026-02-02 12:02:11 -08:00
Patrick Fic
cadcfc9b0d IO-3532 Resolve parts queue pages. 2026-02-02 11:21:22 -08:00
Dave
55023ceaca feature/IO-3534-bill-discrep-coloring: Remove unused console.log 2026-02-02 12:47:04 -05:00
Dave
45e143578c bugfix/IO-3533 - Disable on blurr and on focus handlers in bill entry modal 2026-02-02 12:34:54 -05:00
Patrick Fic
28a41f7637 IO-3534 Adjust value prop to content for antd prop change to fix color display. 2026-02-02 09:33:37 -08:00
Patrick Fic
2a2edeadb9 IO-3535 Resolve federal tax default off on received parts order. 2026-02-02 09:25:58 -08:00
Allan Carr
52c9b9a290 IO-3510 Autohouse Datapump Enhancements
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-01-27 19:20:12 -08:00
92 changed files with 7835 additions and 1134 deletions

View File

@@ -13,4 +13,5 @@
.env.development.local
.env.test.local
.env.production.local
bodyshop_translations.babel
.env.localstack.docker
bodyshop_translations.babel

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

106
client/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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`}

View File

@@ -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>

View File

@@ -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%" }}
>

View File

@@ -57,7 +57,6 @@ const CardPaymentModalComponent = ({
QUERY_RO_AND_OWNER_BY_JOB_PKS,
{
fetchPolicy: "network-only",
notifyOnNetworkStatusChange: true
}
);

View File

@@ -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 } : {})
});

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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>
);
}

View File

@@ -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} />;
}
/**

View File

@@ -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">

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}

View File

@@ -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;

View File

@@ -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} />

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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`}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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) => {

View File

@@ -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()) || ""}
/>
)}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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, dont 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>
);
}

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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
}
}
`;

View File

@@ -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}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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. "

View File

@@ -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": ""

View File

@@ -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": ""

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."bodyshops" add column "chatter_company_id" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_customer_id" text
null;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
alter table "public"."jobs" add column "dms_advisor_id" text
null;

View 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"

View File

@@ -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 });
});
};
/**

View 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
};

View 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;

View File

@@ -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
View 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);
}
}

View File

@@ -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);

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,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 };

View File

@@ -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,

View File

@@ -14,7 +14,7 @@ const {
const _ = require("lodash");
const moment = require("moment-timezone");
const replaceSpecialRegex = /[^a-zA-Z0-9 .,\n #]+/g;
const replaceSpecialRegex = /[^a-zA-Z0-9 ]+/g;
// Helper function to handle FortellisApiError logging
function handleFortellisApiError(socket, error, functionName, additionalDetails = {}) {
@@ -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;

View File

@@ -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
}
}`;

View File

@@ -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");

View 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;

View File

@@ -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);

View File

@@ -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,

View File

@@ -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
};

View File

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

View File

@@ -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(

View File

@@ -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);