Compare commits
22 Commits
feature/IO
...
feature/IO
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f849ea9d0a | ||
|
|
de6038038a | ||
|
|
1f8836d9d8 | ||
|
|
a267d65425 | ||
|
|
cacda3805a | ||
|
|
af757ee71e | ||
|
|
eb666f2ca1 | ||
|
|
2b8990950b | ||
|
|
3f2e05befc | ||
|
|
06bfdeb449 | ||
|
|
66df286ddb | ||
|
|
1b2f9fc027 | ||
|
|
1287c7ec36 | ||
|
|
fb29fa2caa | ||
|
|
6bda497d8c | ||
|
|
a018b6dc5a | ||
|
|
8a4679f86c | ||
|
|
4d558da46a | ||
|
|
90789e743f | ||
|
|
a4dbc5250e | ||
|
|
704543d823 | ||
|
|
fe848b5de4 |
@@ -13,6 +13,7 @@ import { insertAuditTrail } from "../../redux/application/application.actions";
|
|||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||||
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
|
import { buildBillUpdateAuditDetails } from "../../utils/auditTrailDetails";
|
||||||
import AlertComponent from "../alert/alert.component";
|
import AlertComponent from "../alert/alert.component";
|
||||||
import BillFormContainer from "../bill-form/bill-form.container";
|
import BillFormContainer from "../bill-form/bill-form.container";
|
||||||
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
|
import BillMarkExportedButton from "../bill-mark-exported-button/bill-mark-exported-button.component";
|
||||||
@@ -134,10 +135,16 @@ export function BillDetailEditcontainer({ insertAuditTrail, bodyshop }) {
|
|||||||
|
|
||||||
await Promise.all(updates);
|
await Promise.all(updates);
|
||||||
|
|
||||||
|
const details = buildBillUpdateAuditDetails({
|
||||||
|
originalBill: data?.bills_by_pk,
|
||||||
|
bill,
|
||||||
|
billlines
|
||||||
|
});
|
||||||
|
|
||||||
insertAuditTrail({
|
insertAuditTrail({
|
||||||
jobid: bill.jobid,
|
jobid: bill.jobid ?? data?.bills_by_pk?.jobid,
|
||||||
billid: search.billid,
|
billid: search.billid,
|
||||||
operation: AuditTrailMapping.billupdated(bill.invoice_number),
|
operation: AuditTrailMapping.billupdated(bill.invoice_number, details),
|
||||||
type: "billupdated"
|
type: "billupdated"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function normalizeJobAllocations(ack) {
|
|||||||
* RR-specific DMS Allocations Summary
|
* RR-specific DMS Allocations Summary
|
||||||
* Focused on what we actually send to RR:
|
* Focused on what we actually send to RR:
|
||||||
* - ROGOG (split by taxable / non-taxable segments)
|
* - ROGOG (split by taxable / non-taxable segments)
|
||||||
* - ROLABOR shell
|
* - ROLABOR labor rows with bill hours / rates
|
||||||
*
|
*
|
||||||
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
|
* The heavy lifting (ROGOG/ROLABOR split, cost allocation, tax flags)
|
||||||
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
|
* is now done on the backend via buildRogogFromAllocations/buildRolaborFromRogog.
|
||||||
@@ -181,21 +181,30 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
|||||||
const rolaborRows = useMemo(() => {
|
const rolaborRows = useMemo(() => {
|
||||||
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
|
if (!rolaborPreview || !Array.isArray(rolaborPreview.ops)) return [];
|
||||||
|
|
||||||
return rolaborPreview.ops.map((op, idx) => {
|
return rolaborPreview.ops
|
||||||
const rowOpCode = opCode || op.opCode;
|
.filter((op) =>
|
||||||
|
[op.bill?.jobTotalHrs, op.bill?.billTime, op.bill?.billRate, op.amount?.custPrice, op.amount?.totalAmt]
|
||||||
|
.map((value) => Number.parseFloat(value ?? "0"))
|
||||||
|
.some((value) => !Number.isNaN(value) && value !== 0)
|
||||||
|
)
|
||||||
|
.map((op, idx) => {
|
||||||
|
const rowOpCode = opCode || op.opCode;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: `${op.jobNo}-${idx}`,
|
key: `${op.jobNo}-${idx}`,
|
||||||
opCode: rowOpCode,
|
opCode: rowOpCode,
|
||||||
jobNo: op.jobNo,
|
jobNo: op.jobNo,
|
||||||
custPayTypeFlag: op.custPayTypeFlag,
|
custPayTypeFlag: op.custPayTypeFlag,
|
||||||
custTxblNtxblFlag: op.custTxblNtxblFlag,
|
custTxblNtxblFlag: op.custTxblNtxblFlag,
|
||||||
payType: op.bill?.payType,
|
payType: op.bill?.payType,
|
||||||
amtType: op.amount?.amtType,
|
jobTotalHrs: op.bill?.jobTotalHrs,
|
||||||
custPrice: op.amount?.custPrice,
|
billTime: op.bill?.billTime,
|
||||||
totalAmt: op.amount?.totalAmt
|
billRate: op.bill?.billRate,
|
||||||
};
|
amtType: op.amount?.amtType,
|
||||||
});
|
custPrice: op.amount?.custPrice,
|
||||||
|
totalAmt: op.amount?.totalAmt
|
||||||
|
};
|
||||||
|
});
|
||||||
}, [rolaborPreview, opCode]);
|
}, [rolaborPreview, opCode]);
|
||||||
|
|
||||||
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
|
// Totals for ROGOG (sum custPrice + dlrCost over all lines)
|
||||||
@@ -245,6 +254,9 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
|||||||
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
|
{ title: "CustPayType", dataIndex: "custPayTypeFlag", key: "custPayTypeFlag" },
|
||||||
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
|
{ title: "CustTxblFlag", dataIndex: "custTxblNtxblFlag", key: "custTxblNtxblFlag" },
|
||||||
{ title: "PayType", dataIndex: "payType", key: "payType" },
|
{ title: "PayType", dataIndex: "payType", key: "payType" },
|
||||||
|
{ title: "JobTotalHrs", dataIndex: "jobTotalHrs", key: "jobTotalHrs" },
|
||||||
|
{ title: "BillTime", dataIndex: "billTime", key: "billTime" },
|
||||||
|
{ title: "BillRate", dataIndex: "billRate", key: "billRate" },
|
||||||
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
|
{ title: "AmtType", dataIndex: "amtType", key: "amtType" },
|
||||||
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
|
{ title: "CustPrice", dataIndex: "custPrice", key: "custPrice" },
|
||||||
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
|
{ title: "TotalAmt", dataIndex: "totalAmt", key: "totalAmt" }
|
||||||
@@ -317,12 +329,13 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
|
|||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||||
This mirrors the shell that would be sent for ROLABOR when all financials are carried in GOG.
|
This mirrors the labor rows RR will receive, including weighted bill hours and rates derived from the
|
||||||
|
job's labor lines.
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<ResponsiveTable
|
<ResponsiveTable
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={rolaborColumns}
|
columns={rolaborColumns}
|
||||||
mobileColumnKeys={["jobNo", "opCode", "breakOut", "itemType"]}
|
mobileColumnKeys={["jobNo", "opCode", "billRate", "custPrice"]}
|
||||||
rowKey="key"
|
rowKey="key"
|
||||||
dataSource={rolaborRows}
|
dataSource={rolaborRows}
|
||||||
locale={{ emptyText: "No ROLABOR lines would be generated." }}
|
locale={{ emptyText: "No ROLABOR lines would be generated." }}
|
||||||
|
|||||||
@@ -14,16 +14,20 @@ import CriticalPartsScan from "../../utils/criticalPartsScan";
|
|||||||
import UndefinedToNull from "../../utils/undefinedtonull";
|
import UndefinedToNull from "../../utils/undefinedtonull";
|
||||||
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
|
import JobLinesUpdsertModal from "./job-lines-upsert-modal.component";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
|
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||||
|
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||||
|
import { buildJobLineInsertAuditDetails, buildJobLineUpdateAuditDetails } from "../../utils/auditTrailDetails.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
jobLineEditModal: selectJobLineEditModal,
|
jobLineEditModal: selectJobLineEditModal,
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit"))
|
toggleModalVisible: () => dispatch(toggleModalVisible("jobLineEdit")),
|
||||||
|
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||||
});
|
});
|
||||||
|
|
||||||
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop }) {
|
function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
|
||||||
const {
|
const {
|
||||||
treatments: { CriticalPartsScanning }
|
treatments: { CriticalPartsScanning }
|
||||||
} = useTreatmentsWithConfig({
|
} = useTreatmentsWithConfig({
|
||||||
@@ -74,6 +78,11 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
|||||||
notification.success({
|
notification.success({
|
||||||
title: t("joblines.successes.created")
|
title: t("joblines.successes.created")
|
||||||
});
|
});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: jobLineEditModal.context.jobid,
|
||||||
|
operation: AuditTrailMapping.jobmanuallineinsert(buildJobLineInsertAuditDetails(values)),
|
||||||
|
type: "jobmanuallineinsert"
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
notification.error({
|
notification.error({
|
||||||
title: t("joblines.errors.creating", {
|
title: t("joblines.errors.creating", {
|
||||||
@@ -103,6 +112,17 @@ function JobLinesUpsertModalContainer({ jobLineEditModal, toggleModalVisible, bo
|
|||||||
notification.success({
|
notification.success({
|
||||||
title: t("joblines.successes.updated")
|
title: t("joblines.successes.updated")
|
||||||
});
|
});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: jobLineEditModal.context.jobid,
|
||||||
|
operation: AuditTrailMapping.joblineupdate(
|
||||||
|
values.line_desc || jobLineEditModal.context.line_desc || "manual line",
|
||||||
|
buildJobLineUpdateAuditDetails({
|
||||||
|
originalLine: jobLineEditModal.context,
|
||||||
|
values
|
||||||
|
})
|
||||||
|
),
|
||||||
|
type: "joblineupdate"
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("joblines.errors.updating", {
|
title: t("joblines.errors.updating", {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
|||||||
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd";
|
||||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
@@ -52,6 +52,7 @@ const mapDispatchToProps = () => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
|
export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
|
||||||
|
const submitActionRef = useRef("save");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [internalIsDirty, setInternalIsDirty] = useState(false);
|
const [internalIsDirty, setInternalIsDirty] = useState(false);
|
||||||
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
|
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
|
||||||
@@ -128,55 +129,113 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
|||||||
});
|
});
|
||||||
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
|
}, [clearEmployeeFormMeta, currentEmployeeData, form]);
|
||||||
|
|
||||||
|
const syncEmployeeFormToSavedData = useCallback(
|
||||||
|
(employeeData) => {
|
||||||
|
if (employeeData) {
|
||||||
|
form.setFieldsValue(employeeData);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
clearEmployeeFormMeta();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[clearEmployeeFormMeta, form]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetEmployeeFormToCurrentData();
|
resetEmployeeFormToCurrentData();
|
||||||
}, [resetEmployeeFormToCurrentData, search.employeeId]);
|
}, [resetEmployeeFormToCurrentData, search.employeeId]);
|
||||||
|
|
||||||
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
|
const [updateEmployee] = useMutation(UPDATE_EMPLOYEE);
|
||||||
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
|
const [insertEmployees] = useMutation(INSERT_EMPLOYEES);
|
||||||
|
const saveAndResetSubmitAction = useCallback(() => {
|
||||||
|
const submitAction = submitActionRef.current;
|
||||||
|
submitActionRef.current = "save";
|
||||||
|
return submitAction;
|
||||||
|
}, []);
|
||||||
|
const submitEmployeeForm = useCallback(
|
||||||
|
(submitAction = "save") => {
|
||||||
|
submitActionRef.current = submitAction;
|
||||||
|
form.submit();
|
||||||
|
},
|
||||||
|
[form]
|
||||||
|
);
|
||||||
|
const navigateToEmployee = useCallback(
|
||||||
|
(employeeId) => {
|
||||||
|
history({
|
||||||
|
search: queryString.stringify({
|
||||||
|
...search,
|
||||||
|
employeeId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[history, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFinish = async (values) => {
|
||||||
|
const submitAction = saveAndResetSubmitAction();
|
||||||
|
const normalizedValues = {
|
||||||
|
...values,
|
||||||
|
user_email: values.user_email === "" ? null : values.user_email
|
||||||
|
};
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
|
||||||
if (search.employeeId && search.employeeId !== "new") {
|
if (search.employeeId && search.employeeId !== "new") {
|
||||||
//Update a record.
|
//Update a record.
|
||||||
logImEXEvent("shop_employee_update");
|
logImEXEvent("shop_employee_update");
|
||||||
|
|
||||||
updateEmployee({
|
try {
|
||||||
variables: {
|
const result = await updateEmployee({
|
||||||
id: search.employeeId,
|
variables: {
|
||||||
employee: {
|
id: search.employeeId,
|
||||||
...values,
|
employee: normalizedValues
|
||||||
user_email: values.user_email === "" ? null : values.user_email
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
updateDirtyState(false);
|
|
||||||
void refetch();
|
|
||||||
notification.success({
|
|
||||||
title: t("employees.successes.save")
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
notification.error({
|
|
||||||
title: t("employees.errors.save", {
|
|
||||||
message: JSON.stringify(error)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
//New record, insert it.
|
|
||||||
logImEXEvent("shop_employee_insert");
|
|
||||||
|
|
||||||
insertEmployees({
|
syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues);
|
||||||
variables: { employees: [{ ...values, shopid: bodyshop.id }] },
|
void refetch();
|
||||||
refetchQueries: ["QUERY_EMPLOYEES"]
|
if (submitAction === "saveAndNew") {
|
||||||
}).then((r) => {
|
navigateToEmployee("new");
|
||||||
updateDirtyState(false);
|
}
|
||||||
search.employeeId = r.data.insert_employees.returning[0].id;
|
|
||||||
history({ search: queryString.stringify(search) });
|
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("employees.successes.save")
|
title: t("employees.successes.save")
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: t("employees.errors.save", {
|
||||||
|
message: JSON.stringify(error)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//New record, insert it.
|
||||||
|
logImEXEvent("shop_employee_insert");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await insertEmployees({
|
||||||
|
variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] },
|
||||||
|
refetchQueries: ["QUERY_EMPLOYEES"]
|
||||||
|
});
|
||||||
|
const savedEmployee = result?.data?.insert_employees?.returning?.[0];
|
||||||
|
|
||||||
|
syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues);
|
||||||
|
|
||||||
|
if (submitAction === "saveAndNew") {
|
||||||
|
navigateToEmployee("new");
|
||||||
|
} else if (savedEmployee?.id) {
|
||||||
|
navigateToEmployee(savedEmployee.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.success({
|
||||||
|
title: t("employees.successes.save")
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
notification.error({
|
||||||
|
title: t("employees.errors.save", {
|
||||||
|
message: JSON.stringify(error)
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -240,13 +299,24 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi
|
|||||||
<Card
|
<Card
|
||||||
title={employeeCardTitle}
|
title={employeeCardTitle}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
|
<Space wrap>
|
||||||
{t("employees.actions.save_employee")}
|
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
|
||||||
</Button>
|
{t("general.actions.saveandnew") || "Save and New"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => submitEmployeeForm("save")}
|
||||||
|
disabled={!resolvedIsDirty}
|
||||||
|
style={{ minWidth: 170 }}
|
||||||
|
>
|
||||||
|
{t("employees.actions.save_employee")}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
|
onFinishFailed={saveAndResetSubmitAction}
|
||||||
autoComplete={"off"}
|
autoComplete={"off"}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { useApolloClient } from "@apollo/client/react";
|
||||||
|
import { Form } from "antd";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries";
|
||||||
|
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
|
||||||
|
|
||||||
|
const insertEmployeesMock = vi.fn();
|
||||||
|
const updateEmployeeMock = vi.fn();
|
||||||
|
const deleteVacationMock = vi.fn();
|
||||||
|
const useQueryMock = vi.fn();
|
||||||
|
const useMutationMock = vi.fn();
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
const notification = {
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@apollo/client/react", async () => {
|
||||||
|
const actual = await vi.importActual("@apollo/client/react");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useApolloClient: vi.fn(),
|
||||||
|
useQuery: (...args) => useQueryMock(...args),
|
||||||
|
useMutation: (...args) => useMutationMock(...args)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@splitsoftware/splitio-react", () => ({
|
||||||
|
useTreatmentsWithConfig: () => ({
|
||||||
|
treatments: {
|
||||||
|
Enhanced_Payroll: {
|
||||||
|
treatment: "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useLocation: () => ({
|
||||||
|
search: "?employeeId=new"
|
||||||
|
}),
|
||||||
|
useNavigate: () => navigateMock
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key, values = {}) => {
|
||||||
|
const translations = {
|
||||||
|
"bodyshop.labels.employee_options": "Employee Options",
|
||||||
|
"bodyshop.labels.employee_rates": "Employee Rates",
|
||||||
|
"bodyshop.labels.employee_vacation": "Employee Vacation",
|
||||||
|
"bodyshop.labels.employees": "Employees",
|
||||||
|
"employees.actions.addrate": "Add Rate",
|
||||||
|
"employees.actions.addvacation": "Add Vacation",
|
||||||
|
"employees.actions.new": "New Employee",
|
||||||
|
"employees.actions.save_employee": "Save Employee",
|
||||||
|
"employees.fields.active": "Active",
|
||||||
|
"employees.fields.employee_number": "Employee Number",
|
||||||
|
"employees.fields.external_id": "External Id",
|
||||||
|
"employees.fields.first_name": "First Name",
|
||||||
|
"employees.fields.flat_rate": "Flat Rate",
|
||||||
|
"employees.fields.hire_date": "Hire Date",
|
||||||
|
"employees.fields.last_name": "Last Name",
|
||||||
|
"employees.fields.pin": "PIN",
|
||||||
|
"employees.fields.rate": "Rate",
|
||||||
|
"employees.fields.termination_date": "Termination Date",
|
||||||
|
"employees.fields.user_email": "User Email",
|
||||||
|
"employees.labels.active": "Active",
|
||||||
|
"employees.successes.save": "Saved",
|
||||||
|
"general.actions.saveandnew": "Save and New",
|
||||||
|
"general.labels.actions": "Actions"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (key === "employees.errors.save") {
|
||||||
|
return `Save failed: ${values.message ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "employees.validation.unique_employee_number") {
|
||||||
|
return "Employee number must be unique";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "bodyshop.validation.useremailmustexist") {
|
||||||
|
return "User email must exist";
|
||||||
|
}
|
||||||
|
|
||||||
|
return translations[key] || key;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
|
||||||
|
useNotification: () => notification
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../firebase/firebase.utils", () => ({
|
||||||
|
logImEXEvent: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../alert/alert.component", () => ({
|
||||||
|
default: ({ title }) => <div>{title}</div>
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({
|
||||||
|
default: ({ id, value, onChange }) => (
|
||||||
|
<input id={id} type="text" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({
|
||||||
|
default: ({ id, value, onChange }) => (
|
||||||
|
<input id={id} type="email" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../layout-form-row/layout-form-row.component", () => ({
|
||||||
|
default: ({ title, extra, actions, children }) => (
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
{extra}
|
||||||
|
{children}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({
|
||||||
|
default: ({ title, extra, children }) => (
|
||||||
|
<div>
|
||||||
|
{title}
|
||||||
|
{extra}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({
|
||||||
|
default: ({ actionLabel }) => <div>{actionLabel}</div>
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../responsive-table/responsive-table.component", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./shop-employees-add-vacation.component", () => ({
|
||||||
|
default: () => null
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/Ciecaselect", () => ({
|
||||||
|
default: () => []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bodyshop = {
|
||||||
|
id: "shop-1",
|
||||||
|
imexshopid: "split-shop-1",
|
||||||
|
md_responsibility_centers: {
|
||||||
|
costs: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ShopEmployeesFormComponent", () => {
|
||||||
|
let formInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
useQueryMock.mockImplementation((query) => {
|
||||||
|
if (query === QUERY_EMPLOYEE_BY_ID) {
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
data: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
data: null,
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useMutationMock.mockImplementation((mutation) => {
|
||||||
|
if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock];
|
||||||
|
if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock];
|
||||||
|
if (mutation === DELETE_VACATION) return [deleteVacationMock];
|
||||||
|
return [vi.fn()];
|
||||||
|
});
|
||||||
|
|
||||||
|
useApolloClient.mockReturnValue({
|
||||||
|
query: vi.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
employees_aggregate: {
|
||||||
|
aggregate: {
|
||||||
|
count: 0
|
||||||
|
},
|
||||||
|
nodes: []
|
||||||
|
},
|
||||||
|
users: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
insertEmployeesMock.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
insert_employees: {
|
||||||
|
returning: [
|
||||||
|
{
|
||||||
|
id: "employee-123",
|
||||||
|
first_name: "Jamie",
|
||||||
|
last_name: "Rivera",
|
||||||
|
employee_number: "42",
|
||||||
|
active: true,
|
||||||
|
termination_date: null,
|
||||||
|
hire_date: "2026-04-20",
|
||||||
|
flat_rate: false,
|
||||||
|
rates: [],
|
||||||
|
pin: "1234",
|
||||||
|
user_email: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function TestHarness({ onFormReady }) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onFormReady(form);
|
||||||
|
}, [form, onFormReady]);
|
||||||
|
|
||||||
|
return <ShopEmployeesFormComponent bodyshop={bodyshop} form={form} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestHarness
|
||||||
|
onFormReady={(form) => {
|
||||||
|
formInstance = form;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a new employee form clean after save", async () => {
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
|
||||||
|
target: { value: "Jamie" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
|
||||||
|
target: { value: "Rivera" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
|
||||||
|
target: { value: "42" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
|
||||||
|
target: { value: "1234" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
|
||||||
|
target: { value: "2026-04-20" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: "Save Employee" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(saveButton.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(insertEmployeesMock).toHaveBeenCalledWith({
|
||||||
|
variables: {
|
||||||
|
employees: [
|
||||||
|
expect.objectContaining({
|
||||||
|
first_name: "Jamie",
|
||||||
|
last_name: "Rivera",
|
||||||
|
employee_number: "42",
|
||||||
|
pin: "1234",
|
||||||
|
hire_date: "2026-04-20",
|
||||||
|
shopid: "shop-1"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
},
|
||||||
|
refetchQueries: ["QUERY_EMPLOYEES"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(formInstance.isFieldsTouched()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(notification.success).toHaveBeenCalledWith({
|
||||||
|
title: "Saved"
|
||||||
|
});
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith({
|
||||||
|
search: "employeeId=employee-123"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => {
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), {
|
||||||
|
target: { value: "Jamie" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), {
|
||||||
|
target: { value: "Rivera" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), {
|
||||||
|
target: { value: "42" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), {
|
||||||
|
target: { value: "1234" }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), {
|
||||||
|
target: { value: "2026-04-20" }
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save and New" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(insertEmployeesMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(formInstance.isFieldsTouched()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith({
|
||||||
|
search: "employeeId=new"
|
||||||
|
});
|
||||||
|
expect(notification.success).toHaveBeenCalledWith({
|
||||||
|
title: "Saved"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1582,7 +1582,6 @@ export function ShopInfoGeneral({ form }) {
|
|||||||
form={form}
|
form={form}
|
||||||
errorNames={[["md_parts_order_comment", field.name, "label"]]}
|
errorNames={[["md_parts_order_comment", field.name, "label"]]}
|
||||||
noDivider
|
noDivider
|
||||||
titleOnly
|
|
||||||
title={
|
title={
|
||||||
<div style={INLINE_TITLE_ROW_STYLE}>
|
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||||
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
<InlineTitleListIcon style={INLINE_TITLE_HANDLE_STYLE} />
|
||||||
|
|||||||
@@ -810,16 +810,6 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) {
|
|||||||
>
|
>
|
||||||
<Input onBlur={handleBlur} />
|
<Input onBlur={handleBlur} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{!hasDMSKey && (
|
|
||||||
<Form.Item
|
|
||||||
label={t("bodyshop.fields.responsibilitycenter_accountitem")}
|
|
||||||
key={`${index}accountitem`}
|
|
||||||
name={[field.name, "accountitem"]}
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
>
|
|
||||||
<Input onBlur={handleBlur} />
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
{hasDMSKey && !bodyshop.rr_dealerid && (
|
{hasDMSKey && !bodyshop.rr_dealerid && (
|
||||||
<>
|
<>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PageHeader } from "@ant-design/pro-layout";
|
|||||||
import { useMutation, useQuery } from "@apollo/client/react";
|
import { useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||||
import { Button, Form, Modal, Space } from "antd";
|
import { Button, Form, Modal, Space } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
@@ -15,21 +15,27 @@ import { selectBodyshop } from "../../redux/user/user.selectors";
|
|||||||
import dayjs from "../../utils/day";
|
import dayjs from "../../utils/day";
|
||||||
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
|
import TimeTicketsCommitToggleComponent from "../time-tickets-commit-toggle/time-tickets-commit-toggle.component";
|
||||||
import TimeTicketModalComponent from "./time-ticket-modal.component";
|
import TimeTicketModalComponent from "./time-ticket-modal.component";
|
||||||
|
import { insertAuditTrail } from "../../redux/application/application.actions.js";
|
||||||
|
import AuditTrailMapping from "../../utils/AuditTrailMappings";
|
||||||
|
import { buildTimeTicketAuditSummary } from "../../utils/auditTrailDetails.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
timeTicketModal: selectTimeTicket,
|
timeTicketModal: selectTimeTicket,
|
||||||
bodyshop: selectBodyshop
|
bodyshop: selectBodyshop
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket"))
|
toggleModalVisible: () => dispatch(toggleModalVisible("timeTicket")),
|
||||||
|
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||||
});
|
});
|
||||||
|
|
||||||
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop }) {
|
export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible, bodyshop, insertAuditTrail }) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [enterAgain, setEnterAgain] = useState(false);
|
const [enterAgain, setEnterAgain] = useState(false);
|
||||||
|
|
||||||
|
const lastSubmittedRef = useRef(null);
|
||||||
|
|
||||||
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
|
const [lineTicketRefreshKey, setLineTicketRefreshKey] = useState(0);
|
||||||
|
|
||||||
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
|
const [insertTicket] = useMutation(INSERT_NEW_TIME_TICKET);
|
||||||
@@ -48,47 +54,77 @@ export function TimeTicketModalContainer({ timeTicketModal, toggleModalVisible,
|
|||||||
fetchPolicy: "network-only",
|
fetchPolicy: "network-only",
|
||||||
nextFetchPolicy: "network-only"
|
nextFetchPolicy: "network-only"
|
||||||
});
|
});
|
||||||
|
const employees = EmployeeAutoCompleteData?.employees ?? [];
|
||||||
|
|
||||||
const handleFinish = (values) => {
|
const handleFinish = (values) => {
|
||||||
|
lastSubmittedRef.current = values;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const emps = EmployeeAutoCompleteData?.employees.filter((e) => e.id === values.employeeid);
|
const isEdit = Boolean(timeTicketModal.context.id);
|
||||||
if (timeTicketModal.context.id) {
|
const emps = employees.filter((employee) => employee.id === values.employeeid);
|
||||||
updateTicket({
|
const mutation = isEdit
|
||||||
variables: {
|
? updateTicket({
|
||||||
timeticketId: timeTicketModal.context.id,
|
variables: {
|
||||||
timeticket: {
|
timeticketId: timeTicketModal.context.id,
|
||||||
...values,
|
timeticket: {
|
||||||
rate: emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(handleMutationSuccess)
|
|
||||||
.catch(handleMutationError);
|
|
||||||
} else {
|
|
||||||
//Get selected employee rate.
|
|
||||||
insertTicket({
|
|
||||||
variables: {
|
|
||||||
timeTicketInput: [
|
|
||||||
{
|
|
||||||
...values,
|
...values,
|
||||||
rate:
|
rate:
|
||||||
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
|
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0]?.rate : null
|
||||||
bodyshopid: bodyshop.id,
|
|
||||||
created_by: timeTicketModal.context.created_by
|
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
})
|
||||||
})
|
: insertTicket({
|
||||||
.then(handleMutationSuccess)
|
variables: {
|
||||||
.catch(handleMutationError);
|
timeTicketInput: [
|
||||||
}
|
{
|
||||||
|
...values,
|
||||||
|
rate:
|
||||||
|
emps.length === 1 ? emps[0].rates.filter((r) => r.cost_center === values.cost_center)[0].rate : null,
|
||||||
|
bodyshopid: bodyshop.id,
|
||||||
|
created_by: timeTicketModal.context.created_by
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mutation.then((result) => handleMutationSuccess(result, isEdit)).catch(handleMutationError);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMutationSuccess = () => {
|
const handleMutationSuccess = (result, isEdit) => {
|
||||||
notification.success({
|
notification.success({
|
||||||
title: t("timetickets.successes.created")
|
title: t("timetickets.successes.created")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const savedTicket =
|
||||||
|
result?.data?.update_timetickets?.returning?.[0] ?? result?.data?.insert_timetickets?.returning?.[0] ?? {};
|
||||||
|
const originalTicket = timeTicketModal.context?.timeticket ?? {};
|
||||||
|
const submittedValues = {
|
||||||
|
...(lastSubmittedRef.current ?? {}),
|
||||||
|
date: lastSubmittedRef.current?.date ?? savedTicket.date ?? originalTicket.date ?? null,
|
||||||
|
employeeid: lastSubmittedRef.current?.employeeid ?? savedTicket.employeeid ?? originalTicket.employeeid ?? null,
|
||||||
|
jobid:
|
||||||
|
lastSubmittedRef.current?.jobid ??
|
||||||
|
savedTicket.jobid ??
|
||||||
|
timeTicketModal.context.jobId ??
|
||||||
|
originalTicket.job?.id ??
|
||||||
|
originalTicket.jobid ??
|
||||||
|
null
|
||||||
|
};
|
||||||
|
const auditSummary = buildTimeTicketAuditSummary({
|
||||||
|
originalTicket,
|
||||||
|
submittedValues,
|
||||||
|
employees
|
||||||
|
});
|
||||||
|
|
||||||
|
if (auditSummary.jobid) {
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: auditSummary.jobid,
|
||||||
|
operation: isEdit
|
||||||
|
? AuditTrailMapping.timeticketupdated(auditSummary.employeeName, auditSummary.date, auditSummary.details)
|
||||||
|
: AuditTrailMapping.timeticketcreated(auditSummary.employeeName, auditSummary.date, auditSummary.details),
|
||||||
|
type: isEdit ? "timeticketupdated" : "timeticketcreated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh parent screens (Job Labor tab, etc.)
|
// Refresh parent screens (Job Labor tab, etc.)
|
||||||
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();
|
if (timeTicketModal.actions.refetch) timeTicketModal.actions.refetch();
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import { createStructuredSelector } from "reselect";
|
|||||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||||
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
|
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
|
||||||
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
|
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
|
||||||
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
import { insertAuditTrail, setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
|
||||||
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
|
||||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||||
import JobsCreateComponent from "./jobs-create.component";
|
import JobsCreateComponent from "./jobs-create.component";
|
||||||
import JobCreateContext from "./jobs-create.context";
|
import JobCreateContext from "./jobs-create.context";
|
||||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||||
|
import AuditTrailMapping from "../../utils/AuditTrailMappings.js";
|
||||||
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
const mapStateToProps = createStructuredSelector({
|
||||||
bodyshop: selectBodyshop,
|
bodyshop: selectBodyshop,
|
||||||
@@ -22,10 +23,11 @@ const mapStateToProps = createStructuredSelector({
|
|||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
|
||||||
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
|
setSelectedHeader: (key) => dispatch(setSelectedHeader(key)),
|
||||||
|
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
|
||||||
});
|
});
|
||||||
|
|
||||||
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
|
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser, insertAuditTrail }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
|
|
||||||
@@ -84,6 +86,11 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
|
|||||||
newJobId: resp.data.insert_jobs.returning[0].id
|
newJobId: resp.data.insert_jobs.returning[0].id
|
||||||
});
|
});
|
||||||
logImEXEvent("manual_job_create_completed", {});
|
logImEXEvent("manual_job_create_completed", {});
|
||||||
|
insertAuditTrail({
|
||||||
|
jobid: resp.data.insert_jobs.returning[0].id,
|
||||||
|
operation: AuditTrailMapping.jobmanualcreate(),
|
||||||
|
type: "jobmanualcreate"
|
||||||
|
});
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -27,12 +27,19 @@ const mapDispatchToProps = (dispatch) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
|
export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bodyshop }) {
|
||||||
|
const technicianId = technician?.id;
|
||||||
|
const teamIds = (bodyshop?.employee_teams || [])
|
||||||
|
.filter((employeeTeam) =>
|
||||||
|
employeeTeam?.employee_team_members?.some((teamMember) => teamMember?.employeeid === technicianId)
|
||||||
|
)
|
||||||
|
.map((employeeTeam) => employeeTeam.id)
|
||||||
|
.filter(Boolean);
|
||||||
|
const hasAssignedTeams = Boolean(technicianId) && teamIds.length > 0;
|
||||||
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
|
const { loading, error, data, refetch } = useQuery(QUERY_JOBS_TECH_ASIGNED_TO_BY_TEAM, {
|
||||||
variables: {
|
variables: {
|
||||||
teamIds: bodyshop.employee_teams
|
teamIds
|
||||||
.filter((et) => et.employee_team_members.find((etm) => etm.employeeid === technician.id))
|
},
|
||||||
.map((et) => et.id)
|
skip: !technicianId || !hasAssignedTeams
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchParams = queryString.parse(useLocation().search);
|
const searchParams = queryString.parse(useLocation().search);
|
||||||
@@ -177,7 +184,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod
|
|||||||
<Card
|
<Card
|
||||||
extra={
|
extra={
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
|
<Button disabled={!hasAssignedTeams} onClick={() => refetch()} icon={<SyncOutlined />} />
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder={t("general.labels.search")}
|
placeholder={t("general.labels.search")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
"billdeleted": "Bill with invoice number {{invoice_number}} deleted.",
|
"billdeleted": "Bill with invoice number {{invoice_number}} deleted.",
|
||||||
"billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.",
|
"billmarkforreexport": "Bill with invoice number {{invoice_number}} marked for re-export.",
|
||||||
"billposted": "Bill with invoice number {{invoice_number}} posted.",
|
"billposted": "Bill with invoice number {{invoice_number}} posted.",
|
||||||
"billupdated": "Bill with invoice number {{invoice_number}} updated.",
|
"billupdated": "Bill with invoice number {{invoice_number}} updated with the following details: {{details}}.",
|
||||||
"failedpayment": "Failed payment attempt.",
|
"failedpayment": "Failed payment attempt.",
|
||||||
"jobassignmentchange": "Employee {{name}} assigned to {{operation}}",
|
"jobassignmentchange": "Employee {{name}} assigned to {{operation}}",
|
||||||
"jobassignmentremoved": "Employee assignment removed for {{operation}}",
|
"jobassignmentremoved": "Employee assignment removed for {{operation}}",
|
||||||
@@ -137,6 +137,9 @@
|
|||||||
"jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.",
|
"jobintake": "Job intake completed. Status set to {{status}}. Scheduled completion is {{scheduled_completion}}.",
|
||||||
"jobinvoiced": "Job has been invoiced.",
|
"jobinvoiced": "Job has been invoiced.",
|
||||||
"jobioucreated": "IOU Created.",
|
"jobioucreated": "IOU Created.",
|
||||||
|
"joblineupdate": "Job line {{lineDescription}} updated with the following details: {{details}}.",
|
||||||
|
"jobmanualcreate": "Job manually created.",
|
||||||
|
"jobmanuallineinsert": "Job line manually added with the following details: {{details}}.",
|
||||||
"jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.",
|
"jobmodifylbradj": "Labor adjustments modified {{mod_lbr_ty}} / {{hours}}.",
|
||||||
"jobnoteadded": "Note added to Job.",
|
"jobnoteadded": "Note added to Job.",
|
||||||
"jobnotedeleted": "Note deleted from Job.",
|
"jobnotedeleted": "Note deleted from Job.",
|
||||||
@@ -152,7 +155,9 @@
|
|||||||
"tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}",
|
"tasks_deleted": "Task '{{title}}' deleted by {{deletedBy}}",
|
||||||
"tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}",
|
"tasks_uncompleted": "Task '{{title}}' uncompleted by {{uncompletedBy}}",
|
||||||
"tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}",
|
"tasks_undeleted": "Task '{{title}}' undeleted by {{undeletedBy}}",
|
||||||
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}"
|
"tasks_updated": "Task '{{title}}' updated by {{updatedBy}}",
|
||||||
|
"timeticketcreated": "Time Ticket for {{employee}} on {{date}} created with the following details: {{details}}.",
|
||||||
|
"timeticketupdated": "Time Ticket for {{employee}} on {{date}} updated with the following details: {{details}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"billlines": {
|
"billlines": {
|
||||||
@@ -1780,6 +1785,7 @@
|
|||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"addpayer": "Add Payer",
|
||||||
"addDocuments": "Add Job Documents",
|
"addDocuments": "Add Job Documents",
|
||||||
"addNote": "Add Note",
|
"addNote": "Add Note",
|
||||||
"addtopartsqueue": "Add to Parts Queue",
|
"addtopartsqueue": "Add to Parts Queue",
|
||||||
|
|||||||
@@ -122,7 +122,6 @@
|
|||||||
"billdeleted": "",
|
"billdeleted": "",
|
||||||
"billposted": "",
|
"billposted": "",
|
||||||
"billmarkforreexport": "",
|
"billmarkforreexport": "",
|
||||||
"billupdated": "",
|
|
||||||
"failedpayment": "",
|
"failedpayment": "",
|
||||||
"jobassignmentchange": "",
|
"jobassignmentchange": "",
|
||||||
"jobassignmentremoved": "",
|
"jobassignmentremoved": "",
|
||||||
@@ -1780,6 +1779,7 @@
|
|||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"addpayer": "",
|
||||||
"addDocuments": "Agregar documentos de trabajo",
|
"addDocuments": "Agregar documentos de trabajo",
|
||||||
"addNote": "Añadir la nota",
|
"addNote": "Añadir la nota",
|
||||||
"addtopartsqueue": "",
|
"addtopartsqueue": "",
|
||||||
|
|||||||
@@ -122,7 +122,6 @@
|
|||||||
"billdeleted": "",
|
"billdeleted": "",
|
||||||
"billmarkforreexport": "",
|
"billmarkforreexport": "",
|
||||||
"billposted": "",
|
"billposted": "",
|
||||||
"billupdated": "",
|
|
||||||
"failedpayment": "",
|
"failedpayment": "",
|
||||||
"jobassignmentchange": "",
|
"jobassignmentchange": "",
|
||||||
"jobassignmentremoved": "",
|
"jobassignmentremoved": "",
|
||||||
@@ -1780,6 +1779,7 @@
|
|||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"addpayer": "",
|
||||||
"addDocuments": "Ajouter des documents de travail",
|
"addDocuments": "Ajouter des documents de travail",
|
||||||
"addNote": "Ajouter une note",
|
"addNote": "Ajouter une note",
|
||||||
"addtopartsqueue": "",
|
"addtopartsqueue": "",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const AuditTrailMapping = {
|
|||||||
billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }),
|
billdeleted: (invoice_number) => i18n.t("audit_trail.messages.billdeleted", { invoice_number }),
|
||||||
billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }),
|
billmarkforreexport: (invoice_number) => i18n.t("audit_trail.messages.billmarkforreexport", { invoice_number }),
|
||||||
billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }),
|
billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", { invoice_number }),
|
||||||
billupdated: (invoice_number) => i18n.t("audit_trail.messages.billupdated", { invoice_number }),
|
billupdated: (invoice_number, details) => i18n.t("audit_trail.messages.billupdated", { invoice_number, details }),
|
||||||
jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }),
|
jobassignmentchange: (operation, name) => i18n.t("audit_trail.messages.jobassignmentchange", { operation, name }),
|
||||||
jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }),
|
jobassignmentremoved: (operation) => i18n.t("audit_trail.messages.jobassignmentremoved", { operation }),
|
||||||
jobchecklist: (type, inproduction, status) =>
|
jobchecklist: (type, inproduction, status) =>
|
||||||
@@ -26,6 +26,10 @@ const AuditTrailMapping = {
|
|||||||
jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }),
|
jobinproductionchange: (inproduction) => i18n.t("audit_trail.messages.jobinproductionchange", { inproduction }),
|
||||||
jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"),
|
jobinvoiced: () => i18n.t("audit_trail.messages.jobinvoiced"),
|
||||||
jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"),
|
jobclosedwithbypass: () => i18n.t("audit_trail.messages.jobclosedwithbypass"),
|
||||||
|
joblineupdate: (lineDescription, details) =>
|
||||||
|
i18n.t("audit_trail.messages.joblineupdate", { details, lineDescription }),
|
||||||
|
jobmanualcreate: () => i18n.t("audit_trail.messages.jobmanualcreate"),
|
||||||
|
jobmanuallineinsert: (details) => i18n.t("audit_trail.messages.jobmanuallineinsert", { details }),
|
||||||
jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }),
|
jobmodifylbradj: ({ mod_lbr_ty, hours }) => i18n.t("audit_trail.messages.jobmodifylbradj", { mod_lbr_ty, hours }),
|
||||||
jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"),
|
jobnoteadded: () => i18n.t("audit_trail.messages.jobnoteadded"),
|
||||||
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
|
jobnoteupdated: () => i18n.t("audit_trail.messages.jobnoteupdated"),
|
||||||
@@ -72,7 +76,11 @@ const AuditTrailMapping = {
|
|||||||
i18n.t("audit_trail.messages.tasks_uncompleted", {
|
i18n.t("audit_trail.messages.tasks_uncompleted", {
|
||||||
title,
|
title,
|
||||||
uncompletedBy
|
uncompletedBy
|
||||||
})
|
}),
|
||||||
|
timeticketcreated: (employee, date, details) =>
|
||||||
|
i18n.t("audit_trail.messages.timeticketcreated", { employee, date, details }),
|
||||||
|
timeticketupdated: (employee, date, details) =>
|
||||||
|
i18n.t("audit_trail.messages.timeticketupdated", { employee, date, details })
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuditTrailMapping;
|
export default AuditTrailMapping;
|
||||||
|
|||||||
186
client/src/utils/auditTrailDetails.js
Normal file
186
client/src/utils/auditTrailDetails.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import dayjs from "./day";
|
||||||
|
|
||||||
|
const EMPTY_VALUE = "<<empty>>";
|
||||||
|
const NO_CHANGES = "No changes";
|
||||||
|
|
||||||
|
const BILL_LINE_KEYS = ["line_desc", "quantity", "actual_price", "actual_cost", "cost_center"];
|
||||||
|
const JOB_LINE_SKIP_KEYS = new Set(["ah_detail_line", "prt_dsmk_p"]);
|
||||||
|
const DATE_ONLY_KEYS = new Set(["date"]);
|
||||||
|
const DATE_TIME_KEYS = new Set(["clockon", "clockoff"]);
|
||||||
|
const CURRENCY_KEYS = new Set(["actual_price", "actual_cost", "act_price", "db_price", "rate"]);
|
||||||
|
const HOUR_KEYS = new Set(["productivehrs", "actualhrs", "mod_lb_hrs"]);
|
||||||
|
|
||||||
|
const isBlank = (value) => value == null || value === "";
|
||||||
|
|
||||||
|
const isStructuredValue = (value) => value != null && typeof value === "object" && !dayjs.isDayjs?.(value);
|
||||||
|
|
||||||
|
const formatDate = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD"));
|
||||||
|
|
||||||
|
const formatDateTime = (value) => (isBlank(value) ? EMPTY_VALUE : dayjs(value).format("YYYY-MM-DD HH:mm"));
|
||||||
|
|
||||||
|
const formatNumber = (value, fractionDigits) =>
|
||||||
|
typeof value === "number" ? value.toFixed(fractionDigits) : String(value);
|
||||||
|
|
||||||
|
const compareValue = (key, value) => {
|
||||||
|
if (isBlank(value)) return EMPTY_VALUE;
|
||||||
|
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
|
||||||
|
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
|
||||||
|
if (dayjs.isDayjs?.(value)) return formatDateTime(value);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFieldChangeDetails = ({ keys, original = {}, updated = {}, displayValue, skippedKeys = new Set() }) =>
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== "__typename" && !skippedKeys.has(key))
|
||||||
|
.filter((key) => !isStructuredValue(original[key]) && !isStructuredValue(updated[key]))
|
||||||
|
.map((key) => {
|
||||||
|
if (compareValue(key, original[key]) === compareValue(key, updated[key])) return null;
|
||||||
|
return `${key}: ${displayValue(key, original[key])} -> ${displayValue(key, updated[key])}`;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const formatBillValue = (key, value) => {
|
||||||
|
if (isBlank(value)) return EMPTY_VALUE;
|
||||||
|
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
|
||||||
|
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatJobLineValue = (key, value) => {
|
||||||
|
if (isBlank(value)) return EMPTY_VALUE;
|
||||||
|
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
|
||||||
|
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmployeeName = (employeeId, employees = [], fallbackEmployee) => {
|
||||||
|
if (
|
||||||
|
(employeeId == null || fallbackEmployee?.id === employeeId) &&
|
||||||
|
(fallbackEmployee?.first_name || fallbackEmployee?.last_name)
|
||||||
|
) {
|
||||||
|
return [fallbackEmployee.first_name, fallbackEmployee.last_name].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = employees.find(({ id }) => id === employeeId);
|
||||||
|
if (employee) {
|
||||||
|
return [employee.first_name, employee.last_name].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return employeeId ? String(employeeId) : EMPTY_VALUE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeTicketValue = (key, value, { employees = [], fallbackEmployee } = {}) => {
|
||||||
|
if (isBlank(value)) return EMPTY_VALUE;
|
||||||
|
if (key === "employeeid") return getEmployeeName(value, employees, fallbackEmployee);
|
||||||
|
if (DATE_TIME_KEYS.has(key)) return formatDateTime(value);
|
||||||
|
if (DATE_ONLY_KEYS.has(key)) return formatDate(value);
|
||||||
|
if (CURRENCY_KEYS.has(key)) return typeof value === "number" ? `$${value.toFixed(2)}` : String(value);
|
||||||
|
if (HOUR_KEYS.has(key)) return formatNumber(value, 1);
|
||||||
|
if (typeof value === "boolean") return value ? "true" : "false";
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildBillLineSummary = (line) =>
|
||||||
|
BILL_LINE_KEYS.map((key) => `${key}: ${formatBillValue(key, line[key])}`).join(", ");
|
||||||
|
|
||||||
|
export function buildBillUpdateAuditDetails({ originalBill = {}, bill = {}, billlines = [] }) {
|
||||||
|
const updatedBill = { ...bill, billlines };
|
||||||
|
const billKeys = Array.from(new Set([...Object.keys(originalBill), ...Object.keys(updatedBill)])).filter(
|
||||||
|
(key) => key !== "billlines"
|
||||||
|
);
|
||||||
|
|
||||||
|
const changed = buildFieldChangeDetails({
|
||||||
|
keys: billKeys,
|
||||||
|
original: originalBill,
|
||||||
|
updated: updatedBill,
|
||||||
|
displayValue: formatBillValue
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalBillLines = originalBill.billlines ?? [];
|
||||||
|
const updatedBillLines = updatedBill.billlines ?? [];
|
||||||
|
|
||||||
|
const addedLines = updatedBillLines
|
||||||
|
.filter((line) => !line.id)
|
||||||
|
.map((line) => `+${line.line_desc || line.description || "new line"} (${buildBillLineSummary(line)})`);
|
||||||
|
|
||||||
|
const removedLines = originalBillLines
|
||||||
|
.filter((line) => !updatedBillLines.some((updatedLine) => updatedLine.id === line.id))
|
||||||
|
.map(
|
||||||
|
(line) => `-${line.line_desc || line.description || line.id || "removed line"} (${buildBillLineSummary(line)})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const modifiedLines = updatedBillLines
|
||||||
|
.filter((line) => line.id)
|
||||||
|
.flatMap((line) => {
|
||||||
|
const originalLine = originalBillLines.find(({ id }) => id === line.id);
|
||||||
|
if (!originalLine) return [];
|
||||||
|
|
||||||
|
const lineChanges = buildFieldChangeDetails({
|
||||||
|
keys: BILL_LINE_KEYS,
|
||||||
|
original: originalLine,
|
||||||
|
updated: line,
|
||||||
|
displayValue: formatBillValue
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lineChanges.length) return [];
|
||||||
|
|
||||||
|
return [`${line.line_desc || line.description || line.id}: ${lineChanges.join("; ")}`];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (addedLines.length) changed.push(`billlines added: ${addedLines.join(" | ")}`);
|
||||||
|
if (removedLines.length) changed.push(`billlines removed: ${removedLines.join(" | ")}`);
|
||||||
|
if (modifiedLines.length) changed.push(`billlines modified: ${modifiedLines.join(" | ")}`);
|
||||||
|
|
||||||
|
return changed.length ? changed.join("; ") : NO_CHANGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJobLineInsertAuditDetails(values = {}) {
|
||||||
|
const details = Object.entries(values)
|
||||||
|
.filter(([key, value]) => !JOB_LINE_SKIP_KEYS.has(key) && !isBlank(value))
|
||||||
|
.map(([key, value]) => `${key}: ${formatJobLineValue(key, value)}`);
|
||||||
|
|
||||||
|
return details.length ? details.join("; ") : NO_CHANGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJobLineUpdateAuditDetails({ originalLine = {}, values = {} }) {
|
||||||
|
const details = buildFieldChangeDetails({
|
||||||
|
keys: Object.keys(values),
|
||||||
|
original: originalLine,
|
||||||
|
updated: values,
|
||||||
|
displayValue: formatJobLineValue,
|
||||||
|
skippedKeys: JOB_LINE_SKIP_KEYS
|
||||||
|
});
|
||||||
|
|
||||||
|
return details.length ? details.join("; ") : NO_CHANGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimeTicketAuditSummary({ originalTicket = {}, submittedValues = {}, employees = [] }) {
|
||||||
|
const normalizedOriginal = {
|
||||||
|
...originalTicket,
|
||||||
|
jobid: originalTicket.job?.id ?? originalTicket.jobid ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
const details = buildFieldChangeDetails({
|
||||||
|
keys: Object.keys(submittedValues),
|
||||||
|
original: normalizedOriginal,
|
||||||
|
updated: submittedValues,
|
||||||
|
displayValue: (key, value) =>
|
||||||
|
formatTimeTicketValue(key, value, {
|
||||||
|
employees,
|
||||||
|
fallbackEmployee: key === "employeeid" ? normalizedOriginal.employee : null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const employeeName = getEmployeeName(
|
||||||
|
submittedValues.employeeid ?? normalizedOriginal.employeeid,
|
||||||
|
employees,
|
||||||
|
normalizedOriginal.employee
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: formatDate(submittedValues.date ?? normalizedOriginal.date),
|
||||||
|
details: details.length ? details.join("; ") : NO_CHANGES,
|
||||||
|
employeeName,
|
||||||
|
jobid: submittedValues.jobid ?? normalizedOriginal.jobid ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1164,6 +1164,7 @@
|
|||||||
- notification_followers
|
- notification_followers
|
||||||
- state
|
- state
|
||||||
- md_order_statuses
|
- md_order_statuses
|
||||||
|
- md_ro_statuses
|
||||||
retry_conf:
|
retry_conf:
|
||||||
interval_sec: 10
|
interval_sec: 10
|
||||||
num_retries: 0
|
num_retries: 0
|
||||||
@@ -1184,7 +1185,8 @@
|
|||||||
"new": {
|
"new": {
|
||||||
"id": {{$body.event.data.new.id}},
|
"id": {{$body.event.data.new.id}},
|
||||||
"shopname": {{$body.event.data.new.shopname}},
|
"shopname": {{$body.event.data.new.shopname}},
|
||||||
"md_order_statuses": {{$body.event.data.new.md_order_statuses}}
|
"md_order_statuses": {{$body.event.data.new.md_order_statuses}},
|
||||||
|
"md_ro_statuses": {{$body.event.data.new.md_ro_statuses}}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"op": {{$body.event.op}},
|
"op": {{$body.event.op}},
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async function OpenSearchUpdateHandler(req, res) {
|
|||||||
clm_no
|
clm_no
|
||||||
clm_total
|
clm_total
|
||||||
comment
|
comment
|
||||||
|
dms_id
|
||||||
ins_co_nm
|
ins_co_nm
|
||||||
owner_owing
|
owner_owing
|
||||||
ownr_co_nm
|
ownr_co_nm
|
||||||
|
|||||||
@@ -250,7 +250,8 @@ async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShop
|
|||||||
function buildInteractionPayload(bodyshop, j) {
|
function buildInteractionPayload(bodyshop, j) {
|
||||||
const isCompany = Boolean(j.ownr_co_nm);
|
const isCompany = Boolean(j.ownr_co_nm);
|
||||||
|
|
||||||
const locationIdentifier = `${bodyshop.chatter_company_id}-${bodyshop.id}`;
|
const locationIdentifier = bodyshop?.imexshopid ?? `${bodyshop.chatter_company_id}-${bodyshop.id}`;
|
||||||
|
|
||||||
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
|
const timestamp = formatChatterTimestamp(j.actual_delivery, bodyshop.timezone);
|
||||||
|
|
||||||
if (j.actual_delivery && !timestamp) {
|
if (j.actual_delivery && !timestamp) {
|
||||||
|
|||||||
@@ -2442,6 +2442,9 @@ exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) {
|
|||||||
associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) {
|
associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) {
|
||||||
id
|
id
|
||||||
shopid
|
shopid
|
||||||
|
bodyshop {
|
||||||
|
rr_dealerid
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
@@ -2953,6 +2956,7 @@ exports.GET_BODYSHOP_BY_ID = `
|
|||||||
query GET_BODYSHOP_BY_ID($id: uuid!) {
|
query GET_BODYSHOP_BY_ID($id: uuid!) {
|
||||||
bodyshops_by_pk(id: $id) {
|
bodyshops_by_pk(id: $id) {
|
||||||
id
|
id
|
||||||
|
md_ro_statuses
|
||||||
md_order_statuses
|
md_order_statuses
|
||||||
shopname
|
shopname
|
||||||
imexshopid
|
imexshopid
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const queries = require("../graphql-client/queries");
|
|||||||
const client = require("../graphql-client/graphql-client").client;
|
const client = require("../graphql-client/graphql-client").client;
|
||||||
const { pick, isNil } = require("lodash");
|
const { pick, isNil } = require("lodash");
|
||||||
const { getClient } = require("../../libs/awsUtils");
|
const { getClient } = require("../../libs/awsUtils");
|
||||||
|
const { JOB_DOCUMENT_FIELDS, getGlobalSearchQueryStringFields } = require("./os-search-config");
|
||||||
|
|
||||||
async function OpenSearchUpdateHandler(req, res) {
|
async function OpenSearchUpdateHandler(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -21,27 +22,7 @@ async function OpenSearchUpdateHandler(req, res) {
|
|||||||
|
|
||||||
switch (req.body.table.name) {
|
switch (req.body.table.name) {
|
||||||
case "jobs":
|
case "jobs":
|
||||||
document = pick(req.body.event.data.new, [
|
document = pick(req.body.event.data.new, JOB_DOCUMENT_FIELDS);
|
||||||
"id",
|
|
||||||
"bodyshopid",
|
|
||||||
"clm_no",
|
|
||||||
"clm_total",
|
|
||||||
"comment",
|
|
||||||
"ins_co_nm",
|
|
||||||
"owner_owing",
|
|
||||||
"ownr_co_nm",
|
|
||||||
"ownr_fn",
|
|
||||||
"ownr_ln",
|
|
||||||
"ownr_ph1",
|
|
||||||
"ownr_ph2",
|
|
||||||
"plate_no",
|
|
||||||
"ro_number",
|
|
||||||
"status",
|
|
||||||
"v_model_yr",
|
|
||||||
"v_make_desc",
|
|
||||||
"v_model_desc",
|
|
||||||
"v_vin"
|
|
||||||
]);
|
|
||||||
document.bodyshopid = req.body.event.data.new.shopid;
|
document.bodyshopid = req.body.event.data.new.shopid;
|
||||||
break;
|
break;
|
||||||
case "vehicles":
|
case "vehicles":
|
||||||
@@ -197,15 +178,18 @@ async function OpenSearchSearchHandler(req, res) {
|
|||||||
user: req.user.email
|
user: req.user.email
|
||||||
});
|
});
|
||||||
|
|
||||||
if (assocs.length === 0) {
|
if (assocs.associations.length === 0) {
|
||||||
res.sendStatus(401);
|
res.sendStatus(401);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const osClient = await getClient();
|
const osClient = await getClient();
|
||||||
|
|
||||||
|
const activeAssociation = assocs.associations[0];
|
||||||
const bodyShopIdMatchOverride = isNil(process.env.BODY_SHOP_ID_MATCH_OVERRIDE)
|
const bodyShopIdMatchOverride = isNil(process.env.BODY_SHOP_ID_MATCH_OVERRIDE)
|
||||||
? assocs.associations[0].shopid
|
? activeAssociation.shopid
|
||||||
: process.env.BODY_SHOP_ID_MATCH_OVERRIDE;
|
: process.env.BODY_SHOP_ID_MATCH_OVERRIDE;
|
||||||
|
const isReynoldsEnabled = Boolean(activeAssociation.bodyshop?.rr_dealerid);
|
||||||
|
|
||||||
const { body } = await osClient.search({
|
const { body } = await osClient.search({
|
||||||
...(index ? { index } : { index: ["jobs", "vehicles", "owners", "bills", "payments"] }),
|
...(index ? { index } : { index: ["jobs", "vehicles", "owners", "bills", "payments"] }),
|
||||||
@@ -241,21 +225,8 @@ async function OpenSearchSearchHandler(req, res) {
|
|||||||
query: `*${search}*`,
|
query: `*${search}*`,
|
||||||
// Weighted Fields
|
// Weighted Fields
|
||||||
fields: [
|
fields: [
|
||||||
"*ro_number^20",
|
...getGlobalSearchQueryStringFields({ isReynoldsEnabled })
|
||||||
"*clm_no^14",
|
// "*"
|
||||||
"*v_vin^12",
|
|
||||||
"*plate_no^12",
|
|
||||||
"*ownr_ln^10",
|
|
||||||
"transactionid^10",
|
|
||||||
"paymentnum^10",
|
|
||||||
"invoice_number^10",
|
|
||||||
"*ownr_fn^8",
|
|
||||||
"*ownr_co_nm^8",
|
|
||||||
"*ownr_ph1^8",
|
|
||||||
"*ownr_ph2^8",
|
|
||||||
"*vendor.name^8",
|
|
||||||
"*comment^6"
|
|
||||||
// "*"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
server/opensearch/os-search-config.js
Normal file
69
server/opensearch/os-search-config.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Fields to be included in the job document indexed in OpenSearch. These fields are used for both indexing and
|
||||||
|
* searching.
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
const JOB_DOCUMENT_FIELDS = [
|
||||||
|
"id",
|
||||||
|
"bodyshopid",
|
||||||
|
"clm_no",
|
||||||
|
"clm_total",
|
||||||
|
"comment",
|
||||||
|
"dms_id",
|
||||||
|
"ins_co_nm",
|
||||||
|
"owner_owing",
|
||||||
|
"ownr_co_nm",
|
||||||
|
"ownr_fn",
|
||||||
|
"ownr_ln",
|
||||||
|
"ownr_ph1",
|
||||||
|
"ownr_ph2",
|
||||||
|
"plate_no",
|
||||||
|
"ro_number",
|
||||||
|
"status",
|
||||||
|
"v_model_yr",
|
||||||
|
"v_make_desc",
|
||||||
|
"v_model_desc",
|
||||||
|
"v_vin"
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fields to be included in the global search query string. These fields are used for constructing the search query.
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
const BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS = [
|
||||||
|
"*ro_number^20",
|
||||||
|
"*clm_no^14",
|
||||||
|
"*v_vin^12",
|
||||||
|
"*plate_no^12",
|
||||||
|
"*ownr_ln^10",
|
||||||
|
"transactionid^10",
|
||||||
|
"paymentnum^10",
|
||||||
|
"invoice_number^10",
|
||||||
|
"*ownr_fn^8",
|
||||||
|
"*ownr_co_nm^8",
|
||||||
|
"*ownr_ph1^8",
|
||||||
|
"*ownr_ph2^8",
|
||||||
|
"*vendor.name^8",
|
||||||
|
"*comment^6"
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the fields to be included in the global search query string. If Reynolds is enabled, it includes the dms_id
|
||||||
|
* field with a higher boost.
|
||||||
|
* @param param0
|
||||||
|
* @param param0.isReynoldsEnabled
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
const getGlobalSearchQueryStringFields = ({ isReynoldsEnabled = false } = {}) => {
|
||||||
|
if (!isReynoldsEnabled) {
|
||||||
|
return BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["*dms_id^20", ...BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS];
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
JOB_DOCUMENT_FIELDS,
|
||||||
|
BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS,
|
||||||
|
getGlobalSearchQueryStringFields
|
||||||
|
};
|
||||||
21
server/opensearch/tests/os-search-config.test.js
Normal file
21
server/opensearch/tests/os-search-config.test.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { JOB_DOCUMENT_FIELDS, BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS, getGlobalSearchQueryStringFields } = require(
|
||||||
|
"../os-search-config"
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("os-search-config", () => {
|
||||||
|
it("indexes dms_id on job documents", () => {
|
||||||
|
expect(JOB_DOCUMENT_FIELDS).toContain("dms_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes dms_id in global search fields for Reynolds shops", () => {
|
||||||
|
expect(getGlobalSearchQueryStringFields({ isReynoldsEnabled: true })).toContain("*dms_id^20");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the default search fields unchanged for non-Reynolds shops", () => {
|
||||||
|
expect(getGlobalSearchQueryStringFields()).toEqual(BASE_GLOBAL_SEARCH_QUERY_STRING_FIELDS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,11 @@ const summarizeAllocationsArray = (arr) =>
|
|||||||
cost: summarizeMoney(a.cost)
|
cost: summarizeMoney(a.cost)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal per-center bucket shape for *sales*.
|
* Internal per-center bucket shape for *sales*.
|
||||||
* We keep separate buckets for RR so we can split
|
* We keep separate buckets for RR so we can split
|
||||||
@@ -62,6 +67,8 @@ function emptyCenterBucket() {
|
|||||||
// Labor
|
// Labor
|
||||||
laborTaxableSale: zero, // labor that should be taxed in RR
|
laborTaxableSale: zero, // labor that should be taxed in RR
|
||||||
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
|
laborNonTaxableSale: zero, // labor that should NOT be taxed in RR
|
||||||
|
laborTaxableHours: 0,
|
||||||
|
laborNonTaxableHours: 0,
|
||||||
|
|
||||||
// Extras (MAPA/MASH/towing/PAO/etc)
|
// Extras (MAPA/MASH/towing/PAO/etc)
|
||||||
extrasSale: zero, // total extras (taxable + non-taxable)
|
extrasSale: zero, // total extras (taxable + non-taxable)
|
||||||
@@ -453,6 +460,7 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
|
|||||||
|
|
||||||
const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`;
|
const rateKey = `rate_${val.mod_lbr_ty.toLowerCase()}`;
|
||||||
const rate = job[rateKey];
|
const rate = job[rateKey];
|
||||||
|
const lineHours = toFiniteNumber(val.mod_lb_hrs);
|
||||||
|
|
||||||
const laborAmount = Dinero({
|
const laborAmount = Dinero({
|
||||||
amount: Math.round(rate * 100)
|
amount: Math.round(rate * 100)
|
||||||
@@ -460,8 +468,10 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
|
|||||||
|
|
||||||
if (isLaborTaxable(val, taxContext)) {
|
if (isLaborTaxable(val, taxContext)) {
|
||||||
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
|
bucket.laborTaxableSale = bucket.laborTaxableSale.add(laborAmount);
|
||||||
|
bucket.laborTaxableHours += lineHours;
|
||||||
} else {
|
} else {
|
||||||
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
|
bucket.laborNonTaxableSale = bucket.laborNonTaxableSale.add(laborAmount);
|
||||||
|
bucket.laborNonTaxableHours += lineHours;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +488,8 @@ function buildProfitCenterHash(job, debugLog, taxContext) {
|
|||||||
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
|
partsNonTaxable: summarizeMoney(b.partsNonTaxableSale),
|
||||||
laborTaxable: summarizeMoney(b.laborTaxableSale),
|
laborTaxable: summarizeMoney(b.laborTaxableSale),
|
||||||
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
|
laborNonTaxable: summarizeMoney(b.laborNonTaxableSale),
|
||||||
|
laborTaxableHours: b.laborTaxableHours,
|
||||||
|
laborNonTaxableHours: b.laborNonTaxableHours,
|
||||||
extras: summarizeMoney(b.extrasSale),
|
extras: summarizeMoney(b.extrasSale),
|
||||||
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
|
extrasTaxable: summarizeMoney(b.extrasTaxableSale),
|
||||||
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
|
extrasNonTaxable: summarizeMoney(b.extrasNonTaxableSale)
|
||||||
@@ -916,6 +928,8 @@ function buildJobAllocations(bodyshop, profitCenterHash, costCenterHash, debugLo
|
|||||||
// Labor
|
// Labor
|
||||||
laborTaxableSale: bucket.laborTaxableSale,
|
laborTaxableSale: bucket.laborTaxableSale,
|
||||||
laborNonTaxableSale: bucket.laborNonTaxableSale,
|
laborNonTaxableSale: bucket.laborNonTaxableSale,
|
||||||
|
laborTaxableHours: bucket.laborTaxableHours,
|
||||||
|
laborNonTaxableHours: bucket.laborNonTaxableHours,
|
||||||
|
|
||||||
// Extras
|
// Extras
|
||||||
extrasSale,
|
extrasSale,
|
||||||
|
|||||||
187
server/rr/rr-export-logs.test.js
Normal file
187
server/rr/rr-export-logs.test.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const mock = require("mock-require");
|
||||||
|
|
||||||
|
const graphqlRequestModuleId = require.resolve("graphql-request");
|
||||||
|
const queriesModuleId = require.resolve("../graphql-client/queries");
|
||||||
|
const rrLoggerModuleId = require.resolve("./rr-logger-event");
|
||||||
|
const rrExportLogsModuleId = require.resolve("./rr-export-logs");
|
||||||
|
|
||||||
|
const loadExportLogs = ({ requests }) => {
|
||||||
|
mock.stopAll();
|
||||||
|
|
||||||
|
mock(graphqlRequestModuleId, {
|
||||||
|
GraphQLClient: class MockGraphQLClient {
|
||||||
|
constructor(endpoint) {
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.headers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setHeaders(headers) {
|
||||||
|
this.headers = headers;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(query, variables) {
|
||||||
|
requests.push({
|
||||||
|
endpoint: this.endpoint,
|
||||||
|
headers: this.headers,
|
||||||
|
query,
|
||||||
|
variables
|
||||||
|
});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mock(queriesModuleId, {
|
||||||
|
INSERT_EXPORT_LOG: "INSERT_EXPORT_LOG",
|
||||||
|
MARK_JOB_EXPORTED: "MARK_JOB_EXPORTED"
|
||||||
|
});
|
||||||
|
|
||||||
|
mock(rrLoggerModuleId, () => {});
|
||||||
|
|
||||||
|
delete require.cache[rrExportLogsModuleId];
|
||||||
|
return require(rrExportLogsModuleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = {
|
||||||
|
data: { authToken: "socket-token" },
|
||||||
|
user: { email: "tech@example.com" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const job = {
|
||||||
|
id: "job-1",
|
||||||
|
bodyshop: {
|
||||||
|
id: "bodyshop-1",
|
||||||
|
md_ro_statuses: {
|
||||||
|
default_exported: "Exported"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("server/rr/rr-export-logs", () => {
|
||||||
|
const originalEndpoint = process.env.GRAPHQL_ENDPOINT;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.stopAll();
|
||||||
|
delete require.cache[rrExportLogsModuleId];
|
||||||
|
process.env.GRAPHQL_ENDPOINT = originalEndpoint;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks Reynolds full exports as exported using the shared DMS export mutation", async () => {
|
||||||
|
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
|
||||||
|
const requests = [];
|
||||||
|
const { markRRExportSuccess } = loadExportLogs({ requests });
|
||||||
|
|
||||||
|
await markRRExportSuccess({
|
||||||
|
socket,
|
||||||
|
jobId: job.id,
|
||||||
|
job,
|
||||||
|
bodyshop: job.bodyshop,
|
||||||
|
result: {
|
||||||
|
success: true,
|
||||||
|
roStatus: {
|
||||||
|
status: "SUCCESS",
|
||||||
|
statusCode: "0",
|
||||||
|
message: "Finalized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]).toMatchObject({
|
||||||
|
endpoint: "https://graphql.example.test/v1/graphql",
|
||||||
|
headers: { Authorization: "Bearer socket-token" },
|
||||||
|
query: "MARK_JOB_EXPORTED",
|
||||||
|
variables: {
|
||||||
|
jobId: "job-1",
|
||||||
|
job: {
|
||||||
|
status: "Exported",
|
||||||
|
date_exported: expect.any(Date)
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
bodyshopid: "bodyshop-1",
|
||||||
|
jobid: "job-1",
|
||||||
|
successful: true,
|
||||||
|
useremail: "tech@example.com"
|
||||||
|
},
|
||||||
|
bill: {
|
||||||
|
exported: true,
|
||||||
|
exported_at: expect.any(Date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the separately loaded bodyshop statuses when job.bodyshop is missing", async () => {
|
||||||
|
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
|
||||||
|
const requests = [];
|
||||||
|
const { markRRExportSuccess } = loadExportLogs({ requests });
|
||||||
|
|
||||||
|
await markRRExportSuccess({
|
||||||
|
socket,
|
||||||
|
jobId: job.id,
|
||||||
|
job: { id: job.id },
|
||||||
|
bodyshop: job.bodyshop,
|
||||||
|
result: {
|
||||||
|
success: true,
|
||||||
|
roStatus: {
|
||||||
|
status: "SUCCESS",
|
||||||
|
statusCode: "0",
|
||||||
|
message: "Finalized"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]).toMatchObject({
|
||||||
|
query: "MARK_JOB_EXPORTED",
|
||||||
|
variables: {
|
||||||
|
jobId: "job-1",
|
||||||
|
job: {
|
||||||
|
status: "Exported",
|
||||||
|
date_exported: expect.any(Date)
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
bodyshopid: "bodyshop-1",
|
||||||
|
jobid: "job-1",
|
||||||
|
successful: true,
|
||||||
|
useremail: "tech@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark Reynolds early RO creation as exported", async () => {
|
||||||
|
process.env.GRAPHQL_ENDPOINT = "https://graphql.example.test/v1/graphql";
|
||||||
|
const requests = [];
|
||||||
|
const { markRRExportSuccess } = loadExportLogs({ requests });
|
||||||
|
|
||||||
|
await markRRExportSuccess({
|
||||||
|
socket,
|
||||||
|
jobId: job.id,
|
||||||
|
job,
|
||||||
|
bodyshop: job.bodyshop,
|
||||||
|
result: { success: true },
|
||||||
|
isEarlyRo: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(requests[0]).toMatchObject({
|
||||||
|
query: "INSERT_EXPORT_LOG",
|
||||||
|
variables: {
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
bodyshopid: "bodyshop-1",
|
||||||
|
jobid: "job-1",
|
||||||
|
successful: true,
|
||||||
|
useremail: "tech@example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { buildRRRepairOrderPayload } = require("./rr-job-helpers");
|
const { buildRRRepairOrderPayload, buildMinimalRolaborFromJob } = require("./rr-job-helpers");
|
||||||
const { buildClientAndOpts } = require("./rr-lookup");
|
const { buildClientAndOpts } = require("./rr-lookup");
|
||||||
const CreateRRLogEvent = require("./rr-logger-event");
|
const CreateRRLogEvent = require("./rr-logger-event");
|
||||||
const { withRRRequestXml } = require("./rr-log-xml");
|
const { withRRRequestXml } = require("./rr-log-xml");
|
||||||
@@ -56,6 +56,27 @@ const deriveRRStatus = (rrRes = {}) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveRROpCode = (bodyshop, txEnvelope = {}) => {
|
||||||
|
const resolvedBaseOpCode = resolveRROpCodeFromBodyshop(bodyshop);
|
||||||
|
let opCodeOverride = txEnvelope?.opCode || txEnvelope?.opcode || txEnvelope?.op_code || null;
|
||||||
|
|
||||||
|
if (!opCodeOverride) {
|
||||||
|
const opPrefix = txEnvelope?.opPrefix ?? txEnvelope?.op_prefix ?? null;
|
||||||
|
const opBase = txEnvelope?.opBase ?? txEnvelope?.op_base ?? null;
|
||||||
|
const opSuffix = txEnvelope?.opSuffix ?? txEnvelope?.op_suffix ?? null;
|
||||||
|
|
||||||
|
if (opPrefix || opBase || opSuffix) {
|
||||||
|
const combined = `${opPrefix || ""}${opBase || ""}${opSuffix || ""}`.trim();
|
||||||
|
if (combined) {
|
||||||
|
opCodeOverride = combined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opCodeOverride && !resolvedBaseOpCode) return null;
|
||||||
|
return String(opCodeOverride || resolvedBaseOpCode).trim() || null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Early RO Creation: Create a minimal RR Repair Order with basic info (customer, advisor, mileage, story).
|
* 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.
|
* Used when creating RO from convert button or admin page before full job export.
|
||||||
@@ -93,7 +114,9 @@ const createMinimalRRRepairOrder = async (args) => {
|
|||||||
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
||||||
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
|
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).trim() : null;
|
||||||
|
|
||||||
// Build minimal RO payload - just header, no allocations/parts/labor
|
// Build minimal RO payload for early review mode.
|
||||||
|
// We keep it lightweight, but include a single labor row when we can so Ignite
|
||||||
|
// exposes the labor subsection for editing.
|
||||||
const cleanVin =
|
const cleanVin =
|
||||||
(job?.v_vin || "")
|
(job?.v_vin || "")
|
||||||
.toString()
|
.toString()
|
||||||
@@ -116,6 +139,12 @@ const createMinimalRRRepairOrder = async (args) => {
|
|||||||
resolvedMileageIn: mileageIn
|
resolvedMileageIn: mileageIn
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const earlyRoOpCode = resolveRROpCode(bodyshop, txEnvelope);
|
||||||
|
const earlyRoLabor = buildMinimalRolaborFromJob(job, {
|
||||||
|
opCode: earlyRoOpCode,
|
||||||
|
payType: "Cust"
|
||||||
|
});
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
customerNo: String(selected),
|
customerNo: String(selected),
|
||||||
advisorNo: String(advisorNo),
|
advisorNo: String(advisorNo),
|
||||||
@@ -141,9 +170,14 @@ const createMinimalRRRepairOrder = async (args) => {
|
|||||||
if (makeOverride) {
|
if (makeOverride) {
|
||||||
payload.makeOverride = makeOverride;
|
payload.makeOverride = makeOverride;
|
||||||
}
|
}
|
||||||
|
if (earlyRoLabor) {
|
||||||
|
payload.rolabor = earlyRoLabor;
|
||||||
|
}
|
||||||
|
|
||||||
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
|
CreateRRLogEvent(socket, "INFO", "Creating minimal RR Repair Order (early creation)", {
|
||||||
payload
|
payload,
|
||||||
|
earlyRoOpCode,
|
||||||
|
hasRolabor: !!earlyRoLabor
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await client.createRepairOrder(payload, finalOpts);
|
const response = await client.createRepairOrder(payload, finalOpts);
|
||||||
@@ -221,15 +255,10 @@ const updateRRRepairOrderWithFullData = async (args) => {
|
|||||||
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
||||||
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).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
|
// RR-only extras
|
||||||
let rrCentersConfig = null;
|
let rrCentersConfig = null;
|
||||||
let allocations = null;
|
let allocations = null;
|
||||||
let opCode = null;
|
const opCode = resolveRROpCode(bodyshop, txEnvelope);
|
||||||
|
|
||||||
// 1) Responsibility center config (for visibility / debugging)
|
// 1) Responsibility center config (for visibility / debugging)
|
||||||
try {
|
try {
|
||||||
@@ -280,28 +309,9 @@ const updateRRRepairOrderWithFullData = async (args) => {
|
|||||||
allocations = [];
|
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", {
|
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
|
||||||
opCode,
|
opCode,
|
||||||
baseFromConfig: resolvedBaseOpCode,
|
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
|
||||||
opPrefix,
|
|
||||||
opBase,
|
|
||||||
opSuffix
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build full RO payload for update with allocations
|
// Build full RO payload for update with allocations
|
||||||
@@ -426,15 +436,10 @@ const exportJobToRR = async (args) => {
|
|||||||
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
const story = txEnvelope?.story ? String(txEnvelope.story).trim() : null;
|
||||||
const makeOverride = txEnvelope?.makeOverride ? String(txEnvelope.makeOverride).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
|
// RR-only extras
|
||||||
let rrCentersConfig = null;
|
let rrCentersConfig = null;
|
||||||
let allocations = null;
|
let allocations = null;
|
||||||
let opCode = null;
|
const opCode = resolveRROpCode(bodyshop, txEnvelope);
|
||||||
|
|
||||||
// 1) Responsibility center config (for visibility / debugging)
|
// 1) Responsibility center config (for visibility / debugging)
|
||||||
try {
|
try {
|
||||||
@@ -477,28 +482,9 @@ const exportJobToRR = async (args) => {
|
|||||||
allocations = [];
|
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", {
|
CreateRRLogEvent(socket, "SILLY", "RR OP config resolved", {
|
||||||
opCode,
|
opCode,
|
||||||
baseFromConfig: resolvedBaseOpCode,
|
baseFromConfig: resolveRROpCodeFromBodyshop(bodyshop)
|
||||||
opPrefix,
|
|
||||||
opBase,
|
|
||||||
opSuffix
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build RO payload for create.
|
// Build RO payload for create.
|
||||||
|
|||||||
@@ -52,6 +52,19 @@ const asN2 = (dineroLike) => {
|
|||||||
return amount.toFixed(2);
|
return amount.toFixed(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize various "money-like" shapes to integer cents.
|
* Normalize various "money-like" shapes to integer cents.
|
||||||
* Supports:
|
* Supports:
|
||||||
@@ -100,6 +113,100 @@ const toMoneyCents = (value) => {
|
|||||||
|
|
||||||
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 });
|
const asN2FromCents = (cents) => asN2({ amount: Number.isFinite(cents) ? cents : 0, precision: 2 });
|
||||||
|
|
||||||
|
const formatDecimal = (value, maxDecimals = 2) => {
|
||||||
|
const factor = Math.pow(10, maxDecimals);
|
||||||
|
const rounded = Math.round(Math.max(0, toFiniteNumber(value)) * factor) / factor;
|
||||||
|
if (!Number.isFinite(rounded)) return "0";
|
||||||
|
return rounded.toFixed(maxDecimals).replace(/\.?0+$/, "") || "0";
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRolaborBillFields = ({ amountUnits = 0, hours = 0, rate = 0 } = {}) => {
|
||||||
|
const normalizedAmount = toFiniteNumber(amountUnits);
|
||||||
|
|
||||||
|
if (normalizedAmount <= 0) {
|
||||||
|
return {
|
||||||
|
jobTotalHrs: "0",
|
||||||
|
billTime: "0",
|
||||||
|
billRate: "0"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedHours = toFiniteNumber(hours);
|
||||||
|
let resolvedRate = toFiniteNumber(rate);
|
||||||
|
|
||||||
|
if (resolvedHours > 0 && resolvedRate <= 0) {
|
||||||
|
resolvedRate = normalizedAmount / resolvedHours;
|
||||||
|
} else if (resolvedRate > 0 && resolvedHours <= 0) {
|
||||||
|
resolvedHours = normalizedAmount / resolvedRate;
|
||||||
|
} else if (resolvedHours <= 0 && resolvedRate <= 0) {
|
||||||
|
// Keep the math internally consistent even if the source job has dollars but no usable hours.
|
||||||
|
resolvedHours = 1;
|
||||||
|
resolvedRate = normalizedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobTotalHrs: formatDecimal(resolvedHours),
|
||||||
|
billTime: formatDecimal(resolvedHours),
|
||||||
|
billRate: resolvedRate.toFixed(2)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildMinimalRolaborFromJob = (job, { opCode, payType = "Cust" } = {}) => {
|
||||||
|
const trimmedOpCode = opCode != null ? String(opCode).trim() : "";
|
||||||
|
if (!job || !trimmedOpCode) return null;
|
||||||
|
|
||||||
|
let totalHours = 0;
|
||||||
|
let totalAmountUnits = 0;
|
||||||
|
|
||||||
|
for (const line of job?.joblines || []) {
|
||||||
|
const laborType = typeof line?.mod_lbr_ty === "string" ? line.mod_lbr_ty.trim() : "";
|
||||||
|
if (!laborType) continue;
|
||||||
|
|
||||||
|
const lineHours = toFiniteNumber(line?.mod_lb_hrs ?? line?.db_hrs);
|
||||||
|
const configuredRate = toFiniteNumber(job?.[`rate_${laborType.toLowerCase()}`]);
|
||||||
|
let lineAmountUnits = toFiniteNumber(line?.lbr_amt);
|
||||||
|
|
||||||
|
if (lineAmountUnits <= 0 && lineHours > 0 && configuredRate > 0) {
|
||||||
|
lineAmountUnits = lineHours * configuredRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineAmountUnits <= 0 && lineHours <= 0) continue;
|
||||||
|
|
||||||
|
totalHours += lineHours;
|
||||||
|
totalAmountUnits += lineAmountUnits;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAmountUnits <= 0 && totalHours <= 0) return null;
|
||||||
|
|
||||||
|
const bill = buildRolaborBillFields({
|
||||||
|
amountUnits: totalAmountUnits,
|
||||||
|
hours: totalHours,
|
||||||
|
rate: totalHours > 0 ? totalAmountUnits / totalHours : 0
|
||||||
|
});
|
||||||
|
const formattedAmount = totalAmountUnits.toFixed(2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ops: [
|
||||||
|
{
|
||||||
|
opCode: trimmedOpCode,
|
||||||
|
jobNo: "1",
|
||||||
|
custPayTypeFlag: "C",
|
||||||
|
custTxblNtxblFlag: toFiniteNumber(job?.tax_lbr_rt) > 0 ? "T" : "N",
|
||||||
|
bill: {
|
||||||
|
payType,
|
||||||
|
...bill
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
payType,
|
||||||
|
amtType: "Job",
|
||||||
|
custPrice: formattedAmount,
|
||||||
|
totalAmt: formattedAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build RR estimate block from allocation totals.
|
* Build RR estimate block from allocation totals.
|
||||||
* @param {Array} allocations
|
* @param {Array} allocations
|
||||||
@@ -326,6 +433,13 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
|||||||
// Each segment becomes its own op / JobNo with a single line
|
// Each segment becomes its own op / JobNo with a single line
|
||||||
segments.forEach((seg, idx) => {
|
segments.forEach((seg, idx) => {
|
||||||
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
|
const jobNo = String(ops.length + 1); // global, 1-based JobNo across all centers/segments
|
||||||
|
const isLaborSegment = seg.kind === "laborTaxable" || seg.kind === "laborNonTaxable";
|
||||||
|
const segmentHours = isLaborSegment
|
||||||
|
? seg.kind === "laborTaxable"
|
||||||
|
? toFiniteNumber(alloc.laborTaxableHours)
|
||||||
|
: toFiniteNumber(alloc.laborNonTaxableHours)
|
||||||
|
: 0;
|
||||||
|
const segmentBillRate = isLaborSegment && segmentHours > 0 ? seg.saleCents / 100 / segmentHours : 0;
|
||||||
|
|
||||||
const line = {
|
const line = {
|
||||||
breakOut,
|
breakOut,
|
||||||
@@ -349,7 +463,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
|||||||
// Extra metadata for UI / debugging
|
// Extra metadata for UI / debugging
|
||||||
segmentKind: seg.kind,
|
segmentKind: seg.kind,
|
||||||
segmentIndex: idx,
|
segmentIndex: idx,
|
||||||
segmentCount
|
segmentCount,
|
||||||
|
segmentHours,
|
||||||
|
segmentBillRate
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -368,9 +484,9 @@ const buildRogogFromAllocations = (allocations, { opCode, payType = "Cust", roNo
|
|||||||
*
|
*
|
||||||
* We still keep a 1:1 mapping with GOG ops: each op gets a corresponding
|
* We still keep a 1:1 mapping with GOG ops: each op gets a corresponding
|
||||||
* OpCodeLaborInfo entry using the same JobNo and the same tax flag as its
|
* OpCodeLaborInfo entry using the same JobNo and the same tax flag as its
|
||||||
* GOG line. Labor-specific hours/rate remain zeroed out, but actual labor
|
* GOG line. Labor sale amounts are mirrored into ROLABOR and, when hours
|
||||||
* sale amounts are mirrored into ROLABOR for labor segments so RR receives
|
* are available from allocations, weighted bill hours/rates are also
|
||||||
* the expected labor pricing on updates. Non-labor ops remain zeroed.
|
* populated so the labor subsection is editable in Ignite.
|
||||||
*
|
*
|
||||||
* @param {Object} rogg - result of buildRogogFromAllocations
|
* @param {Object} rogg - result of buildRogogFromAllocations
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
@@ -391,6 +507,17 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
|
|||||||
const linePayType = firstLine.custPayTypeFlag || "C";
|
const linePayType = firstLine.custPayTypeFlag || "C";
|
||||||
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
|
const isLaborSegment = op.segmentKind === "laborTaxable" || op.segmentKind === "laborNonTaxable";
|
||||||
const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0";
|
const laborAmount = isLaborSegment ? String(firstLine?.amount?.custPrice ?? "0") : "0";
|
||||||
|
const laborBill = isLaborSegment
|
||||||
|
? buildRolaborBillFields({
|
||||||
|
amountUnits: laborAmount,
|
||||||
|
hours: op.segmentHours,
|
||||||
|
rate: op.segmentBillRate
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
jobTotalHrs: "0",
|
||||||
|
billTime: "0",
|
||||||
|
billRate: "0"
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
opCode: op.opCode,
|
opCode: op.opCode,
|
||||||
@@ -399,9 +526,7 @@ const buildRolaborFromRogog = (rogg, { payType = "Cust" } = {}) => {
|
|||||||
custTxblNtxblFlag: txFlag,
|
custTxblNtxblFlag: txFlag,
|
||||||
bill: {
|
bill: {
|
||||||
payType,
|
payType,
|
||||||
jobTotalHrs: "0",
|
...laborBill
|
||||||
billTime: "0",
|
|
||||||
billRate: "0"
|
|
||||||
},
|
},
|
||||||
amount: {
|
amount: {
|
||||||
payType,
|
payType,
|
||||||
@@ -686,5 +811,6 @@ module.exports = {
|
|||||||
normalizeCustomerCandidates,
|
normalizeCustomerCandidates,
|
||||||
normalizeVehicleCandidates,
|
normalizeVehicleCandidates,
|
||||||
buildRogogFromAllocations,
|
buildRogogFromAllocations,
|
||||||
buildRolaborFromRogog
|
buildRolaborFromRogog,
|
||||||
|
buildMinimalRolaborFromJob
|
||||||
};
|
};
|
||||||
|
|||||||
118
server/rr/rr-job-helpers.test.js
Normal file
118
server/rr/rr-job-helpers.test.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const mock = require("mock-require");
|
||||||
|
|
||||||
|
const graphClientModuleId = require.resolve("../graphql-client/graphql-client");
|
||||||
|
const queriesModuleId = require.resolve("../graphql-client/queries");
|
||||||
|
const helpersModuleId = require.resolve("./rr-job-helpers");
|
||||||
|
|
||||||
|
const loadHelpers = () => {
|
||||||
|
mock.stopAll();
|
||||||
|
mock(graphClientModuleId, { client: { request: async () => ({}) } });
|
||||||
|
mock(queriesModuleId, { GET_JOB_BY_PK: "GET_JOB_BY_PK" });
|
||||||
|
delete require.cache[helpersModuleId];
|
||||||
|
return require(helpersModuleId);
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.stopAll();
|
||||||
|
delete require.cache[helpersModuleId];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("server/rr/rr-job-helpers", () => {
|
||||||
|
it("builds a single early-RO labor row from aggregated job labor", () => {
|
||||||
|
const { buildMinimalRolaborFromJob } = loadHelpers();
|
||||||
|
|
||||||
|
const rolabor = buildMinimalRolaborFromJob(
|
||||||
|
{
|
||||||
|
tax_lbr_rt: 13,
|
||||||
|
joblines: [
|
||||||
|
{ mod_lbr_ty: "LAB", mod_lb_hrs: 2, lbr_amt: 200 },
|
||||||
|
{ mod_lbr_ty: "LAD", mod_lb_hrs: 1.5, lbr_amt: 180 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ opCode: "51DOZ" }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rolabor).toEqual({
|
||||||
|
ops: [
|
||||||
|
{
|
||||||
|
opCode: "51DOZ",
|
||||||
|
jobNo: "1",
|
||||||
|
custPayTypeFlag: "C",
|
||||||
|
custTxblNtxblFlag: "T",
|
||||||
|
bill: {
|
||||||
|
payType: "Cust",
|
||||||
|
jobTotalHrs: "3.5",
|
||||||
|
billTime: "3.5",
|
||||||
|
billRate: "108.57"
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
payType: "Cust",
|
||||||
|
amtType: "Job",
|
||||||
|
custPrice: "380.00",
|
||||||
|
totalAmt: "380.00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("populates labor bill fields from allocation hours on the full RR payload", () => {
|
||||||
|
const { buildRRRepairOrderPayload } = loadHelpers();
|
||||||
|
|
||||||
|
const payload = buildRRRepairOrderPayload({
|
||||||
|
job: {
|
||||||
|
id: "job-1",
|
||||||
|
ro_number: "RO-123",
|
||||||
|
v_vin: "1HGBH41JXMN109186"
|
||||||
|
},
|
||||||
|
selectedCustomer: { customerNo: "1134485" },
|
||||||
|
advisorNo: "70754",
|
||||||
|
allocations: [
|
||||||
|
{
|
||||||
|
center: "Body Labor",
|
||||||
|
partsSale: { amount: 0, precision: 2 },
|
||||||
|
laborTaxableSale: { amount: 24000, precision: 2 },
|
||||||
|
laborNonTaxableSale: { amount: 0, precision: 2 },
|
||||||
|
extrasSale: { amount: 0, precision: 2 },
|
||||||
|
totalSale: { amount: 24000, precision: 2 },
|
||||||
|
cost: { amount: 12000, precision: 2 },
|
||||||
|
laborTaxableHours: 2,
|
||||||
|
laborNonTaxableHours: 0,
|
||||||
|
profitCenter: {
|
||||||
|
rr_gogcode: "BL",
|
||||||
|
rr_item_type: "G",
|
||||||
|
accountdesc: "BODY LABOR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
opCode: "51DOZ"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.rolabor).toEqual({
|
||||||
|
ops: [
|
||||||
|
{
|
||||||
|
opCode: "51DOZ",
|
||||||
|
jobNo: "1",
|
||||||
|
custPayTypeFlag: "C",
|
||||||
|
custTxblNtxblFlag: "T",
|
||||||
|
bill: {
|
||||||
|
payType: "Cust",
|
||||||
|
jobTotalHrs: "2",
|
||||||
|
billTime: "2",
|
||||||
|
billRate: "120.00"
|
||||||
|
},
|
||||||
|
amount: {
|
||||||
|
payType: "Cust",
|
||||||
|
amtType: "Job",
|
||||||
|
custPrice: "240.00",
|
||||||
|
totalAmt: "240.00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user