diff --git a/server/rr/rr-helpers.js b/server/rr/rr-helpers.js index 8f0a59ff4..58c8fc199 100644 --- a/server/rr/rr-helpers.js +++ b/server/rr/rr-helpers.js @@ -6,6 +6,8 @@ * - Parses XML response (faults + STAR payload result) */ +"use strict"; + const fs = require("fs/promises"); const path = require("path"); const axios = require("axios"); @@ -68,7 +70,7 @@ async function loadTemplate(templateName) { async function renderXmlTemplate(templateName, data) { const tpl = await loadTemplate(templateName); // Render and strip any XML declaration to keep a single root element for the BOD - const rendered = mustache.render(tpl, data || {}); + const rendered = mustache.render(tpl, { STAR_NS: RR_NS.STAR_BUSINESS, ...(data || {}) }); return rendered.replace(/^\s*<\?xml[^>]*\?>\s*/i, ""); } @@ -202,12 +204,26 @@ function wrapWithApplicationArea(innerXml, { CreationDateTime, BODId, Sender, De return xml; } -async function buildStarEnvelope(innerBusinessXml, creds, appArea = {}) { +const TASK_BY_ACTION = { + CreateRepairOrder: { Task: "BSMRO", ReferenceId: "Insert" }, + UpdateRepairOrder: { Task: "BSMRO", ReferenceId: "Update" }, + InsertCustomer: { Task: "CU", ReferenceId: "Insert" }, + UpdateCustomer: { Task: "CU", ReferenceId: "Update" }, + InsertServiceVehicle: { Task: "SV", ReferenceId: "Insert" } +}; + +async function buildStarEnvelope(innerBusinessXml, creds, appArea = {}, action) { const now = new Date().toISOString(); + + // Derive sensible defaults for Sender from action, unless caller provided explicit values + const senderDefaults = + appArea?.Sender || + (action && TASK_BY_ACTION[action] ? { Component: "Rome", ...TASK_BY_ACTION[action] } : { Component: "Rome" }); + const payloadWithAppArea = wrapWithApplicationArea(innerBusinessXml, { CreationDateTime: appArea.CreationDateTime || now, BODId: appArea.BODId || `BOD-${Date.now()}`, - Sender: appArea.Sender || { Component: "Rome", Task: "SV", ReferenceId: "Update" }, + Sender: senderDefaults, Destination: appArea.Destination || { DealerNumber: creds.dealerNumber, StoreNumber: String(creds.storeNumber ?? "").padStart(2, "0"), @@ -241,6 +257,7 @@ async function buildStarEnvelope(innerBusinessXml, creds, appArea = {}) { async function MakeRRCall({ action, body, + appArea, // <-- allow explicit ApplicationArea overrides at the top level socket, dealerConfig, // required in runtime code; rr-test.js can still pass env-inflated cfg retries = 1, @@ -259,8 +276,9 @@ async function MakeRRCall({ const templateName = body?.template || action; const renderedBusiness = await renderXmlTemplate(templateName, body?.data || {}); - // Build STAR envelope - const envelope = await buildStarEnvelope(renderedBusiness, cfg, body?.appArea); + // Build STAR envelope (use explicit appArea if provided; else accept body.appArea; else derive from action) + const selectedAppArea = appArea || body?.appArea || {}; + const envelope = await buildStarEnvelope(renderedBusiness, cfg, selectedAppArea, action); const formattedEnvelope = prettyPrintXml(envelope); // Guardrails @@ -310,6 +328,7 @@ async function MakeRRCall({ return MakeRRCall({ action, body, + appArea: selectedAppArea, socket, dealerConfig: cfg, retries: retries - 1, diff --git a/server/rr/rr-mappers.js b/server/rr/rr-mappers.js index b986075b9..601f62019 100644 --- a/server/rr/rr-mappers.js +++ b/server/rr/rr-mappers.js @@ -32,6 +32,22 @@ function getDSB(cfg) { return { dealerNumber, storeNumber, branchNumber }; } +/** + * Normalize an address-like object to the template's
block. + */ +function mapAddress(addr) { + if (!addr) return undefined; + const out = { + Line1: addr.line1, + Line2: addr.line2, + City: addr.city, + State: addr.state, + PostalCode: addr.postal_code || addr.postalCode, + Country: addr.country + }; + return hasAny(out) ? out : undefined; +} + // // ===================== CUSTOMER ===================== // @@ -39,71 +55,36 @@ function getDSB(cfg) { /** * Map internal customer record to Rome CustomerInsertRq. */ -function mapCustomerInsert(customer, bodyshopConfig) { - if (!customer) return {}; - const { dealerNumber, storeNumber, branchNumber } = getDSB(bodyshopConfig); - +function mapCustomerInsert(src) { + const name = src.company_name?.trim() || [src.first_name, src.last_name].filter(Boolean).join(" ").trim(); return { - DealerCode: bodyshopConfig?.dealer_code || "ROME", - DealerNumber: dealerNumber, - StoreNumber: storeNumber, - BranchNumber: branchNumber, - RequestId: `CUST-INSERT-${customer.id}`, - Environment: process.env.NODE_ENV, - - CustomerId: customer.external_id || undefined, - CustomerType: customer.type || "RETAIL", - CompanyName: customer.company_name, - FirstName: customer.first_name, - MiddleName: customer.middle_name, - LastName: customer.last_name, - PreferredName: customer.display_name || customer.first_name, - ActiveFlag: customer.active ? "true" : "false", - - CustomerGroup: customer.group_name, - TaxExempt: customer.tax_exempt ? "true" : "false", - DiscountLevel: num(customer.discount_level), - PreferredLanguage: customer.language || "EN", - - Addresses: (customer.addresses || []).map((a) => ({ - AddressType: a.type || "BILLING", - AddressLine1: a.line1, - AddressLine2: a.line2, + CustomerNumber: src.external_id, // optional + CustomerType: src.type === "BUSINESS" ? "BUSINESS" : "RETAIL", + CustomerName: name, + DisplayName: src.display_name || name, + Language: src.language || "EN", + TaxExempt: src.tax_exempt ? "Y" : "N", + Active: src.active ? "Y" : "N", + Addresses: (src.addresses || []).map((a) => ({ + Type: a.type || "P", + Line1: a.line1, + Line2: a.line2, City: a.city, State: a.state, PostalCode: a.postal_code, - Country: a.country || "US" + Country: a.country })), - - Phones: (customer.phones || []).map((p) => ({ - PhoneNumber: p.number, - PhoneType: p.type || "MOBILE", - Preferred: p.preferred ? "true" : "false" + Phones: (src.phones || []).map((p) => ({ + Type: p.type || "H", + Number: p.number, + Extension: p.extension, + Preferred: p.preferred ? "Y" : "N" })), - - Emails: (customer.emails || []).map((e) => ({ - EmailAddress: e.address, - EmailType: e.type || "WORK", - Preferred: e.preferred ? "true" : "false" - })), - - Insurance: customer.insurance - ? { - CompanyName: customer.insurance.company, - PolicyNumber: customer.insurance.policy, - ExpirationDate: formatDate(customer.insurance.expiration_date), - ContactName: customer.insurance.contact_name, - ContactPhone: customer.insurance.contact_phone - } - : undefined, - - LinkedAccounts: (customer.linked_accounts || []).map((a) => ({ - Type: a.type, - AccountNumber: a.account_number, - CreditLimit: num(a.credit_limit) - })), - - Notes: customer.notes?.length ? { Items: customer.notes.map((n) => n.text || n) } : undefined + Emails: (src.emails || []).map((e) => ({ + Type: e.type || "W", + Address: e.address, + Preferred: e.preferred ? "Y" : "N" + })) }; } @@ -184,6 +165,8 @@ function mapServiceVehicle(vehicle, ownerCustomer, bodyshopConfig) { /** * Map internal job to Rome RepairOrderInsertRq. + * NOTE: The CreateRepairOrder.xml template expects *flat* fields for Customer and ServiceVehicle + * (no {{#Customer}} or {{#ServiceVehicle}} sections). Therefore, we flatten those values here. */ function mapRepairOrderCreate(job, bodyshopConfig) { if (!job) return {}; @@ -192,7 +175,11 @@ function mapRepairOrderCreate(job, bodyshopConfig) { const cust = job.customer || {}; const veh = job.vehicle || {}; + // Prefer a concrete address on the customer, fall back to job-level + const customerAddress = cust.address || job.customer_address || job.address || undefined; + return { + // Routing/meta we keep available for logging or other templates DealerCode: bodyshopConfig?.dealer_code || "ROME", DealerNumber: dealerNumber, StoreNumber: storeNumber, @@ -200,6 +187,7 @@ function mapRepairOrderCreate(job, bodyshopConfig) { RequestId: `RO-${job.id}`, Environment: process.env.NODE_ENV, + // Header fields RepairOrderNumber: job.ro_number, DmsRepairOrderId: job.external_id, @@ -215,26 +203,26 @@ function mapRepairOrderCreate(job, bodyshopConfig) { ROType: job.ro_type, Status: job.status, IsBodyShop: "true", - DRPFlag: job.drp_flag ? "true" : "false", + DRPFlag: toBoolStr(!!job.drp_flag) || "false", - Customer: { - CustomerId: cust.external_id, - CustomerName: cust.full_name, - PhoneNumber: cust.phone, - EmailAddress: cust.email - }, + // Customer block is FLAT (template does not use {{#Customer}} section) + CustomerId: cust.external_id, + CustomerName: cust.full_name || [cust.first_name, cust.last_name].filter(Boolean).join(" ").trim() || undefined, + PhoneNumber: cust.phone, + EmailAddress: cust.email, + Address: mapAddress(customerAddress), - Vehicle: { - VehicleId: veh.external_id, - VIN: veh.vin, - LicensePlate: veh.license_plate, - Year: num(veh.year), - Make: veh.make, - Model: veh.model, - Odometer: num(veh.odometer), - Color: veh.color - }, + // ServiceVehicle block is FLAT (template does not use {{#ServiceVehicle}} section) + VehicleId: veh.external_id, + VIN: veh.vin, + LicensePlate: veh.license_plate, + Year: num(veh.year), + Make: veh.make, + Model: veh.model, + Odometer: num(veh.odometer), + Color: veh.color, + // Lines JobLines: (job.joblines || []).map((l, i) => ({ Sequence: i + 1, ParentSequence: l.parent_sequence, @@ -264,16 +252,28 @@ function mapRepairOrderCreate(job, bodyshopConfig) { : undefined })), - Totals: { + // Totals + Totals: hasAny({ Currency: job.currency || "CAD", - LaborTotal: num(job.totals?.labor), - PartsTotal: num(job.totals?.parts), - MiscTotal: num(job.totals?.misc), - DiscountTotal: num(job.totals?.discount), - TaxTotal: num(job.totals?.tax), - GrandTotal: num(job.totals?.grand) - }, + LaborTotal: job.totals?.labor, + PartsTotal: job.totals?.parts, + MiscTotal: job.totals?.misc, + DiscountTotal: job.totals?.discount, + TaxTotal: job.totals?.tax, + GrandTotal: job.totals?.grand + }) + ? { + Currency: job.currency || "CAD", + LaborTotal: num(job.totals?.labor), + PartsTotal: num(job.totals?.parts), + MiscTotal: num(job.totals?.misc), + DiscountTotal: num(job.totals?.discount), + TaxTotal: num(job.totals?.tax), + GrandTotal: num(job.totals?.grand) + } + : undefined, + // Payments Payments: job.payments?.length ? { Items: job.payments.map((p) => ({ @@ -287,6 +287,7 @@ function mapRepairOrderCreate(job, bodyshopConfig) { } : undefined, + // Insurance Insurance: job.insurance ? { CompanyName: job.insurance.company, @@ -296,6 +297,7 @@ function mapRepairOrderCreate(job, bodyshopConfig) { } : undefined, + // Notes Notes: job.notes?.length ? { Items: job.notes.map((n) => n.text || n) } : undefined }; } diff --git a/server/rr/rr-repair-orders.js b/server/rr/rr-repair-orders.js index fad0a3fc3..8a7f66107 100644 --- a/server/rr/rr-repair-orders.js +++ b/server/rr/rr-repair-orders.js @@ -4,11 +4,63 @@ * Handles creation and updates of repair orders (BSMRepairOrderRq/Resp). */ +"use strict"; + const { MakeRRCall } = require("./rr-helpers"); const { mapRepairOrderCreate, mapRepairOrderUpdate } = require("./rr-mappers"); const RRLogger = require("./rr-logger"); const { RrApiError } = require("./rr-error"); +/** + * Very light sanity checks before we build XML. + * We keep these minimal because the mapper may derive some fields. + * Throws RrApiError when a required precondition is missing. + * @param {"CreateRepairOrder"|"UpdateRepairOrder"} action + * @param {Object} job + */ +function preflight(action, job) { + if (!job || !job.id) { + throw new RrApiError("Missing job payload or job.id", "RR_BAD_JOB_PAYLOAD"); + } + // VIN is almost always required for BSM RO flows + const vin = job?.vehicle?.vin || job?.vehicle?.VIN || job?.VIN || job?.vin; + if (!vin) { + throw new RrApiError("Missing VIN on job.vehicle", "RR_MISSING_VIN"); + } + + if (action === "UpdateRepairOrder") { + // If your mapper expects a DMS RO number or an external RO number, + // you can tighten this guard based on your schema, e.g.: + // const hasKey = job?.dms_ro_no || job?.external_ro_number || job?.roNumber; + // if (!hasKey) throw new RrApiError("Missing RO key for update", "RR_MISSING_RO_KEY"); + } +} + +/** + * Build an explicit ApplicationArea override so the envelope is always correct, + * even if the helper falls back to defaults. We include routing info + * and set Task/ReferenceId for BSM repair orders. + * @param {"CreateRepairOrder"|"UpdateRepairOrder"} action + * @param {Object} cfg + */ +function buildAppArea(action, cfg) { + const isCreate = action === "CreateRepairOrder"; + return { + Sender: { + Component: "Rome", + Task: "BSMRO", + ReferenceId: isCreate ? "Insert" : "Update" + }, + Destination: { + DealerNumber: cfg?.DealerNumber || cfg?.dealerNumber, + StoreNumber: cfg?.StoreNumber || cfg?.storeNumber, + AreaNumber: cfg?.AreaNumber || cfg?.areaNumber, + DestinationNameCode: "RR" + } + // CreationDateTime and BODId will be provided by rr-helpers if omitted. + }; +} + /** * Create a new repair order in Rome. * @param {Socket} socket - active socket connection @@ -21,13 +73,20 @@ async function createRepairOrder(socket, job, bodyshopConfig) { const template = "CreateRepairOrder"; // maps to xml-templates/CreateRepairOrder.xml try { - RRLogger(socket, "info", `Starting RR ${action} for job ${job.id}`); + RRLogger(socket, "info", `Starting RR ${action} for job ${job?.id}`, { + jobid: job?.id, + dealer: bodyshopConfig?.DealerNumber || bodyshopConfig?.dealerNumber, + store: bodyshopConfig?.StoreNumber || bodyshopConfig?.storeNumber, + area: bodyshopConfig?.AreaNumber || bodyshopConfig?.areaNumber + }); + preflight(action, job); const data = mapRepairOrderCreate(job, bodyshopConfig); const resultXml = await MakeRRCall({ action, body: { template, data }, + appArea: buildAppArea(action, bodyshopConfig), socket, dealerConfig: bodyshopConfig, jobid: job.id @@ -43,9 +102,9 @@ async function createRepairOrder(socket, job, bodyshopConfig) { xml: resultXml }; } catch (error) { - RRLogger(socket, "error", `Error in ${action} for job ${job.id}`, { - message: error.message, - stack: error.stack + RRLogger(socket, "error", `Error in ${action} for job ${job?.id}`, { + message: error?.message, + stack: error?.stack }); throw new RrApiError(`RR CreateRepairOrder failed: ${error.message}`, "CREATE_RO_ERROR"); } @@ -63,13 +122,20 @@ async function updateRepairOrder(socket, job, bodyshopConfig) { const template = "UpdateRepairOrder"; try { - RRLogger(socket, "info", `Starting RR ${action} for job ${job.id}`); + RRLogger(socket, "info", `Starting RR ${action} for job ${job?.id}`, { + jobid: job?.id, + dealer: bodyshopConfig?.DealerNumber || bodyshopConfig?.dealerNumber, + store: bodyshopConfig?.StoreNumber || bodyshopConfig?.storeNumber, + area: bodyshopConfig?.AreaNumber || bodyshopConfig?.areaNumber + }); + preflight(action, job); const data = mapRepairOrderUpdate(job, bodyshopConfig); const resultXml = await MakeRRCall({ action, body: { template, data }, + appArea: buildAppArea(action, bodyshopConfig), socket, dealerConfig: bodyshopConfig, jobid: job.id @@ -85,9 +151,9 @@ async function updateRepairOrder(socket, job, bodyshopConfig) { xml: resultXml }; } catch (error) { - RRLogger(socket, "error", `Error in ${action} for job ${job.id}`, { - message: error.message, - stack: error.stack + RRLogger(socket, "error", `Error in ${action} for job ${job?.id}`, { + message: error?.message, + stack: error?.stack }); throw new RrApiError(`RR UpdateRepairOrder failed: ${error.message}`, "UPDATE_RO_ERROR"); } diff --git a/server/rr/rr-test.js b/server/rr/rr-test.js index a032cf01e..6a677efa0 100644 --- a/server/rr/rr-test.js +++ b/server/rr/rr-test.js @@ -1,265 +1,484 @@ #!/usr/bin/env node /** - * RR smoke test / CLI (STAR-only) + * rr-test.js — end-to-end exerciser for Reynolds "Rome"/STAR actions. + * + * Key improvements vs prior version: + * - Prints FULL XML responses (no truncation). + * - CombinedSearch now sends at least one search criterion by default. + * - InsertCustomer sets required IBFlag + LastName/FirstName. + * - Orchestrates dependent steps (CombinedSearch → InsertCustomer → InsertVehicle → Create/Update RO). + * + * Usage examples: + * node rr-test.js --ping + * node rr-test.js --all + * node rr-test.js --combined --last "SMITH" --phone 9375550001 --vin 1FTFW1E50JFA00000 + * + * Common flags: + * --first, --last, --phone, --email, --vin, --plate, --advisor, --max N + * + * Env fallbacks: + * RR_TEST_LAST, RR_TEST_FIRST, RR_TEST_PHONE, RR_TEST_EMAIL, RR_TEST_VIN, RR_TEST_PLATE, RR_TEST_PARTDESC + * + * Dealer creds/env (used only in this test harness; runtime pulls from DB): + * RR_DEALER_NUMBER, RR_STORE_NUMBER, RR_AREA_NUMBER, RR_USERNAME, RR_PASSWORD, RR_ENDPOINT */ const path = require("path"); const fs = require("fs"); -const dotenv = require("dotenv"); -const { GraphQLClient, gql } = require("graphql-request"); -const { MakeRRCall, renderXmlTemplate, buildStarEnvelope } = require("./rr-helpers"); -const { getBaseRRConfig } = require("./rr-constants"); +const fsp = require("fs/promises"); +const minimist = require("minimist"); +const dayjs = require("dayjs"); +const { XMLParser } = require("fast-xml-parser"); -// Load env file for local runs -const defaultEnvPath = path.resolve(__dirname, "../../.env.development"); -if (fs.existsSync(defaultEnvPath)) { - const result = dotenv.config({ path: defaultEnvPath }); - if (result?.parsed) { - console.log( - `${defaultEnvPath}\n[dotenv@${require("dotenv/package.json").version}] injecting env (${Object.keys(result.parsed).length}) from ../../.env.development` - ); +// Load dev env if present +const envPath = path.resolve(__dirname, "../../.env.development"); +if (fs.existsSync(envPath)) { + require("dotenv").config({ path: envPath }); + console.log(envPath); + console.log( + `[dotenv@${require("dotenv/package.json").version}] injecting env (${Object.keys(process.env).length}) from ../../.env.development` + ); +} + +const { MakeRRCall, parseRRResponse } = require("./rr-helpers"); +const RRLogger = require("./rr-logger"); + +// CLI flags +const argv = minimist(process.argv.slice(2)); +const FLAG = (k, d) => (argv[k] !== undefined ? argv[k] : d); + +// For templates that expect STAR NS in {{STAR_NS}} +const STAR_NS = "http://www.starstandards.org/STAR"; + +// Basic XML parser for optional extractions +const parser = new XMLParser({ ignoreAttributes: false, removeNSPrefix: true }); + +// Ensure templates exist +async function verifyTemplates() { + const required = [ + "CombinedSearch", + "GetAdvisors", + "GetParts", + "InsertCustomer", + "UpdateCustomer", + "InsertServiceVehicle", + "CreateRepairOrder", + "UpdateRepairOrder" + ]; + for (const t of required) { + const p = path.join(__dirname, "xml-templates", `${t}.xml`); + await fsp.readFile(p, "utf8"); + } + console.log("✅ Templates verified."); +} + +// Format AppArea per action (Sender.Task/ReferenceId) +function appAreaFor(action, cfg) { + const pad2 = (x) => String(x ?? "").padStart(2, "0"); + const base = { + CreationDateTime: new Date().toISOString(), + BODId: `BOD-${Date.now()}`, + Sender: { Component: "Rome", CreatorNameCode: "RCI", SenderNameCode: "RCI" }, + Destination: { + DealerNumber: cfg.dealerNumber, + StoreNumber: pad2(cfg.storeNumber), + AreaNumber: pad2(cfg.branchNumber || "01") + } + }; + switch (action) { + case "CreateRepairOrder": + return { ...base, Sender: { ...base.Sender, Task: "BSMRO", ReferenceId: "Insert" } }; + case "UpdateRepairOrder": + return { ...base, Sender: { ...base.Sender, Task: "BSMRO", ReferenceId: "Update" } }; + case "InsertCustomer": + return { ...base, Sender: { ...base.Sender, Task: "CU", ReferenceId: "Insert" } }; + case "UpdateCustomer": + return { ...base, Sender: { ...base.Sender, Task: "CU", ReferenceId: "Update" } }; + case "InsertServiceVehicle": + return { ...base, Sender: { ...base.Sender, Task: "SV", ReferenceId: "Insert" } }; + case "CombinedSearch": + return { ...base, Sender: { ...base.Sender, Task: "SV", ReferenceId: "Query" } }; + case "GetAdvisors": + return { ...base, Sender: { ...base.Sender, Task: "RCI", ReferenceId: "Lookup" } }; + case "GetParts": + return { ...base, Sender: { ...base.Sender, Task: "RCI", ReferenceId: "Lookup" } }; + default: + return base; } } -// ---- CLI args parsing ---- -const argv = process.argv.slice(2); -const args = { _: [] }; -for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - - if (a.startsWith("--")) { - const eq = a.indexOf("="); - if (eq > -1) { - const k = a.slice(2, eq); - const v = a.slice(eq + 1); - args[k] = v; - } else { - const k = a.slice(2); - const next = argv[i + 1]; - if (next && !next.startsWith("-")) { - args[k] = next; - i++; - } else { - args[k] = true; - } - } - } else if (a.startsWith("-") && a.length > 1) { - const k = a.slice(1); - const next = argv[i + 1]; - if (next && !next.startsWith("-")) { - args[k] = next; - i++; - } else { - args[k] = true; - } - } else { - args._.push(a); - } -} - -function toIntOr(defaultVal, maybe) { - const n = parseInt(maybe, 10); - return Number.isFinite(n) ? n : defaultVal; -} - -// ---------------- GraphQL helpers ---------------- - -function buildGqlClient() { - const endpoint = process.env.GRAPHQL_ENDPOINT; - if (!endpoint) throw new Error("GRAPHQL_ENDPOINT env var is required when using --bodyshopId."); - - const headers = {}; - if (process.env.HASURA_ADMIN_SECRET) { - headers["x-hasura-admin-secret"] = process.env.HASURA_ADMIN_SECRET; - } else if (process.env.GRAPHQL_BEARER) { - headers["authorization"] = `Bearer ${process.env.GRAPHQL_BEARER}`; - } - - return new GraphQLClient(endpoint, { headers }); -} - -const Q_BODYSHOPS_BY_PK = gql` - query BodyshopRR($id: uuid!) { - bodyshops_by_pk(id: $id) { - rr_dealerid - rr_configuration - } - } -`; - -function normalizeConfigRecord(rec) { - if (!rec) return null; - let cfg = rec.rr_configuration || {}; - if (typeof cfg === "string") { - try { - cfg = JSON.parse(cfg); - } catch { - cfg = {}; - } - } +// Build cfg for test harness (env fallbacks allowed here) +function cfgFromEnv() { return { - dealerNumber: rec.rr_dealerid || undefined, - storeNumber: cfg.storeNumber || undefined, - branchNumber: cfg.branchNumber || undefined + baseUrl: + process.env.RR_ENDPOINT || process.env.RR_BASE_URL || "https://b2b-test.reyrey.com/Sync/RCI/Rome/Receive.ashx", + username: process.env.RR_USERNAME || "Rome", + password: process.env.RR_PASSWORD || "", + timeout: Number(process.env.RR_TIMEOUT || 30000), + dealerNumber: process.env.RR_DEALER_NUMBER || "PPERASV02000000", + storeNumber: process.env.RR_STORE_NUMBER || "05", + branchNumber: process.env.RR_AREA_NUMBER || "03" }; } -/** - * Load RR config overrides from DB (bodyshops_by_pk only). - */ -async function loadBodyshopRRConfig(bodyshopId) { - if (!bodyshopId) return null; - - const client = buildGqlClient(); - const { bodyshops_by_pk: bs } = await client.request(Q_BODYSHOPS_BY_PK, { id: bodyshopId }); - - if (!bs) throw new Error("Bodyshop not found."); - if (!bs.rr_dealerid) throw new Error("Bodyshop is not configured for RR (missing rr_dealerid)."); - - return normalizeConfigRecord(bs); -} - -// ---------------- rr-test logic ---------------- - -function pickActionName(raw) { - if (!raw || typeof raw !== "string") return "ping"; - const x = raw.toLowerCase(); - if (x === "combined" || x === "combinedsearch" || x === "comb") return "combined"; - if (x === "advisors" || x === "advisor" || x === "getadvisors") return "advisors"; - if (x === "parts" || x === "getparts" || x === "part") return "parts"; - if (x === "ping") return "ping"; - return x; -} - -function buildBodyForAction(action, args, cfg) { - switch (action) { - case "ping": - case "advisors": { - const max = toIntOr(1, args.max); - const data = { - DealerCode: cfg.dealerNumber, - DealerNumber: cfg.dealerNumber, - StoreNumber: cfg.storeNumber, - BranchNumber: cfg.branchNumber, - SearchCriteria: { - AdvisorId: args.advisorId, - FirstName: args.first || args.firstname, - LastName: args.last || args.lastname, - Department: args.department, - Status: args.status || "ACTIVE", - IncludeInactive: args.includeInactive ? "true" : undefined, - MaxResults: max +// Utility: get a value from parsed JSON or raw XML (element or attribute) +function findFirstId(parsed, rawXml, keyRegex) { + // search parsed object + if (parsed && typeof parsed === "object") { + const stack = [parsed]; + while (stack.length) { + const cur = stack.pop(); + if (cur && typeof cur === "object") { + for (const [k, v] of Object.entries(cur)) { + if (keyRegex.test(k) && (typeof v === "string" || typeof v === "number")) { + const s = String(v).trim(); + if (s) return s; + } + if (v && typeof v === "object") stack.push(v); } - }; - return { template: "GetAdvisors", data, appArea: {} }; + } } - - case "combined": { - const max = toIntOr(10, args.max); - const data = { - DealerNumber: cfg.dealerNumber, - StoreNumber: cfg.storeNumber, - BranchNumber: cfg.branchNumber, - Customer: { - FirstName: args.first, - LastName: args.last, - PhoneNumber: args.phone, - EmailAddress: args.email - }, - Vehicle: { - VIN: args.vin, - LicensePlate: args.plate - }, - MaxResults: max - }; - return { template: "CombinedSearch", data, appArea: {} }; - } - - case "parts": { - const max = toIntOr(5, args.max); - const data = { - DealerNumber: cfg.dealerNumber, - StoreNumber: cfg.storeNumber, - BranchNumber: cfg.branchNumber, - SearchCriteria: { - PartNumber: args.part, - Description: args.desc, - Make: args.make, - Model: args.model, - Year: args.year, - MaxResults: max - } - }; - return { template: "GetParts", data, appArea: {} }; - } - - default: - throw new Error(`Unsupported action: ${action}`); } + // search raw XML: value or Key="value" + if (rawXml) { + const re1 = new RegExp(`<([A-Za-z0-9:_-]*${keyRegex.source}[A-Za-z0-9:_-]*)>([^<]+)`, "i"); + const m1 = re1.exec(rawXml); + if (m1 && m1[2]) return m1[2].trim(); + const re2 = new RegExp(`${keyRegex.source}="([^"]+)"`, "i"); + const m2 = re2.exec(rawXml); + if (m2 && m2[1]) return m2[1].trim(); + } + return undefined; } -async function main() { - const action = pickActionName(args.action || args.a || args._[0]); - const bodyshopId = args.bodyshopId || args.bodyshop || args.b; +// Call wrapper: prints FULL XML +async function callRR(action, dataObj, cfg) { + const respXml = await MakeRRCall({ + action, + dealerConfig: cfg, + body: { data: { STAR_NS, ...dataObj }, appArea: appAreaFor(action, cfg) } + }); + const parsed = parseRRResponse(respXml); + return { respXml, parsed }; +} - const rrAction = - action === "ping" - ? "GetAdvisors" - : action === "advisors" - ? "GetAdvisors" - : action === "combined" - ? "CombinedSearch" - : action === "parts" - ? "GetParts" - : action; +// --------- Individual Steps (full XML printed) --------- - // Start with env-based defaults… - const cfg = getBaseRRConfig(); +async function stepGetAdvisors(ctx) { + const cfg = ctx.cfg; + const data = { + SearchCriteria: { + Department: FLAG("dept", "B"), // critical to avoid 201 + Status: FLAG("status", "ACTIVE"), + MaxResults: Number(FLAG("max", 5)) || 5 + } + }; + const { respXml, parsed } = await callRR("GetAdvisors", data, cfg); - // …then override with per-bodyshop values if provided. - if (bodyshopId) { + console.log("\n[GetAdvisors] RESPONSE (FULL):\n"); + console.log(respXml); + + const advId = + findFirstId(parsed.parsed, respXml, /(Advisor(ID|No|Number)?|AdvNo)/i) || + findFirstId(parsed.parsed, respXml, /(Employee(ID|No|Number)?)/i); + if (advId) ctx.advisorId = advId; + + return { ok: true, code: parsed.code, advisorId: advId || null }; +} + +async function stepCombinedSearch(ctx) { + const cfg = ctx.cfg; + + // Provide at least one criterion by default to avoid 201 + const defaultLast = FLAG("last", process.env.RR_TEST_LAST || "SMITH"); + const defaultPhone = FLAG("phone", process.env.RR_TEST_PHONE || ""); + const defaultVin = FLAG("vin", process.env.RR_TEST_VIN || ""); + + const data = { + MaxResults: Number(FLAG("max", 5)) || 5, + Customer: {}, + Vehicle: {} + }; + if (defaultLast) data.Customer.LastName = defaultLast; + if (defaultPhone) data.Customer.PhoneNumber = defaultPhone; + if (defaultVin) data.Vehicle.VIN = defaultVin; + + const { respXml, parsed } = await callRR("CombinedSearch", data, cfg); + + console.log("\n[CombinedSearch] RESPONSE (FULL):\n"); + console.log(respXml); + + const custId = findFirstId(parsed.parsed, respXml, /(Cust(omer)?(No|Id|ID|Number|Key)|NameRecId)/i); + const vehId = findFirstId(parsed.parsed, respXml, /(Veh(icle)?(No|Id|ID|Number|Key))/i); + const vin = findFirstId(parsed.parsed, respXml, /VIN/i); + + if (custId) ctx.customerId = custId; + if (vehId) ctx.vehicleId = vehId; + if (vin) ctx.vin = vin; + + return { ok: true, code: parsed.code, customerId: custId || null, vehicleId: vehId || null, vin: vin || null }; +} + +async function stepInsertCustomer(ctx) { + const cfg = ctx.cfg; + + // RR requires IBFlag and LastName for individual customers + const ts = dayjs().format("YYYYMMDD-HHmmss"); + const firstName = FLAG("first", process.env.RR_TEST_FIRST || "QA"); + const lastName = FLAG("last", process.env.RR_TEST_LAST || "Test"); + const email = FLAG("email", process.env.RR_TEST_EMAIL || `qa.${ts}@example.com`); + const phone = FLAG( + "phone", + process.env.RR_TEST_PHONE || `937555${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}` + ); + + const data = { + IBFlag: "I", // <== REQUIRED (I=Individual, B=Business) + FirstName: firstName, // <== REQUIRED with IBFlag=I + LastName: lastName, // <== REQUIRED with IBFlag=I + Active: "Y", + Phones: [{ Type: "Mobile", CountryCode: "1", Number: phone, Preferred: "Y" }], + Emails: [{ Type: "Personal", Address: email, Preferred: "Y" }], + Addresses: [ + { + Type: "Home", + Line1: "123 Test St", + City: "Dayton", + State: "OH", + PostalCode: "45402", + Country: "US", + IsPrimary: "Y" + } + ] + }; + + const { respXml, parsed } = await callRR("InsertCustomer", data, cfg); + + console.log("\n[InsertCustomer] RESPONSE (FULL):\n"); + console.log(respXml); + + // RR often returns the new customer key as DMSRecKey attribute in TransStatus + const custId = findFirstId(parsed.parsed, respXml, /(DMSRecKey|Cust(omer)?(No|Id|ID|Number|Key)|NameRecId)/i); + + if (custId) ctx.customerId = custId; + return { ok: !!custId, code: parsed.code, customerId: custId || null }; +} + +async function stepUpdateCustomer(ctx) { + const cfg = ctx.cfg; + if (!ctx.customerId) return { ok: false, code: "NO_CONTEXT", message: "No customerId" }; + + const data = { + CustomerId: ctx.customerId, + Emails: [{ Type: "Personal", Address: `qa.${dayjs().format("HHmmss")}@example.com`, Preferred: "Y" }] + }; + + const { respXml, parsed } = await callRR("UpdateCustomer", data, cfg); + + console.log("\n[UpdateCustomer] RESPONSE (FULL):\n"); + console.log(respXml); + + return { ok: parsed.success, code: parsed.code }; +} + +async function stepInsertServiceVehicle(ctx) { + const cfg = ctx.cfg; + if (!ctx.customerId) return { ok: false, code: "NO_CONTEXT", message: "No customerId" }; + + const vin = + FLAG("vin", process.env.RR_TEST_VIN) || + `1FTFW1E50JFA${String(Math.floor(Math.random() * 1000000)).padStart(6, "0")}`; + + const data = { + CustomerId: ctx.customerId, + VIN: vin, + Year: "2018", + Make: "FORD", + Model: "F-150", + Color: "WHITE", + LicensePlate: + FLAG("plate", process.env.RR_TEST_PLATE) || `QA${String(Math.floor(Math.random() * 100000)).padStart(5, "0")}`, + LicenseState: "OH", + Odometer: "123456", + OdometerUnits: "MI" + }; + + const { respXml, parsed } = await callRR("InsertServiceVehicle", data, cfg); + + console.log("\n[InsertServiceVehicle] RESPONSE (FULL):\n"); + console.log(respXml); + + const vehId = findFirstId(parsed.parsed, respXml, /(Veh(icle)?(No|Id|ID|Number|Key))/i); + if (vehId) ctx.vehicleId = vehId; + ctx.vin = vin; + + return { ok: parsed.success || !!vehId, code: parsed.code, vehicleId: vehId || null, vin }; +} + +async function stepCreateRepairOrder(ctx) { + const cfg = ctx.cfg; + if (!ctx.customerId) return { ok: false, code: "NO_CONTEXT", message: "No customerId" }; + + const roNumber = `BSM-${dayjs().format("MMDD-HHmmss")}-${String(Math.floor(Math.random() * 1000)).padStart(3, "0")}`; + + const data = { + RepairOrderNumber: roNumber, + Department: "B", + ROType: "INS", + Status: "OPEN", + IsBodyShop: "Y", + ServiceAdvisorId: ctx.advisorId || FLAG("advisor", process.env.RR_TEST_ADVISOR) || undefined, + Customer: { + CustomerId: ctx.customerId, + FirstName: FLAG("first", process.env.RR_TEST_FIRST || "QA"), + LastName: FLAG("last", process.env.RR_TEST_LAST || "Test"), + PhoneNumber: FLAG("phone", process.env.RR_TEST_PHONE || "9375550000"), + EmailAddress: FLAG("email", process.env.RR_TEST_EMAIL || "qa@example.com") + }, + ServiceVehicle: { + VIN: ctx.vin || FLAG("vin", process.env.RR_TEST_VIN) || undefined, + Odometer: "4321" + }, + Totals: { + Currency: "USD", + LaborTotal: "0", + PartsTotal: "0", + MiscTotal: "0", + DiscountTotal: "0", + TaxTotal: "0", + GrandTotal: "0" + } + }; + + const respXml = await MakeRRCall({ + action: "CreateRepairOrder", + dealerConfig: cfg, + body: { data: { STAR_NS, ...data }, appArea: appAreaFor("CreateRepairOrder", cfg) } + }); + + const parsed = parseRRResponse(respXml); + + console.log("\n[CreateRepairOrder] RESPONSE (FULL):\n"); + console.log(respXml); + + const roNo = findFirstId(parsed.parsed, respXml, /(DMSRoNo|RO?No|OutsdRoNo|RepairOrder(Id|Number))/i) || roNumber; + + ctx.roNumber = roNo; + + return { ok: parsed.success, code: parsed.code, roNumber: roNo }; +} + +async function stepUpdateRepairOrder(ctx) { + const cfg = ctx.cfg; + if (!ctx.roNumber) return { ok: false, code: "NO_CONTEXT", message: "No roNumber" }; + + const data = { + RepairOrderNumber: ctx.roNumber, + Department: "B", + Status: "OPEN", + Totals: { LaborTotal: "0", PartsTotal: "0", MiscTotal: "0", TaxTotal: "0", GrandTotal: "0" } + }; + + const { respXml, parsed } = await callRR("UpdateRepairOrder", data, cfg); + + console.log("\n[UpdateRepairOrder] RESPONSE (FULL):\n"); + console.log(respXml); + + return { ok: parsed.success, code: parsed.code }; +} + +async function stepGetParts(ctx) { + const cfg = ctx.cfg; + + const data = { + MaxResults: Number(FLAG("max", 5)) || 5, + SearchMode: "Description", + Description: FLAG("partdesc", process.env.RR_TEST_PARTDESC || "clip") + }; + + const { respXml, parsed } = await callRR("GetParts", data, cfg); + + console.log("\n[GetParts] RESPONSE (FULL):\n"); + console.log(respXml); + + return { ok: parsed.success, code: parsed.code }; +} + +// --------- Runner --------- + +async function runAll() { + await verifyTemplates(); + + const cfg = cfgFromEnv(); + const ctx = { cfg }; + + const results = []; + + async function runStep(name, fn) { + const t0 = Date.now(); try { - const overrides = await loadBodyshopRRConfig(bodyshopId); - if (overrides?.dealerNumber) cfg.dealerNumber = overrides.dealerNumber; - if (overrides?.storeNumber) cfg.storeNumber = overrides.storeNumber; - if (overrides?.branchNumber) cfg.branchNumber = overrides.branchNumber; - console.log("ℹ️ RR config loaded from DB via: bodyshops_by_pk"); - } catch (e) { - console.error("❌ Failed to load per-bodyshop RR config:", e.message); - process.exit(1); + const res = await fn(ctx); + results.push({ step: name, ok: !!res.ok, code: res.code || "OK", ms: Date.now() - t0 }); + console.log(`\n✔ ${name} done in ${Date.now() - t0}ms`); + return res; + } catch (err) { + results.push({ step: name, ok: false, code: err.code || "ERR", ms: Date.now() - t0, error: err.message }); + console.error(`\n✖ ${name} failed: ${err.message}`); + throw err; } } - const body = buildBodyForAction(action, args, cfg); - const templateName = body.template || rrAction; + // Sequence (dependencies respected) + await runStep("GetAdvisors", stepGetAdvisors); + await runStep("CombinedSearch", stepCombinedSearch); - try { - await renderXmlTemplate(templateName, body.data); - console.log("✅ Templates verified."); - } catch (e) { - console.error("❌ Template verification failed:", e.message); - process.exit(1); - } + if (!ctx.customerId) await runStep("InsertCustomer", stepInsertCustomer); + if (!ctx.vehicleId) await runStep("InsertServiceVehicle", stepInsertServiceVehicle); - if (args.dry) { - const business = await renderXmlTemplate(templateName, body.data); - const envelope = await buildStarEnvelope(business, cfg, body.appArea); - console.log("\n--- FULL SOAP ENVELOPE ---\n"); - console.log(envelope); - console.log("\n(dry run) 🚫 Skipping network call."); + await runStep("CreateRepairOrder", stepCreateRepairOrder); + await runStep("UpdateRepairOrder", stepUpdateRepairOrder); + await runStep("GetParts", stepGetParts); + + // Summary + console.log("\n=== SUMMARY ==="); + console.table(results.map((r) => ({ step: r.step, ok: r.ok ? "✅" : "❌", code: r.code, ms: r.ms }))); + const failed = results.some((r) => !r.ok); + console.log(`Total: ${results.reduce((a, r) => a + r.ms, 0)}ms | Status: ${failed ? "FAIL" : "PASS"}`); + if (failed) process.exitCode = 1; +} + +// Entrypoint +(async () => { + if (FLAG("ping", false)) { + await verifyTemplates(); + const cfg = cfgFromEnv(); + console.log("\n▶ Calling Rome action: GetAdvisors"); + const r = await stepGetAdvisors({ cfg }); + if (r.ok) console.log("\n✅ RR call completed.\n"); return; } - try { - console.log(`\n▶ Calling Rome action: ${rrAction}`); - const xml = await MakeRRCall({ action: rrAction, body, dealerConfig: cfg }); - console.log("\n✅ RR call succeeded.\n"); - console.log(xml); - } catch (err) { - console.dir(err); - console.error("[RR] rr-test failed", { message: err.message, stack: err.stack }); - process.exit(1); + if (FLAG("all", true)) { + await runAll(); + return; } -} -main().catch(() => process.exit(1)); + // Individual flags (optional) + await verifyTemplates(); + const cfg = cfgFromEnv(); + const ctx = { cfg }; + const want = []; + if (FLAG("get-advisors", false)) want.push(stepGetAdvisors); + if (FLAG("combined", false)) want.push(stepCombinedSearch); + if (FLAG("insert-customer", false)) want.push(stepInsertCustomer); + if (FLAG("update-customer", false)) want.push(stepUpdateCustomer); + if (FLAG("insert-vehicle", false)) want.push(stepInsertServiceVehicle); + if (FLAG("create-ro", false)) want.push(stepCreateRepairOrder); + if (FLAG("update-ro", false)) want.push(stepUpdateRepairOrder); + if (FLAG("get-parts", false)) want.push(stepGetParts); + + for (const fn of want) { + const res = await fn(ctx).catch((e) => ({ ok: false, code: "ERR", error: e.message })); + console.log(res); + } +})(); diff --git a/server/rr/xml-templates/CreateRepairOrder.xml b/server/rr/xml-templates/CreateRepairOrder.xml index 10e8776b2..0220d63df 100644 --- a/server/rr/xml-templates/CreateRepairOrder.xml +++ b/server/rr/xml-templates/CreateRepairOrder.xml @@ -14,6 +14,7 @@ {{#Status}}{{Status}}{{/Status}} {{#IsBodyShop}}{{IsBodyShop}}{{/IsBodyShop}} {{#DRPFlag}}{{DRPFlag}}{{/DRPFlag}} + {{#CustomerId}}{{CustomerId}}{{/CustomerId}} {{#CustomerName}}{{CustomerName}}{{/CustomerName}} @@ -30,6 +31,7 @@
{{/Address}} + {{#VehicleId}}{{VehicleId}}{{/VehicleId}} {{#VIN}}{{VIN}}{{/VIN}} @@ -40,14 +42,13 @@ {{#Odometer}}{{Odometer}}{{/Odometer}} {{#Color}}{{Color}}{{/Color}} + {{#JobLines}} {{Sequence}} {{#ParentSequence}}{{ParentSequence}}{{/ParentSequence}} - {{#LineType}} - {{LineType}}{{/LineType} - {{#Category}} - {{Category}}{{/Category}} + {{#LineType}}{{LineType}}{{/LineType}} + {{#Category}}{{Category}}{{/Category}} {{#OpCode}}{{OpCode}}{{/OpCode}} {{#Description}}{{Description}}{{/Description}} {{#LaborHours}}{{LaborHours}}{{/LaborHours}} @@ -61,6 +62,7 @@ {{#TaxCode}}{{TaxCode}}{{/TaxCode}} {{#GLAccount}}{{GLAccount}}{{/GLAccount}} {{#ControlNumber}}{{ControlNumber}}{{/ControlNumber}} + {{#Taxes}} {{#Items}} @@ -74,6 +76,7 @@ {{/Taxes}} {{/JobLines}} + {{#Totals}} {{#Currency}}{{Currency}}{{/Currency}} @@ -85,6 +88,7 @@ {{GrandTotal}} {{/Totals}} + {{#Payments}} {{#Items}} @@ -99,6 +103,7 @@ {{/Items}} {{/Payments}} + {{#Insurance}} {{#CompanyName}}{{CompanyName}}{{/CompanyName}} @@ -107,6 +112,7 @@ {{#AdjusterPhone}}{{AdjusterPhone}}{{/AdjusterPhone}} {{/Insurance}} + {{#Notes}} {{#Items}}{{.}}{{/Items}} diff --git a/server/rr/xml-templates/UpdateRepairOrder.xml b/server/rr/xml-templates/UpdateRepairOrder.xml index 6b0805c35..85a1d4428 100644 --- a/server/rr/xml-templates/UpdateRepairOrder.xml +++ b/server/rr/xml-templates/UpdateRepairOrder.xml @@ -1,29 +1,37 @@ - + - {{#RepairOrderId}}{{RepairOrderId}}{{/RepairOrderId}} - {{#RepairOrderNumber}}{{RepairOrderNumber}}{{/RepairOrderNumber}} - {{#Status}} - {{Status}}{{/Status}} - {{#ROType}} - {{ROType}}{{/ROType}} + {{RepairOrderNumber}} + {{#DmsRepairOrderId}}{{DmsRepairOrderId}}{{/DmsRepairOrderId}} {{#OpenDate}}{{OpenDate}}{{/OpenDate}} {{#PromisedDate}}{{PromisedDate}}{{/PromisedDate}} {{#CloseDate}}{{CloseDate}}{{/CloseDate}} {{#ServiceAdvisorId}}{{ServiceAdvisorId}}{{/ServiceAdvisorId}} {{#TechnicianId}}{{TechnicianId}}{{/TechnicianId}} - {{#LocationCode}}{{LocationCode}}{{/LocationCode}} {{#Department}}{{Department}}{{/Department}} - {{#PurchaseOrder}}{{PurchaseOrder}}{{/PurchaseOrder}} - {{#Customer}} + {{#ProfitCenter}}{{ProfitCenter}}{{/ProfitCenter}} + {{#ROType}}{{ROType}}{{/ROType}} + {{#Status}}{{Status}}{{/Status}} + {{#IsBodyShop}}{{IsBodyShop}}{{/IsBodyShop}} + {{#DRPFlag}}{{DRPFlag}}{{/DRPFlag}} + {{#CustomerId}}{{CustomerId}}{{/CustomerId}} {{#CustomerName}}{{CustomerName}}{{/CustomerName}} {{#PhoneNumber}}{{PhoneNumber}}{{/PhoneNumber}} {{#EmailAddress}}{{EmailAddress}}{{/EmailAddress}} + {{#Address}} +
+ {{#Line1}}{{Line1}}{{/Line1}} + {{#Line2}}{{Line2}}{{/Line2}} + {{#City}}{{City}}{{/City}} + {{#State}}{{State}}{{/State}} + {{#PostalCode}}{{PostalCode}}{{/PostalCode}} + {{#Country}}{{Country}}{{/Country}} +
+ {{/Address}}
- {{/Customer}} - {{#Vehicle}} + {{#VehicleId}}{{VehicleId}}{{/VehicleId}} {{#VIN}}{{VIN}}{{/VIN}} @@ -34,93 +42,68 @@ {{#Odometer}}{{Odometer}}{{/Odometer}} {{#Color}}{{Color}}{{/Color}} - {{/Vehicle}} - {{#AddedJobLines}} - - {{#Items}} - - {{#Sequence}}{{Sequence}}{{/Sequence}} - {{#ParentSequence}}{{ParentSequence}}{{/ParentSequence}} - {{#OpCode}}{{OpCode}}{{/OpCode}} - {{#Description}}{{Description}}{{/Description}} - {{#LineType}} - {{LineType}}{{/LineType}} - {{#Category}} - {{Category}}{{/Category}} - {{#LaborHours}}{{LaborHours}}{{/LaborHours}} - {{#LaborRate}}{{LaborRate}}{{/LaborRate}} - {{#PartNumber}}{{PartNumber}}{{/PartNumber}} - {{#PartDescription}}{{PartDescription}}{{/PartDescription}} - {{#Quantity}}{{Quantity}}{{/Quantity}} - {{#UnitPrice}}{{UnitPrice}}{{/UnitPrice}} - {{#ExtendedPrice}}{{ExtendedPrice}}{{/ExtendedPrice}} - {{#DiscountAmount}}{{DiscountAmount}}{{/DiscountAmount}} - {{#TaxCode}}{{TaxCode}}{{/TaxCode}} - {{#GLAccount}}{{GLAccount}}{{/GLAccount}} - {{#ControlNumber}}{{ControlNumber}}{{/ControlNumber}} - {{#Taxes}} - - {{#Items}} - - {{Code}} - {{Amount}} - {{#Rate}}{{Rate}}{{/Rate}} - - {{/Items}} - - {{/Taxes}} - {{#PayType}} - {{PayType}}{{/PayType}} - {{#Reason}}{{Reason}}{{/Reason}} - - {{/Items}} - - {{/AddedJobLines}} - {{#UpdatedJobLines}} - - {{#Items}} - - {{#LineId}}{{LineId}}{{/LineId}} - {{#Sequence}}{{Sequence}}{{/Sequence}} - {{#ChangeType}} - {{ChangeType}}{{/ChangeType}} - {{#OpCode}}{{OpCode}}{{/OpCode}} - {{#Description}}{{Description}}{{/Description}} - {{#LaborHours}}{{LaborHours}}{{/LaborHours}} - {{#LaborRate}}{{LaborRate}}{{/LaborRate}} - {{#PartNumber}}{{PartNumber}}{{/PartNumber}} - {{#PartDescription}}{{PartDescription}}{{/PartDescription}} - {{#Quantity}}{{Quantity}}{{/Quantity}} - {{#UnitPrice}}{{UnitPrice}}{{/UnitPrice}} - {{#ExtendedPrice}}{{ExtendedPrice}}{{/ExtendedPrice}} - {{#TaxCode}}{{TaxCode}}{{/TaxCode}} - {{#PayType}}{{PayType}}{{/PayType}} - {{#Reason}}{{Reason}}{{/Reason}} - - {{/Items}} - - {{/UpdatedJobLines}} - {{#RemovedJobLines}} - - {{#Items}} - - {{#LineId}}{{LineId}}{{/LineId}} - {{#Sequence}}{{Sequence}}{{/Sequence}} - {{#OpCode}}{{OpCode}}{{/OpCode}} - {{#Reason}}{{Reason}}{{/Reason}} - - {{/Items}} - - {{/RemovedJobLines}} + + {{#JobLines}} + + {{Sequence}} + {{#ParentSequence}}{{ParentSequence}}{{/ParentSequence}} + {{#LineType}}{{LineType}}{{/LineType}} + {{#Category}}{{Category}}{{/Category}} + {{#OpCode}}{{OpCode}}{{/OpCode}} + {{#Description}}{{Description}}{{/Description}} + {{#LaborHours}}{{LaborHours}}{{/LaborHours}} + {{#LaborRate}}{{LaborRate}}{{/LaborRate}} + {{#PartNumber}}{{PartNumber}}{{/PartNumber}} + {{#PartDescription}}{{PartDescription}}{{/PartDescription}} + {{#Quantity}}{{Quantity}}{{/Quantity}} + {{#UnitPrice}}{{UnitPrice}}{{/UnitPrice}} + {{#ExtendedPrice}}{{ExtendedPrice}}{{/ExtendedPrice}} + {{#DiscountAmount}}{{DiscountAmount}}{{/DiscountAmount}} + {{#TaxCode}}{{TaxCode}}{{/TaxCode}} + {{#GLAccount}}{{GLAccount}}{{/GLAccount}} + {{#ControlNumber}}{{ControlNumber}}{{/ControlNumber}} + + {{#Taxes}} + + {{#Items}} + + {{Code}} + {{Amount}} + {{#Rate}}{{Rate}}{{/Rate}} + + {{/Items}} + + {{/Taxes}} + + {{/JobLines}} + {{#Totals}} + {{#Currency}}{{Currency}}{{/Currency}} {{#LaborTotal}}{{LaborTotal}}{{/LaborTotal}} {{#PartsTotal}}{{PartsTotal}}{{/PartsTotal}} {{#MiscTotal}}{{MiscTotal}}{{/MiscTotal}} + {{#DiscountTotal}}{{DiscountTotal}}{{/DiscountTotal}} {{#TaxTotal}}{{TaxTotal}}{{/TaxTotal}} - {{#GrandTotal}}{{GrandTotal}}{{/GrandTotal}} + {{GrandTotal}} {{/Totals}} + + {{#Payments}} + + {{#Items}} + + {{PayerType}} + {{#PayerName}}{{PayerName}}{{/PayerName}} + {{Amount}} + {{#Method}}{{Method}}{{/Method}} + {{#Reference}}{{Reference}}{{/Reference}} + {{#ControlNumber}}{{ControlNumber}}{{/ControlNumber}} + + {{/Items}} + + {{/Payments}} + {{#Insurance}} {{#CompanyName}}{{CompanyName}}{{/CompanyName}} @@ -129,11 +112,12 @@ {{#AdjusterPhone}}{{AdjusterPhone}}{{/AdjusterPhone}} {{/Insurance}} + {{#Notes}} {{#Items}}{{.}}{{/Items}} {{/Notes}}
-
+