Compare commits

...

43 Commits

Author SHA1 Message Date
Dave Richer
609ac2bd33 Merged in feature/IO-3255-simplified-part-management (pull request #2530)
Feature/IO-3255 simplified part management
2025-09-02 19:24:59 +00:00
Dave
0883274320 feature/IO-3255-simplified-parts-management - Expand deprovision route 2025-09-02 15:23:44 -04:00
Dave
fa33b88632 feature/IO-3255-simplified-parts-management - Expand deprovision route 2025-09-02 15:23:36 -04:00
Dave
bec32c1d70 feature/IO-3255-simplified-parts-management - Checkpoint 2025-09-02 15:01:56 -04:00
Patrick Fic
f817902d5c Merged in feature/IO-3340-imgproxy-pdf-contenttype (pull request #2527)
IO-3340 Resolve unenforced content-type
2025-08-28 22:08:09 +00:00
Patrick Fic
7a383aaec9 IO-3340 Resolve unenforced content-type 2025-08-28 15:07:18 -07:00
Dave Richer
e5c0ace6cb Merged in feature/IO-3255-simplified-part-management (pull request #2525)
feature/IO-3255-simplified-parts-management - Checkpoint
2025-08-28 20:01:35 +00:00
Dave
814447373a feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 16:00:39 -04:00
Dave Richer
d766a468c3 Merged in release/2025-08-29 (pull request #2523)
Release/2025 08 29
2025-08-28 19:36:10 +00:00
Dave
0ede2d0649 Merge remote-tracking branch 'origin/master-AIO' into release/2025-08-29 2025-08-28 15:33:26 -04:00
Dave Richer
54089c2ab3 Merged in feature/IO-3255-simplified-part-management (pull request #2521)
Feature/IO-3255 simplified part management
2025-08-28 19:26:25 +00:00
Dave
73e3d71cf1 feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 14:50:26 -04:00
Dave
f071a5cc9e feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 14:24:35 -04:00
Dave
67002b8443 Merge remote-tracking branch 'origin/feature/IO-3255-parts-management-review1' into feature/IO-3255-simplified-part-management 2025-08-28 14:06:07 -04:00
Dave
3f83c6afa7 Merge remote-tracking branch 'origin/release/2025-08-29' into feature/IO-3255-simplified-part-management 2025-08-28 13:27:16 -04:00
Dave
b7f57e91aa feature/IO-3255-simplified-parts-management - Checkpoint 2025-08-28 13:26:47 -04:00
Allan Carr
b8465c0cc7 Merged in feature/IO-3330-CARFAX-Query (pull request #2520)
IO-3330 CARFAX Bodyshop Query Adjustment

Approved-by: Dave Richer
2025-08-28 17:25:18 +00:00
Allan Carr
6bae0b8406 IO-3330 CARFAX Bodyshop Query Adjustment
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-08-27 23:41:16 -07:00
Patrick Fic
553c154e46 IO-3255 Review comments. 2025-08-27 11:50:00 -07:00
Dave Richer
35cbb921d2 Merged in feature/IO-3255-simplified-part-management (pull request #2516)
feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management
2025-08-27 18:19:40 +00:00
Dave
6b926401d0 feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management 2025-08-27 14:17:23 -04:00
Patrick Fic
5b400dce4f Merge branch 'feature/IO-3255-simplified-part-management' into feature/IO-3255-parts-management-review1 2025-08-27 09:39:55 -07:00
Dave Richer
914946c264 Merged in feature/IO-3255-simplified-part-management (pull request #2514)
feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management
2025-08-27 16:37:27 +00:00
Dave
2939b5795b feature/IO-3255-simplified-parts-management - Change Parts Status logic for Parts Management 2025-08-27 12:35:53 -04:00
Dave Richer
531f968ce6 Merged in feature/IO-3255-simplified-part-management (pull request #2512)
feature/IO-3255-simplified-parts-management - Change From Claim to JobID (chgrq)
2025-08-27 15:37:47 +00:00
Dave
24cc3fa6a4 feature/IO-3255-simplified-parts-management - Change From Claim to JobID (chgrq) 2025-08-27 11:36:39 -04:00
Dave Richer
a6f17e7db5 Merged in feature/IO-3255-simplified-part-management (pull request #2510)
feature/IO-3255-simplified-parts-management - Typo in chg request route
2025-08-26 14:51:18 +00:00
Dave
29c904feea feature/IO-3255-simplified-parts-management - Typo in chg request route 2025-08-26 10:50:44 -04:00
Dave Richer
0743048e75 Merged in feature/IO-3255-simplified-part-management (pull request #2509)
Feature/IO-3255 simplified part management
2025-08-25 16:58:43 +00:00
Dave
e2f1758378 feature/IO-3255-simplified-parts-management - Simplified Parts print center stacks on top of each other, not beside each other 2025-08-25 11:33:12 -04:00
Dave
b6dc7a4d92 feature/IO-3255-simplified-parts-management - Notes are appended if they are not duplicates 2025-08-25 11:30:18 -04:00
Dave Richer
130745d7e7 Merged in hotfix/2025-08-22 (pull request #2508)
DO NOT MERGE Hotfix/2025 08 22
2025-08-22 19:36:51 +00:00
Patrick Fic
a722ab9758 hotfix/2025-08-22 - Datahog 2025-08-22 15:35:55 -04:00
Patrick Fic
41c9c0be49 Add in amplititude 2025-08-22 15:22:45 -04:00
Patrick Fic
52f62126e1 Merged in feature/IO-3340-imgproxy-pdf-contenttype (pull request #2507)
IO-3340 Change application type on PDFs uploaded via imgproxy to allow inline display.
2025-08-22 19:02:44 +00:00
Patrick Fic
cfdfb8110b IO-3340 Change application type on PDFs uploaded via imgproxy to allow inline display. 2025-08-22 12:01:16 -07:00
Allan Carr
eb04a8b6c5 Merged in feature/IO-3349-Chart-Enqueue-and-Label-Required (pull request #2505)
IO-3349 Chart Enqueue and Label Required

Approved-by: Dave Richer
2025-08-22 18:55:58 +00:00
Allan Carr
141b05f558 IO-3349 Chart Enqueue and Label Required
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-08-22 11:29:33 -07:00
Dave Richer
e9ea36fdad Merged in feature/IO-3255-simplified-part-management (pull request #2504)
feature/IO-3255-simplified-parts-management - Bug changes / Socket close cleanup
2025-08-22 18:26:38 +00:00
Dave
e990781ff7 feature/IO-3255-simplified-parts-management - Bug changes / Socket close cleanup 2025-08-22 14:24:03 -04:00
Patrick Fic
e24237e010 Merged in feature/IO-3325-amplitude (pull request #2502)
Feature/IO-3325 amplitude
2025-08-22 18:00:37 +00:00
Patrick Fic
fabe1508ac Merged in feature/IO-3325-amplitude (pull request #2500)
IO-3325 Add amplitude tracking.
2025-08-22 17:40:33 +00:00
Dave Richer
8c9ef375be Merged in hotfix/background-colors-dark-mode (pull request #2485)
Hotfix/background colors dark mode
2025-08-18 18:39:42 +00:00
27 changed files with 963 additions and 2720 deletions

View File

@@ -1,20 +1,20 @@
import { ApolloProvider } from "@apollo/client";
import * as Sentry from "@sentry/react";
import { SplitFactoryProvider, useSplitClient } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { connect, useSelector } from "react-redux";
import { createStructuredSelector } from "reselect";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import client from "../utils/GraphQLClient";
import App from "./App";
import * as Sentry from "@sentry/react";
import getTheme from "./themeProvider";
import { CookiesProvider } from "react-cookie";
import { createStructuredSelector } from "reselect";
import { selectCurrentUser } from "../redux/user/user.selectors.js";
import { selectDarkMode } from "../redux/application/application.selectors";
import { setDarkMode } from "../redux/application/application.actions";
// Base Split configuration
const config = {

View File

@@ -67,11 +67,14 @@ export const uploadToS3 = async (
}
//Key should be same as we provided to maintain backwards compatibility.
const { presignedUrl: preSignedUploadUrlToS3, key: s3Key } = signedURLResponse.data.signedUrls[0];
const { presignedUrl: preSignedUploadUrlToS3, key: s3Key, contentType } = signedURLResponse.data.signedUrls[0];
const options = {
onUploadProgress: (e) => {
if (onProgress) onProgress({ percent: (e.loaded / e.total) * 100 });
},
headers: {
...contentType ? { "Content-Type": fileType } : {}
}
};

View File

@@ -12,13 +12,15 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobLineConvertToLabor from "../job-line-convert-to-labor/job-line-convert-to-labor.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({
technician: selectTechnician
technician: selectTechnician,
isPartsEntry: selectIsPartsEntry
});
const mapDispatchToProps = () => ({});
export function JobLinesPartPriceChange({ job, line, refetch, technician }) {
export function JobLinesPartPriceChange({ job, line, refetch, technician, isPartsEntry }) {
const [loading, setLoading] = useState(false);
const [updatePartPrice] = useMutation(UPDATE_LINE_PPC);
const notification = useNotification();
@@ -64,6 +66,7 @@ export function JobLinesPartPriceChange({ job, line, refetch, technician }) {
const popcontent =
!technician &&
!isPartsEntry &&
InstanceRenderManager({
imex: null,
rome: (

View File

@@ -481,48 +481,50 @@ export function JobLinesComponent({
{Enhanced_Payroll.treatment === "on" && (
<JobLineBulkAssignComponent selectedLines={selectedLines} setSelectedLines={setSelectedLines} job={job} />
)}
<Button
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
onClick={() => {
setBillEnterContext({
actions: { refetch: refetch },
context: {
disableInvNumber: true,
job: { id: job.id },
bill: {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
isinhouse: true,
date: dayjs(),
total: 0,
billlines: selectedLines.map((p) => {
return {
joblineid: p.id,
actual_price: p.act_price,
actual_cost: 0, //p.act_price,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: {
local: false,
state: false,
federal: false
}
};
})
{!isPartsEntry && (
<Button
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
onClick={() => {
setBillEnterContext({
actions: { refetch: refetch },
context: {
disableInvNumber: true,
job: { id: job.id },
bill: {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
isinhouse: true,
date: dayjs(),
total: 0,
billlines: selectedLines.map((p) => {
return {
joblineid: p.id,
actual_price: p.act_price,
actual_cost: 0, //p.act_price,
line_desc: p.line_desc,
line_remarks: p.line_remarks,
part_type: p.part_type,
quantity: p.quantity || 1,
applicable_taxes: {
local: false,
state: false,
federal: false
}
};
})
}
}
}
});
});
//Clear out the selected lines. IO-785
setSelectedLines([]);
}}
>
<HomeOutlined />
{t("parts.actions.orderinhouse")}
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
//Clear out the selected lines. IO-785
setSelectedLines([]);
}}
>
<HomeOutlined />
{t("parts.actions.orderinhouse")}
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
)}
<Button
id="job-lines-order-parts-button"
disabled={(job && !job.converted) || (selectedLines.length > 0 ? false : true) || jobRO || technician}
@@ -578,7 +580,8 @@ export function JobLinesComponent({
{t("joblines.actions.new")}
</Button>
)}
{InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })}
{!isPartsEntry &&
InstanceRenderManager({ rome: <JobSendPartPriceChangeComponent job={job} disabled={technician} /> })}
<JobCreateIOU job={job} selectedJobLines={selectedLines} />
<Input.Search
placeholder={t("general.labels.search")}

View File

@@ -8,19 +8,20 @@ import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { UPDATE_JOB_STATUS } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectIsPartsEntry, selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly
jobRO: selectJobReadOnly,
isPartsEntry: selectIsPartsEntry
});
const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
});
export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail, isPartsEntry }) {
const { t } = useTranslation();
const [availableStatuses, setAvailableStatuses] = useState([]);
@@ -45,25 +46,43 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail }) {
});
};
// Updates available statuses based on job and bodyshop context
useEffect(() => {
//Figure out what scenario were in, populate accodingly
if (job && bodyshop) {
if (bodyshop.md_ro_statuses.pre_production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.pre_production_statuses);
} else if (bodyshop.md_ro_statuses.production_statuses.includes(job.status)) {
setAvailableStatuses(bodyshop.md_ro_statuses.production_statuses);
} else if (bodyshop.md_ro_statuses.post_production_statuses.includes(job.status)) {
setAvailableStatuses(
bodyshop.md_ro_statuses.post_production_statuses.filter(
(s) => s !== bodyshop.md_ro_statuses.default_invoiced && s !== bodyshop.md_ro_statuses.default_exported
)
);
} else {
console.log("Status didn't match any restrictions. Allowing all status changes.");
setAvailableStatuses(bodyshop.md_ro_statuses.statuses);
}
if (!job || !bodyshop) return;
const { md_ro_statuses } = bodyshop;
const {
parts_statuses,
pre_production_statuses,
production_statuses,
post_production_statuses,
statuses,
default_invoiced,
default_exported
} = md_ro_statuses;
if (isPartsEntry) {
// Set parts-specific statuses for parts entry scenario
setAvailableStatuses(parts_statuses);
return;
}
}, [job, setAvailableStatuses, bodyshop]);
// Handle non-parts entry scenarios based on job status
if (pre_production_statuses.includes(job.status)) {
setAvailableStatuses(pre_production_statuses);
} else if (production_statuses.includes(job.status)) {
setAvailableStatuses(production_statuses);
} else if (post_production_statuses.includes(job.status)) {
// Filter out invoiced and exported statuses for post-production
setAvailableStatuses(
post_production_statuses.filter((status) => status !== default_invoiced && status !== default_exported)
);
} else {
// Default to all statuses if no specific restrictions apply
console.log("Status didn't match any restrictions. Allowing all status changes.");
setAvailableStatuses(statuses);
}
}, [job, bodyshop, isPartsEntry, setAvailableStatuses]);
const statusMenu = {
items: [

View File

@@ -139,17 +139,18 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
<DataLabel label={t("jobs.fields.comment")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<ProductionListColumnComment record={job} />
</DataLabel>
<DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel>
{!isPartsEntry && <DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel>}
<DataLabel label={t("jobs.fields.clm_no")}>{job.clm_no}</DataLabel>
<DataLabel label={t("jobs.fields.ponumber")} hideIfNull>
{job.po_number}
</DataLabel>
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
{!isPartsEntry && (
<DataLabel label={t("jobs.fields.repairtotal")}>
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<span style={{ margin: "0rem .5rem" }}>/</span>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
)}
{!isPartsEntry && (
<>
<DataLabel label={t("jobs.fields.alt_transport")}>

View File

@@ -37,7 +37,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
.filter(
(temp) =>
(!temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) ||
temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)) &&
(!temp.dms || temp.dms === false)
)
@@ -46,7 +46,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
.filter(
(temp) =>
!temp.regions ||
(temp.regions && temp.regions[bodyshop.region_config]) ||
temp.regions?.[bodyshop.region_config] ||
(temp.regions && bodyshop.region_config.includes(Object.keys(temp.regions)) === true)
);
@@ -82,7 +82,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
variables: { id: jobId }
},
{
to: job && job.ownr_ea,
to: job?.ownr_ea,
subject: cards.find((c) => c.key === key)?.subject
},
"e",
@@ -129,7 +129,7 @@ export function PrintCenterJobsPartsComponent({ printCenterModal, bodyshop, tech
const columns = `repeat(${actions.length}, 1fr)`;
return (
<Col key={item.key} xs={24} sm={12}>
<Col key={item.key} xs={24} sm={24} md={24} lg={24} xl={24}>
<Card hoverable style={{ minHeight: 100 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: "1 1 70%", minWidth: 0 }}>

View File

@@ -64,7 +64,7 @@ export default function ShopInfoContainer() {
onFinish={handleFinish}
initialValues={
data
? data.bodyshops[0].accountingconfig.ClosingPeriod
? data?.bodyshops?.[0]?.accountingconfig?.ClosingPeriod
? {
...data.bodyshops[0],
accountingconfig: {

View File

@@ -9,7 +9,6 @@ import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { pageLimit } from "../../utils/config";
import { alphaSort, statusSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
@@ -96,13 +95,15 @@ export function SimplifiedPartsJobsListComponent({
key: "status",
ellipsis: true,
sorter: search?.search ? (a, b) => statusSort(a.status, b.status, bodyshop.md_ro_statuses.active_statuses) : true,
sorter: search?.search
? (a, b) => statusSort(a.status, b.status, bodyshop.md_ro_statuses.parts_active_statuses)
: true,
sortOrder: sortcolumn === "status" && sortorder,
render: (text, record) => {
return record.status || t("general.labels.na");
},
filteredValue: filter?.status || null,
filters: bodyshop.md_ro_statuses.statuses.map((s) => {
filters: bodyshop.md_ro_statuses.parts_statuses.map((s) => {
return { text: s, value: [s] };
}),
onFilter: (value, record) => value.includes(record.status)
@@ -142,26 +143,6 @@ export function SimplifiedPartsJobsListComponent({
sortOrder: sortcolumn === "clm_no" && sortorder,
render: (text, record) => `${record.clm_no || ""}${record.po_number ? ` (PO: ${record.po_number})` : ""}`
},
{
title: t("jobs.fields.ins_co_nm"),
dataIndex: "ins_co_nm",
key: "ins_co_nm",
ellipsis: true
},
{
title: t("jobs.fields.clm_total"),
dataIndex: "clm_total",
key: "clm_total",
sorter: search?.search ? (a, b) => a.clm_total - b.clm_total : true,
sortOrder: sortcolumn === "clm_total" && sortorder,
render: (text, record) => {
return record.clm_total ? (
<CurrencyFormatter>{record.clm_total}</CurrencyFormatter>
) : (
t("general.labels.unknown")
);
}
},
{
title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus",

View File

@@ -167,7 +167,7 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
socketRef.current = socketInstance;
const handleBodyshopMessage = (message) => {
if (!message || !message.type) return;
if (!message?.type) return;
switch (message.type) {
case "alert-update":
store.dispatch(addAlerts(message.payload));
@@ -512,21 +512,20 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
};
const unsubscribe = auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
initializeSocket(token).catch((err) =>
console.error(`Something went wrong Initializing Sockets: ${err?.message || ""}`)
);
}
if (!user) {
socketRef.current?.disconnect();
socketRef.current = null;
setIsConnected(false);
return;
}
const token = await user.getIdToken();
if (socketRef.current) {
socketRef.current.emit("update-token", { token, bodyshopId: bodyshop.id });
} else {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setIsConnected(false);
}
initializeSocket(token).catch((err) =>
console.error("Something went wrong Initializing Sockets:", err?.message || "")
);
}
});

View File

@@ -16,6 +16,7 @@ import "./translations/i18n";
import "./utils/CleanAxios";
import * as amplitude from "@amplitude/analytics-browser";
import { PostHogProvider } from "posthog-js/react";
import posthog from "posthog-js";
window.global ||= window;
@@ -25,7 +26,29 @@ registerSW({ immediate: true });
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
amplitude.init("6228a598e57cd66875cfd41604f1f891", {});
amplitude.init("6228a598e57cd66875cfd41604f1f891", {
defaultTracking: true
// {
// attribution: {
// excludeReferrers: true,
// initialEmptyValue: true,
// resetSessionOnNewCampaign: true,
// },
// fileDownloads: true,
// formInteractions: true,
// pageViews: {
// trackHistoryChanges: 'all'
// },
// sessions: true
// }
});
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
autocapture: false,
capture_exceptions: true,
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST
});
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
const router = sentryCreateBrowserRouter(createRoutesFromElements(<Route path="*" element={<AppContainer />} />));
@@ -39,10 +62,7 @@ function App() {
return (
<PersistGate loading={<LoadingSpinner message="Restoring your settings..." />} persistor={persistor}>
<Provider store={store}>
<PostHogProvider
apiKey={import.meta.env.VITE_PUBLIC_POSTHOG_KEY}
options={{ autocapture: false, capture_exceptions: true }}
>
<PostHogProvider client={posthog}>
<RouterProvider router={router} />
</PostHogProvider>
</Provider>

View File

@@ -298,6 +298,7 @@ export function* signInSuccessSaga({ payload }) {
setUserId(analytics, payload.email);
setUserProperties(analytics, payload);
yield;
}
export function* onSendPasswordResetStart() {

View File

@@ -181,7 +181,10 @@ const cache = new InMemoryCache({
const client = new ApolloClient({
link: ApolloLink.from(middlewares),
cache,
connectToDevTools: import.meta.env.DEV,
devtools: {
name: "Imex Client",
enabled: import.meta.env.DEV
},
defaultOptions: {
watchQuery: {
fetchPolicy: "network-only",

2565
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,14 +18,14 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.873.0",
"@aws-sdk/client-elasticache": "^3.872.0",
"@aws-sdk/client-s3": "^3.872.0",
"@aws-sdk/client-secrets-manager": "^3.872.0",
"@aws-sdk/client-ses": "^3.872.0",
"@aws-sdk/credential-provider-node": "^3.873.0",
"@aws-sdk/lib-storage": "^3.872.0",
"@aws-sdk/s3-request-presigner": "^3.872.0",
"@aws-sdk/client-cloudwatch-logs": "^3.876.0",
"@aws-sdk/client-elasticache": "^3.876.0",
"@aws-sdk/client-s3": "^3.876.0",
"@aws-sdk/client-secrets-manager": "^3.876.0",
"@aws-sdk/client-ses": "^3.876.0",
"@aws-sdk/credential-provider-node": "^3.876.0",
"@aws-sdk/lib-storage": "^3.876.0",
"@aws-sdk/s3-request-presigner": "^3.876.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
@@ -33,14 +33,14 @@
"aws4": "^1.13.2",
"axios": "^1.11.0",
"better-queue": "^3.8.12",
"bullmq": "^5.58.0",
"bullmq": "^5.58.2",
"chart.js": "^4.5.0",
"cloudinary": "^2.7.0",
"compression": "^1.8.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"crisp-status-reporter": "^1.2.2",
"dd-trace": "^5.63.3",
"dd-trace": "^5.64.0",
"dinero.js": "^1.9.1",
"dotenv": "^17.2.1",
"express": "^4.21.1",
@@ -62,7 +62,7 @@
"query-string": "7.1.3",
"recursive-diff": "^1.0.9",
"rimraf": "^6.0.1",
"skia-canvas": "^3.0.3",
"skia-canvas": "^3.0.4",
"soap": "^1.3.0",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
@@ -76,8 +76,8 @@
"yazl": "^3.3.1"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"eslint": "^9.33.0",
"@eslint/js": "^9.34.0",
"eslint": "^9.34.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0",
"mock-require": "^3.0.3",

View File

@@ -1854,7 +1854,7 @@ exports.GET_CHATTER_SHOPS = `query GET_CHATTER_SHOPS {
}`;
exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS {
bodyshops{
bodyshops(where: {external_shop_id: {_is_null: true}}){
id
shopname
imexshopid

View File

@@ -35,6 +35,11 @@
"headerMargin": "135"
},
"md_ro_statuses": {
"parts_statuses": [
"Open",
"In Progress",
"Completed"
],
"statuses": [
"Open",
"Scheduled",
@@ -54,6 +59,10 @@
"Void"
],
"default_void": "Void",
"parts_active_statuses": [
"Open",
"In Progress"
],
"active_statuses": [
"Open",
"Scheduled",
@@ -766,6 +775,7 @@
"csi:page": 11,
"jobs:void": 80,
"shop:rbac": 99,
"shop:responsibilitycenter": 99,
"bills:list": 11,
"bills:view": 11,
"csi:export": 11,

View File

@@ -23,10 +23,16 @@ const KNOWN_PART_RATE_TYPES = [
* @returns {object} The parts tax rates object.
*/
//TODO: Major validation would be required on this - EMS files are inconsistent with things like 5% being passed as 5.0 or .05.
const extractPartsTaxRates = (profile = {}) => {
const rateInfos = Array.isArray(profile.RateInfo) ? profile.RateInfo : [profile.RateInfo || {}];
const partsTaxRates = {};
/**
* In this context, r.RateType._ accesses the property named _ on the RateType object.
* This pattern is common when handling data parsed from XML, where element values are stored under the _ key. So,
* _ aligns to the actual value/content of the RateType field when RateType is an object (not a string).
*/
for (const r of rateInfos) {
const rateTypeRaw =
typeof r?.RateType === "string"

View File

@@ -0,0 +1,197 @@
{
"OP0": {
"desc": "REMOVE / REPLACE PARTIAL",
"opcode": "OP11",
"partcode": "PAA"
},
"OP1": {
"desc": "REFINISH / REPAIR",
"opcode": "OP1",
"partcode": "PAE"
},
"OP2": {
"desc": "REMOVE / INSTALL",
"opcode": "OP2",
"partcode": "PAE"
},
"OP3": {
"desc": "ADDITIONAL LABOR",
"opcode": "OP9",
"partcode": "PAE"
},
"OP4": {
"desc": "ALIGNMENT",
"opcode": "OP4",
"partcode": "PAS"
},
"OP5": {
"desc": "OVERHAUL",
"opcode": "OP5",
"partcode": "PAE"
},
"OP6": {
"desc": "REFINISH",
"opcode": "OP6",
"partcode": "PAE"
},
"OP7": {
"desc": "INSPECT",
"opcode": "OP7",
"partcode": "PAE"
},
"OP8": {
"desc": "CHECK / ADJUST",
"opcode": "OP8",
"partcode": "PAE"
},
"OP9": {
"desc": "REPAIR",
"opcode": "OP9",
"partcode": "PAE"
},
"OP10": {
"desc": "REPAIR , PARTIAL",
"opcode": "OP9",
"partcode": "PAE"
},
"OP11": {
"desc": "REMOVE / REPLACE",
"opcode": "OP11",
"partcode": "PAN"
},
"OP12": {
"desc": "REMOVE / REPLACE PARTIAL",
"opcode": "OP11",
"partcode": "PAN"
},
"OP13": {
"desc": "ADDITIONAL COSTS",
"opcode": "OP13",
"partcode": "PAE"
},
"OP14": {
"desc": "ADDITIONAL OPERATIONS",
"opcode": "OP14",
"partcode": "PAE"
},
"OP15": {
"desc": "BLEND",
"opcode": "OP15",
"partcode": "PAE"
},
"OP16": {
"desc": "SUBLET",
"opcode": "OP16",
"partcode": "PAS"
},
"OP17": {
"desc": "POLICY LIMIT ADJUSTMENT",
"opcode": "OP9",
"partcode": "PAE"
},
"OP18": {
"desc": "APPEAR ALLOWANCE",
"opcode": "OP7",
"partcode": "PAE"
},
"OP20": {
"desc": "REMOVE AND REINSTALL",
"opcode": "OP20",
"partcode": "PAE"
},
"OP24": {
"desc": "CHIPGUARD",
"opcode": "OP6",
"partcode": "PAE"
},
"OP25": {
"desc": "TWO TONE",
"opcode": "OP6",
"partcode": "PAE"
},
"OP26": {
"desc": "PAINTLESS DENT REPAIR",
"opcode": "OP16",
"partcode": "PAE"
},
"OP100": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP101": {
"desc": "REMOVE/REPLACE RECYCLED PART",
"opcode": "OP11",
"partcode": "PAL"
},
"OP103": {
"desc": "REMOVE / REPLACE PARTIAL",
"opcode": "OP11",
"partcode": "PAA"
},
"OP104": {
"desc": "REMOVE / REPLACE PARTIAL LABOUR",
"opcode": "OP11",
"partcode": "PAA"
},
"OP105": {
"desc": "!!ADJUST MANUALLY!!",
"opcode": "OP99",
"partcode": "PAE"
},
"OP106": {
"desc": "REPAIR , PARTIAL",
"opcode": "OP9",
"partcode": "PAE"
},
"OP107": {
"desc": "CHIPGUARD",
"opcode": "OP6",
"partcode": "PAE"
},
"OP108": {
"desc": "MULTI TONE",
"opcode": "OP6",
"partcode": "PAE"
},
"OP109": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP110": {
"desc": "REFINISH / REPAIR",
"opcode": "OP1",
"partcode": "PAE"
},
"OP111": {
"desc": "REMOVE / REPLACE",
"opcode": "OP11",
"partcode": "PAN"
},
"OP112": {
"desc": "REMOVE / REPLACE",
"opcode": "OP11",
"partcode": "PAA"
},
"OP113": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP114": {
"desc": "REPLACE PRE-PRICED",
"opcode": "OP11",
"partcode": "PAA"
},
"OP120": {
"desc": "REPAIR , PARTIAL",
"opcode": "OP9",
"partcode": "PAE"
},
"OP260": {
"desc": "SUBLET",
"opcode": "OP16",
"partcode": "PAE"
}
}

View File

@@ -83,78 +83,80 @@ const deleteJobsByIds = async (jobIds) => {
*/
const partsManagementDeprovisioning = async (req, res) => {
const { logger } = req;
const p = req.body;
const body = req.body;
if (process.env.NODE_ENV === "production") {
return res.status(403).json({ error: "Deprovisioning not allowed in production environment." });
}
try {
if (!p.shopId) {
if (!body.shopId) {
throw { status: 400, message: "shopId is required." };
}
// Fetch bodyshop and check external_shop_id
const shopResp = await client.request(GET_BODYSHOP, { id: p.shopId });
const shopResp = await client.request(GET_BODYSHOP, { id: body.shopId });
const shop = shopResp.bodyshops_by_pk;
if (!shop) {
throw { status: 404, message: `Bodyshop with id ${p.shopId} not found.` };
throw { status: 404, message: `Bodyshop with id ${body.shopId} not found.` };
}
if (!shop.external_shop_id) {
throw { status: 400, message: "Cannot delete bodyshop without external_shop_id." };
}
logger.log("admin-delete-shop", "debug", null, null, {
shopId: p.shopId,
shopId: body.shopId,
shopname: shop.shopname,
ioadmin: true
});
// Get vendors
const vendorsResp = await client.request(GET_VENDORS, { shopId: p.shopId });
const vendorsResp = await client.request(GET_VENDORS, { shopId: body.shopId });
const deletedVendors = vendorsResp.vendors.map((v) => v.name);
// Get associated users
const assocResp = await client.request(GET_ASSOCIATED_USERS, { shopId: p.shopId });
const assocResp = await client.request(GET_ASSOCIATED_USERS, { shopId: body.shopId });
const associatedUsers = assocResp.associations.map((assoc) => ({
authId: assoc.user.authid,
email: assoc.user.email
}));
// Delete associations for the shop
const assocDeleteResp = await client.request(DELETE_ASSOCIATIONS_BY_SHOP, { shopId: p.shopId });
const assocDeleteResp = await client.request(DELETE_ASSOCIATIONS_BY_SHOP, { shopId: body.shopId });
const associationsDeleted = assocDeleteResp.delete_associations.affected_rows;
// For each user, check if they have remaining associations; if not, delete user and Firebase account
const deletedUsers = [];
for (const user of associatedUsers) {
const countResp = await client.request(GET_USER_ASSOCIATIONS_COUNT, { userEmail: user.email });
const assocCount = countResp.associations_aggregate.aggregate.count;
if (assocCount === 0) {
await client.request(DELETE_USER, { email: user.email });
await deleteFirebaseUser(user.authId);
deletedUsers.push(user.email);
}
// Determine which users now have zero associations and should be deleted (defer deletion until end)
const emailsToAuthId = associatedUsers.reduce((acc, u) => {
acc[u.email] = u.authId;
return acc;
}, {});
const emailsToDelete = [];
await client.request(DELETE_USER, { email: user.email });
await deleteFirebaseUser(user.authId);
deletedUsers.push(user.email);
}
// Get all job ids for this shop, then delete joblines and jobs (joblines first)
const jobIds = await getJobIdsForShop(p.shopId);
emailsToDelete.push(user.email);
const jobIds = await getJobIdsForShop(body.shopId);
const joblinesDeleted = await deleteJoblinesForJobs(jobIds);
const jobsDeleted = await deleteJobsByIds(jobIds);
// Delete any audit trail entries tied to this bodyshop to avoid FK violations
const auditResp = await client.request(DELETE_AUDIT_TRAIL_BY_SHOP, { shopId: p.shopId });
const auditResp = await client.request(DELETE_AUDIT_TRAIL_BY_SHOP, { shopId: body.shopId });
const auditDeleted = auditResp.delete_audit_trail.affected_rows;
// Delete vendors
await deleteVendorsByShop(p.shopId);
await deleteVendorsByShop(body.shopId);
// Delete shop
await deleteBodyshop(p.shopId);
await deleteBodyshop(body.shopId);
// Summary log
logger.log("admin-delete-shop-summary", "info", null, null, {
shopId: p.shopId,
shopId: body.shopId,
shopname: shop.shopname,
associationsDeleted,
deletedUsers,
@@ -165,8 +167,8 @@ const partsManagementDeprovisioning = async (req, res) => {
});
return res.status(200).json({
message: `Bodyshop ${p.shopId} and associated resources deleted successfully.`,
deletedShop: { id: p.shopId, name: shop.shopname },
message: `Bodyshop ${body.shopId} and associated resources deleted successfully.`,
deletedShop: { id: body.shopId, name: shop.shopname },
deletedAssociationsCount: associationsDeleted,
deletedUsers: deletedUsers,
deletedVendors: deletedVendors,
@@ -174,6 +176,16 @@ const partsManagementDeprovisioning = async (req, res) => {
deletedJobsCount: jobsDeleted,
deletedAuditTrailCount: auditDeleted
});
// Now delete users that have no remaining associations and their Firebase accounts
const deletedUsers = [];
for (const email of emailsToDelete) {
await client.request(DELETE_USER, { email });
const authId = emailsToAuthId[email];
if (authId) {
await deleteFirebaseUser(authId);
}
deletedUsers.push(email);
}
} catch (err) {
logger.log("admin-delete-shop-error", "error", null, null, {
message: err.message,

View File

@@ -123,7 +123,7 @@ const insertUserAssociation = async (uid, email, shopId) => {
authid: uid,
validemail: true,
associations: {
data: [{ shopid: shopId, authlevel: 80, active: true }]
data: [{ shopid: shopId, authlevel: 99, active: true }]
}
}
};
@@ -139,11 +139,11 @@ const insertUserAssociation = async (uid, email, shopId) => {
*/
const partsManagementProvisioning = async (req, res) => {
const { logger } = req;
const p = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() };
const body = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() };
try {
await ensureEmailNotRegistered(p.userEmail);
requireFields(p, [
await ensureEmailNotRegistered(body.userEmail);
requireFields(body, [
"external_shop_id",
"shopname",
"address1",
@@ -155,33 +155,33 @@ const partsManagementProvisioning = async (req, res) => {
"phone",
"userEmail"
]);
await ensureExternalIdUnique(p.external_shop_id);
await ensureExternalIdUnique(body.external_shop_id);
logger.log("admin-create-shop-user", "debug", p.userEmail, null, {
logger.log("admin-create-shop-user", "debug", body.userEmail, null, {
request: req.body,
ioadmin: true
});
const shopInput = {
shopname: p.shopname,
address1: p.address1,
address2: p.address2 || null,
city: p.city,
state: p.state,
zip_post: p.zip_post,
country: p.country,
email: p.email,
external_shop_id: p.external_shop_id,
timezone: p.timezone || DefaultNewShop.timezone,
phone: p.phone,
shopname: body.shopname,
address1: body.address1,
address2: body.address2 || null,
city: body.city,
state: body.state,
zip_post: body.zip_post,
country: body.country,
email: body.email,
external_shop_id: body.external_shop_id,
timezone: body.timezone || DefaultNewShop.timezone,
phone: body.phone,
logo_img_path: {
src: p.logoUrl,
src: body.logoUrl,
width: "",
height: "",
headerMargin: DefaultNewShop.logo_img_path.headerMargin
},
features: {
allAccess: true, // TODO: should be false?
allAccess: false,
partsManagementOnly: true
},
md_ro_statuses: DefaultNewShop.md_ro_statuses,
@@ -191,16 +191,16 @@ const partsManagementProvisioning = async (req, res) => {
md_messaging_presets: DefaultNewShop.md_messaging_presets,
md_rbac: DefaultNewShop.md_rbac,
md_classes: DefaultNewShop.md_classes,
md_ins_cos: DefaultNewShop.md_ins_cos, // TODO need?
md_categories: DefaultNewShop.md_categories, // TODO need?
md_labor_rates: DefaultNewShop.md_labor_rates, // TODO need?
md_payment_types: DefaultNewShop.md_payment_types, // TODO need?
md_hour_split: DefaultNewShop.md_hour_split, // TODO need?
md_ccc_rates: DefaultNewShop.md_ccc_rates, // TODO need?
appt_alt_transport: DefaultNewShop.appt_alt_transport, // TODO need?
md_jobline_presets: DefaultNewShop.md_jobline_presets, // TODO need?
md_ins_cos: DefaultNewShop.md_ins_cos,
md_categories: DefaultNewShop.md_categories,
md_labor_rates: DefaultNewShop.md_labor_rates,
md_payment_types: DefaultNewShop.md_payment_types,
md_hour_split: DefaultNewShop.md_hour_split,
md_ccc_rates: DefaultNewShop.md_ccc_rates,
appt_alt_transport: DefaultNewShop.appt_alt_transport,
md_jobline_presets: DefaultNewShop.md_jobline_presets,
vendors: {
data: p.vendors.map((v) => ({
data: body.vendors.map((v) => ({
name: v.name,
street1: v.street1 || null,
street2: v.street2 || null,
@@ -221,14 +221,14 @@ const partsManagementProvisioning = async (req, res) => {
};
const newShopId = await insertBodyshop(shopInput);
const userRecord = await createFirebaseUser(p.userEmail, p.userPassword);
const userRecord = await createFirebaseUser(body.userEmail, body.userPassword);
let resetLink = null;
if (!p.userPassword) resetLink = await generateResetLink(p.userEmail);
if (!body.userPassword) resetLink = await generateResetLink(body.userEmail);
const createdUser = await insertUserAssociation(userRecord.uid, p.userEmail, newShopId);
const createdUser = await insertUserAssociation(userRecord.uid, body.userEmail, newShopId);
return res.status(200).json({
shop: { id: newShopId, shopname: p.shopname },
shop: { id: newShopId, shopname: body.shopname },
user: {
id: createdUser.id,
email: createdUser.email,
@@ -236,7 +236,7 @@ const partsManagementProvisioning = async (req, res) => {
}
});
} catch (err) {
logger.log("admin-create-shop-user-error", "error", p.userEmail, null, {
logger.log("admin-create-shop-user-error", "error", body.userEmail, null, {
message: err.message,
detail: err.detail || err
});

View File

@@ -14,7 +14,7 @@ const {
} = require("../partsManagement.queries");
// Defaults
const FALLBACK_DEFAULT_ORDER_STATUS = "OPEN";
const FALLBACK_DEFAULT_JOB_STATUS = "Open";
/**
* Fetches the default order status for a bodyshop.
@@ -22,13 +22,13 @@ const FALLBACK_DEFAULT_ORDER_STATUS = "OPEN";
* @param {object} logger - The logger instance.
* @returns {Promise<string>} The default status or fallback.
*/
const getDefaultOrderStatus = async (shopId, logger) => {
const getDefaultJobStatus = async (shopId, logger) => {
try {
const { bodyshop_by_pk } = await client.request(GET_BODYSHOP_STATUS, { id: shopId });
return bodyshop_by_pk?.md_order_statuses?.default_open || FALLBACK_DEFAULT_ORDER_STATUS;
return bodyshop_by_pk?.md_ro_statuses?.default_imported || FALLBACK_DEFAULT_JOB_STATUS;
} catch (err) {
logger.log("parts-bodyshop-fetch-failed", "warn", shopId, null, { error: err });
return FALLBACK_DEFAULT_ORDER_STATUS;
return FALLBACK_DEFAULT_JOB_STATUS;
}
};
/**
@@ -66,6 +66,7 @@ const extractJobData = (rq) => {
return {
shopId: rq.ShopID || rq.shopId,
// status: ci.ClaimStatus || null, Proper, setting it default for now
refClaimNum: rq.RefClaimNum,
ciecaid: rq.RqUID || null,
// Pull Cieca_ttl from ClaimInfo per schema/sample
@@ -81,7 +82,6 @@ const extractJobData = (rq) => {
scheduled_in: ev.RepairEvent?.RequestedPickUpDateTime || null,
scheduled_completion: ev.RepairEvent?.TargetCompletionDateTime || null,
clm_no: ci.ClaimNum || null,
status: ci.ClaimStatus || null,
policy_no: ci.PolicyInfo?.PolicyInfo?.PolicyNum || ci.PolicyInfo?.PolicyNum || null,
ded_amt: parseFloat(ci.PolicyInfo?.CoverageInfo?.Coverage?.DeductibleInfo?.DeductibleAmt || 0)
};
@@ -100,17 +100,19 @@ const extractOwnerData = (rq, shopId) => {
const personName = personInfo.PersonName || {};
const address = personInfo.Communications?.Address || {};
let ownr_ph1, ownr_ph2, ownr_ea, ownr_alt_ph;
let ownr_ph1, ownr_ph2, ownr_ea;
const comms = Array.isArray(ownerOrClaimant.ContactInfo?.Communications)
? ownerOrClaimant.ContactInfo.Communications
: [ownerOrClaimant.ContactInfo?.Communications || {}];
for (const c of comms) {
// TODO: Should document this logic. 1 and 2 don't
// typically indicate type in EMS. This makes sense, but good to document.
if (c.CommQualifier === "CP") ownr_ph1 = c.CommPhone;
if (c.CommQualifier === "WP") ownr_ph2 = c.CommPhone;
if (c.CommQualifier === "EM") ownr_ea = c.CommEmail;
if (c.CommQualifier === "AL") ownr_alt_ph = c.CommPhone;
// if (c.CommQualifier === "AL") ownr_alt_ph = c.CommPhone;
}
return {
@@ -126,8 +128,8 @@ const extractOwnerData = (rq, shopId) => {
ownr_ctry: address.Country || null,
ownr_ph1,
ownr_ph2,
ownr_ea,
ownr_alt_ph
ownr_ea
// ownr_alt_ph
// ownr_id_qualifier: ownerOrClaimant.IDInfo?.IDQualifierCode || null // New
// ownr_id_num: ownerOrClaimant.IDInfo?.IDNum || null, // New
// ownr_preferred_contact: ownerOrClaimant.PreferredContactMethod || null // New
@@ -158,37 +160,40 @@ const extractEstimatorData = (rq) => {
* @param {object} rq - The VehicleDamageEstimateAddRq object.
* @returns {object} Adjuster data.
*/
const extractAdjusterData = (rq) => {
const adjParty = rq.AdminInfo?.Adjuster?.Party || {};
const adjComms = Array.isArray(adjParty.ContactInfo?.Communications)
? adjParty.ContactInfo.Communications
: [adjParty.ContactInfo?.Communications || {}];
return {
agt_ct_fn: adjParty.PersonInfo?.PersonName?.FirstName || null,
agt_ct_ln: adjParty.PersonInfo?.PersonName?.LastName || null,
agt_ct_ph: adjComms.find((c) => c.CommQualifier === "CP")?.CommPhone || null,
agt_ea: adjComms.find((c) => c.CommQualifier === "EM")?.CommEmail || null
};
};
// const extractAdjusterData = (rq) => {
// const adjParty = rq.AdminInfo?.Adjuster?.Party || {};
// const adjComms = Array.isArray(adjParty.ContactInfo?.Communications)
// ? adjParty.ContactInfo.Communications
// : [adjParty.ContactInfo?.Communications || {}];
//
// return {
// //TODO: I dont think we display agt_ct_* fields in app. Have they typically been sending data here?
// agt_ct_fn: adjParty.PersonInfo?.PersonName?.FirstName || null,
// agt_ct_ln: adjParty.PersonInfo?.PersonName?.LastName || null,
// agt_ct_ph: adjComms.find((c) => c.CommQualifier === "CP")?.CommPhone || null,
// agt_ea: adjComms.find((c) => c.CommQualifier === "EM")?.CommEmail || null
// };
// };
/**
* Extracts repair facility data from the XML request.
* @param {object} rq - The VehicleDamageEstimateAddRq object.
* @returns {object} Repair facility data.
*/
const extractRepairFacilityData = (rq) => {
const rfParty = rq.AdminInfo?.RepairFacility?.Party || {};
const rfComms = Array.isArray(rfParty.ContactInfo?.Communications)
? rfParty.ContactInfo.Communications
: [rfParty.ContactInfo?.Communications || {}];
return {
servicing_dealer: rfParty.OrgInfo?.CompanyName || null,
servicing_dealer_contact:
rfComms.find((c) => c.CommQualifier === "WP" || c.CommQualifier === "FX")?.CommPhone || null
};
};
// const extractRepairFacilityData = (rq) => {
// const rfParty = rq.AdminInfo?.RepairFacility?.Party || {};
// const rfComms = Array.isArray(rfParty.ContactInfo?.Communications)
// ? rfParty.ContactInfo.Communications
// : [rfParty.ContactInfo?.Communications || {}];
//
// return {
// servicing_dealer: rfParty.OrgInfo?.CompanyName || null,
// // TODO: The servicing dealer fields are a relic from synergy for a few folks
// // TODO: I suspect RF data could be ignored since they are the RF.
// servicing_dealer_contact:
// rfComms.find((c) => c.CommQualifier === "WP" || c.CommQualifier === "FX")?.CommPhone || null
// };
// };
/**
* Extracts loss information from the XML request.
@@ -202,10 +207,12 @@ const extractLossInfo = (rq) => {
loss_date: loss.LossDateTime || null,
loss_type: custom.LossTypeCode || null,
loss_desc: custom.LossTypeDesc || null
// primary_poi: loss.PrimaryPOI?.POICode || null,
// secondary_poi: loss.SecondaryPOI?.POICode || null,
// area_of_impact: {
// impact_1: loss.PrimaryPOI?.POICode || null,
// imact_2 :loss.SecondaryPOI?.POICode || null,
// },
// tlosind: rq.ClaimInfo?.LossInfo?.TotalLossInd || null,
// damage_memo: loss.DamageMemo || null, //(maybe ins_memo)
// total_loss_ind: rq.ClaimInfo?.LossInfo?.TotalLossInd || null // New
};
};
@@ -287,8 +294,10 @@ const extractVehicleData = (rq, shopId) => {
v_color: exterior.Color?.ColorName || null,
v_bstyle: desc.BodyStyle || null,
v_engine: desc.EngineDesc || null,
// TODO Need to confirm with exact data, but this is typically a list of options. Not used AFAIK.
v_options: desc.SubModelDesc || null,
v_type: desc.FuelType || null,
// TODO there is a separate driveable flag on the job.
v_cond: rq.VehicleInfo?.Condition?.DrivableInd,
v_trimcode: desc.TrimCode || null,
v_tone: exterior.Tone || null,
@@ -341,6 +350,7 @@ const extractJobLines = (rq) => {
line.ManualLineInd === true ||
line.ManualLineInd === 1 ||
line.ManualLineInd === "1" ||
// TODO: manual line tracks manual in IO or not, this woudl presumably always be false
(typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y");
} else {
lineOut.manual_line = null;
@@ -354,7 +364,9 @@ const extractJobLines = (rq) => {
const price = parseFloat(partInfo.PartPrice || partInfo.ListPrice || 0);
lineOut.part_type = partInfo.PartType || null ? String(partInfo.PartType).toUpperCase() : null;
lineOut.part_qty = parseFloat(partInfo.Quantity || 0) || 1;
//TODO: if aftermarket part, we have alt_part_no to capture.
lineOut.oem_partno = partInfo.OEMPartNum || partInfo.PartNum || null;
//TODO: the Db and act price often are different. These should map back to their EMS equivalents.
lineOut.db_price = isNaN(price) ? 0 : price;
lineOut.act_price = isNaN(price) ? 0 : price;
@@ -371,7 +383,10 @@ const extractJobLines = (rq) => {
partInfo.TaxableInd === "1" ||
(typeof partInfo.TaxableInd === "string" && partInfo.TaxableInd.toUpperCase() === "Y");
}
} else if (hasSublet) {
}
//TODO: Some nuance here. Usually a part and sublet amount shouldnt be on the same line, but they theoretically
// could.May require additional discussion.
else if (hasSublet) {
const amt = parseFloat(subletInfo.SubletAmount || 0);
lineOut.part_type = "PAS"; // Sublet as parts-as-service
lineOut.part_qty = 1;
@@ -388,10 +403,14 @@ const extractJobLines = (rq) => {
if (hasLabor) {
lineOut.mod_lbr_ty = laborInfo.LaborType || null;
lineOut.mod_lb_hrs = isNaN(hrs) ? 0 : hrs;
//TODO: can add lbr_op_desc according to mapping available in new partner.
lineOut.lbr_op = laborInfo.LaborOperation || null;
lineOut.lbr_amt = isNaN(amt) ? 0 : amt;
}
//TODO: what's the BMS logic for this? Body and refinish operations can often happen to the same part,
// but most systems output a second line for the refinish labor.
//TODO: 2nd line may include a duplicate of the part price, but that can be removed. This is the case for CCC.
// Refinish labor (if present) recorded on the same line using secondary labor fields
const rHrs = parseFloat(refinishInfo.LaborHours || 0);
const rAmt = parseFloat(refinishInfo.LaborAmt || 0);
@@ -402,9 +421,9 @@ const extractJobLines = (rq) => {
!isNaN(rAmt) ||
!!refinishInfo.LaborOperation);
if (hasRefinish) {
lineOut.lbr_typ_j = refinishInfo.LaborType || "LAR";
lineOut.lbr_hrs_j = isNaN(rHrs) ? 0 : rHrs;
lineOut.lbr_op_j = refinishInfo.LaborOperation || null;
lineOut.lbr_typ_j = refinishInfo.LaborType || "LAR"; //TODO: _j fields indicate judgement, and are bool type.
lineOut.lbr_hrs_j = isNaN(rHrs) ? 0 : rHrs; //TODO: _j fields indicate judgement, and are bool type.
lineOut.lbr_op_j = refinishInfo.LaborOperation || null; //TODO: _j fields indicate judgement, and are bool type.
// Aggregate refinish labor amount into the total labor amount for the line
if (!isNaN(rAmt)) {
lineOut.lbr_amt = (Number.isFinite(lineOut.lbr_amt) ? lineOut.lbr_amt : 0) + rAmt;
@@ -420,26 +439,26 @@ const extractJobLines = (rq) => {
};
// Helper to extract a GRAND TOTAL amount from RepairTotalsInfo
const extractGrandTotal = (rq) => {
const rti = rq.RepairTotalsInfo;
const groups = Array.isArray(rti) ? rti : rti ? [rti] : [];
for (const grp of groups) {
const sums = Array.isArray(grp.SummaryTotalsInfo)
? grp.SummaryTotalsInfo
: grp.SummaryTotalsInfo
? [grp.SummaryTotalsInfo]
: [];
for (const s of sums) {
const type = (s.TotalType || "").toString().toUpperCase();
const desc = (s.TotalTypeDesc || "").toString().toUpperCase();
if (type.includes("GRAND") || type === "TOTAL" || desc.includes("GRAND")) {
const amt = parseFloat(s.TotalAmt ?? "NaN");
if (!isNaN(amt)) return amt;
}
}
}
return null;
};
// const extractGrandTotal = (rq) => {
// const rti = rq.RepairTotalsInfo;
// const groups = Array.isArray(rti) ? rti : rti ? [rti] : [];
// for (const grp of groups) {
// const sums = Array.isArray(grp.SummaryTotalsInfo)
// ? grp.SummaryTotalsInfo
// : grp.SummaryTotalsInfo
// ? [grp.SummaryTotalsInfo]
// : [];
// for (const s of sums) {
// const type = (s.TotalType || "").toString().toUpperCase();
// const desc = (s.TotalTypeDesc || "").toString().toUpperCase();
// if (type.includes("GRAND") || type === "TOTAL" || desc.includes("GRAND")) {
// const amt = parseFloat(s.TotalAmt ?? "NaN");
// if (!isNaN(amt)) return amt;
// }
// }
// }
// return null;
// };
/**
* Inserts an owner and returns the owner ID.
@@ -458,24 +477,26 @@ const insertOwner = async (ownerInput, logger) => {
};
// Fallback: compute a naive total from joblines (parts + sublet + labor amounts)
const computeLinesTotal = (joblines = []) => {
let parts = 0;
let labor = 0;
for (const jl of joblines) {
if (jl?.part_type) {
const qty = Number.isFinite(jl.part_qty) ? jl.part_qty : 1;
const price = Number.isFinite(jl.act_price) ? jl.act_price : 0;
parts += price * (qty || 1);
} else if (!jl.part_type && Number.isFinite(jl.act_price)) {
parts += jl.act_price;
}
if (Number.isFinite(jl.lbr_amt)) {
labor += jl.lbr_amt;
}
}
const total = parts + labor;
return Number.isFinite(total) && total > 0 ? total : 0;
};
// const computeLinesTotal = (joblines = []) => {
// let parts = 0;
// let labor = 0;
// for (const jl of joblines) {
// if (jl?.part_type) {
// const qty = Number.isFinite(jl.part_qty) ? jl.part_qty : 1;
// const price = Number.isFinite(jl.act_price) ? jl.act_price : 0;
// parts += price * (qty || 1);
// } else if (!jl.part_type && Number.isFinite(jl.act_price)) {
// parts += jl.act_price;
// }
// if (Number.isFinite(jl.lbr_amt)) {
// labor += jl.lbr_amt;
// }
// }
// const total = parts + labor;
//
// //TODO: clm_total is the 100% full amount of the repair including deductible, betterment and taxes. Typically provided by the source system.
// return Number.isFinite(total) && total > 0 ? total : 0;
// };
/**
* Handles the VehicleDamageEstimateAddRq XML request from parts management.
@@ -512,9 +533,9 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
scheduled_in,
scheduled_completion,
clm_no,
status,
policy_no,
ded_amt
// status,
} = extractJobData(rq);
if (!shopId) {
@@ -522,22 +543,22 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
}
// Get default status
const defaultStatus = await getDefaultOrderStatus(shopId, logger);
const defaultStatus = await getDefaultJobStatus(shopId, logger);
// Extract additional data
const parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo);
const ownerData = extractOwnerData(rq, shopId);
const estimatorData = extractEstimatorData(rq);
const adjusterData = extractAdjusterData(rq);
const repairFacilityData = extractRepairFacilityData(rq);
// const adjusterData = extractAdjusterData(rq);
// const repairFacilityData = extractRepairFacilityData(rq);
const vehicleData = extractVehicleData(rq, shopId);
const lossInfo = extractLossInfo(rq);
const joblinesData = extractJobLines(rq);
const insuranceData = extractInsuranceData(rq);
// Derive clm_total: prefer RepairTotalsInfo SummaryTotals GRAND TOTAL; else sum from lines
const grandTotal = extractGrandTotal(rq);
const computedTotal = grandTotal ?? computeLinesTotal(joblinesData);
// const grandTotal = extractGrandTotal(rq);
// const computedTotal = grandTotal ?? computeLinesTotal(joblinesData);
// Find or create relationships
const ownerid = await insertOwner(ownerData, logger);
@@ -556,8 +577,8 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
class: classType,
parts_tax_rates,
clm_no,
status: status || defaultStatus,
clm_total: computedTotal || null,
status: defaultStatus,
clm_total: 0, // computedTotal || null,
policy_no,
ded_amt,
comment,
@@ -572,8 +593,8 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
...lossInfo,
...ownerData,
...estimatorData,
...adjusterData,
...repairFacilityData,
// ...adjusterData,
// ...repairFacilityData,
// Inline vehicle data
v_vin: vehicleData.v_vin,
v_model_yr: vehicleData.v_model_yr,

View File

@@ -6,22 +6,23 @@ const { parseXml, normalizeXmlObject } = require("../partsManagementUtils");
const { extractPartsTaxRates } = require("./lib/extractPartsTaxRates");
const {
GET_JOB_BY_CLAIM,
GET_JOB_BY_ID,
UPDATE_JOB_BY_ID,
SOFT_DELETE_JOBLINES_BY_IDS,
INSERT_JOBLINES
INSERT_JOBLINES,
GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ
} = require("../partsManagement.queries");
/**
* Finds a job by shop ID and claim number.
* @param shopId
* @param claimNum
* @param jobId
* @param logger
* @returns {Promise<*|null>}
*/
const findJob = async (shopId, claimNum, logger) => {
const findJob = async (shopId, jobId, logger) => {
try {
const { jobs } = await client.request(GET_JOB_BY_CLAIM, { shopid: shopId, clm_no: claimNum });
const { jobs } = await client.request(GET_JOB_BY_ID, { shopid: shopId, jobid: jobId });
return jobs?.[0] || null;
} catch (err) {
logger.log("parts-job-lookup-failed", "error", null, null, { error: err });
@@ -37,13 +38,15 @@ const findJob = async (shopId, claimNum, logger) => {
const extractUpdatedJobData = (rq) => {
const doc = rq.DocumentInfo || {};
const claim = rq.ClaimInfo || {};
//TODO: In the full BMS world, much more can change, this will need to be expanded
// before it can be considered an generic BMS importer, currently it is bespoke to webest
const policyNo = claim.PolicyInfo?.PolicyInfo?.PolicyNum || claim.PolicyInfo?.PolicyNum || null;
const out = {
comment: doc.Comment || null,
clm_no: claim.ClaimNum || null,
status: claim.ClaimStatus || null,
// TODO: Commented out so they do not blow over with 'Auth Cust'
// status: claim.ClaimStatus || null,
policy_no: policyNo
};
@@ -60,8 +63,9 @@ const extractUpdatedJobData = (rq) => {
* - Keep part and labor on the same jobline
* - Aggregate RefinishLabor into secondary labor fields and add its amount to lbr_amt
* - SUBLET-only lines become PAS part_type with act_price = SubletAmount
* Accepts currentJobLineNotes map for notes merging.
*/
const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
const extractUpdatedJobLines = (addsChgs = {}, jobId, currentJobLineNotes = {}) => {
const linesIn = Array.isArray(addsChgs.DamageLineInfo) ? addsChgs.DamageLineInfo : [addsChgs.DamageLineInfo || {}];
const coerceManual = (val) =>
@@ -83,12 +87,33 @@ const extractUpdatedJobLines = (addsChgs = {}, jobId) => {
unq_seq: parseInt(line.UniqueSequenceNum || 0, 10),
status: line.LineStatusCode || null,
line_desc: line.LineDesc || null,
notes: line.LineMemo || null,
// notes will be set below
manual_line: line.ManualLineInd !== undefined ? coerceManual(line.ManualLineInd) : null
};
const lineOut = { ...base };
// --- Notes merge logic ---
const unqSeq = lineOut.unq_seq;
const currentNotes = currentJobLineNotes?.[unqSeq] || null;
const newNotes = line.LineMemo || null;
if (newNotes && currentNotes) {
if (currentNotes === newNotes) {
lineOut.notes = currentNotes;
} else if (currentNotes.includes(newNotes)) {
lineOut.notes = currentNotes;
} else {
lineOut.notes = `${currentNotes} | ${newNotes}`;
}
} else if (newNotes) {
lineOut.notes = newNotes;
} else if (currentNotes) {
lineOut.notes = currentNotes;
} else {
lineOut.notes = null;
}
// --- End notes merge logic ---
const hasPart = Object.keys(partInfo).length > 0;
const hasSublet = Object.keys(subletInfo).length > 0;
@@ -191,28 +216,45 @@ const partsManagementVehicleDamageEstimateChgRq = async (req, res) => {
if (!rq) return res.status(400).send("Missing <VehicleDamageEstimateChgRq>");
const shopId = rq.ShopID;
const claimNum = rq.ClaimInfo?.ClaimNum;
const jobId = rq.JobID;
if (!shopId || !claimNum) return res.status(400).send("Missing ShopID or ClaimNum");
if (!shopId || !jobId) return res.status(400).send("Missing ShopID or JobID");
const job = await findJob(shopId, jobId, logger);
const job = await findJob(shopId, claimNum, logger);
if (!job) return res.status(404).send("Job not found");
// --- Get updated lines and their unq_seq ---
const linesIn = Array.isArray(rq.AddsChgs?.DamageLineInfo)
? rq.AddsChgs.DamageLineInfo
: [rq.AddsChgs?.DamageLineInfo || {}];
const updatedSeqs = Array.from(
new Set((linesIn || []).map((l) => parseInt(l?.UniqueSequenceNum || 0, 10)).filter((v) => Number.isInteger(v)))
);
let currentJobLineNotes = {};
if (updatedSeqs.length > 0) {
const resp = await client.request(GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ, { jobid: job.id, unqSeqs: updatedSeqs });
if (resp?.joblines) {
for (const jl of resp.joblines) {
currentJobLineNotes[jl.unq_seq] = jl.notes;
}
}
}
// --- End fetch current notes ---
const updatedJobData = extractUpdatedJobData(rq);
const updatedLines = extractUpdatedJobLines(rq.AddsChgs, job.id);
const updatedLines = extractUpdatedJobLines(rq.AddsChgs, job.id, currentJobLineNotes);
const deletedLineIds = extractDeletions(rq.Deletions);
await client.request(UPDATE_JOB_BY_ID, { id: job.id, job: updatedJobData });
// Build a set of unq_seq that will be updated (replaced). We delete them first to avoid duplicates.
const updatedSeqs = Array.from(
new Set((updatedLines || []).map((l) => l?.unq_seq).filter((v) => Number.isInteger(v)))
);
//TODO: for changed lines, are they deleted and then reinserted?
//TODO: Updated lines should get an upsert to update things like desc, price, etc.
if (deletedLineIds?.length || updatedSeqs?.length) {
const allToDelete = Array.from(new Set([...(deletedLineIds || []), ...(updatedSeqs || [])]));
if (allToDelete.length) {
await client.request(SOFT_DELETE_JOBLINES_BY_IDS, { jobid: job.id, unqSeqs: allToDelete });
//TODO: appears to soft delete updated lines as well.
}
}

View File

@@ -2,7 +2,7 @@
const GET_BODYSHOP_STATUS = `
query GetBodyshopStatus($id: uuid!) {
bodyshops_by_pk(id: $id) {
md_order_statuses
md_ro_statuses
}
}
`;
@@ -44,60 +44,22 @@ const GET_JOB_BY_CLAIM = `
}
`;
const UPDATE_JOB_BY_ID = `
mutation UpdateJobById($id: uuid!, $job: jobs_set_input!) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: $job) {
const GET_JOB_BY_ID = `
query GetJobByID($shopid: uuid!, $jobid: uuid!) {
jobs(
where: { shopid: { _eq: $shopid }, id: { _eq: $jobid } }
order_by: { created_at: desc }
limit: 1
) {
id
}
}
`;
const UPSERT_JOBLINES = `
mutation UpsertJoblines($joblines: [joblines_insert_input!]!) {
insert_joblines(
objects: $joblines
on_conflict: {
constraint: joblines_pkey
update_columns: [
jobid
status
line_desc
part_type
part_qty
oem_partno
db_price
act_price
mod_lbr_ty
mod_lb_hrs
lbr_op
lbr_amt
notes
manual_line
]
}
) {
affected_rows
}
}
`;
const DELETE_JOBLINES_BY_JOBID = `
mutation DeleteJoblinesByJobId($jobid: uuid!) {
delete_joblines(where: { jobid: { _eq: $jobid } }) {
affected_rows
}
}
`;
const DELETE_JOBLINES_BY_IDS = `
mutation DeleteJoblinesByIds($jobid: uuid!, $unqSeqs: [Int!]!) {
delete_joblines(
where: {
jobid: { _eq: $jobid },
unq_seq: { _in: $unqSeqs }
}
) {
affected_rows
const UPDATE_JOB_BY_ID = `
mutation UpdateJobById($id: uuid!, $job: jobs_set_input!) {
update_jobs_by_pk(pk_columns: { id: $id }, _set: $job) {
id
}
}
`;
@@ -245,6 +207,45 @@ const DELETE_AUDIT_TRAIL_BY_SHOP = `
}
`;
const GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ = `
query GetJoblinesNotesByJobIdUnqSeq($jobid: uuid!, $unqSeqs: [Int!]!) {
joblines(where: { jobid: { _eq: $jobid }, unq_seq: { _in: $unqSeqs }, removed: { _neq: true } }) {
unq_seq
notes
}
}
`;
// Clear task links to parts orders for all jobs in a shop to avoid FK violations when deleting parts orders
const CLEAR_TASKS_PARTSORDER_LINKS_BY_JOBIDS = `
mutation ClearTasksPartsOrderLinks($jobIds: [uuid!]!) {
update_tasks(
where: { parts_order: { jobid: { _in: $jobIds } } },
_set: { partsorderid: null }
) {
affected_rows
}
}
`;
// Delete parts order lines where the parent order belongs to any of the provided job IDs
const DELETE_PARTS_ORDER_LINES_BY_JOB_IDS = `
mutation DeletePartsOrderLinesByJobIds($jobIds: [uuid!]!) {
delete_parts_order_lines(where: { parts_order: { jobid: { _in: $jobIds } } }) {
affected_rows
}
}
`;
// Delete parts orders for the given job IDs
const DELETE_PARTS_ORDERS_BY_JOB_IDS = `
mutation DeletePartsOrdersByJobIds($jobIds: [uuid!]!) {
delete_parts_orders(where: { jobid: { _in: $jobIds } }) {
affected_rows
}
}
`;
module.exports = {
GET_BODYSHOP_STATUS,
GET_VEHICLE_BY_SHOP_VIN,
@@ -252,9 +253,6 @@ module.exports = {
INSERT_JOB_WITH_LINES,
GET_JOB_BY_CLAIM,
UPDATE_JOB_BY_ID,
UPSERT_JOBLINES,
DELETE_JOBLINES_BY_JOBID,
DELETE_JOBLINES_BY_IDS,
SOFT_DELETE_JOBLINES_BY_IDS,
INSERT_JOBLINES,
CHECK_EXTERNAL_SHOP_ID,
@@ -271,5 +269,11 @@ module.exports = {
GET_JOBS_BY_SHOP,
DELETE_JOBLINES_BY_JOB_IDS,
DELETE_JOBS_BY_IDS,
DELETE_AUDIT_TRAIL_BY_SHOP
DELETE_AUDIT_TRAIL_BY_SHOP,
GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ,
GET_JOB_BY_ID,
// newly added exports
CLEAR_TASKS_PARTSORDER_LINKS_BY_JOBIDS,
DELETE_PARTS_ORDER_LINES_BY_JOB_IDS,
DELETE_PARTS_ORDERS_BY_JOB_IDS
};

View File

@@ -44,13 +44,34 @@ const generateSignedUploadUrls = async (req, res) => {
for (const filename of filenames) {
const key = filename;
const client = new S3Client({ region: InstanceRegion() });
const command = new PutObjectCommand({
// Check if filename indicates PDF and set content type accordingly
const isPdf = filename.toLowerCase().endsWith('.pdf');
const commandParams = {
Bucket: imgproxyDestinationBucket,
Key: key,
StorageClass: "INTELLIGENT_TIERING"
};
if (isPdf) {
commandParams.ContentType = "application/pdf";
}
const command = new PutObjectCommand(commandParams);
// For PDFs, we need to add conditions to the presigned URL to enforce content type
const presignedUrlOptions = { expiresIn: 360 };
if (isPdf) {
presignedUrlOptions.signableHeaders = new Set(['content-type']);
}
const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions);
signedUrls.push({
filename,
presignedUrl,
key,
...(isPdf && { contentType: "application/pdf" })
});
const presignedUrl = await getSignedUrl(client, command, { expiresIn: 360 });
signedUrls.push({ filename, presignedUrl, key });
}
logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls });

View File

@@ -73,37 +73,23 @@ const processCanvasRequest = async (req, res) => {
// Default width and height
const width = isNumber(w) && w > 0 ? w : 500;
const height = isNumber(h) && h > 0 ? h : 275;
const configuration = getChartConfiguration(keys, values, override);
let canvas = null;
let ctx = null;
let chart = null;
let chartImage = null;
try {
// Create the canvas
canvas = new Canvas(width, height);
ctx = canvas.getContext("2d");
const canvas = new Canvas(width, height);
const ctx = canvas.getContext("2d");
// Render the chart
chart = new Chart(ctx, configuration);
// Generate and send the image
chartImage = (await canvas.toBuffer("image/png")).toString("base64");
const chartImage = (await canvas.toBuffer("image/png")).toString("base64");
res.status(200).send(`data:image/png;base64,${chartImage}`);
} catch (error) {
// Log the error and send the response
logger.log("canvas-error", "error", "jsr", null, { error: error.message });
res.status(500).send("Failed to generate canvas.");
res.status(500).send("Error generating canvas");
} finally {
// Cleanup resources
if (chart) {
chart.destroy();
}
ctx = null;
canvas = null;
chartImage = null;
chart?.destroy();
}
};
@@ -118,15 +104,18 @@ const enqueueRequest = (req, res) => {
};
const processNextInQueue = async () => {
while (requestQueue.length > 0) {
const { req, res } = requestQueue.shift();
try {
await processCanvasRequest(req, res);
} catch (err) {
console.error("canvas-queue-error", "error", "jsr", null, { error: err.message });
try {
while (requestQueue.length > 0) {
const { req, res } = requestQueue.shift();
try {
await processCanvasRequest(req, res);
} catch (err) {
console.error("canvas-queue-error", "error", "jsr", null, { error: err.message });
}
}
} finally {
isProcessing = false;
}
isProcessing = false;
};
exports.canvastest = function (req, res) {
@@ -134,7 +123,10 @@ exports.canvastest = function (req, res) {
};
exports.canvas = async (req, res) => {
if (isProcessing || !enqueueRequest(req, res)) return;
isProcessing = true;
processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
if (!enqueueRequest(req, res)) return;
if (!isProcessing) {
isProcessing = true;
processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
}
};

View File

@@ -38,7 +38,7 @@ if (typeof PARTS_MANAGEMENT_INTEGRATION_SECRET === "string" && PARTS_MANAGEMENT_
* Route to handle Vehicle Damage Estimate Change Request
*/
router.post(
"/parts-management/VehicleDamageEstimateChqRq",
"/parts-management/VehicleDamageEstimateChgRq",
express.raw({ type: "application/xml", limit: XML_BODY_LIMIT }), // Parse XML body
partsManagementIntegrationMiddleware,
partsManagementVehicleDamageEstimateChqRq