Merged in release/2025-09-12 (pull request #2536)

Release/2025 09 12
This commit is contained in:
Dave Richer
2025-09-03 18:54:16 +00:00
23 changed files with 863 additions and 2664 deletions

View File

@@ -67,11 +67,14 @@ export const uploadToS3 = async (
} }
//Key should be same as we provided to maintain backwards compatibility. //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 = { const options = {
onUploadProgress: (e) => { onUploadProgress: (e) => {
if (onProgress) onProgress({ percent: (e.loaded / e.total) * 100 }); 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 CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobLineConvertToLabor from "../job-line-convert-to-labor/job-line-convert-to-labor.component"; import JobLineConvertToLabor from "../job-line-convert-to-labor/job-line-convert-to-labor.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
technician: selectTechnician technician: selectTechnician,
isPartsEntry: selectIsPartsEntry
}); });
const mapDispatchToProps = () => ({}); const mapDispatchToProps = () => ({});
export function JobLinesPartPriceChange({ job, line, refetch, technician }) { export function JobLinesPartPriceChange({ job, line, refetch, technician, isPartsEntry }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [updatePartPrice] = useMutation(UPDATE_LINE_PPC); const [updatePartPrice] = useMutation(UPDATE_LINE_PPC);
const notification = useNotification(); const notification = useNotification();
@@ -64,6 +66,7 @@ export function JobLinesPartPriceChange({ job, line, refetch, technician }) {
const popcontent = const popcontent =
!technician && !technician &&
!isPartsEntry &&
InstanceRenderManager({ InstanceRenderManager({
imex: null, imex: null,
rome: ( rome: (

View File

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

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" }}> <DataLabel label={t("jobs.fields.comment")} valueStyle={{ overflow: "hidden", textOverflow: "ellipsis" }}>
<ProductionListColumnComment record={job} /> <ProductionListColumnComment record={job} />
</DataLabel> </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.clm_no")}>{job.clm_no}</DataLabel>
<DataLabel label={t("jobs.fields.ponumber")} hideIfNull> <DataLabel label={t("jobs.fields.ponumber")} hideIfNull>
{job.po_number} {job.po_number}
</DataLabel> </DataLabel>
<DataLabel label={t("jobs.fields.repairtotal")}> {!isPartsEntry && (
<CurrencyFormatter>{job.clm_total}</CurrencyFormatter> <DataLabel label={t("jobs.fields.repairtotal")}>
<span style={{ margin: "0rem .5rem" }}>/</span> <CurrencyFormatter>{job.clm_total}</CurrencyFormatter>
<CurrencyFormatter>{job.owner_owing}</CurrencyFormatter> <span style={{ margin: "0rem .5rem" }}>/</span>
</DataLabel> <CurrencyFormatter>{job.owner_owing}</CurrencyFormatter>
</DataLabel>
)}
{!isPartsEntry && ( {!isPartsEntry && (
<> <>
<DataLabel label={t("jobs.fields.alt_transport")}> <DataLabel label={t("jobs.fields.alt_transport")}>

View File

@@ -40,27 +40,26 @@ export function ScheduleCalendarWrapperComponent({
const currentView = search.view || defaultView || "week"; const currentView = search.view || defaultView || "week";
const handleEventPropStyles = (event) => { const handleEventPropStyles = (event) => {
const hasColor = Boolean(event?.color?.hex || event?.color); const { color, block, arrived } = event ?? {};
const hasColor = Boolean(color?.hex || color);
const useBg = currentView !== "agenda"; const useBg = currentView !== "agenda";
// Prioritize explicit blocked-day background to ensure red in all themes // Prioritize explicit blocked-day background to ensure red in all themes
let bg; let bg;
if (useBg) { if (useBg) {
if (event?.block) { bg = block
bg = "var(--event-block-bg)"; ? "var(--event-block-bg)"
} else if (hasColor) { : arrived
bg = event?.color?.hex ?? event?.color; ? "var(--event-arrived-bg)"
} else { : (color?.hex ?? color ?? "var(--event-bg-fallback)");
bg = "var(--event-bg-fallback)";
}
} }
const usedFallback = !hasColor && !event?.block; // only mark as fallback when not blocked const usedFallback = !hasColor && !block && !arrived; // only mark as fallback when not blocked or arrived
const classes = [ const classes = [
"imex-event", "imex-event",
event.arrived && "imex-event-arrived", arrived && "imex-event-arrived",
event.block && "imex-event-block", block && "imex-event-block",
usedFallback && "imex-event-fallback" usedFallback && "imex-event-fallback"
] ]
.filter(Boolean) .filter(Boolean)

View File

@@ -23,13 +23,24 @@ export default function ShopInfoContainer() {
}); });
const notification = useNotification(); const notification = useNotification();
const combinedFeatureConfig = { const combineFeatureConfigs = (...configs) =>
...FEATURE_CONFIGS.general, (configs || [])
...FEATURE_CONFIGS.responsibilitycenters .filter(Boolean)
}; .flatMap((cfg) => Object.entries(cfg))
.reduce((acc, [featureName, fieldPaths]) => {
if (!Array.isArray(fieldPaths)) return acc;
acc[featureName] = [...(acc[featureName] ?? []), ...fieldPaths];
return acc;
}, {});
const combinedFeatureConfig = combineFeatureConfigs(FEATURE_CONFIGS.general, FEATURE_CONFIGS.responsibilitycenters);
// Use form data preservation for all shop-info features // Use form data preservation for all shop-info features
const { createSubmissionHandler } = useFormDataPreservation(form, data?.bodyshops[0], combinedFeatureConfig); const { createSubmissionHandler, preserveHiddenFormData } = useFormDataPreservation(
form,
data?.bodyshops[0],
combinedFeatureConfig
);
const handleFinish = createSubmissionHandler((values) => { const handleFinish = createSubmissionHandler((values) => {
setSaveLoading(true); setSaveLoading(true);
@@ -51,8 +62,11 @@ export default function ShopInfoContainer() {
}); });
useEffect(() => { useEffect(() => {
if (data) form.resetFields(); if (!data) return;
}, [form, data]); form.resetFields();
// After reset, re-apply hidden field preservation so values aren't wiped
preserveHiddenFormData();
}, [data, form, preserveHiddenFormData]);
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error" />;
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component"; import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
/** /**
@@ -8,73 +8,57 @@ import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component
* @param {Object} featureConfig - Configuration object defining which features and their associated fields to preserve * @param {Object} featureConfig - Configuration object defining which features and their associated fields to preserve
*/ */
export const useFormDataPreservation = (form, bodyshop, featureConfig) => { export const useFormDataPreservation = (form, bodyshop, featureConfig) => {
const getNestedValue = (obj, path) => { // Safe nested getters/setters using path arrays
return path.reduce((current, key) => current?.[key], obj); const getNestedValue = (obj, path) => path?.reduce((acc, key) => acc?.[key], obj);
};
const setNestedValue = (obj, path, value) => { const setNestedValue = (obj, path, value) => {
const lastKey = path[path.length - 1]; const lastKey = path[path.length - 1];
const parentPath = path.slice(0, -1); const parent = path.slice(0, -1).reduce((curr, key) => {
if (!curr[key] || typeof curr[key] !== "object") curr[key] = {};
const parent = parentPath.reduce((current, key) => { return curr[key];
if (!current[key]) current[key] = {};
return current[key];
}, obj); }, obj);
parent[lastKey] = value; parent[lastKey] = value;
}; };
const preserveHiddenFormData = useCallback(() => { // Paths for features that are NOT accessible
const preservationData = {}; const disabledPaths = useMemo(() => {
let hasDataToPreserve = false; const result = [];
if (!featureConfig) return result;
Object.entries(featureConfig).forEach(([featureName, fieldPaths]) => { Object.entries(featureConfig).forEach(([featureName, fieldPaths]) => {
const hasAccess = HasFeatureAccess({ featureName, bodyshop }); const hasAccess = HasFeatureAccess({ featureName, bodyshop });
if (hasAccess || !Array.isArray(fieldPaths)) return;
fieldPaths.forEach((p) => Array.isArray(p) && p.length && result.push(p));
});
return result;
}, [featureConfig, bodyshop]);
if (!hasAccess) { const preserveHiddenFormData = useCallback(() => {
fieldPaths.forEach((fieldPath) => { const currentValues = form.getFieldsValue();
const currentValues = form.getFieldsValue(); const preservationData = {};
let value = getNestedValue(currentValues, fieldPath); let hasAny = false;
if (value === undefined || value === null) { disabledPaths.forEach((path) => {
value = getNestedValue(bodyshop, fieldPath); let value = getNestedValue(currentValues, path);
} if (value == null) value = getNestedValue(bodyshop, path);
if (value != null) {
if (value !== undefined && value !== null) { setNestedValue(preservationData, path, value);
setNestedValue(preservationData, fieldPath, value); hasAny = true;
hasDataToPreserve = true;
}
});
} }
}); });
if (hasDataToPreserve) { if (hasAny) form.setFieldsValue(preservationData);
form.setFieldsValue(preservationData); }, [form, bodyshop, disabledPaths]);
}
}, [form, featureConfig, bodyshop]);
const getCompleteFormValues = () => { const getCompleteFormValues = () => {
const currentFormValues = form.getFieldsValue(); const currentValues = form.getFieldsValue();
const completeValues = { ...currentFormValues }; const complete = { ...currentValues };
Object.entries(featureConfig).forEach(([featureName, fieldPaths]) => { disabledPaths.forEach((path) => {
const hasAccess = HasFeatureAccess({ featureName, bodyshop }); let value = getNestedValue(currentValues, path);
if (value == null) value = getNestedValue(bodyshop, path);
if (!hasAccess) { if (value != null) setNestedValue(complete, path, value);
fieldPaths.forEach((fieldPath) => {
let value = getNestedValue(currentFormValues, fieldPath);
if (value === undefined || value === null) {
value = getNestedValue(bodyshop, fieldPath);
}
if (value !== undefined && value !== null) {
setNestedValue(completeValues, fieldPath, value);
}
});
}
}); });
return completeValues; return complete;
}; };
const createSubmissionHandler = (originalHandler) => { const createSubmissionHandler = (originalHandler) => {
@@ -103,8 +87,8 @@ export const FEATURE_CONFIGS = {
["md_responsibility_centers", "profits"], ["md_responsibility_centers", "profits"],
["md_responsibility_centers", "defaults"], ["md_responsibility_centers", "defaults"],
["md_responsibility_centers", "dms_defaults"], ["md_responsibility_centers", "dms_defaults"],
["md_responsibility_centers", "taxes", "itemexemptcode"], ["md_responsibility_centers", "taxes"],
["md_responsibility_centers", "taxes", "invoiceexemptcode"], ["md_responsibility_centers", "cieca_pfl"],
["md_responsibility_centers", "ar"], ["md_responsibility_centers", "ar"],
["md_responsibility_centers", "refund"], ["md_responsibility_centers", "refund"],
["md_responsibility_centers", "sales_tax_codes"], ["md_responsibility_centers", "sales_tax_codes"],

View File

@@ -9,7 +9,6 @@ import { connect } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { pageLimit } from "../../utils/config"; import { pageLimit } from "../../utils/config";
import { alphaSort, statusSort } from "../../utils/sorters"; import { alphaSort, statusSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage"; import useLocalStorage from "../../utils/useLocalStorage";
@@ -144,26 +143,6 @@ export function SimplifiedPartsJobsListComponent({
sortOrder: sortcolumn === "clm_no" && sortorder, sortOrder: sortcolumn === "clm_no" && sortorder,
render: (text, record) => `${record.clm_no || ""}${record.po_number ? ` (PO: ${record.po_number})` : ""}` 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"), title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus", dataIndex: "partsstatus",

View File

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

View File

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

View File

@@ -181,7 +181,10 @@ const cache = new InMemoryCache({
const client = new ApolloClient({ const client = new ApolloClient({
link: ApolloLink.from(middlewares), link: ApolloLink.from(middlewares),
cache, cache,
connectToDevTools: import.meta.env.DEV, devtools: {
name: "Imex Client",
enabled: import.meta.env.DEV
},
defaultOptions: { defaultOptions: {
watchQuery: { watchQuery: {
fetchPolicy: "network-only", 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" "job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.873.0", "@aws-sdk/client-cloudwatch-logs": "^3.876.0",
"@aws-sdk/client-elasticache": "^3.872.0", "@aws-sdk/client-elasticache": "^3.876.0",
"@aws-sdk/client-s3": "^3.872.0", "@aws-sdk/client-s3": "^3.876.0",
"@aws-sdk/client-secrets-manager": "^3.872.0", "@aws-sdk/client-secrets-manager": "^3.876.0",
"@aws-sdk/client-ses": "^3.872.0", "@aws-sdk/client-ses": "^3.876.0",
"@aws-sdk/credential-provider-node": "^3.873.0", "@aws-sdk/credential-provider-node": "^3.876.0",
"@aws-sdk/lib-storage": "^3.872.0", "@aws-sdk/lib-storage": "^3.876.0",
"@aws-sdk/s3-request-presigner": "^3.872.0", "@aws-sdk/s3-request-presigner": "^3.876.0",
"@opensearch-project/opensearch": "^2.13.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
@@ -34,14 +34,14 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"axios-curlirize": "^2.0.0", "axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bullmq": "^5.58.0", "bullmq": "^5.58.2",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"cloudinary": "^2.7.0", "cloudinary": "^2.7.0",
"compression": "^1.8.1", "compression": "^1.8.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"crisp-status-reporter": "^1.2.2", "crisp-status-reporter": "^1.2.2",
"dd-trace": "^5.63.3", "dd-trace": "^5.64.0",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"express": "^4.21.1", "express": "^4.21.1",
@@ -63,7 +63,7 @@
"query-string": "7.1.3", "query-string": "7.1.3",
"recursive-diff": "^1.0.9", "recursive-diff": "^1.0.9",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"skia-canvas": "^3.0.3", "skia-canvas": "^3.0.4",
"soap": "^1.3.0", "soap": "^1.3.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5", "socket.io-adapter": "^2.5.5",
@@ -77,8 +77,8 @@
"yazl": "^3.3.1" "yazl": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.34.0",
"eslint": "^9.33.0", "eslint": "^9.34.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^15.15.0", "globals": "^15.15.0",
"mock-require": "^3.0.3", "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 { exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS {
bodyshops{ bodyshops(where: {external_shop_id: {_is_null: true}}){
id id
shopname shopname
imexshopid imexshopid

View File

@@ -775,6 +775,7 @@
"csi:page": 11, "csi:page": 11,
"jobs:void": 80, "jobs:void": 80,
"shop:rbac": 99, "shop:rbac": 99,
"shop:responsibilitycenter": 99,
"bills:list": 11, "bills:list": 11,
"bills:view": 11, "bills:view": 11,
"csi:export": 11, "csi:export": 11,

View File

@@ -23,10 +23,16 @@ const KNOWN_PART_RATE_TYPES = [
* @returns {object} The parts tax rates object. * @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 extractPartsTaxRates = (profile = {}) => {
const rateInfos = Array.isArray(profile.RateInfo) ? profile.RateInfo : [profile.RateInfo || {}]; const rateInfos = Array.isArray(profile.RateInfo) ? profile.RateInfo : [profile.RateInfo || {}];
const partsTaxRates = {}; 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) { for (const r of rateInfos) {
const rateTypeRaw = const rateTypeRaw =
typeof r?.RateType === "string" 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

@@ -1,6 +1,5 @@
const admin = require("firebase-admin"); const admin = require("firebase-admin");
const client = require("../../../graphql-client/graphql-client").client; const client = require("../../../graphql-client/graphql-client").client;
const { const {
DELETE_SHOP, DELETE_SHOP,
DELETE_VENDORS_BY_SHOP, DELETE_VENDORS_BY_SHOP,
@@ -18,143 +17,154 @@ const {
/** /**
* Deletes a Firebase user by UID. * Deletes a Firebase user by UID.
* @param uid * @param {string} uid - The Firebase user ID
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const deleteFirebaseUser = async (uid) => { const deleteFirebaseUser = async (uid) => {
if (!uid) throw new Error("User UID is required");
return admin.auth().deleteUser(uid); return admin.auth().deleteUser(uid);
}; };
/** /**
* Deletes all vendors associated with a shop. * Deletes all vendors associated with a shop.
* @param shopId * @param {string} shopId - The shop ID
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const deleteVendorsByShop = async (shopId) => { const deleteVendorsByShop = async (shopId) => {
if (!shopId) throw new Error("Shop ID is required");
await client.request(DELETE_VENDORS_BY_SHOP, { shopId }); await client.request(DELETE_VENDORS_BY_SHOP, { shopId });
}; };
/** /**
* Deletes a bodyshop from the database. * Deletes a bodyshop from the database.
* @param shopId * @param {string} shopId - The shop ID
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const deleteBodyshop = async (shopId) => { const deleteBodyshop = async (shopId) => {
if (!shopId) throw new Error("Shop ID is required");
await client.request(DELETE_SHOP, { id: shopId }); await client.request(DELETE_SHOP, { id: shopId });
}; };
/** /**
* Fetch job ids for a given shop * Fetch job ids for a given shop
* @param shopId * @param {string} shopId - The shop ID
* @returns {Promise<string[]>} * @returns {Promise<string[]>}
*/ */
const getJobIdsForShop = async (shopId) => { const getJobIdsForShop = async (shopId) => {
if (!shopId) throw new Error("Shop ID is required");
const resp = await client.request(GET_JOBS_BY_SHOP, { shopId }); const resp = await client.request(GET_JOBS_BY_SHOP, { shopId });
return resp.jobs.map((j) => j.id); return resp.jobs?.map((j) => j.id) || [];
}; };
/** /**
* Delete joblines for the given job ids * Delete joblines for the given job ids
* @param jobIds {string[]} * @param {string[]} jobIds - Array of job IDs
* @returns {Promise<number>} affected rows * @returns {Promise<number>} affected rows
*/ */
const deleteJoblinesForJobs = async (jobIds) => { const deleteJoblinesForJobs = async (jobIds) => {
if (!jobIds.length) return 0; if (!jobIds?.length) return 0;
const resp = await client.request(DELETE_JOBLINES_BY_JOB_IDS, { jobIds }); const resp = await client.request(DELETE_JOBLINES_BY_JOB_IDS, { jobIds });
return resp.delete_joblines.affected_rows; return resp.delete_joblines?.affected_rows || 0;
}; };
/** /**
* Delete jobs for the given job ids * Delete jobs for the given job ids
* @param jobIds {string[]} * @param {string[]} jobIds - Array of job IDs
* @returns {Promise<number>} affected rows * @returns {Promise<number>} affected rows
*/ */
const deleteJobsByIds = async (jobIds) => { const deleteJobsByIds = async (jobIds) => {
if (!jobIds.length) return 0; if (!jobIds?.length) return 0;
const resp = await client.request(DELETE_JOBS_BY_IDS, { jobIds }); const resp = await client.request(DELETE_JOBS_BY_IDS, { jobIds });
return resp.delete_jobs.affected_rows; return resp.delete_jobs?.affected_rows || 0;
}; };
/** /**
* Handles deprovisioning a shop for parts management. * Handles deprovisioning a shop for parts management.
* @param req * @param {Object} req - Express request object
* @param res * @param {Object} res - Express response object
* @returns {Promise<*>} * @returns {Promise<*>}
*/ */
const partsManagementDeprovisioning = async (req, res) => { const partsManagementDeprovisioning = async (req, res) => {
const { logger } = req; const { logger } = req;
const p = req.body; const { shopId } = req.body;
if (process.env.NODE_ENV === "production") { if (process.env.NODE_ENV === "production") {
return res.status(403).json({ error: "Deprovisioning not allowed in production environment." }); return res.status(403).json({ error: "Deprovisioning not allowed in production environment." });
} }
try { try {
if (!p.shopId) { if (!shopId) {
throw { status: 400, message: "shopId is required." }; throw { status: 400, message: "shopId is required." };
} }
// Fetch bodyshop and check external_shop_id // 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: shopId });
const shop = shopResp.bodyshops_by_pk; const shop = shopResp.bodyshops_by_pk;
if (!shop) { if (!shop) {
throw { status: 404, message: `Bodyshop with id ${p.shopId} not found.` }; throw { status: 404, message: `Bodyshop with id ${shopId} not found.` };
} }
if (!shop.external_shop_id) { if (!shop.external_shop_id) {
throw { status: 400, message: "Cannot delete bodyshop without external_shop_id." }; throw { status: 400, message: "Cannot delete bodyshop without external_shop_id." };
} }
logger.log("admin-delete-shop", "debug", null, null, { logger.log("admin-delete-shop", "debug", null, null, {
shopId: p.shopId, shopId,
shopname: shop.shopname, shopname: shop.shopname,
ioadmin: true ioadmin: true
}); });
// Get vendors // Get vendors
const vendorsResp = await client.request(GET_VENDORS, { shopId: p.shopId }); const vendorsResp = await client.request(GET_VENDORS, { shopId });
const deletedVendors = vendorsResp.vendors.map((v) => v.name); const deletedVendors = vendorsResp.vendors?.map((v) => v.name) || [];
// Get associated users // Get associated users
const assocResp = await client.request(GET_ASSOCIATED_USERS, { shopId: p.shopId }); const assocResp = await client.request(GET_ASSOCIATED_USERS, { shopId });
const associatedUsers = assocResp.associations.map((assoc) => ({ const associatedUsers =
authId: assoc.user.authid, assocResp.associations?.map((assoc) => ({
email: assoc.user.email authId: assoc.user?.authid,
})); email: assoc.user?.email
})) || [];
// Delete associations for the shop // 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 });
const associationsDeleted = assocDeleteResp.delete_associations.affected_rows; const associationsDeleted = assocDeleteResp.delete_associations?.affected_rows || 0;
// For each user, check if they have remaining associations; if not, delete user and Firebase account // Delete users with no remaining associations
const deletedUsers = []; const deletedUsers = [];
for (const user of associatedUsers) { for (const user of associatedUsers) {
const countResp = await client.request(GET_USER_ASSOCIATIONS_COUNT, { userEmail: user.email }); if (!user.email || !user.authId) continue;
const assocCount = countResp.associations_aggregate.aggregate.count; try {
if (assocCount === 0) { const countResp = await client.request(GET_USER_ASSOCIATIONS_COUNT, { userEmail: user.email });
await client.request(DELETE_USER, { email: user.email }); const assocCount = countResp.associations_aggregate?.aggregate?.count || 0;
await deleteFirebaseUser(user.authId); if (assocCount === 0) {
deletedUsers.push(user.email); await client.request(DELETE_USER, { email: user.email });
await deleteFirebaseUser(user.authId);
deletedUsers.push(user.email);
}
} catch (userError) {
logger.log("admin-delete-user-error", "warn", null, null, {
email: user.email,
error: userError.message
});
} }
} }
// Get all job ids for this shop, then delete joblines and jobs (joblines first) // Delete jobs and joblines
const jobIds = await getJobIdsForShop(p.shopId); const jobIds = await getJobIdsForShop(shopId);
const joblinesDeleted = await deleteJoblinesForJobs(jobIds); const joblinesDeleted = await deleteJoblinesForJobs(jobIds);
const jobsDeleted = await deleteJobsByIds(jobIds); const jobsDeleted = await deleteJobsByIds(jobIds);
// Delete any audit trail entries tied to this bodyshop to avoid FK violations // Delete audit trail
const auditResp = await client.request(DELETE_AUDIT_TRAIL_BY_SHOP, { shopId: p.shopId }); const auditResp = await client.request(DELETE_AUDIT_TRAIL_BY_SHOP, { shopId });
const auditDeleted = auditResp.delete_audit_trail.affected_rows; const auditDeleted = auditResp.delete_audit_trail?.affected_rows || 0;
// Delete vendors // Delete vendors and shop
await deleteVendorsByShop(p.shopId); await deleteVendorsByShop(shopId);
await deleteBodyshop(shopId);
// Delete shop
await deleteBodyshop(p.shopId);
// Summary log // Summary log
logger.log("admin-delete-shop-summary", "info", null, null, { logger.log("admin-delete-shop-summary", "info", null, null, {
shopId: p.shopId, shopId,
shopname: shop.shopname, shopname: shop.shopname,
associationsDeleted, associationsDeleted,
deletedUsers, deletedUsers,
@@ -165,11 +175,11 @@ const partsManagementDeprovisioning = async (req, res) => {
}); });
return res.status(200).json({ return res.status(200).json({
message: `Bodyshop ${p.shopId} and associated resources deleted successfully.`, message: `Bodyshop ${shopId} and associated resources deleted successfully.`,
deletedShop: { id: p.shopId, name: shop.shopname }, deletedShop: { id: shopId, name: shop.shopname },
deletedAssociationsCount: associationsDeleted, deletedAssociationsCount: associationsDeleted,
deletedUsers: deletedUsers, deletedUsers,
deletedVendors: deletedVendors, deletedVendors,
deletedJoblinesCount: joblinesDeleted, deletedJoblinesCount: joblinesDeleted,
deletedJobsCount: jobsDeleted, deletedJobsCount: jobsDeleted,
deletedAuditTrailCount: auditDeleted deletedAuditTrailCount: auditDeleted
@@ -177,9 +187,8 @@ const partsManagementDeprovisioning = async (req, res) => {
} catch (err) { } catch (err) {
logger.log("admin-delete-shop-error", "error", null, null, { logger.log("admin-delete-shop-error", "error", null, null, {
message: err.message, message: err.message,
detail: err.detail || err detail: err.detail || err.stack || err
}); });
return res.status(err.status || 500).json({ error: err.message || "Internal server error" }); return res.status(err.status || 500).json({ error: err.message || "Internal server error" });
} }
}; };

View File

@@ -123,7 +123,7 @@ const insertUserAssociation = async (uid, email, shopId) => {
authid: uid, authid: uid,
validemail: true, validemail: true,
associations: { 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 partsManagementProvisioning = async (req, res) => {
const { logger } = req; const { logger } = req;
const p = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() }; const body = { ...req.body, userEmail: req.body.userEmail?.toLowerCase() };
try { try {
await ensureEmailNotRegistered(p.userEmail); await ensureEmailNotRegistered(body.userEmail);
requireFields(p, [ requireFields(body, [
"external_shop_id", "external_shop_id",
"shopname", "shopname",
"address1", "address1",
@@ -155,27 +155,27 @@ const partsManagementProvisioning = async (req, res) => {
"phone", "phone",
"userEmail" "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, request: req.body,
ioadmin: true ioadmin: true
}); });
const shopInput = { const shopInput = {
shopname: p.shopname, shopname: body.shopname,
address1: p.address1, address1: body.address1,
address2: p.address2 || null, address2: body.address2 || null,
city: p.city, city: body.city,
state: p.state, state: body.state,
zip_post: p.zip_post, zip_post: body.zip_post,
country: p.country, country: body.country,
email: p.email, email: body.email,
external_shop_id: p.external_shop_id, external_shop_id: body.external_shop_id,
timezone: p.timezone || DefaultNewShop.timezone, timezone: body.timezone || DefaultNewShop.timezone,
phone: p.phone, phone: body.phone,
logo_img_path: { logo_img_path: {
src: p.logoUrl, src: body.logoUrl,
width: "", width: "",
height: "", height: "",
headerMargin: DefaultNewShop.logo_img_path.headerMargin headerMargin: DefaultNewShop.logo_img_path.headerMargin
@@ -200,7 +200,7 @@ const partsManagementProvisioning = async (req, res) => {
appt_alt_transport: DefaultNewShop.appt_alt_transport, appt_alt_transport: DefaultNewShop.appt_alt_transport,
md_jobline_presets: DefaultNewShop.md_jobline_presets, md_jobline_presets: DefaultNewShop.md_jobline_presets,
vendors: { vendors: {
data: p.vendors.map((v) => ({ data: body.vendors.map((v) => ({
name: v.name, name: v.name,
street1: v.street1 || null, street1: v.street1 || null,
street2: v.street2 || null, street2: v.street2 || null,
@@ -221,14 +221,14 @@ const partsManagementProvisioning = async (req, res) => {
}; };
const newShopId = await insertBodyshop(shopInput); const newShopId = await insertBodyshop(shopInput);
const userRecord = await createFirebaseUser(p.userEmail, p.userPassword); const userRecord = await createFirebaseUser(body.userEmail, body.userPassword);
let resetLink = null; 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({ return res.status(200).json({
shop: { id: newShopId, shopname: p.shopname }, shop: { id: newShopId, shopname: body.shopname },
user: { user: {
id: createdUser.id, id: createdUser.id,
email: createdUser.email, email: createdUser.email,
@@ -236,7 +236,7 @@ const partsManagementProvisioning = async (req, res) => {
} }
}); });
} catch (err) { } 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, message: err.message,
detail: err.detail || err detail: err.detail || err
}); });

View File

@@ -14,7 +14,7 @@ const {
} = require("../partsManagement.queries"); } = require("../partsManagement.queries");
// Defaults // Defaults
const FALLBACK_DEFAULT_ORDER_STATUS = "Open"; const FALLBACK_DEFAULT_JOB_STATUS = "Open";
/** /**
* Fetches the default order status for a bodyshop. * Fetches the default order status for a bodyshop.
@@ -22,13 +22,13 @@ const FALLBACK_DEFAULT_ORDER_STATUS = "Open";
* @param {object} logger - The logger instance. * @param {object} logger - The logger instance.
* @returns {Promise<string>} The default status or fallback. * @returns {Promise<string>} The default status or fallback.
*/ */
const getDefaultOrderStatus = async (shopId, logger) => { const getDefaultJobStatus = async (shopId, logger) => {
try { try {
const { bodyshop_by_pk } = await client.request(GET_BODYSHOP_STATUS, { id: shopId }); 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) { } catch (err) {
logger.log("parts-bodyshop-fetch-failed", "warn", shopId, null, { error: 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 { return {
shopId: rq.ShopID || rq.shopId, shopId: rq.ShopID || rq.shopId,
// status: ci.ClaimStatus || null, Proper, setting it default for now
refClaimNum: rq.RefClaimNum, refClaimNum: rq.RefClaimNum,
ciecaid: rq.RqUID || null, ciecaid: rq.RqUID || null,
// Pull Cieca_ttl from ClaimInfo per schema/sample // Pull Cieca_ttl from ClaimInfo per schema/sample
@@ -81,8 +82,6 @@ const extractJobData = (rq) => {
scheduled_in: ev.RepairEvent?.RequestedPickUpDateTime || null, scheduled_in: ev.RepairEvent?.RequestedPickUpDateTime || null,
scheduled_completion: ev.RepairEvent?.TargetCompletionDateTime || null, scheduled_completion: ev.RepairEvent?.TargetCompletionDateTime || null,
clm_no: ci.ClaimNum || null, clm_no: ci.ClaimNum || null,
// status: ci.ClaimStatus || null, Proper, setting it default for now
status: FALLBACK_DEFAULT_ORDER_STATUS,
policy_no: ci.PolicyInfo?.PolicyInfo?.PolicyNum || ci.PolicyInfo?.PolicyNum || null, policy_no: ci.PolicyInfo?.PolicyInfo?.PolicyNum || ci.PolicyInfo?.PolicyNum || null,
ded_amt: parseFloat(ci.PolicyInfo?.CoverageInfo?.Coverage?.DeductibleInfo?.DeductibleAmt || 0) ded_amt: parseFloat(ci.PolicyInfo?.CoverageInfo?.Coverage?.DeductibleInfo?.DeductibleAmt || 0)
}; };
@@ -101,17 +100,19 @@ const extractOwnerData = (rq, shopId) => {
const personName = personInfo.PersonName || {}; const personName = personInfo.PersonName || {};
const address = personInfo.Communications?.Address || {}; 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) const comms = Array.isArray(ownerOrClaimant.ContactInfo?.Communications)
? ownerOrClaimant.ContactInfo.Communications ? ownerOrClaimant.ContactInfo.Communications
: [ownerOrClaimant.ContactInfo?.Communications || {}]; : [ownerOrClaimant.ContactInfo?.Communications || {}];
for (const c of comms) { 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 === "CP") ownr_ph1 = c.CommPhone;
if (c.CommQualifier === "WP") ownr_ph2 = c.CommPhone; if (c.CommQualifier === "WP") ownr_ph2 = c.CommPhone;
if (c.CommQualifier === "EM") ownr_ea = c.CommEmail; 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 { return {
@@ -127,8 +128,8 @@ const extractOwnerData = (rq, shopId) => {
ownr_ctry: address.Country || null, ownr_ctry: address.Country || null,
ownr_ph1, ownr_ph1,
ownr_ph2, ownr_ph2,
ownr_ea, ownr_ea
ownr_alt_ph // ownr_alt_ph
// ownr_id_qualifier: ownerOrClaimant.IDInfo?.IDQualifierCode || null // New // ownr_id_qualifier: ownerOrClaimant.IDInfo?.IDQualifierCode || null // New
// ownr_id_num: ownerOrClaimant.IDInfo?.IDNum || null, // New // ownr_id_num: ownerOrClaimant.IDInfo?.IDNum || null, // New
// ownr_preferred_contact: ownerOrClaimant.PreferredContactMethod || null // New // ownr_preferred_contact: ownerOrClaimant.PreferredContactMethod || null // New
@@ -159,37 +160,40 @@ const extractEstimatorData = (rq) => {
* @param {object} rq - The VehicleDamageEstimateAddRq object. * @param {object} rq - The VehicleDamageEstimateAddRq object.
* @returns {object} Adjuster data. * @returns {object} Adjuster data.
*/ */
const extractAdjusterData = (rq) => { // const extractAdjusterData = (rq) => {
const adjParty = rq.AdminInfo?.Adjuster?.Party || {}; // const adjParty = rq.AdminInfo?.Adjuster?.Party || {};
const adjComms = Array.isArray(adjParty.ContactInfo?.Communications) // const adjComms = Array.isArray(adjParty.ContactInfo?.Communications)
? adjParty.ContactInfo.Communications // ? adjParty.ContactInfo.Communications
: [adjParty.ContactInfo?.Communications || {}]; // : [adjParty.ContactInfo?.Communications || {}];
//
return { // return {
agt_ct_fn: adjParty.PersonInfo?.PersonName?.FirstName || null, // //TODO: I dont think we display agt_ct_* fields in app. Have they typically been sending data here?
agt_ct_ln: adjParty.PersonInfo?.PersonName?.LastName || null, // agt_ct_fn: adjParty.PersonInfo?.PersonName?.FirstName || null,
agt_ct_ph: adjComms.find((c) => c.CommQualifier === "CP")?.CommPhone || null, // agt_ct_ln: adjParty.PersonInfo?.PersonName?.LastName || null,
agt_ea: adjComms.find((c) => c.CommQualifier === "EM")?.CommEmail || 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. * Extracts repair facility data from the XML request.
* @param {object} rq - The VehicleDamageEstimateAddRq object. * @param {object} rq - The VehicleDamageEstimateAddRq object.
* @returns {object} Repair facility data. * @returns {object} Repair facility data.
*/ */
const extractRepairFacilityData = (rq) => { // const extractRepairFacilityData = (rq) => {
const rfParty = rq.AdminInfo?.RepairFacility?.Party || {}; // const rfParty = rq.AdminInfo?.RepairFacility?.Party || {};
const rfComms = Array.isArray(rfParty.ContactInfo?.Communications) // const rfComms = Array.isArray(rfParty.ContactInfo?.Communications)
? rfParty.ContactInfo.Communications // ? rfParty.ContactInfo.Communications
: [rfParty.ContactInfo?.Communications || {}]; // : [rfParty.ContactInfo?.Communications || {}];
//
return { // return {
servicing_dealer: rfParty.OrgInfo?.CompanyName || null, // servicing_dealer: rfParty.OrgInfo?.CompanyName || null,
servicing_dealer_contact: // // TODO: The servicing dealer fields are a relic from synergy for a few folks
rfComms.find((c) => c.CommQualifier === "WP" || c.CommQualifier === "FX")?.CommPhone || null // // 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. * Extracts loss information from the XML request.
@@ -203,10 +207,12 @@ const extractLossInfo = (rq) => {
loss_date: loss.LossDateTime || null, loss_date: loss.LossDateTime || null,
loss_type: custom.LossTypeCode || null, loss_type: custom.LossTypeCode || null,
loss_desc: custom.LossTypeDesc || null loss_desc: custom.LossTypeDesc || null
// primary_poi: loss.PrimaryPOI?.POICode || null, // area_of_impact: {
// secondary_poi: loss.SecondaryPOI?.POICode || null, // 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) // damage_memo: loss.DamageMemo || null, //(maybe ins_memo)
// total_loss_ind: rq.ClaimInfo?.LossInfo?.TotalLossInd || null // New
}; };
}; };
@@ -288,8 +294,10 @@ const extractVehicleData = (rq, shopId) => {
v_color: exterior.Color?.ColorName || null, v_color: exterior.Color?.ColorName || null,
v_bstyle: desc.BodyStyle || null, v_bstyle: desc.BodyStyle || null,
v_engine: desc.EngineDesc || 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_options: desc.SubModelDesc || null,
v_type: desc.FuelType || null, v_type: desc.FuelType || null,
// TODO there is a separate driveable flag on the job.
v_cond: rq.VehicleInfo?.Condition?.DrivableInd, v_cond: rq.VehicleInfo?.Condition?.DrivableInd,
v_trimcode: desc.TrimCode || null, v_trimcode: desc.TrimCode || null,
v_tone: exterior.Tone || null, v_tone: exterior.Tone || null,
@@ -342,6 +350,7 @@ const extractJobLines = (rq) => {
line.ManualLineInd === true || line.ManualLineInd === true ||
line.ManualLineInd === 1 || line.ManualLineInd === 1 ||
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"); (typeof line.ManualLineInd === "string" && line.ManualLineInd.toUpperCase() === "Y");
} else { } else {
lineOut.manual_line = null; lineOut.manual_line = null;
@@ -355,7 +364,9 @@ const extractJobLines = (rq) => {
const price = parseFloat(partInfo.PartPrice || partInfo.ListPrice || 0); const price = parseFloat(partInfo.PartPrice || partInfo.ListPrice || 0);
lineOut.part_type = partInfo.PartType || null ? String(partInfo.PartType).toUpperCase() : null; lineOut.part_type = partInfo.PartType || null ? String(partInfo.PartType).toUpperCase() : null;
lineOut.part_qty = parseFloat(partInfo.Quantity || 0) || 1; 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; 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.db_price = isNaN(price) ? 0 : price;
lineOut.act_price = isNaN(price) ? 0 : price; lineOut.act_price = isNaN(price) ? 0 : price;
@@ -372,7 +383,10 @@ const extractJobLines = (rq) => {
partInfo.TaxableInd === "1" || partInfo.TaxableInd === "1" ||
(typeof partInfo.TaxableInd === "string" && partInfo.TaxableInd.toUpperCase() === "Y"); (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); const amt = parseFloat(subletInfo.SubletAmount || 0);
lineOut.part_type = "PAS"; // Sublet as parts-as-service lineOut.part_type = "PAS"; // Sublet as parts-as-service
lineOut.part_qty = 1; lineOut.part_qty = 1;
@@ -389,10 +403,14 @@ const extractJobLines = (rq) => {
if (hasLabor) { if (hasLabor) {
lineOut.mod_lbr_ty = laborInfo.LaborType || null; lineOut.mod_lbr_ty = laborInfo.LaborType || null;
lineOut.mod_lb_hrs = isNaN(hrs) ? 0 : hrs; 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_op = laborInfo.LaborOperation || null;
lineOut.lbr_amt = isNaN(amt) ? 0 : amt; 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 // Refinish labor (if present) recorded on the same line using secondary labor fields
const rHrs = parseFloat(refinishInfo.LaborHours || 0); const rHrs = parseFloat(refinishInfo.LaborHours || 0);
const rAmt = parseFloat(refinishInfo.LaborAmt || 0); const rAmt = parseFloat(refinishInfo.LaborAmt || 0);
@@ -403,9 +421,9 @@ const extractJobLines = (rq) => {
!isNaN(rAmt) || !isNaN(rAmt) ||
!!refinishInfo.LaborOperation); !!refinishInfo.LaborOperation);
if (hasRefinish) { if (hasRefinish) {
lineOut.lbr_typ_j = refinishInfo.LaborType || "LAR"; lineOut.lbr_typ_j = refinishInfo.LaborType || "LAR"; //TODO: _j fields indicate judgement, and are bool type.
lineOut.lbr_hrs_j = isNaN(rHrs) ? 0 : rHrs; lineOut.lbr_hrs_j = isNaN(rHrs) ? 0 : rHrs; //TODO: _j fields indicate judgement, and are bool type.
lineOut.lbr_op_j = refinishInfo.LaborOperation || null; 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 // Aggregate refinish labor amount into the total labor amount for the line
if (!isNaN(rAmt)) { if (!isNaN(rAmt)) {
lineOut.lbr_amt = (Number.isFinite(lineOut.lbr_amt) ? lineOut.lbr_amt : 0) + rAmt; lineOut.lbr_amt = (Number.isFinite(lineOut.lbr_amt) ? lineOut.lbr_amt : 0) + rAmt;
@@ -421,26 +439,26 @@ const extractJobLines = (rq) => {
}; };
// Helper to extract a GRAND TOTAL amount from RepairTotalsInfo // Helper to extract a GRAND TOTAL amount from RepairTotalsInfo
const extractGrandTotal = (rq) => { // const extractGrandTotal = (rq) => {
const rti = rq.RepairTotalsInfo; // const rti = rq.RepairTotalsInfo;
const groups = Array.isArray(rti) ? rti : rti ? [rti] : []; // const groups = Array.isArray(rti) ? rti : rti ? [rti] : [];
for (const grp of groups) { // for (const grp of groups) {
const sums = Array.isArray(grp.SummaryTotalsInfo) // const sums = Array.isArray(grp.SummaryTotalsInfo)
? grp.SummaryTotalsInfo // ? grp.SummaryTotalsInfo
: grp.SummaryTotalsInfo // : grp.SummaryTotalsInfo
? [grp.SummaryTotalsInfo] // ? [grp.SummaryTotalsInfo]
: []; // : [];
for (const s of sums) { // for (const s of sums) {
const type = (s.TotalType || "").toString().toUpperCase(); // const type = (s.TotalType || "").toString().toUpperCase();
const desc = (s.TotalTypeDesc || "").toString().toUpperCase(); // const desc = (s.TotalTypeDesc || "").toString().toUpperCase();
if (type.includes("GRAND") || type === "TOTAL" || desc.includes("GRAND")) { // if (type.includes("GRAND") || type === "TOTAL" || desc.includes("GRAND")) {
const amt = parseFloat(s.TotalAmt ?? "NaN"); // const amt = parseFloat(s.TotalAmt ?? "NaN");
if (!isNaN(amt)) return amt; // if (!isNaN(amt)) return amt;
} // }
} // }
} // }
return null; // return null;
}; // };
/** /**
* Inserts an owner and returns the owner ID. * Inserts an owner and returns the owner ID.
@@ -459,24 +477,26 @@ const insertOwner = async (ownerInput, logger) => {
}; };
// Fallback: compute a naive total from joblines (parts + sublet + labor amounts) // Fallback: compute a naive total from joblines (parts + sublet + labor amounts)
const computeLinesTotal = (joblines = []) => { // const computeLinesTotal = (joblines = []) => {
let parts = 0; // let parts = 0;
let labor = 0; // let labor = 0;
for (const jl of joblines) { // for (const jl of joblines) {
if (jl?.part_type) { // if (jl?.part_type) {
const qty = Number.isFinite(jl.part_qty) ? jl.part_qty : 1; // const qty = Number.isFinite(jl.part_qty) ? jl.part_qty : 1;
const price = Number.isFinite(jl.act_price) ? jl.act_price : 0; // const price = Number.isFinite(jl.act_price) ? jl.act_price : 0;
parts += price * (qty || 1); // parts += price * (qty || 1);
} else if (!jl.part_type && Number.isFinite(jl.act_price)) { // } else if (!jl.part_type && Number.isFinite(jl.act_price)) {
parts += jl.act_price; // parts += jl.act_price;
} // }
if (Number.isFinite(jl.lbr_amt)) { // if (Number.isFinite(jl.lbr_amt)) {
labor += jl.lbr_amt; // labor += jl.lbr_amt;
} // }
} // }
const total = parts + labor; // const total = parts + labor;
return Number.isFinite(total) && total > 0 ? total : 0; //
}; // //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. * Handles the VehicleDamageEstimateAddRq XML request from parts management.
@@ -513,9 +533,9 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
scheduled_in, scheduled_in,
scheduled_completion, scheduled_completion,
clm_no, clm_no,
status,
policy_no, policy_no,
ded_amt ded_amt
// status,
} = extractJobData(rq); } = extractJobData(rq);
if (!shopId) { if (!shopId) {
@@ -523,22 +543,22 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
} }
// Get default status // Get default status
const defaultStatus = await getDefaultOrderStatus(shopId, logger); const defaultStatus = await getDefaultJobStatus(shopId, logger);
// Extract additional data // Extract additional data
const parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo); const parts_tax_rates = extractPartsTaxRates(rq.ProfileInfo);
const ownerData = extractOwnerData(rq, shopId); const ownerData = extractOwnerData(rq, shopId);
const estimatorData = extractEstimatorData(rq); const estimatorData = extractEstimatorData(rq);
const adjusterData = extractAdjusterData(rq); // const adjusterData = extractAdjusterData(rq);
const repairFacilityData = extractRepairFacilityData(rq); // const repairFacilityData = extractRepairFacilityData(rq);
const vehicleData = extractVehicleData(rq, shopId); const vehicleData = extractVehicleData(rq, shopId);
const lossInfo = extractLossInfo(rq); const lossInfo = extractLossInfo(rq);
const joblinesData = extractJobLines(rq); const joblinesData = extractJobLines(rq);
const insuranceData = extractInsuranceData(rq); const insuranceData = extractInsuranceData(rq);
// Derive clm_total: prefer RepairTotalsInfo SummaryTotals GRAND TOTAL; else sum from lines // Derive clm_total: prefer RepairTotalsInfo SummaryTotals GRAND TOTAL; else sum from lines
const grandTotal = extractGrandTotal(rq); // const grandTotal = extractGrandTotal(rq);
const computedTotal = grandTotal ?? computeLinesTotal(joblinesData); // const computedTotal = grandTotal ?? computeLinesTotal(joblinesData);
// Find or create relationships // Find or create relationships
const ownerid = await insertOwner(ownerData, logger); const ownerid = await insertOwner(ownerData, logger);
@@ -557,8 +577,8 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
class: classType, class: classType,
parts_tax_rates, parts_tax_rates,
clm_no, clm_no,
status: status || defaultStatus, status: defaultStatus,
clm_total: computedTotal || null, clm_total: 0, // computedTotal || null,
policy_no, policy_no,
ded_amt, ded_amt,
comment, comment,
@@ -573,8 +593,8 @@ const vehicleDamageEstimateAddRq = async (req, res) => {
...lossInfo, ...lossInfo,
...ownerData, ...ownerData,
...estimatorData, ...estimatorData,
...adjusterData, // ...adjusterData,
...repairFacilityData, // ...repairFacilityData,
// Inline vehicle data // Inline vehicle data
v_vin: vehicleData.v_vin, v_vin: vehicleData.v_vin,
v_model_yr: vehicleData.v_model_yr, v_model_yr: vehicleData.v_model_yr,

View File

@@ -38,13 +38,15 @@ const findJob = async (shopId, jobId, logger) => {
const extractUpdatedJobData = (rq) => { const extractUpdatedJobData = (rq) => {
const doc = rq.DocumentInfo || {}; const doc = rq.DocumentInfo || {};
const claim = rq.ClaimInfo || {}; 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 policyNo = claim.PolicyInfo?.PolicyInfo?.PolicyNum || claim.PolicyInfo?.PolicyNum || null;
const out = { const out = {
comment: doc.Comment || null, comment: doc.Comment || null,
clm_no: claim.ClaimNum || 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 policy_no: policyNo
}; };
@@ -246,10 +248,13 @@ const partsManagementVehicleDamageEstimateChgRq = async (req, res) => {
await client.request(UPDATE_JOB_BY_ID, { id: job.id, job: updatedJobData }); await client.request(UPDATE_JOB_BY_ID, { id: job.id, job: updatedJobData });
//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) { if (deletedLineIds?.length || updatedSeqs?.length) {
const allToDelete = Array.from(new Set([...(deletedLineIds || []), ...(updatedSeqs || [])])); const allToDelete = Array.from(new Set([...(deletedLineIds || []), ...(updatedSeqs || [])]));
if (allToDelete.length) { if (allToDelete.length) {
await client.request(SOFT_DELETE_JOBLINES_BY_IDS, { jobid: job.id, unqSeqs: allToDelete }); 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 = ` const GET_BODYSHOP_STATUS = `
query GetBodyshopStatus($id: uuid!) { query GetBodyshopStatus($id: uuid!) {
bodyshops_by_pk(id: $id) { bodyshops_by_pk(id: $id) {
md_order_statuses md_ro_statuses
} }
} }
`; `;
@@ -216,6 +216,36 @@ const GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ = `
} }
`; `;
// 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 = { module.exports = {
GET_BODYSHOP_STATUS, GET_BODYSHOP_STATUS,
GET_VEHICLE_BY_SHOP_VIN, GET_VEHICLE_BY_SHOP_VIN,
@@ -241,5 +271,9 @@ module.exports = {
DELETE_JOBS_BY_IDS, DELETE_JOBS_BY_IDS,
DELETE_AUDIT_TRAIL_BY_SHOP, DELETE_AUDIT_TRAIL_BY_SHOP,
GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ, GET_JOBLINES_NOTES_BY_JOBID_UNQSEQ,
GET_JOB_BY_ID 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

@@ -58,8 +58,20 @@ const generateSignedUploadUrls = async (req, res) => {
} }
const command = new PutObjectCommand(commandParams); const command = new PutObjectCommand(commandParams);
const presignedUrl = await getSignedUrl(client, command, { expiresIn: 360 });
signedUrls.push({ filename, presignedUrl, key }); // 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" })
});
} }
logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls }); logger.log("imgproxy-upload-success", "DEBUG", req.user?.email, jobid, { signedUrls });