feature/IO-3357-Reynolds-and-Reynolds-DMS-API-Integration / RRScratch2 / Checkpoint

This commit is contained in:
Dave
2025-11-25 14:13:10 -05:00
parent ae7d150a6c
commit 2b1836d450
4 changed files with 172 additions and 301 deletions

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import Dinero from "dinero.js";
import { selectBodyshop } from "../../redux/user/user.selectors";
@@ -15,54 +14,6 @@ const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(RrAllocationsSummary);
function normalizeDineroJson(d) {
if (!d) return null;
// If it's already a Dinero instance, leave it to the caller
if (typeof d.toUnit === "function") return d;
// New server shape: { cents: 54144, currency?: "USD" }
if (typeof d.cents === "number") {
return {
amount: d.cents,
precision: 2,
currency: d.currency || "USD"
};
}
// Classic Dinero JSON: { amount, precision, currency }
if (typeof d.amount === "number") {
return {
amount: d.amount,
precision: d.precision ?? 2,
currency: d.currency || "USD"
};
}
return null;
}
/**
* Convert a Dinero-like object into an "N2" string ("123.45").
* Works with real Dinero instances or plain JSON objects
* that have { amount, precision }.
*/
function dineroToN2(dineroLike) {
if (!dineroLike) return "0.00";
// If it's an actual Dinero instance
if (typeof dineroLike.toUnit === "function") {
return dineroLike.toUnit().toFixed(2);
}
const normalized = normalizeDineroJson(dineroLike);
if (!normalized) return "0.00";
const { amount, precision = 2 } = normalized;
const value = amount / Math.pow(10, precision);
return value.toFixed(2);
}
/**
* Normalize job allocations into a flat list for display / preview building.
* @param ack
@@ -89,209 +40,20 @@ function normalizeJobAllocations(ack) {
}));
}
/**
* Build a minimal ROGOG preview from job allocation rows.
* Mirrors the backend buildRogogFromAllocations logic:
* - parts+extras segment
* - taxable labor segment
* - non-taxable labor segment
* Each segment becomes its *own* JobNo with a single GOG line.
*/
function buildRogogPreviewFromJobRows(jobRows, opCode) {
if (!Array.isArray(jobRows) || !jobRows.length || !opCode) return null;
const ops = [];
const toDinero = (d) => {
if (!d) return Dinero();
if (typeof d.toUnit === "function") return d;
const normalized = normalizeDineroJson(d);
return normalized ? Dinero(normalized) : Dinero();
};
const cents = (d) => toDinero(d).getAmount();
const segmentLabelMap = {
partsExtras: "Parts/Extras",
laborTaxable: "Taxable Labor",
laborNonTaxable: "Non-Taxable Labor"
};
for (const row of jobRows) {
const pc = row.profitCenter || {};
const breakOut = pc.rr_gogcode;
const itemType = pc.rr_item_type;
// Only centers configured for RR GOG should appear
if (!breakOut || !itemType) continue;
// Bucketed sales (Dinero-like objects coming from the backend)
const partsSale = toDinero(row.partsSale);
const extrasSale = toDinero(row.extrasSale);
const laborTaxableSale = toDinero(row.laborTaxableSale);
const laborNonTaxableSale = toDinero(row.laborNonTaxableSale);
const costMoney = toDinero(row.cost);
const partsExtrasSale = partsSale.add(extrasSale);
const segments = [];
// 1) Parts + extras segment (uses center's default tax flag)
if (!partsExtrasSale.isZero()) {
segments.push({
kind: "partsExtras",
sale: partsExtrasSale,
txFlag: pc.rr_cust_txbl_flag || "T"
});
}
// 2) Taxable labor -> always "T"
if (!laborTaxableSale.isZero()) {
segments.push({
kind: "laborTaxable",
sale: laborTaxableSale,
txFlag: "T"
});
}
// 3) Non-taxable labor -> always "N"
if (!laborNonTaxableSale.isZero()) {
segments.push({
kind: "laborNonTaxable",
sale: laborNonTaxableSale,
txFlag: "N"
});
}
if (!segments.length) continue;
// Proportionally split cost across segments (same logic as backend)
const totalCostCents = cents(costMoney);
const totalSaleCents = segments.reduce((sum, seg) => sum + cents(seg.sale), 0);
let remainingCostCents = totalCostCents;
segments.forEach((seg, idx) => {
let costCents = 0;
if (totalCostCents > 0 && totalSaleCents > 0) {
if (idx === segments.length - 1) {
// Last segment gets the remainder to avoid rounding drift
costCents = remainingCostCents;
} else {
const segSaleCents = cents(seg.sale);
costCents = Math.round((segSaleCents / totalSaleCents) * totalCostCents);
remainingCostCents -= costCents;
}
}
seg.costCents = costCents;
});
const itemDescBase = pc.accountdesc || pc.accountname || row.center || "";
// 🔑 Each segment becomes its own JobNo with a single GOG line
segments.forEach((seg, segIndex) => {
const jobNo = String(ops.length + 1); // 1-based, global across all centers
const segmentCount = segments.length;
const segmentKind = seg.kind;
const segmentLabel = segmentLabelMap[segmentKind] || segmentKind;
// If there is a split, annotate the description so its obvious which segment this is
const displayDesc = segmentCount > 1 ? `${itemDescBase} (${segmentLabel})` : itemDescBase;
const saleN2 = dineroToN2(seg.sale);
const costN2 = dineroToN2({
amount: seg.costCents || 0,
precision: 2
});
ops.push({
opCode,
jobNo,
segmentKind,
segmentIndex: segIndex,
segmentCount,
lines: [
{
breakOut,
itemType,
itemDesc: displayDesc,
custQty: "1.0",
custPayTypeFlag: "C",
// canonical property name used on the server
custTxblNTxblFlag: seg.txFlag || "T",
// legacy alias used by the table
custTxblNtxblFlag: seg.txFlag || "T",
amount: {
payType: "Cust",
amtType: "Unit",
custPrice: saleN2,
dlrCost: costN2
}
}
]
});
});
}
if (!ops.length) return null;
return {
roNo: null, // preview only
ops
};
}
/**
* Build a minimal ROLABOR preview from a ROGOG preview.
* Mirrors server-side buildRolaborFromRogog.
*/
function buildRolaborPreviewFromRogog(rogg) {
if (!rogg || !Array.isArray(rogg.ops)) return null;
const ops = rogg.ops.map((op) => {
const firstLine = op.lines?.[0] || {};
// Prefer the server-side property name, but fall back to legacy
const txFlag = firstLine.custTxblNTxblFlag ?? firstLine.custTxblNtxblFlag ?? "N";
const payFlag = firstLine.custPayTypeFlag || "C";
return {
opCode: op.opCode,
jobNo: op.jobNo,
custPayTypeFlag: payFlag,
// this is what the table uses
custTxblNtxblFlag: txFlag,
bill: {
payType: "Cust",
jobTotalHrs: "0",
billTime: "0",
billRate: "0"
},
amount: {
payType: "Cust",
amtType: "Job",
custPrice: "0",
totalAmt: "0"
}
};
});
if (!ops.length) return null;
return { ops };
}
/**
* RR-specific DMS Allocations Summary
* Focused on what we actually send to RR:
* - ROGOG (split by taxable / non-taxable segments)
* - ROLABOR shell
*
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
* This component just renders the preview from `ack.rogg` / `ack.rolabor`.
*/
export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) {
const { t } = useTranslation();
const [jobRows, setJobRows] = useState([]);
const [roggPreview, setRoggPreview] = useState(null);
const [rolaborPreview, setRolaborPreview] = useState(null);
const [error, setError] = useState(null);
const fetchAllocations = useCallback(() => {
@@ -300,7 +62,8 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) {
try {
socket.emit("rr-calculate-allocations", jobId, (ack) => {
if (ack && ack.ok === false) {
setJobRows([]);
setRoggPreview(null);
setRolaborPreview(null);
setError(ack.error || t("dms.labels.allocations_error"));
if (socket) {
socket.allocationsSummary = [];
@@ -311,7 +74,8 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) {
const jobAllocRows = normalizeJobAllocations(ack);
setJobRows(jobAllocRows);
setRoggPreview(ack?.rogg || null);
setRolaborPreview(ack?.rolabor || null);
setError(null);
if (socket) {
@@ -320,7 +84,8 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) {
}
});
} catch {
setJobRows([]);
setRoggPreview(null);
setRolaborPreview(null);
setError(t("dms.labels.allocations_error"));
if (socket) {
socket.allocationsSummary = [];
@@ -334,29 +99,38 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title }) {
const opCode = bodyshop?.rr_configuration?.baseOpCode || "28TOZ";
const roggPreview = useMemo(() => buildRogogPreviewFromJobRows(jobRows, opCode), [jobRows, opCode]);
const rolaborPreview = useMemo(() => buildRolaborPreviewFromRogog(roggPreview), [roggPreview]);
const segmentLabelMap = {
partsExtras: "Parts/Extras",
laborTaxable: "Taxable Labor",
laborNonTaxable: "Non-Taxable Labor"
};
const roggRows = useMemo(() => {
if (!roggPreview || !Array.isArray(roggPreview.ops)) return [];
const rows = [];
roggPreview.ops.forEach((op) => {
(op.lines || []).forEach((line, idx) => {
const baseDesc = line.itemDesc;
const segmentKind = op.segmentKind;
const segmentCount = op.segmentCount || 0;
const segmentLabel = segmentLabelMap[segmentKind] || segmentKind;
const displayDesc = segmentCount > 1 && segmentLabel ? `${baseDesc} (${segmentLabel})` : baseDesc;
rows.push({
key: `${op.jobNo}-${idx}`,
opCode: op.opCode,
jobNo: op.jobNo,
breakOut: line.breakOut,
itemType: line.itemType,
itemDesc: line.itemDesc,
itemDesc: displayDesc,
custQty: line.custQty,
custPayTypeFlag: line.custPayTypeFlag,
custTxblNtxblFlag: line.custTxblNtxblFlag,
custPrice: line.amount?.custPrice,
dlrCost: line.amount?.dlrCost,
// segment metadata for visual styling
segmentKind: op.segmentKind,
segmentCount: op.segmentCount
segmentKind,
segmentCount
});
});
});