Merged in release/2026-02-13 (pull request #3000)

Release/2026 02 13
This commit is contained in:
Dave Richer
2026-02-13 00:32:41 +00:00
13 changed files with 620 additions and 79 deletions

View File

@@ -208,16 +208,16 @@ export default function RRPostForm({
}); });
}; };
// Check if early RO was created (job has dms_id) // Check if early RO was created (job has all early RO fields)
const hasEarlyRO = !!job?.dms_id; const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
return ( return (
<Card title={t("jobs.labels.dms.postingform")}> <Card title={t("jobs.labels.dms.postingform")}>
{hasEarlyRO && ( {hasEarlyRO && (
<Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}> <Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}>
Early RO Created: {job.dms_id} {t("jobs.labels.dms.earlyro.created")} {job.dms_id}
<br /> <br />
<Typography.Text type="secondary">This will update the existing RO with full job data.</Typography.Text> <Typography.Text type="secondary">{t("jobs.labels.dms.earlyro.willupdate")}</Typography.Text>
</Typography.Paragraph> </Typography.Paragraph>
)} )}
<Form <Form
@@ -372,7 +372,7 @@ export default function RRPostForm({
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} /> <Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title> <Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}> <Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{hasEarlyRO ? t("jobs.actions.dms.update_ro", "Update RO") : t("jobs.actions.dms.post")} {hasEarlyRO ? t("jobs.actions.dms.update_ro") : t("jobs.actions.dms.post")}
</Button> </Button>
</Space> </Space>
); );

View File

@@ -154,8 +154,8 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
setEarlyRoCreated(true); setEarlyRoCreated(true);
setEarlyRoCreatedThisSession(true); setEarlyRoCreatedThisSession(true);
notification.success({ notification.success({
title: t("jobs.successes.early_ro_created", "Early RO Created"), title: t("jobs.successes.early_ro_created"),
message: `RO Number: ${result.roNumber || "N/A"}` description: `RO Number: ${result.roNumber || "N/A"}`
}); });
setTimeout(() => { setTimeout(() => {

View File

@@ -238,7 +238,6 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
label: s label: s
}))} /> }))} />
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer"> <Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} /> <Input disabled={jobRO} />
</Form.Item> </Form.Item>
@@ -254,6 +253,21 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.lost_sale_reason")} name="lost_sale_reason"> <Form.Item label={t("jobs.fields.lost_sale_reason")} name="lost_sale_reason">
<Input disabled={jobRO} allowClear /> <Input disabled={jobRO} allowClear />
</Form.Item> </Form.Item>
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.id")} name="dms_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.advisor")} name="dms_advisor_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.customer")} name="dms_customer_id">
<Input disabled />
</Form.Item>
)}
</FormRow> </FormRow>
</Card> </Card>
); );

View File

@@ -494,6 +494,9 @@ export const GET_JOB_BY_PK = gql`
ded_status ded_status
deliverchecklist deliverchecklist
depreciation_taxes depreciation_taxes
dms_id
dms_advisor_id
dms_customer_id
driveable driveable
employee_body employee_body
employee_body_rel { employee_body_rel {
@@ -1998,6 +2001,9 @@ export const QUERY_JOB_CLOSE_DETAILS = gql`
qb_multiple_payers qb_multiple_payers
lbr_adjustments lbr_adjustments
ownr_ea ownr_ea
dms_id
dms_customer_id
dms_advisor_id
payments { payments {
amount amount
created_at created_at

View File

@@ -426,6 +426,24 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />; if (data.jobs_by_pk?.date_exported) return <Result status="warning" title={t("dms.errors.alreadyexported")} />;
// Check if Reynolds mode requires early RO
const hasEarlyRO = !!(data.jobs_by_pk?.dms_id && data.jobs_by_pk?.dms_customer_id && data.jobs_by_pk?.dms_advisor_id);
if (isRrMode && !hasEarlyRO) {
return (
<Result
status="warning"
title={t("dms.errors.earlyrorequired")}
subTitle={t("dms.errors.earlyrorequired.message")}
extra={
<Link to={`/manage/jobs/${jobId}/admin`}>
<Button type="primary">{t("general.actions.gotoadmin")}</Button>
</Link>
}
/>
);
}
return ( return (
<div> <div>
<AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable /> <AlertComponent style={{ marginBottom: 10 }} title={bannerMessage} type="warning" showIcon closable />

View File

@@ -1,10 +1,12 @@
import { useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Col, Result, Row, Space, Typography } from "antd"; import { Button, Card, Col, Form, Input, Modal, Result, Row, Select, Space, Switch, Typography } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { some } from "lodash";
import axios from "axios";
import AlertComponent from "../../components/alert/alert.component"; import AlertComponent from "../../components/alert/alert.component";
import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.component"; import JobCalculateTotals from "../../components/job-calculate-totals/job-calculate-totals.component";
import ScoreboardAddButton from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component"; import ScoreboardAddButton from "../../components/job-scoreboard-add-button/job-scoreboard-add-button.component";
@@ -21,14 +23,16 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com
import NotFound from "../../components/not-found/not-found.component"; import NotFound from "../../components/not-found/not-found.component";
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal"; import RREarlyROModal from "../../components/dms-post-form/rr-early-ro-modal";
import { GET_JOB_BY_PK } from "../../graphql/jobs.queries"; import { GET_JOB_BY_PK, CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useSocket } from "../../contexts/SocketIO/useSocket"; import { useSocket } from "../../contexts/SocketIO/useSocket";
import { useNotification } from "../../contexts/Notifications/notificationContext"; import { useNotification } from "../../contexts/Notifications/notificationContext";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils"; import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -36,7 +40,8 @@ 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 }))
}); });
const colSpan = { const colSpan = {
@@ -50,7 +55,7 @@ const cardStyle = {
height: "100%" height: "100%"
}; };
export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop }) { export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop, insertAuditTrail }) {
const { jobId } = useParams(); const { jobId } = useParams();
const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, { const { loading, error, data, refetch } = useQuery(GET_JOB_BY_PK, {
variables: { id: jobId }, variables: { id: jobId },
@@ -61,6 +66,11 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
const { socket } = useSocket(); // Extract socket from context const { socket } = useSocket(); // Extract socket from context
const notification = useNotification(); const notification = useNotification();
const [showEarlyROModal, setShowEarlyROModal] = useState(false); const [showEarlyROModal, setShowEarlyROModal] = useState(false);
const [showConvertModal, setShowConvertModal] = useState(false);
const [convertLoading, setConvertLoading] = useState(false);
const [form] = Form.useForm();
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const allFormValues = Form.useWatch([], form);
// Get Fortellis treatment for proper DMS mode detection // Get Fortellis treatment for proper DMS mode detection
const { const {
@@ -105,13 +115,53 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
const handleEarlyROSuccess = (result) => { const handleEarlyROSuccess = (result) => {
notification.success({ notification.success({
title: t("jobs.successes.early_ro_created", "Early RO Created"), title: t("jobs.successes.early_ro_created"),
message: `RO Number: ${result.roNumber || "N/A"}` description: `RO Number: ${result.roNumber || "N/A"}`
}); });
setShowEarlyROModal(false); setShowEarlyROModal(false);
refetch?.(); refetch?.();
}; };
const handleConvert = async ({ employee_csr, category, ...values }) => {
if (!job?.id) return;
setConvertLoading(true);
const res = await mutationConvertJob({
variables: {
jobId: job.id,
job: {
converted: true,
...(bodyshop?.enforce_conversion_csr ? { employee_csr } : {}),
...(bodyshop?.enforce_conversion_category ? { category } : {}),
...values
}
}
});
if (values.ca_gst_registrant) {
await axios.post("/job/totalsssu", {
id: job.id
});
}
if (!res.errors) {
refetch();
notification.success({
title: t("jobs.successes.converted")
});
insertAuditTrail({
jobid: job.id,
operation: AuditTrailMapping.jobconverted(res.data.update_jobs.returning[0].ro_number),
type: "jobconverted"
});
setShowConvertModal(false);
}
setConvertLoading(false);
};
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;
if (error) return <AlertComponent title={error.message} type="error" />; if (error) return <AlertComponent title={error.message} type="error" />;
if (!data.jobs_by_pk) return <NotFound />; if (!data.jobs_by_pk) return <NotFound />;
@@ -138,7 +188,12 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
<JobsAdminRemoveAR job={data ? data.jobs_by_pk : {}} /> <JobsAdminRemoveAR job={data ? data.jobs_by_pk : {}} />
{isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && ( {isReynoldsMode && job?.converted && !job?.dms_id && !job?.dms_customer_id && !job?.dms_advisor_id && (
<Button className="ant-btn ant-btn-default" onClick={() => setShowEarlyROModal(true)}> <Button className="ant-btn ant-btn-default" onClick={() => setShowEarlyROModal(true)}>
Create RR RO {t("jobs.actions.dms.createearlyro", "Create RR RO")}
</Button>
)}
{isReynoldsMode && !job?.converted && !job?.dms_id && (
<Button type="primary" danger onClick={() => setShowConvertModal(true)}>
{t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
</Button> </Button>
)} )}
</Space> </Space>
@@ -176,6 +231,161 @@ export function JobsCloseContainer({ setBreadcrumbs, setSelectedHeader, bodyshop
socket={socket} socket={socket}
job={job} job={job}
/> />
{/* Convert without Early RO Modal */}
<Modal
open={showConvertModal}
onCancel={() => setShowConvertModal(false)}
title={t("jobs.actions.convertwithoutearlyro", "Convert without Early RO")}
footer={null}
width={700}
destroyOnHidden
>
<Form
layout="vertical"
form={form}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job?.towin,
ca_gst_registrant: job?.ca_gst_registrant,
employee_csr: job?.employee_csr,
category: job?.category,
referral_source: job?.referral_source,
referral_source_extra: job?.referral_source_extra ?? ""
}}
>
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
rules={[
{
required: true
}
]}
>
<Select showSearch>
{bodyshop?.md_ins_cos?.map((s, i) => (
<Select.Option key={i} value={s.name}>
{s.name}
</Select.Option>
))}
</Select>
</Form.Item>
{bodyshop?.enforce_class && (
<Form.Item
name={"class"}
label={t("jobs.fields.class")}
rules={[
{
required: bodyshop.enforce_class
}
]}
>
<Select>
{bodyshop?.md_classes?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.enforce_referral && (
<>
<Form.Item
name={"referral_source"}
label={t("jobs.fields.referralsource")}
rules={[
{
required: bodyshop.enforce_referral
}
]}
>
<Select>
{bodyshop?.md_referral_sources?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
<Input />
</Form.Item>
</>
)}
{bodyshop?.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
}
]}
>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}}
style={{ width: 200 }}
>
{bodyshop?.employees
?.filter((emp) => emp.active)
?.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
}
]}
>
<Select allowClear>
{bodyshop?.md_categories?.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop?.region_config?.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch />
</Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap style={{ marginTop: 16 }}>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={convertLoading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setShowConvertModal(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</Modal>
</RbacWrapper> </RbacWrapper>
); );
} }

View File

@@ -9,6 +9,7 @@ import {
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal,
Popconfirm, Popconfirm,
Row, Row,
Select, Select,
@@ -42,7 +43,7 @@ import { setModalContext } from "../../redux/modals/modals.actions.js";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } 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 { bodyshopHasDmsKey } from "../../utils/dmsUtils.js"; import { bodyshopHasDmsKey, DMS_MAP, getDmsMode } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
@@ -71,6 +72,11 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
const notification = useNotification(); const notification = useNotification();
const hasDMSKey = bodyshopHasDmsKey(bodyshop); const hasDMSKey = bodyshopHasDmsKey(bodyshop);
const dmsMode = getDmsMode(bodyshop, "off");
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
const canSendToDMS = !isReynoldsMode || hasEarlyRO;
const [showEarlyROModal, setShowEarlyROModal] = useState(false);
const { const {
treatments: { Qb_Multi_Ar, ClosingPeriod } treatments: { Qb_Multi_Ar, ClosingPeriod }
@@ -84,11 +90,11 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
setLoading(true); setLoading(true);
// Validate that all joblines have valid IDs // Validate that all joblines have valid IDs
const joblinesWithIds = values.joblines.filter(jl => jl && jl.id); const joblinesWithIds = values.joblines.filter((jl) => jl && jl.id);
if (joblinesWithIds.length !== values.joblines.length) { if (joblinesWithIds.length !== values.joblines.length) {
notification.error({ notification.error({
title: t("jobs.errors.invalidjoblines"), title: t("jobs.errors.invalidjoblines"),
message: t("jobs.errors.missingjoblineids") description: t("jobs.errors.missingjoblineids")
}); });
setLoading(false); setLoading(false);
return; return;
@@ -208,9 +214,17 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
</Button> </Button>
</Popconfirm> </Popconfirm>
{bodyshopHasDmsKey(bodyshop) && ( {bodyshopHasDmsKey(bodyshop) && (
<Link to={`/manage/dms?jobId=${job.id}`}> <>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button> {canSendToDMS ? (
</Link> <Link to={`/manage/dms?jobId=${job.id}`}>
<Button disabled={job.date_exported || !jobRO}>{t("jobs.actions.sendtodms")}</Button>
</Link>
) : (
<Button disabled={job.date_exported || !jobRO} onClick={() => setShowEarlyROModal(true)}>
{t("jobs.actions.sendtodms")}
</Button>
)}
</>
)} )}
<Button <Button
onClick={() => { onClick={() => {
@@ -529,6 +543,30 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
<Divider /> <Divider />
<JobsCloseLines job={job} /> <JobsCloseLines job={job} />
</Form> </Form>
{/* Early RO Required Modal */}
<Modal
open={showEarlyROModal}
onCancel={() => setShowEarlyROModal(false)}
footer={null}
title={
<Space>
<Typography.Text type="warning" style={{ fontSize: "1.2em" }}>
</Typography.Text>
<span>{t("dms.errors.earlyrorequired")}</span>
</Space>
}
>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Typography.Paragraph>{t("dms.errors.earlyrorequired.message")}</Typography.Paragraph>
<Link to={`/manage/jobs/${job.id}/admin`}>
<Button type="primary" block onClick={() => setShowEarlyROModal(false)}>
{t("general.actions.gotoadmin")}
</Button>
</Link>
</Space>
</Modal>
</div> </div>
); );
} }

View File

@@ -1047,7 +1047,9 @@
}, },
"dms": { "dms": {
"errors": { "errors": {
"alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export." "alreadyexported": "This job has already been sent to the DMS. If you need to resend it, please use admin permissions to mark the job for re-export.",
"earlyrorequired": "Early RO Required",
"earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first."
}, },
"labels": { "labels": {
"refreshallocations": "Refresh to see DMS Allocations." "refreshallocations": "Refresh to see DMS Allocations."
@@ -1244,6 +1246,7 @@
"deselectall": "Deselect All", "deselectall": "Deselect All",
"download": "Download", "download": "Download",
"edit": "Edit", "edit": "Edit",
"gotoadmin": "Go to Admin Panel",
"login": "Login", "login": "Login",
"next": "Next", "next": "Next",
"ok": "Ok", "ok": "Ok",
@@ -1622,11 +1625,13 @@
"changestatus": "Change Status", "changestatus": "Change Status",
"changestimator": "Change Estimator", "changestimator": "Change Estimator",
"convert": "Convert", "convert": "Convert",
"convertwithoutearlyro": "Convert without Early RO",
"createiou": "Create IOU", "createiou": "Create IOU",
"deliver": "Deliver", "deliver": "Deliver",
"deliver_quick": "Quick Deliver", "deliver_quick": "Quick Deliver",
"dms": { "dms": {
"addpayer": "Add Payer", "addpayer": "Add Payer",
"createearlyro": "Create RR RO",
"createnewcustomer": "Create New Customer", "createnewcustomer": "Create New Customer",
"findmakemodelcode": "Find Make/Model Code", "findmakemodelcode": "Find Make/Model Code",
"getmakes": "Get Makes", "getmakes": "Get Makes",
@@ -1635,6 +1640,7 @@
}, },
"post": "Post", "post": "Post",
"refetchmakesmodels": "Refetch Make and Model Codes", "refetchmakesmodels": "Refetch Make and Model Codes",
"update_ro": "Update RO",
"usegeneric": "Use Generic Customer", "usegeneric": "Use Generic Customer",
"useselected": "Use Selected Customer" "useselected": "Use Selected Customer"
}, },
@@ -1794,6 +1800,7 @@
}, },
"cost": "Cost", "cost": "Cost",
"cost_dms_acctnumber": "Cost DMS Acct #", "cost_dms_acctnumber": "Cost DMS Acct #",
"customer": "Customer #",
"dms_make": "DMS Make", "dms_make": "DMS Make",
"dms_model": "DMS Model", "dms_model": "DMS Model",
"dms_model_override": "Override DMS Make/Model", "dms_model_override": "Override DMS Make/Model",
@@ -2107,6 +2114,11 @@
"damageto": "Damage to $t(jobs.fields.area_of_damage_impact.{{area_of_damage}}).", "damageto": "Damage to $t(jobs.fields.area_of_damage_impact.{{area_of_damage}}).",
"defaultstory": "B/S RO: {{ro_number}}. Owner: {{ownr_nm}}. Insurance Co: {{ins_co_nm}}. Claim/PO #: {{clm_po}}", "defaultstory": "B/S RO: {{ro_number}}. Owner: {{ownr_nm}}. Insurance Co: {{ins_co_nm}}. Claim/PO #: {{clm_po}}",
"disablebillwip": "Cost and WIP for bills has been ignored per shop configuration.", "disablebillwip": "Cost and WIP for bills has been ignored per shop configuration.",
"earlyro": {
"created": "Early RO Created:",
"fields": "Required fields:",
"willupdate": "This will update the existing RO with full job data."
},
"invoicedatefuture": "Invoice date must be today or in the future for CDK posting.", "invoicedatefuture": "Invoice date must be today or in the future for CDK posting.",
"kmoutnotgreaterthankmin": "Mileage out must be greater than mileage in.", "kmoutnotgreaterthankmin": "Mileage out must be greater than mileage in.",
"logs": "Logs", "logs": "Logs",
@@ -2264,6 +2276,7 @@
"delete": "Job deleted successfully.", "delete": "Job deleted successfully.",
"deleted": "Job deleted successfully.", "deleted": "Job deleted successfully.",
"duplicated": "Job duplicated successfully. ", "duplicated": "Job duplicated successfully. ",
"early_ro_created": "Early RO Created",
"exported": "Job(s) exported successfully. ", "exported": "Job(s) exported successfully. ",
"invoiced": "Job closed and invoiced successfully.", "invoiced": "Job closed and invoiced successfully.",
"ioucreated": "IOU created successfully. Click to see.", "ioucreated": "IOU created successfully. Click to see.",

View File

@@ -1047,7 +1047,9 @@
}, },
"dms": { "dms": {
"errors": { "errors": {
"alreadyexported": "" "alreadyexported": "",
"earlyrorequired": "",
"earlyrorequired.message": ""
}, },
"labels": { "labels": {
"refreshallocations": "" "refreshallocations": ""
@@ -1244,6 +1246,7 @@
"deselectall": "", "deselectall": "",
"download": "", "download": "",
"edit": "Editar", "edit": "Editar",
"gotoadmin": "",
"login": "", "login": "",
"next": "", "next": "",
"ok": "", "ok": "",
@@ -1622,11 +1625,13 @@
"changestatus": "Cambiar Estado", "changestatus": "Cambiar Estado",
"changestimator": "", "changestimator": "",
"convert": "Convertir", "convert": "Convertir",
"convertwithoutearlyro": "",
"createiou": "", "createiou": "",
"deliver": "", "deliver": "",
"deliver_quick": "", "deliver_quick": "",
"dms": { "dms": {
"addpayer": "", "addpayer": "",
"createearlyro": "",
"createnewcustomer": "", "createnewcustomer": "",
"findmakemodelcode": "", "findmakemodelcode": "",
"getmakes": "", "getmakes": "",
@@ -1635,6 +1640,7 @@
}, },
"post": "", "post": "",
"refetchmakesmodels": "", "refetchmakesmodels": "",
"update_ro": "",
"usegeneric": "", "usegeneric": "",
"useselected": "" "useselected": ""
}, },
@@ -1794,6 +1800,7 @@
}, },
"cost": "", "cost": "",
"cost_dms_acctnumber": "", "cost_dms_acctnumber": "",
"customer": "",
"dms_make": "", "dms_make": "",
"dms_model": "", "dms_model": "",
"dms_model_override": "", "dms_model_override": "",
@@ -2107,6 +2114,11 @@
"damageto": "", "damageto": "",
"defaultstory": "", "defaultstory": "",
"disablebillwip": "", "disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "", "invoicedatefuture": "",
"kmoutnotgreaterthankmin": "", "kmoutnotgreaterthankmin": "",
"logs": "", "logs": "",
@@ -2264,6 +2276,7 @@
"delete": "", "delete": "",
"deleted": "Trabajo eliminado con éxito.", "deleted": "Trabajo eliminado con éxito.",
"duplicated": "", "duplicated": "",
"early_ro_created": "",
"exported": "", "exported": "",
"invoiced": "", "invoiced": "",
"ioucreated": "", "ioucreated": "",

View File

@@ -1047,7 +1047,9 @@
}, },
"dms": { "dms": {
"errors": { "errors": {
"alreadyexported": "" "alreadyexported": "",
"earlyrorequired": "",
"earlyrorequired.message": ""
}, },
"labels": { "labels": {
"refreshallocations": "" "refreshallocations": ""
@@ -1244,6 +1246,7 @@
"deselectall": "", "deselectall": "",
"download": "", "download": "",
"edit": "modifier", "edit": "modifier",
"gotoadmin": "",
"login": "", "login": "",
"next": "", "next": "",
"ok": "", "ok": "",
@@ -1622,11 +1625,13 @@
"changestatus": "Changer le statut", "changestatus": "Changer le statut",
"changestimator": "", "changestimator": "",
"convert": "Convertir", "convert": "Convertir",
"convertwithoutearlyro": "",
"createiou": "", "createiou": "",
"deliver": "", "deliver": "",
"deliver_quick": "", "deliver_quick": "",
"dms": { "dms": {
"addpayer": "", "addpayer": "",
"createearlyro": "",
"createnewcustomer": "", "createnewcustomer": "",
"findmakemodelcode": "", "findmakemodelcode": "",
"getmakes": "", "getmakes": "",
@@ -1635,6 +1640,7 @@
}, },
"post": "", "post": "",
"refetchmakesmodels": "", "refetchmakesmodels": "",
"update_ro": "",
"usegeneric": "", "usegeneric": "",
"useselected": "" "useselected": ""
}, },
@@ -1794,6 +1800,7 @@
}, },
"cost": "", "cost": "",
"cost_dms_acctnumber": "", "cost_dms_acctnumber": "",
"customer": "",
"dms_make": "", "dms_make": "",
"dms_model": "", "dms_model": "",
"dms_model_override": "", "dms_model_override": "",
@@ -2107,6 +2114,11 @@
"damageto": "", "damageto": "",
"defaultstory": "", "defaultstory": "",
"disablebillwip": "", "disablebillwip": "",
"earlyro": {
"created": "",
"fields": "",
"willupdate": ""
},
"invoicedatefuture": "", "invoicedatefuture": "",
"kmoutnotgreaterthankmin": "", "kmoutnotgreaterthankmin": "",
"logs": "", "logs": "",
@@ -2264,6 +2276,7 @@
"delete": "", "delete": "",
"deleted": "Le travail a bien été supprimé.", "deleted": "Le travail a bien été supprimé.",
"duplicated": "", "duplicated": "",
"early_ro_created": "",
"exported": "", "exported": "",
"invoiced": "", "invoiced": "",
"ioucreated": "", "ioucreated": "",

View File

@@ -40,6 +40,8 @@ const { loadEmailQueue } = require("./server/notifications/queues/emailQueue");
const { loadAppQueue } = require("./server/notifications/queues/appQueue"); const { loadAppQueue } = require("./server/notifications/queues/appQueue");
const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket"); const { SetLegacyWebsocketHandlers } = require("./server/web-sockets/web-socket");
const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue"); const { loadFcmQueue } = require("./server/notifications/queues/fcmQueue");
const { loadChatterApiQueue } = require("./server/data/queues/chatterApiQueue");
const { processChatterApiJob } = require("./server/data/chatter-api");
const CLUSTER_RETRY_BASE_DELAY = 100; const CLUSTER_RETRY_BASE_DELAY = 100;
const CLUSTER_RETRY_MAX_DELAY = 5000; const CLUSTER_RETRY_MAX_DELAY = 5000;
@@ -391,6 +393,15 @@ const applySocketIO = async ({ server, app }) => {
const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => { const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
const queueSettings = { pubClient, logger, redisHelpers, ioRedis }; const queueSettings = { pubClient, logger, redisHelpers, ioRedis };
// Load chatterApi queue with processJob function and redis helpers
const chatterApiQueue = await loadChatterApiQueue({
pubClient,
logger,
processJob: processChatterApiJob,
getChatterToken: redisHelpers.getChatterToken,
setChatterToken: redisHelpers.setChatterToken
});
// Assuming loadEmailQueue and loadAppQueue return Promises // Assuming loadEmailQueue and loadAppQueue return Promises
const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([ const [notificationsEmailsQueue, notificationsAppQueue, notificationsFcmQueue] = await Promise.all([
loadEmailQueue(queueSettings), loadEmailQueue(queueSettings),
@@ -410,6 +421,10 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
notificationsFcmQueue.on("error", (error) => { notificationsFcmQueue.on("error", (error) => {
logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message }); logger.log(`Error in notificationsFCMQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
}); });
chatterApiQueue.on("error", (error) => {
logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
});
}; };
/** /**

View File

@@ -40,7 +40,6 @@ const logger = require("../utils/logger");
const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client"); const { ChatterApiClient, getChatterApiToken, CHATTER_BASE_URL } = require("../chatter/chatter-client");
const client = require("../graphql-client/graphql-client").client; const client = require("../graphql-client/graphql-client").client;
const { sendServerEmail } = require("../email/sendemail");
const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION"; const CHATTER_EVENT = process.env.NODE_ENV === "production" ? "delivery" : "TEST_INTEGRATION";
const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5); const MAX_CONCURRENCY = Number(process.env.CHATTER_API_CONCURRENCY || 5);
@@ -53,74 +52,98 @@ const clientCache = new Map(); // companyId -> ChatterApiClient
const tokenInFlight = new Map(); // companyId -> Promise<string> (for in-flight deduplication) const tokenInFlight = new Map(); // companyId -> Promise<string> (for in-flight deduplication)
const companyRateLimiters = new Map(); // companyId -> rate limiter const companyRateLimiters = new Map(); // companyId -> rate limiter
/**
* Core processing function for Chatter API jobs.
* This can be called by the HTTP handler or the BullMQ worker.
*
* @param {Object} options - Processing options
* @param {string} options.start - Start date for the delivery window
* @param {string} options.end - End date for the delivery window
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process
* @param {boolean} options.skipUpload - Dry-run flag
* @param {Object} options.sessionUtils - Optional session utils for token caching
* @returns {Promise<Object>} Result with totals, allShopSummaries, and allErrors
*/
async function processChatterApiJob({ start, end, bodyshopIds, skipUpload, sessionUtils }) {
logger.log("chatter-api-start", "DEBUG", "api", null, null);
const allErrors = [];
const allShopSummaries = [];
// Shops that DO have chatter_company_id
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY);
const shopsToProcess =
bodyshopIds?.length > 0 ? bodyshops.filter((shop) => bodyshopIds.includes(shop.id)) : bodyshops;
logger.log("chatter-api-shopsToProcess-generated", "DEBUG", "api", null, { count: shopsToProcess.length });
if (shopsToProcess.length === 0) {
logger.log("chatter-api-shopsToProcess-empty", "DEBUG", "api", null, null);
return {
totals: { shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 },
allShopSummaries: [],
allErrors: []
};
}
await processBatchApi({
shopsToProcess,
start,
end,
skipUpload,
allShopSummaries,
allErrors,
sessionUtils
});
const totals = allShopSummaries.reduce(
(acc, s) => {
acc.shops += 1;
acc.jobs += s.jobs || 0;
acc.sent += s.sent || 0;
acc.duplicates += s.duplicates || 0;
acc.failed += s.failed || 0;
return acc;
},
{ shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 }
);
logger.log("chatter-api-end", "DEBUG", "api", null, totals);
return { totals, allShopSummaries, allErrors };
}
exports.default = async (req, res) => { exports.default = async (req, res) => {
if (process.env.NODE_ENV !== "production") return res.sendStatus(403); if (process.env.NODE_ENV !== "production") return res.sendStatus(403);
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) return res.sendStatus(401); if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) return res.sendStatus(401);
res.status(202).json({ res.status(202).json({
success: true, success: true,
message: "Processing Chatter-API Cron request ...", message: "Chatter API job queued for processing",
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
try { try {
logger.log("chatter-api-start", "DEBUG", "api", null, null); const { dispatchChatterApiJob } = require("./queues/chatterApiQueue");
const { start, end, bodyshopIds, skipUpload } = req.body;
const allErrors = []; await dispatchChatterApiJob({
const allShopSummaries = [];
// Shops that DO have chatter_company_id
const { bodyshops } = await client.request(queries.GET_CHATTER_SHOPS_WITH_COMPANY);
const specificShopIds = req.body.bodyshopIds;
const { start, end, skipUpload } = req.body; // keep same flag; now acts like "dry run"
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("chatter-api-shopsToProcess-generated", "DEBUG", "api", null, { count: shopsToProcess.length });
if (shopsToProcess.length === 0) {
logger.log("chatter-api-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
await processBatchApi({
shopsToProcess,
start, start,
end, end,
skipUpload, bodyshopIds,
allShopSummaries, skipUpload
allErrors,
sessionUtils: req.sessionUtils
}); });
const totals = allShopSummaries.reduce(
(acc, s) => {
acc.shops += 1;
acc.jobs += s.jobs || 0;
acc.sent += s.sent || 0;
acc.duplicates += s.duplicates || 0;
acc.failed += s.failed || 0;
return acc;
},
{ shops: 0, jobs: 0, sent: 0, duplicates: 0, failed: 0 }
);
await sendServerEmail({
subject: `Chatter API Report ${moment().format("MM-DD-YY")}`,
text:
`Totals:\n${JSON.stringify(totals, null, 2)}\n\n` +
`Shop summaries:\n${JSON.stringify(allShopSummaries, null, 2)}\n\n` +
`Errors:\n${JSON.stringify(allErrors, null, 2)}\n`
});
logger.log("chatter-api-end", "DEBUG", "api", null, totals);
} catch (error) { } catch (error) {
logger.log("chatter-api-error", "ERROR", "api", null, { error: error.message, stack: error.stack }); logger.log("chatter-api-queue-dispatch-error", "ERROR", "api", null, {
error: error.message,
stack: error.stack
});
} }
}; };
exports.processChatterApiJob = processChatterApiJob;
async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) { async function processBatchApi({ shopsToProcess, start, end, skipUpload, allShopSummaries, allErrors, sessionUtils }) {
for (const bodyshop of shopsToProcess) { for (const bodyshop of shopsToProcess) {
const summary = { const summary = {

View File

@@ -0,0 +1,178 @@
const { Queue, Worker } = require("bullmq");
const { registerCleanupTask } = require("../../utils/cleanupManager");
const getBullMQPrefix = require("../../utils/getBullMQPrefix");
const devDebugLogger = require("../../utils/devDebugLogger");
const moment = require("moment-timezone");
const { sendServerEmail } = require("../../email/sendemail");
let chatterApiQueue;
let chatterApiWorker;
/**
* Initializes the Chatter API queue and worker.
*
* @param {Object} options - Configuration options for queue initialization.
* @param {Object} options.pubClient - Redis client instance for queue communication.
* @param {Object} options.logger - Logger instance for logging events and debugging.
* @param {Function} options.processJob - Function to process the Chatter API job.
* @param {Function} options.getChatterToken - Function to get Chatter token from Redis.
* @param {Function} options.setChatterToken - Function to set Chatter token in Redis.
* @returns {Queue} The initialized `chatterApiQueue` instance.
*/
const loadChatterApiQueue = async ({ pubClient, logger, processJob, getChatterToken, setChatterToken }) => {
if (!chatterApiQueue) {
const prefix = getBullMQPrefix();
devDebugLogger(`Initializing Chatter API Queue with prefix: ${prefix}`);
chatterApiQueue = new Queue("chatterApi", {
prefix,
connection: pubClient,
defaultJobOptions: {
removeOnComplete: true,
removeOnFail: false,
attempts: 3,
backoff: {
type: "exponential",
delay: 60000 // 1 minute base delay
}
}
});
chatterApiWorker = new Worker(
"chatterApi",
async (job) => {
const { start, end, bodyshopIds, skipUpload } = job.data;
logger.log("chatter-api-queue-job-start", "INFO", "api", null, {
jobId: job.id,
start,
end,
bodyshopIds,
skipUpload
});
try {
// Provide sessionUtils-like object with token caching functions
const sessionUtils = {
getChatterToken,
setChatterToken
};
const result = await processJob({
start,
end,
bodyshopIds,
skipUpload,
sessionUtils
});
logger.log("chatter-api-queue-job-complete", "INFO", "api", null, {
jobId: job.id,
totals: result.totals
});
// Send email summary
await sendServerEmail({
subject: `Chatter API Report ${moment().format("MM-DD-YY")}`,
text:
`Totals:\n${JSON.stringify(result.totals, null, 2)}\n\n` +
`Shop summaries:\n${JSON.stringify(result.allShopSummaries, null, 2)}\n\n` +
`Errors:\n${JSON.stringify(result.allErrors, null, 2)}\n`
});
return result;
} catch (error) {
logger.log("chatter-api-queue-job-error", "ERROR", "api", null, {
jobId: job.id,
error: error.message,
stack: error.stack
});
// Send error email
await sendServerEmail({
subject: `Chatter API Error ${moment().format("MM-DD-YY")}`,
text: `Job failed:\n${error.message}\n\n${error.stack}`
});
throw error;
}
},
{
prefix,
connection: pubClient,
concurrency: 1, // Process one job at a time
lockDuration: 14400000 // 4 hours - allow long-running jobs
}
);
// Event handlers
chatterApiWorker.on("completed", (job) => {
devDebugLogger(`Chatter API job ${job.id} completed`);
});
chatterApiWorker.on("failed", (job, err) => {
logger.log("chatter-api-queue-job-failed", "ERROR", "api", null, {
jobId: job?.id,
message: err?.message,
stack: err?.stack
});
});
chatterApiWorker.on("progress", (job, progress) => {
devDebugLogger(`Chatter API job ${job.id} progress: ${progress}%`);
});
// Register cleanup task
const shutdown = async () => {
devDebugLogger("Closing Chatter API queue worker...");
await chatterApiWorker.close();
devDebugLogger("Chatter API queue worker closed");
};
registerCleanupTask(shutdown);
}
return chatterApiQueue;
};
/**
* Retrieves the initialized `chatterApiQueue` instance.
*
* @returns {Queue} The `chatterApiQueue` instance.
* @throws {Error} If `chatterApiQueue` is not initialized.
*/
const getQueue = () => {
if (!chatterApiQueue) {
throw new Error("Chatter API queue not initialized. Ensure loadChatterApiQueue is called during bootstrap.");
}
return chatterApiQueue;
};
/**
* Dispatches a Chatter API job to the queue.
*
* @param {Object} options - Options for the job.
* @param {string} options.start - Start date for the delivery window.
* @param {string} options.end - End date for the delivery window.
* @param {Array<string>} options.bodyshopIds - Optional specific shops to process.
* @param {boolean} options.skipUpload - Dry-run flag.
* @returns {Promise<void>} Resolves when the job is added to the queue.
*/
const dispatchChatterApiJob = async ({ start, end, bodyshopIds, skipUpload }) => {
const queue = getQueue();
const jobData = {
start: start || moment().subtract(1, "days").startOf("day").toISOString(),
end: end || moment().endOf("day").toISOString(),
bodyshopIds: bodyshopIds || [],
skipUpload: skipUpload || false
};
await queue.add("process-chatter-api", jobData, {
jobId: `chatter-api-${moment().format("YYYY-MM-DD-HHmmss")}`
});
devDebugLogger(`Added Chatter API job to queue: ${JSON.stringify(jobData)}`);
};
module.exports = { loadChatterApiQueue, getQueue, dispatchChatterApiJob };