From 52c9b9a2909fe20bdce175ae898dce2210e47ea2 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Tue, 27 Jan 2026 19:20:12 -0800 Subject: [PATCH 01/19] IO-3510 Autohouse Datapump Enhancements Signed-off-by: Allan Carr --- server/data/autohouse.js | 76 +++++++++++++++++++++++++++++++- server/graphql-client/queries.js | 10 ++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/server/data/autohouse.js b/server/data/autohouse.js index 19138c5c0..819c1d292 100644 --- a/server/data/autohouse.js +++ b/server/data/autohouse.js @@ -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 { diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index a0cd0bc91..8add6e694 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -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 { From 2126cccff11055d0a38d13997aacf5323a11c9f7 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 6 Feb 2026 11:23:34 -0800 Subject: [PATCH 02/19] IO-3533 Actual Cost Click to Focus Signed-off-by: Allan Carr --- client/src/components/bill-form/bill-form.lines.component.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/bill-form/bill-form.lines.component.jsx b/client/src/components/bill-form/bill-form.lines.component.jsx index a841f0534..884d06f72 100644 --- a/client/src/components/bill-form/bill-form.lines.component.jsx +++ b/client/src/components/bill-form/bill-form.lines.component.jsx @@ -336,7 +336,7 @@ export function BillEnterModalLinesComponent({ controls={false} tabIndex={0} style={{ width: "100%", height: CONTROL_HEIGHT }} - // NOTE: No auto-fill on focus/blur; only triggered from Retail on Tab + onFocus={() => autofillActualCost(index)} /> From fe7bf684aa6b5ccda1b9755a4cef54dd0e17ecf0 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 6 Feb 2026 15:04:00 -0800 Subject: [PATCH 03/19] IO-3503 Job Costing Fix Signed-off-by: Allan Carr --- server/job/job-costing.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/job/job-costing.js b/server/job/job-costing.js index 6014f3422..0cee8b647 100644 --- a/server/job/job-costing.js +++ b/server/job/job-costing.js @@ -545,7 +545,7 @@ function GenerateCostingData(job) { if ( job.materials["MAPA"] && job.materials["MAPA"].cal_maxdlr !== undefined && - job.materials["MAPA"].cal_maxdlr >= 0 + (InstanceManager({ rome: true }) ? job.materials["MAPA"].cal_maxdlr >= 0 : job.materials["MAPA"].cal_maxdlr > 0) ) { //It has an upper threshhold. threshold = Dinero({ @@ -595,7 +595,7 @@ function GenerateCostingData(job) { if ( job.materials["MASH"] && job.materials["MASH"].cal_maxdlr !== undefined && - job.materials["MASH"].cal_maxdlr >= 0 + (InstanceManager({ rome: true }) ? job.materials["MASH"].cal_maxdlr >= 0 : job.materials["MASH"].cal_maxdlr > 0) ) { //It has an upper threshhold. threshold = Dinero({ From d8b400cb8c34569beb76d4347ed78de3cd5c79f4 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 6 Feb 2026 15:15:11 -0800 Subject: [PATCH 04/19] IO-3503 InstanceManager change Signed-off-by: Allan Carr --- server/job/job-costing.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/server/job/job-costing.js b/server/job/job-costing.js index 0cee8b647..93c62ae75 100644 --- a/server/job/job-costing.js +++ b/server/job/job-costing.js @@ -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 && - (InstanceManager({ rome: true }) ? job.materials["MAPA"].cal_maxdlr >= 0 : 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 && - (InstanceManager({ rome: true }) ? job.materials["MASH"].cal_maxdlr >= 0 : 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"); From 9818cac30e1d7ed6fa67722e2eb6a0bfd57858f8 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 6 Feb 2026 16:10:03 -0800 Subject: [PATCH 05/19] IO-3521 Pagination Disable Show Size Changer Signed-off-by: Allan Carr --- .../accounting-payables-table.component.jsx | 2 +- .../accounting-payments-table.component.jsx | 2 +- .../accounting-receivables-table.component.jsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/accounting-payables-table/accounting-payables-table.component.jsx b/client/src/components/accounting-payables-table/accounting-payables-table.component.jsx index 07439658b..e6d7d9b1e 100644 --- a/client/src/components/accounting-payables-table/accounting-payables-table.component.jsx +++ b/client/src/components/accounting-payables-table/accounting-payables-table.component.jsx @@ -182,7 +182,7 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref Date: Fri, 6 Feb 2026 20:45:44 -0800 Subject: [PATCH 06/19] IO-3551 Export Reports Return Data Signed-off-by: Allan Carr --- client/src/utils/graphQLmodifier.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/utils/graphQLmodifier.js b/client/src/utils/graphQLmodifier.js index f759fff06..3ace9c04d 100644 --- a/client/src/utils/graphQLmodifier.js +++ b/client/src/utils/graphQLmodifier.js @@ -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; } From 3745d7a414b6306f44702481f2b9679f789524d5 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 10 Feb 2026 12:48:48 -0500 Subject: [PATCH 07/19] feature/IO-3556-Chattr-Integration --- .ebignore | 3 +- docker-compose-cluster.yml | 51 ++------------- docker-compose.yml | 50 ++------------ hasura/metadata/tables.yaml | 2 + .../down.sql | 4 ++ .../up.sql | 2 + localstack/init/10-bootstrap.sh | 65 +++++++++++++++++++ server.js | 1 + server/chatter/createLocation.js | 22 +++++++ server/graphql-client/queries.js | 11 +++- server/routes/chatterRoutes.js | 12 ++++ 11 files changed, 131 insertions(+), 92 deletions(-) create mode 100644 hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql create mode 100644 hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql create mode 100644 localstack/init/10-bootstrap.sh create mode 100644 server/chatter/createLocation.js create mode 100644 server/routes/chatterRoutes.js diff --git a/.ebignore b/.ebignore index f407b0596..4043a0f46 100644 --- a/.ebignore +++ b/.ebignore @@ -13,4 +13,5 @@ .env.development.local .env.test.local .env.production.local -bodyshop_translations.babel \ No newline at end of file +.env.localstack.docker +bodyshop_translations.babel diff --git a/docker-compose-cluster.yml b/docker-compose-cluster.yml index bbce31dd4..6988d4d35 100644 --- a/docker-compose-cluster.yml +++ b/docker-compose-cluster.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 0662dd9bd..f2a0f160c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 7cf11c8de..2f29ac26c 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -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 diff --git a/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql new file mode 100644 index 000000000..291344aa9 --- /dev/null +++ b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/down.sql @@ -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; diff --git a/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql new file mode 100644 index 000000000..4a6e1e8d4 --- /dev/null +++ b/hasura/migrations/1770737662785_alter_table_public_bodyshops_add_column_chatter_company_id/up.sql @@ -0,0 +1,2 @@ +alter table "public"."bodyshops" add column "chatter_company_id" text + null; diff --git a/localstack/init/10-bootstrap.sh b/localstack/init/10-bootstrap.sh new file mode 100644 index 000000000..ee8183d1e --- /dev/null +++ b/localstack/init/10-bootstrap.sh @@ -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" diff --git a/server.js b/server.js index 099ae3562..07901ab6e 100644 --- a/server.js +++ b/server.js @@ -125,6 +125,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) => { diff --git a/server/chatter/createLocation.js b/server/chatter/createLocation.js new file mode 100644 index 000000000..2bd85f95f --- /dev/null +++ b/server/chatter/createLocation.js @@ -0,0 +1,22 @@ +const DEFAULT_COMPANY_ID = process.env.CHATTER_DEFAULT_COMPANY_ID; + +const createLocation = (req, res) => { + const { logger } = req; + const { bodyshopID } = req.body; + + // No Default company + if (!DEFAULT_COMPANY_ID) { + logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID }); + return res.json({ success: false }); + } + + // No Bodyshop data available + if (!bodyshopID) { + logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID }); + return res.json({ success: false }); + } + + return res.json({ success: true }); +}; + +module.exports = createLocation; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 54714f723..e6576b080 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -1911,10 +1911,19 @@ 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 } diff --git a/server/routes/chatterRoutes.js b/server/routes/chatterRoutes.js new file mode 100644 index 000000000..52675bc9b --- /dev/null +++ b/server/routes/chatterRoutes.js @@ -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; From 1b2fc8b11477032443a7f4abf8b0511346b57d66 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 10 Feb 2026 17:17:44 -0500 Subject: [PATCH 08/19] feature/IO-3556-Chattr-Integration --- server/chatter/chatter-client.js | 126 +++++++++++ server/chatter/createLocation.js | 115 +++++++++- server/data/chatter-api.js | 350 +++++++++++++++++++++++++++++++ server/data/chatter.js | 15 +- server/data/data.js | 3 +- server/graphql-client/queries.js | 16 ++ server/routes/dataRoutes.js | 13 +- 7 files changed, 628 insertions(+), 10 deletions(-) create mode 100644 server/chatter/chatter-client.js create mode 100644 server/data/chatter-api.js diff --git a/server/chatter/chatter-client.js b/server/chatter/chatter-client.js new file mode 100644 index 000000000..a25f0c812 --- /dev/null +++ b/server/chatter/chatter-client.js @@ -0,0 +1,126 @@ +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; + throw err; + } + return data; + } +} + +/** + * Safely parse JSON, returning original text if parsing fails + */ +function safeJson(text) { + try { + return JSON.parse(text); + } catch { + return text; + } +} + +/** + * Fetches Chatter API token from AWS Secrets Manager + * SecretId format: CHATTER_COMPANY_KEY_ + * + * @param {string|number} companyId - The company ID + * @returns {Promise} 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} 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 +}; diff --git a/server/chatter/createLocation.js b/server/chatter/createLocation.js index 2bd85f95f..46766dcf8 100644 --- a/server/chatter/createLocation.js +++ b/server/chatter/createLocation.js @@ -1,22 +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 createLocation = (req, res) => { +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 } = req.body; + const { bodyshopID, googlePlaceID } = req.body; + + console.dir({ body: req.body }); - // No Default company if (!DEFAULT_COMPANY_ID) { logger.log("chatter-create-location-no-default-company", "warn", null, null, { bodyshopID }); - return res.json({ success: false }); + 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" }); } - // No Bodyshop data available if (!bodyshopID) { logger.log("chatter-create-location-invalid-bodyshop", "warn", null, null, { bodyshopID }); - return res.json({ success: false }); + return res.json({ success: false, message: "No bodyshop id" }); } - return res.json({ success: true }); + 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; diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js new file mode 100644 index 000000000..e49e35e42 --- /dev/null +++ b/server/data/chatter-api.js @@ -0,0 +1,350 @@ +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 { defaultProvider } = require("@aws-sdk/credential-provider-node"); +const { isString, isEmpty } = require("lodash"); + +const client = require("../graphql-client/graphql-client").client; +const { sendServerEmail } = require("../email/sendemail"); + +const CHATTER_EVENT = process.env.CHATTER_SOLICITATION_EVENT || "delivery"; +const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5); +const AWS_REGION = process.env.AWS_REGION || "ca-central-1"; + +// Configure SecretsManager client with localstack support for caching implementation +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`; +} + +// Token and client caching for performance +const tokenCache = new Map(); // companyId -> token string +const tokenInFlight = new Map(); // companyId -> Promise +const clientCache = new Map(); // companyId -> ChatterApiClient + +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: "Processing request ...", + timestamp: new Date().toISOString() + }); + + try { + 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 specificShopIds = req.body.bodyshopIds; + const { start, end, skipUpload } = req.body; // keep same flag; now acts like "dry run" + + const shopsToProcess = + specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.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; + } + + await processBatchApi({ + shopsToProcess, + start, + end, + skipUpload, + allShopSummaries, + allErrors + }); + + 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 } + ); + + await sendServerEmail({ + subject: `Chatter API Report ${moment().format("MM-DD-YY")}`, + text: + `Totals:\n${JSON.stringify(totals, null, 2)}\n\n` + + `Shop summaries:\n${JSON.stringify(allShopSummaries, null, 2)}\n\n` + + `Errors:\n${JSON.stringify(allErrors, null, 2)}\n` + }); + + logger.log("chatter-api-end", "DEBUG", "api", null, totals); + } catch (error) { + logger.log("chatter-api-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); + } +}; + +async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors }) { + 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); + + 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 })); + + 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}`; + + return { + locationIdentifier: locationIdentifier, + event: CHATTER_EVENT, + transactionId: j.ro_number != null ? String(j.ro_number) : undefined, + timestamp: j.actual_delivery ? moment(j.actual_delivery).tz(bodyshop.timezone).toISOString() : undefined, + 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) { + for (let attempt = 0; attempt < 6; attempt++) { + 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) { + await sleep(backoffMs(attempt)); + continue; + } + + return { ok: false, status: e.status, error: e.data ?? e.message }; + } + } + return { ok: false, status: 429, error: "rate limit retry exhausted" }; +} + +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 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(); + }); +} + +/** + * Returns a per-company Chatter API client, caching both the token and the client. + */ +async function getChatterApiClient(companyId) { + const key = String(companyId); + + const existing = clientCache.get(key); + if (existing) return existing; + + const apiToken = await getChatterApiTokenCached(companyId); + 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 caching + * SecretId: CHATTER_COMPANY_KEY_ + * + * Uses caching + in-flight dedupe to avoid hammering Secrets Manager. + */ +async function getChatterApiTokenCached(companyId) { + 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; + + const cached = tokenCache.get(key); + if (cached) return cached; + + const inflight = tokenInFlight.get(key); + if (inflight) return inflight; + + const p = (async () => { + logger.log("chatter-api-get-token", "DEBUG", "api", null, { companyId: key }); + + // Use the shared function from chatter-client + const token = await getChatterApiToken(companyId); + tokenCache.set(key, token); + return token; + })(); + + tokenInFlight.set(key, p); + + try { + return await p; + } finally { + tokenInFlight.delete(key); + } +} diff --git a/server/data/chatter.js b/server/data/chatter.js index 45402a67e..86182fbf9 100644 --- a/server/data/chatter.js +++ b/server/data/chatter.js @@ -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); diff --git a/server/data/data.js b/server/data/data.js index 1706d78af..52e72f6b8 100644 --- a/server/data/data.js +++ b/server/data/data.js @@ -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; \ No newline at end of file +exports.documentAnalytics = require("./analytics/documents").default; +exports.chatterApi = require("chatter-api").default(); diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index e6576b080..73d132a93 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -1929,6 +1929,22 @@ exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS { } }`; +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 + } +}`; + exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS { bodyshops(where: {external_shop_id: {_is_null: true}, carfax_exclude: {_neq: "true"}}){ id diff --git a/server/routes/dataRoutes.js b/server/routes/dataRoutes.js index c72a2a502..9024f13fc 100644 --- a/server/routes/dataRoutes.js +++ b/server/routes/dataRoutes.js @@ -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); From 0340ca5fccd11d9e22231d5c5040738d53847aa6 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 10 Feb 2026 17:25:59 -0500 Subject: [PATCH 09/19] feature/IO-3556-Chattr-Integration - Add in Redis caching for Chatter --- server/data/chatter-api.js | 59 +++++++++++++++++----------------- server/utils/redisHelpers.js | 61 +++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js index e49e35e42..89f29b8ae 100644 --- a/server/data/chatter-api.js +++ b/server/data/chatter-api.js @@ -2,32 +2,16 @@ 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 { defaultProvider } = require("@aws-sdk/credential-provider-node"); -const { isString, isEmpty } = require("lodash"); const client = require("../graphql-client/graphql-client").client; const { sendServerEmail } = require("../email/sendemail"); const CHATTER_EVENT = process.env.CHATTER_SOLICITATION_EVENT || "delivery"; const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5); -const AWS_REGION = process.env.AWS_REGION || "ca-central-1"; -// Configure SecretsManager client with localstack support for caching implementation -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`; -} - -// Token and client caching for performance -const tokenCache = new Map(); // companyId -> token string -const tokenInFlight = new Map(); // companyId -> Promise +// Client caching (in-memory) - tokens are now cached in Redis const clientCache = new Map(); // companyId -> ChatterApiClient +const tokenInFlight = new Map(); // companyId -> Promise (for in-flight deduplication) exports.default = async (req, res) => { if (process.env.NODE_ENV !== "production") return res.sendStatus(403); @@ -67,7 +51,8 @@ exports.default = async (req, res) => { end, skipUpload, allShopSummaries, - allErrors + allErrors, + sessionUtils: req.sessionUtils }); const totals = allShopSummaries.reduce( @@ -96,7 +81,7 @@ exports.default = async (req, res) => { } }; -async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors }) { +async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) { for (const bodyshop of shopsToProcess) { const summary = { bodyshopid: bodyshop.id, @@ -127,7 +112,7 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop continue; } - const chatterApi = await getChatterApiClient(companyId); + const chatterApi = await getChatterApiClient(companyId, sessionUtils); const { jobs } = await client.request(queries.CHATTER_QUERY, { bodyshopid: bodyshop.id, @@ -299,13 +284,13 @@ function createConcurrencyLimit(max) { /** * Returns a per-company Chatter API client, caching both the token and the client. */ -async function getChatterApiClient(companyId) { +async function getChatterApiClient(companyId, sessionUtils) { const key = String(companyId); const existing = clientCache.get(key); if (existing) return existing; - const apiToken = await getChatterApiTokenCached(companyId); + const apiToken = await getChatterApiTokenCached(companyId, sessionUtils); const chatterApi = new ChatterApiClient({ baseUrl: CHATTER_BASE_URL, apiToken }); clientCache.set(key, chatterApi); @@ -313,30 +298,42 @@ async function getChatterApiClient(companyId) { } /** - * Fetches the per-company token from AWS Secrets Manager with caching + * Fetches the per-company token from AWS Secrets Manager with Redis caching * SecretId: CHATTER_COMPANY_KEY_ * - * Uses caching + in-flight dedupe to avoid hammering Secrets Manager. + * Uses Redis caching + in-flight dedupe to avoid hammering Secrets Manager. */ -async function getChatterApiTokenCached(companyId) { +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; - const cached = tokenCache.get(key); - if (cached) return cached; + // 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", "DEBUG", "api", null, { companyId: key }); + logger.log("chatter-api-get-token-cache-miss", "DEBUG", "api", null, { companyId: key }); - // Use the shared function from chatter-client + // Fetch token from Secrets Manager using shared function const token = await getChatterApiToken(companyId); - tokenCache.set(key, token); + + // Store in Redis cache if sessionUtils is available + if (sessionUtils?.setChatterToken) { + await sessionUtils.setChatterToken(key, token); + } + return token; })(); diff --git a/server/utils/redisHelpers.js b/server/utils/redisHelpers.js index c8336b607..a317e7e7a 100644 --- a/server/utils/redisHelpers.js +++ b/server/utils/redisHelpers.js @@ -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} + */ + 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} + */ + 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); From 503c217c99e1a6d3dafb34258817378a54e5a704 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 11 Feb 2026 09:52:22 -0500 Subject: [PATCH 10/19] fix --- server/data/data.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/data/data.js b/server/data/data.js index 52e72f6b8..82accebbb 100644 --- a/server/data/data.js +++ b/server/data/data.js @@ -10,4 +10,4 @@ exports.carfax = require("./carfax").default; exports.carfaxRps = require("./carfax-rps").default; exports.vehicletype = require("./vehicletype/vehicletype").default; exports.documentAnalytics = require("./analytics/documents").default; -exports.chatterApi = require("chatter-api").default(); +exports.chatterApi = require("./chatter-api").default; From d08bfc61cdd098e5b1e5331d019daf4ff3774183 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 11 Feb 2026 11:37:47 -0500 Subject: [PATCH 11/19] feature/IO-3556-Chattr-Integration - Retry beef up / tweeks --- server/chatter/chatter-client.js | 13 ++ server/data/chatter-api.js | 199 +++++++++++++++++++++++++++++-- 2 files changed, 204 insertions(+), 8 deletions(-) diff --git a/server/chatter/chatter-client.js b/server/chatter/chatter-client.js index a25f0c812..047e94459 100644 --- a/server/chatter/chatter-client.js +++ b/server/chatter/chatter-client.js @@ -61,6 +61,8 @@ class ChatterApiClient { 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; @@ -78,6 +80,17 @@ function safeJson(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_ diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js index 89f29b8ae..7c50d0ca1 100644 --- a/server/data/chatter-api.js +++ b/server/data/chatter-api.js @@ -1,3 +1,39 @@ +/** + * 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"); @@ -6,12 +42,16 @@ const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../c const client = require("../graphql-client/graphql-client").client; const { sendServerEmail } = require("../email/sendemail"); -const CHATTER_EVENT = process.env.CHATTER_SOLICITATION_EVENT || "delivery"; +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 (for in-flight deduplication) +const companyRateLimiters = new Map(); // companyId -> rate limiter exports.default = async (req, res) => { if (process.env.NODE_ENV !== "production") return res.sendStatus(403); @@ -19,7 +59,7 @@ exports.default = async (req, res) => { res.status(202).json({ success: true, - message: "Processing request ...", + message: "Processing Chatter-API Cron request ...", timestamp: new Date().toISOString() }); @@ -149,7 +189,11 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop const failures = results .filter((r) => r && r.ok === false) .slice(0, 25) - .map((r) => ({ status: r.status, error: r.error })); + .map((r) => ({ + status: r.status, + error: r.error, + context: r.context + })); if (failures.length) { summary.ok = false; @@ -184,12 +228,22 @@ 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, transactionId: j.ro_number != null ? String(j.ro_number) : undefined, - timestamp: j.actual_delivery ? moment(j.actual_delivery).tz(bodyshop.timezone).toISOString() : undefined, + timestamp, firstName: isCompany ? null : j.ownr_fn || null, lastName: isCompany ? j.ownr_co_nm : j.ownr_ln || null, emailAddress: j.ownr_ea || undefined, @@ -203,7 +257,19 @@ function buildInteractionPayload(bodyshop, j) { } async function postInteractionWithPolicy(chatterApi, companyId, payload) { - for (let attempt = 0; attempt < 6; attempt++) { + 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 }; @@ -213,14 +279,40 @@ async function postInteractionWithPolicy(chatterApi, companyId, payload) { // rate limited -> backoff + retry if (e.status === 429) { - await sleep(backoffMs(attempt)); + 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; } - return { ok: false, status: e.status, error: e.data ?? e.message }; + 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 }; } } - return { ok: false, status: 429, error: "rate limit retry exhausted" }; + + 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) { @@ -251,6 +343,26 @@ function backoffMs(attempt) { 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 = []; @@ -281,6 +393,77 @@ function createConcurrencyLimit(max) { }); } +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. */ From 188a7b47b129b0a4772a003d16383b0bd9b2ea3f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 11 Feb 2026 11:59:16 -0500 Subject: [PATCH 12/19] feature/IO-3556-Chattr-Integration - Switch Consent to true --- server/data/chatter-api.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js index 7c50d0ca1..caee129f0 100644 --- a/server/data/chatter-api.js +++ b/server/data/chatter-api.js @@ -242,6 +242,7 @@ function buildInteractionPayload(bodyshop, j) { 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, From c46804cfdf20268e4e44658a4d03a3b3454109db Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 11 Feb 2026 15:33:59 -0500 Subject: [PATCH 13/19] feature/IO-3558-Reynolds-Part-2 - Initial --- .../dms-customer-selector.component.jsx | 4 +- .../rr-customer-selector.jsx | 69 +- .../dms-log-events.component.jsx | 4 +- .../dms-post-form/rr-dms-post-form.jsx | 195 ++--- .../dms-post-form/rr-early-ro-form.jsx | 367 +++++++++ .../dms-post-form/rr-early-ro-modal.jsx | 33 + .../jobs-close-lines.component.jsx | 8 +- .../jobs-convert-button.component.jsx | 335 ++++---- client/src/graphql/jobs.queries.js | 3 + client/src/pages/dms/dms.container.jsx | 1 + .../src/pages/jobs-admin/jobs-admin.page.jsx | 64 +- client/src/translations/en_us/common.json | 6 +- client/src/translations/es/common.json | 6 +- client/src/translations/fr/common.json | 6 +- hasura/metadata/tables.yaml | 6 + .../down.sql | 4 + .../up.sql | 2 + .../down.sql | 4 + .../up.sql | 2 + server/graphql-client/queries.js | 9 +- server/rr/rr-export-logs.js | 36 +- server/rr/rr-job-export.js | 322 +++++++- server/rr/rr-register-socket-events.js | 719 +++++++++++++++++- 23 files changed, 1928 insertions(+), 277 deletions(-) create mode 100644 client/src/components/dms-post-form/rr-early-ro-form.jsx create mode 100644 client/src/components/dms-post-form/rr-early-ro-modal.jsx create mode 100644 hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/down.sql create mode 100644 hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/up.sql create mode 100644 hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/down.sql create mode 100644 hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/up.sql diff --git a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx index 205ceb414..86e08e803 100644 --- a/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx +++ b/client/src/components/dms-customer-selector/dms-customer-selector.component.jsx @@ -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: { diff --git a/client/src/components/dms-customer-selector/rr-customer-selector.jsx b/client/src/components/dms-customer-selector/rr-customer-selector.jsx index 3622f61f1..ad5fbd4bf 100644 --- a/client/src/components/dms-customer-selector/rr-customer-selector.jsx +++ b/client/src/components/dms-customer-selector/rr-customer-selector.jsx @@ -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 ( +
+ +
+ We created the Repair Order. Please validate the totals and taxes in the DMS system. When done, + click Finished to finalize and mark this export as complete. +
+
+ + + +
+
+ } + /> + + ); + } + return null; + } + return ( -
+
(
@@ -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 && ( ({ disabled: rrDisableRow(record) }) }} /> - + ); } diff --git a/client/src/components/dms-log-events/dms-log-events.component.jsx b/client/src/components/dms-log-events/dms-log-events.component.jsx index 25f8c27ff..d90623df6 100644 --- a/client/src/components/dms-log-events/dms-log-events.component.jsx +++ b/client/src/components/dms-log-events/dms-log-events.component.jsx @@ -69,7 +69,7 @@ export function DmsLogEvents({ return { key: idx, color: logLevelColor(level), - children: ( + content: ( {/* Row 1: summary + inline "Details" toggle */} @@ -113,7 +113,7 @@ export function DmsLogEvents({ [logs, openSet, colorizeJson, isDarkMode, showDetails] ); - return ; + return ; } /** diff --git a/client/src/components/dms-post-form/rr-dms-post-form.jsx b/client/src/components/dms-post-form/rr-dms-post-form.jsx index 6cdea243e..a1d8154e6 100644 --- a/client/src/components/dms-post-form/rr-dms-post-form.jsx +++ b/client/src/components/dms-post-form/rr-dms-post-form.jsx @@ -208,8 +208,18 @@ export default function RRPostForm({ }); }; + // Check if early RO was created (job has dms_id) + const hasEarlyRO = !!job?.dms_id; + return ( + {hasEarlyRO && ( + + ✅ Early RO Created: {job.dms_id} +
+ This will update the existing RO with full job data. +
+ )}
- {/* Advisor + inline Refresh */} -
- - - - { + 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")} + /> + + - )} - - } - > - - - - - - - - - - - - - + aria-label={t("general.actions.refresh")} + icon={} + onClick={() => fetchRrAdvisors(true)} + loading={advLoading} + /> + + + + + )} + + {/* RR OpCode (prefix / base / suffix) - Only show if no early RO */} + {!hasEarlyRO && ( + + + {t("jobs.fields.dms.rr_opcode", "RR OpCode")} + {isCustomOpCode && ( + + )} + + } + > + + + + + + + + + + + + + + )} @@ -355,13 +365,14 @@ export default function RRPostForm({ {/* Validation */} {() => { - 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 ( = - ); diff --git a/client/src/components/dms-post-form/rr-early-ro-form.jsx b/client/src/components/dms-post-form/rr-early-ro-form.jsx new file mode 100644 index 000000000..cda20e543 --- /dev/null +++ b/client/src/components/dms-post-form/rr-early-ro-form.jsx @@ -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) => ( + 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 ( +
+
+
+ + +
+ + ); +} + +/** + * 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 ( + + ); + } + + // If showing customer selector, render modal + if (showCustomerSelector) { + return ( + <> + Create Early Reynolds RO + Waiting for customer selection... + + { + setShowCustomerSelector(false); + setIsSubmitting(false); + }} + > + + + + ); + } + + // 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 ( +
+ Create Early Reynolds RO + + Complete this section to create a minimal RO in Reynolds before converting the job. + + + + + + + + + + + + {/* RR OpCode (prefix / base / suffix) */} + + + + + + + + + + + + + + + + + + +
+ + + {showCancelButton && } + +
+ +
+ ); +} diff --git a/client/src/components/dms-post-form/rr-early-ro-modal.jsx b/client/src/components/dms-post-form/rr-early-ro-modal.jsx new file mode 100644 index 000000000..ffb1b7f00 --- /dev/null +++ b/client/src/components/dms-post-form/rr-early-ro-modal.jsx @@ -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 ( + + + + ); +} diff --git a/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx b/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx index e6bdc5321..a6ad151fb 100644 --- a/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx +++ b/client/src/components/jobs-close-lines/jobs-close-lines.component.jsx @@ -42,11 +42,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
{fields.map((field, index) => ( - {/* Hidden field to preserve jobline ID */} - */} + + + ({ 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 [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; + + console.log(`2309-829038721093820938290382903`); + console.log(isReynoldsMode); const handleConvert = async ({ employee_csr, category, ...values }) => { if (parentFormIsFieldsTouched()) { @@ -82,177 +104,216 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]); - const popMenu = ( -
-
{ + console.log("Early RO Success - result:", result); + setEarlyRoCreated(true); // Mark early RO as created + notification.success({ + title: t("jobs.successes.early_ro_created", "Early RO Created"), + message: `RO Number: ${result.roNumber || "N/A"}` + }); + // Don't close the modal - just refetch so the form updates + refetch?.(); + }; + + if (job.converted) return <>; + + return ( + <> + - - - -
- ); - if (job.converted) return <>; + {/* Show Reynolds Early RO section if applicable */} + {isReynoldsMode && !job.dms_id && !earlyRoCreated && ( + <> + + + + )} - return ( - - - + + + + + + + ); } diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index e32163b1a..03ac9e2ea 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -2216,6 +2216,9 @@ export const QUERY_JOB_EXPORT_DMS = gql` plate_no plate_st ownr_co_nm + dms_id + dms_customer_id + dms_advisor_id } } `; diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index c4d5c8190..d43f22a08 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -486,6 +486,7 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse ({ setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), setSelectedHeader: (key) => dispatch(setSelectedHeader(key)) @@ -39,14 +50,31 @@ const cardStyle = { height: "100%" }; -export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) { +export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop }) { 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); + + // 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 +103,15 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) { ]); }, [setBreadcrumbs, t, jobId, data, setSelectedHeader]); + const handleEarlyROSuccess = (result) => { + notification.success({ + title: t("jobs.successes.early_ro_created", "Early RO Created"), + message: `RO Number: ${result.roNumber || "N/A"}` + }); + setShowEarlyROModal(false); + refetch?.(); + }; + if (loading) return ; if (error) return ; if (!data.jobs_by_pk) return ; @@ -99,6 +136,15 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) { + {isReynoldsMode && !job?.dms_id && job?.converted && ( + + )} @@ -124,8 +170,18 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader }) { + + {/* Early RO Modal */} + setShowEarlyROModal(false)} + onSuccess={handleEarlyROSuccess} + bodyshop={bodyshop} + socket={socket} + job={job} + /> ); } -export default connect(null, mapDispatchToProps)(JobsCloseContainer); +export default connect(mapStateToProps, mapDispatchToProps)(JobsCloseContainer); diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index af14320db..5f5c5514f 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1818,7 +1818,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", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 5c7a31350..6559e1038 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1818,7 +1818,11 @@ "sale": "", "sale_dms_acctnumber": "", "story": "", - "vinowner": "" + "vinowner": "", + "rr_opcode": "", + "rr_opcode_prefix": "", + "rr_opcode_suffix": "", + "rr_opcode_base": "" }, "dms_allocation": "", "driveable": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index e74e06bf0..00af4c6aa 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1818,7 +1818,11 @@ "sale": "", "sale_dms_acctnumber": "", "story": "", - "vinowner": "" + "vinowner": "", + "rr_opcode": "", + "rr_opcode_prefix": "", + "rr_opcode_suffix": "", + "rr_opcode_base": "" }, "dms_allocation": "", "driveable": "", diff --git a/hasura/metadata/tables.yaml b/hasura/metadata/tables.yaml index 2f29ac26c..5d3d3eb85 100644 --- a/hasura/metadata/tables.yaml +++ b/hasura/metadata/tables.yaml @@ -3704,7 +3704,9 @@ - ded_status - deliverchecklist - depreciation_taxes + - dms_advisor_id - dms_allocation + - dms_customer_id - dms_id - driveable - employee_body @@ -3985,7 +3987,9 @@ - ded_status - deliverchecklist - depreciation_taxes + - dms_advisor_id - dms_allocation + - dms_customer_id - dms_id - driveable - employee_body @@ -4278,7 +4282,9 @@ - ded_status - deliverchecklist - depreciation_taxes + - dms_advisor_id - dms_allocation + - dms_customer_id - dms_id - driveable - employee_body diff --git a/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/down.sql b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/down.sql new file mode 100644 index 000000000..68a9ffd86 --- /dev/null +++ b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/down.sql @@ -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; diff --git a/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/up.sql b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/up.sql new file mode 100644 index 000000000..9f63afe7f --- /dev/null +++ b/hasura/migrations/1770837989352_alter_table_public_jobs_add_column_dms_customer_id/up.sql @@ -0,0 +1,2 @@ +alter table "public"."jobs" add column "dms_customer_id" text + null; diff --git a/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/down.sql b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/down.sql new file mode 100644 index 000000000..f58388cff --- /dev/null +++ b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/down.sql @@ -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; diff --git a/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/up.sql b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/up.sql new file mode 100644 index 000000000..0a81aec8f --- /dev/null +++ b/hasura/migrations/1770838205706_alter_table_public_jobs_add_column_dms_advisor_id/up.sql @@ -0,0 +1,2 @@ +alter table "public"."jobs" add column "dms_advisor_id" text + null; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 54714f723..131a7643c 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -1612,6 +1612,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 @@ -3203,9 +3206,11 @@ 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) { + 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 }) { id dms_id + dms_customer_id + dms_advisor_id } }`; diff --git a/server/rr/rr-export-logs.js b/server/rr/rr-export-logs.js index 27acca0e3..184d54a4f 100644 --- a/server/rr/rr-export-logs.js +++ b/server/rr/rr-export-logs.js @@ -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, diff --git a/server/rr/rr-job-export.js b/server/rr/rr-job-export.js index 534ef61d3..e211041d7 100644 --- a/server/rr/rr-job-export.js +++ b/server/rr/rr-job-export.js @@ -56,7 +56,319 @@ 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 + }; + + // 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 +627,10 @@ const finalizeRRRepairOrder = async (args) => { }; }; -module.exports = { exportJobToRR, finalizeRRRepairOrder, deriveRRStatus }; +module.exports = { + exportJobToRR, + createMinimalRRRepairOrder, + updateRRRepairOrderWithFullData, + finalizeRRRepairOrder, + deriveRRStatus +}; diff --git a/server/rr/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js index a039760f5..b0d20ae80 100644 --- a/server/rr/rr-register-socket-events.js +++ b/server/rr/rr-register-socket-events.js @@ -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} */ -const setJobDmsIdForSocket = async ({ socket, jobId, dmsId }) => { +const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId }) => { if (!jobId || !dmsId) { CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", { jobId, @@ -149,16 +156,25 @@ 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 + }); CreateRRLogEvent(socket, "INFO", "Linked job.dms_id to RR RO", { jobId, - dmsId: String(dmsId) + dmsId: String(dmsId), + dmsCustomerId, + dmsAdvisorId }); } catch (err) { CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", { jobId, dmsId, + dmsCustomerId, + dmsAdvisorId, message: err?.message || String(err), stack: err?.stack }); @@ -373,7 +389,501 @@ 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, and advisor ID on the job + if (dmsRoNo) { + CreateRRLogEvent(socket, "DEBUG", "Calling setJobDmsIdForSocket", { + jobId: rid, + dmsId: dmsRoNo, + customerId: effectiveCustNo, + advisorId: String(advisorNo) + }); + await setJobDmsIdForSocket({ + socket, + jobId: rid, + dmsId: dmsRoNo, + dmsCustomerId: effectiveCustNo, + dmsAdvisorId: String(advisorNo) + }); + } 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 +932,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 +1263,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( From 331dcfc063ea17fe24b058096081d38f0f579ef9 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 11 Feb 2026 15:47:46 -0500 Subject: [PATCH 14/19] feature/IO-3558-Reynolds-Part-2 - Admin Panel --- client/src/graphql/jobs.queries.js | 3 +++ client/src/pages/jobs-admin/jobs-admin.page.jsx | 12 ++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 03ac9e2ea..98b349e8a 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -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 diff --git a/client/src/pages/jobs-admin/jobs-admin.page.jsx b/client/src/pages/jobs-admin/jobs-admin.page.jsx index c237846be..65635618b 100644 --- a/client/src/pages/jobs-admin/jobs-admin.page.jsx +++ b/client/src/pages/jobs-admin/jobs-admin.page.jsx @@ -1,5 +1,5 @@ import { useQuery } from "@apollo/client/react"; -import { Card, Col, Result, Row, Space, Typography } from "antd"; +import { Button, Card, Col, Result, Row, Space, Typography } from "antd"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; @@ -136,14 +136,10 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop - {isReynoldsMode && !job?.dms_id && job?.converted && ( - + )} From 0ea254ed4e82918d5b9b25c395c904cd6f3e81f1 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 11 Feb 2026 16:57:13 -0500 Subject: [PATCH 15/19] feature/IO-3558-Reynolds-Part-2 - Admin Panel --- .../jobs-convert-button.component.jsx | 49 ++++++++++++------- server/graphql-client/queries.js | 5 +- server/rr/rr-register-socket-events.js | 18 ++++--- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx index 692150a5a..ee06e93d0 100644 --- a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx +++ b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx @@ -111,8 +111,14 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr title: t("jobs.successes.early_ro_created", "Early RO Created"), message: `RO Number: ${result.roNumber || "N/A"}` }); - // Don't close the modal - just refetch so the form updates - refetch?.(); + // Delay refetch to keep success message visible for 2 seconds + setTimeout(() => { + refetch?.(); + }, 2000); + }; + + const handleModalClose = () => { + setOpen(false); }; if (job.converted) return <>; @@ -136,7 +142,9 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr {/* Convert Job Modal */} setOpen(false)} + onCancel={handleModalClose} + closable={!(earlyRoCreated && !job.converted)} // Disable X button if early RO created but not converted + maskClosable={!(earlyRoCreated && !job.converted)} // Disable clicking outside to close title={t("jobs.actions.convert")} footer={null} width={700} @@ -157,6 +165,20 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr referral_source_extra: job.referral_source_extra ?? "" }} > + {/* Show Reynolds Early RO section at the top if applicable */} + {isReynoldsMode && !job.dms_id && !earlyRoCreated && ( + <> + + + + )} + - {/* Show Reynolds Early RO section if applicable */} - {isReynoldsMode && !job.dms_id && !earlyRoCreated && ( - <> - - - - )} - - + diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 131a7643c..b174f79c5 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -3206,11 +3206,12 @@ exports.UPDATE_USER_FCM_TOKENS_BY_EMAIL = /* GraphQL */ ` } `; -exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!, $dms_customer_id: String, $dms_advisor_id: String) { - 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 }) { +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 } }`; diff --git a/server/rr/rr-register-socket-events.js b/server/rr/rr-register-socket-events.js index b0d20ae80..98897eaa7 100644 --- a/server/rr/rr-register-socket-events.js +++ b/server/rr/rr-register-socket-events.js @@ -137,7 +137,7 @@ const getBodyshopForSocket = async ({ bodyshopId, socket }) => { * @param dmsAdvisorId * @returns {Promise} */ -const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId }) => { +const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAdvisorId, mileageIn }) => { if (!jobId || !dmsId) { CreateRRLogEvent(socket, "WARN", "setJobDmsIdForSocket called without jobId or dmsId", { jobId, @@ -160,14 +160,16 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAd id: jobId, dms_id: String(dmsId), dms_customer_id: dmsCustomerId ? String(dmsCustomerId) : null, - dms_advisor_id: dmsAdvisorId ? String(dmsAdvisorId) : 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), dmsCustomerId, - dmsAdvisorId + dmsAdvisorId, + mileageIn }); } catch (err) { CreateRRLogEvent(socket, "ERROR", "Failed to set job.dms_id after RR create/update", { @@ -175,6 +177,7 @@ const setJobDmsIdForSocket = async ({ socket, jobId, dmsId, dmsCustomerId, dmsAd dmsId, dmsCustomerId, dmsAdvisorId, + mileageIn, message: err?.message || String(err), stack: err?.stack }); @@ -709,20 +712,23 @@ const registerRREvents = ({ socket, redisHelpers }) => { jobId: rid }); - // ✅ Persist DMS RO number, customer ID, and advisor ID on the job + // ✅ 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) + advisorId: String(advisorNo), + mileageIn }); await setJobDmsIdForSocket({ socket, jobId: rid, dmsId: dmsRoNo, dmsCustomerId: effectiveCustNo, - dmsAdvisorId: String(advisorNo) + dmsAdvisorId: String(advisorNo), + mileageIn }); } else { CreateRRLogEvent(socket, "WARN", "RR early RO creation succeeded but no DMS RO number was returned", { From 6e0b1f65a72be2d40852993910542e35689f93d6 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 11 Feb 2026 18:12:56 -0500 Subject: [PATCH 16/19] feature/IO-3558-Reynolds-Part-2 - Admin Panel --- .../jobs-convert-button.component.jsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx index ee06e93d0..88019d0ce 100644 --- a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx +++ b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx @@ -38,6 +38,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr 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(); @@ -58,9 +59,6 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr const dmsMode = getDmsMode(bodyshop, Fortellis.treatment); const isReynoldsMode = dmsMode === DMS_MAP.reynolds; - console.log(`2309-829038721093820938290382903`); - console.log(isReynoldsMode); - const handleConvert = async ({ employee_csr, category, ...values }) => { if (parentFormIsFieldsTouched()) { alert(t("jobs.labels.savebeforeconversion")); @@ -105,8 +103,8 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]); const handleEarlyROSuccess = (result) => { - console.log("Early RO Success - result:", 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", "Early RO Created"), message: `RO Number: ${result.roNumber || "N/A"}` @@ -133,6 +131,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr loading={loading} onClick={() => { setEarlyRoCreated(!!job?.dms_id); // Initialize state based on current job + setEarlyRoCreatedThisSession(false); // Reset session state when opening modal setOpen(true); }} > @@ -143,8 +142,8 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr {t("jobs.actions.convert")} - From ab02da47a22c1cca1eba505fab4b27ab3b582385 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Wed, 11 Feb 2026 18:39:36 -0800 Subject: [PATCH 17/19] IO-3557 Reynolds DMS Info Signed-off-by: Allan Carr --- .../jobs-detail-general.component.jsx | 16 +++++++++++++++- client/src/graphql/jobs.queries.js | 3 +++ client/src/translations/en_us/common.json | 1 + client/src/translations/es/common.json | 1 + client/src/translations/fr/common.json | 1 + 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx index 172400edd..5eae62abf 100644 --- a/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx +++ b/client/src/components/jobs-detail-general/jobs-detail-general.component.jsx @@ -251,7 +251,6 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { ))}
- @@ -267,6 +266,21 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) { + {bodyshop.rr_dealerid && ( + + + + )} + {bodyshop.rr_dealerid && ( + + + + )} + {bodyshop.rr_dealerid && ( + + + + )} ); diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 98b349e8a..c63f5a302 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -494,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 { diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 5f5c5514f..3252b160c 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1794,6 +1794,7 @@ }, "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", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 6559e1038..20789b76c 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1794,6 +1794,7 @@ }, "cost": "", "cost_dms_acctnumber": "", + "customer": "", "dms_make": "", "dms_model": "", "dms_model_override": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 00af4c6aa..75be07749 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1794,6 +1794,7 @@ }, "cost": "", "cost_dms_acctnumber": "", + "customer": "", "dms_make": "", "dms_model": "", "dms_model_override": "", From 34fe0cc3bfc431282ea3ac42eb3ed2086dcf8c00 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 12 Feb 2026 12:56:17 -0500 Subject: [PATCH 18/19] 6feature/IO-3556-Chattr-Integration - Move to BULLMQ stack --- server.js | 15 +++ server/data/chatter-api.js | 125 ++++++++++-------- server/data/queues/chatterApiQueue.js | 178 ++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 51 deletions(-) create mode 100644 server/data/queues/chatterApiQueue.js diff --git a/server.js b/server.js index 07901ab6e..33cbe014c 100644 --- a/server.js +++ b/server.js @@ -40,6 +40,8 @@ const { loadEmailQueue } = require("./server/notifications/queues/emailQueue"); const { loadAppQueue } = require("./server/notifications/queues/appQueue"); const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket"); const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue"); +const { loadChatterApiQueue } = require("./server/data/queues/chatterApiQueue"); +const { processChatterApiJob } = require("./server/data/chatter-api"); const CLUSTER_RETRY_BASE_DELAY = 100; const CLUSTER_RETRY_MAX_DELAY = 5000; @@ -391,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), @@ -410,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 }); + }); }; /** diff --git a/server/data/chatter-api.js b/server/data/chatter-api.js index caee129f0..1aecb7cfb 100644 --- a/server/data/chatter-api.js +++ b/server/data/chatter-api.js @@ -40,7 +40,6 @@ const logger = require("../utils/logger"); const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client"); const client = require("../graphql-client/graphql-client").client; -const { sendServerEmail } = require("../email/sendemail"); const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION"; const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5); @@ -53,74 +52,98 @@ const clientCache = new Map(); // companyId -> ChatterApiClient const tokenInFlight = new Map(); // companyId -> Promise (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} 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} 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: "Processing Chatter-API Cron request ...", + message: "Chatter API job queued for processing", timestamp: new Date().toISOString() }); try { - logger.log("chatter-api-start", "DEBUG", "api", null, null); + const { dispatchChatterApiJob } = require("./queues/chatterApiQueue"); + const { start, end, bodyshopIds, skipUpload } = req.body; - const allErrors = []; - const allShopSummaries = []; - - // Shops that DO have chatter_company_id - const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY); - - const specificShopIds = req.body.bodyshopIds; - const { start, end, skipUpload } = req.body; // keep same flag; now acts like "dry run" - - const shopsToProcess = - specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.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; - } - - await processBatchApi({ - shopsToProcess, + await dispatchChatterApiJob({ start, end, - skipUpload, - allShopSummaries, - allErrors, - sessionUtils: req.sessionUtils + bodyshopIds, + skipUpload }); - - 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 } - ); - - await sendServerEmail({ - subject: `Chatter API Report ${moment().format("MM-DD-YY")}`, - text: - `Totals:\n${JSON.stringify(totals, null, 2)}\n\n` + - `Shop summaries:\n${JSON.stringify(allShopSummaries, null, 2)}\n\n` + - `Errors:\n${JSON.stringify(allErrors, null, 2)}\n` - }); - - logger.log("chatter-api-end", "DEBUG", "api", null, totals); } catch (error) { - logger.log("chatter-api-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); + 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 = { diff --git a/server/data/queues/chatterApiQueue.js b/server/data/queues/chatterApiQueue.js new file mode 100644 index 000000000..dc0f63f47 --- /dev/null +++ b/server/data/queues/chatterApiQueue.js @@ -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} options.bodyshopIds - Optional specific shops to process. + * @param {boolean} options.skipUpload - Dry-run flag. + * @returns {Promise} 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 }; From 7619360f371489b55ff5f10bffb689ba24b33aa7 Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 12 Feb 2026 17:13:06 -0500 Subject: [PATCH 19/19] feature/IO-3558-Reynolds-Part-2 - Prevent exporting without early ro / add a way to fake sconvert state in admin panel --- .../dms-post-form/rr-dms-post-form.jsx | 10 +- .../jobs-convert-button.component.jsx | 4 +- client/src/graphql/jobs.queries.js | 3 + client/src/pages/dms/dms.container.jsx | 18 ++ .../src/pages/jobs-admin/jobs-admin.page.jsx | 228 +++++++++++++++++- .../pages/jobs-close/jobs-close.component.jsx | 54 ++++- client/src/translations/en_us/common.json | 14 +- client/src/translations/es/common.json | 14 +- client/src/translations/fr/common.json | 14 +- 9 files changed, 332 insertions(+), 27 deletions(-) diff --git a/client/src/components/dms-post-form/rr-dms-post-form.jsx b/client/src/components/dms-post-form/rr-dms-post-form.jsx index a1d8154e6..c887826fd 100644 --- a/client/src/components/dms-post-form/rr-dms-post-form.jsx +++ b/client/src/components/dms-post-form/rr-dms-post-form.jsx @@ -208,16 +208,16 @@ export default function RRPostForm({ }); }; - // Check if early RO was created (job has dms_id) - const hasEarlyRO = !!job?.dms_id; + // 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 ( {hasEarlyRO && ( - ✅ Early RO Created: {job.dms_id} + ✅ {t("jobs.labels.dms.earlyro.created")} {job.dms_id}
- This will update the existing RO with full job data. + {t("jobs.labels.dms.earlyro.willupdate")}
)}
= ); diff --git a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx index 88019d0ce..72de99502 100644 --- a/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx +++ b/client/src/components/jobs-convert-button/jobs-convert-button.component.jsx @@ -106,8 +106,8 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr setEarlyRoCreated(true); // Mark early RO as created setEarlyRoCreatedThisSession(true); // Mark as created in this session notification.success({ - title: t("jobs.successes.early_ro_created", "Early RO Created"), - message: `RO Number: ${result.roNumber || "N/A"}` + 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(() => { diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 98b349e8a..c8119258d 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -1998,6 +1998,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 diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index d43f22a08..22d79a0bb 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -426,6 +426,24 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse if (data.jobs_by_pk?.date_exported) return ; + // 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 ( + + + + } + /> + ); + } + return (
diff --git a/client/src/pages/jobs-admin/jobs-admin.page.jsx b/client/src/pages/jobs-admin/jobs-admin.page.jsx index 65635618b..e7f9606d8 100644 --- a/client/src/pages/jobs-admin/jobs-admin.page.jsx +++ b/client/src/pages/jobs-admin/jobs-admin.page.jsx @@ -1,10 +1,12 @@ -import { useQuery } from "@apollo/client/react"; -import { Button, Card, Col, Result, Row, Space, Typography } from "antd"; -import { useEffect, useState } 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"; @@ -21,14 +23,16 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com import NotFound from "../../components/not-found/not-found.component"; import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal"; -import { GET_JOB_BY_PK } from "../../graphql/jobs.queries"; +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 @@ -36,7 +40,8 @@ const mapStateToProps = createStructuredSelector({ 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 = { @@ -50,7 +55,7 @@ const cardStyle = { height: "100%" }; -export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop }) { +export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop, insertAuditTrail }) { const { jobId } = useParams(); const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, { variables: { id: jobId }, @@ -61,6 +66,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop 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 { @@ -105,13 +115,53 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop const handleEarlyROSuccess = (result) => { notification.success({ - title: t("jobs.successes.early_ro_created", "Early RO Created"), - message: `RO Number: ${result.roNumber || "N/A"}` + 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 ; if (error) return ; if (!data.jobs_by_pk) return ; @@ -138,7 +188,12 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop {isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && ( + )} + {isReynoldsMode && !job?.converted && !job?.dms_id && ( + )} @@ -176,6 +231,161 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop socket={socket} job={job} /> + + {/* Convert without Early RO Modal */} + setShowConvertModal(false)} + title={t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")} + footer={null} + width={700} + destroyOnHidden + > + + + + + {bodyshop?.enforce_class && ( + + + + )} + {bodyshop?.enforce_referral && ( + <> + + + + + + + + )} + {bodyshop?.enforce_conversion_csr && ( + + + + )} + {bodyshop?.enforce_conversion_category && ( + + + + )} + {bodyshop?.region_config?.toLowerCase().startsWith("ca") && ( + + + + )} + + + + + + + + + + + + + ); } diff --git a/client/src/pages/jobs-close/jobs-close.component.jsx b/client/src/pages/jobs-close/jobs-close.component.jsx index 9a429cb2a..6587e8399 100644 --- a/client/src/pages/jobs-close/jobs-close.component.jsx +++ b/client/src/pages/jobs-close/jobs-close.component.jsx @@ -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 {bodyshopHasDmsKey(bodyshop) && ( - - - + <> + {canSendToDMS ? ( + + + + ) : ( + + )} + )} + + +
); } diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 5f5c5514f..72421d49d 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -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." @@ -1244,6 +1246,7 @@ "deselectall": "Deselect All", "download": "Download", "edit": "Edit", + "gotoadmin": "Go to Admin Panel", "login": "Login", "next": "Next", "ok": "Ok", @@ -1622,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", @@ -1635,6 +1640,7 @@ }, "post": "Post", "refetchmakesmodels": "Refetch Make and Model Codes", + "update_ro": "Update RO", "usegeneric": "Use Generic Customer", "useselected": "Use Selected Customer" }, @@ -2107,6 +2113,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", @@ -2264,6 +2275,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.", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 6559e1038..7862cdd39 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1047,7 +1047,9 @@ }, "dms": { "errors": { - "alreadyexported": "" + "alreadyexported": "", + "earlyrorequired": "", + "earlyrorequired.message": "" }, "labels": { "refreshallocations": "" @@ -1244,6 +1246,7 @@ "deselectall": "", "download": "", "edit": "Editar", + "gotoadmin": "", "login": "", "next": "", "ok": "", @@ -1622,11 +1625,13 @@ "changestatus": "Cambiar Estado", "changestimator": "", "convert": "Convertir", + "convertwithoutearlyro": "", "createiou": "", "deliver": "", "deliver_quick": "", "dms": { "addpayer": "", + "createearlyro": "", "createnewcustomer": "", "findmakemodelcode": "", "getmakes": "", @@ -1635,6 +1640,7 @@ }, "post": "", "refetchmakesmodels": "", + "update_ro": "", "usegeneric": "", "useselected": "" }, @@ -2107,6 +2113,11 @@ "damageto": "", "defaultstory": "", "disablebillwip": "", + "earlyro": { + "created": "", + "fields": "", + "willupdate": "" + }, "invoicedatefuture": "", "kmoutnotgreaterthankmin": "", "logs": "", @@ -2264,6 +2275,7 @@ "delete": "", "deleted": "Trabajo eliminado con éxito.", "duplicated": "", + "early_ro_created": "", "exported": "", "invoiced": "", "ioucreated": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 00af4c6aa..9f73906f7 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1047,7 +1047,9 @@ }, "dms": { "errors": { - "alreadyexported": "" + "alreadyexported": "", + "earlyrorequired": "", + "earlyrorequired.message": "" }, "labels": { "refreshallocations": "" @@ -1244,6 +1246,7 @@ "deselectall": "", "download": "", "edit": "modifier", + "gotoadmin": "", "login": "", "next": "", "ok": "", @@ -1622,11 +1625,13 @@ "changestatus": "Changer le statut", "changestimator": "", "convert": "Convertir", + "convertwithoutearlyro": "", "createiou": "", "deliver": "", "deliver_quick": "", "dms": { "addpayer": "", + "createearlyro": "", "createnewcustomer": "", "findmakemodelcode": "", "getmakes": "", @@ -1635,6 +1640,7 @@ }, "post": "", "refetchmakesmodels": "", + "update_ro": "", "usegeneric": "", "useselected": "" }, @@ -2107,6 +2113,11 @@ "damageto": "", "defaultstory": "", "disablebillwip": "", + "earlyro": { + "created": "", + "fields": "", + "willupdate": "" + }, "invoicedatefuture": "", "kmoutnotgreaterthankmin": "", "logs": "", @@ -2264,6 +2275,7 @@ "delete": "", "deleted": "Le travail a bien été supprimé.", "duplicated": "", + "early_ro_created": "", "exported": "", "invoiced": "", "ioucreated": "",
+ {/* Hidden field to preserve jobline ID without injecting a div under