@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
15
server.js
15
server.js
@@ -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 });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
178
server/data/queues/chatterApiQueue.js
Normal file
178
server/data/queues/chatterApiQueue.js
Normal 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 };
|
||||||
Reference in New Issue
Block a user