Compare commits

...

15 Commits

Author SHA1 Message Date
Allan Carr
7004ed9880 IO-3373 Dashboard Component Refresh Cyclic object fix
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-09 09:13:03 -07:00
Allan Carr
4dc72986d0 IO-3373 Remove Cache Eviction
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-07 11:07:12 -07:00
Allan Carr
7ffb2c1aad IO-3373 Dashboard Component Redux Fix
This properly saves the components with out triggering a redux flush

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-03 12:11:09 -07:00
Allan Carr
cf3f94bf98 Merged in feature/IO-3386-carfax-rps (pull request #2606)
Feature/IO-3386 carfax rps

Approved-by: Patrick Fic
2025-10-03 18:29:39 +00:00
Allan Carr
4899297539 IO-3386 CARFAX RPS
Add Brad Rhoades to Report

Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-03 11:24:10 -07:00
Allan Carr
0bd8e862e4 Merged in IO-3389-Parts-Scanning-Part-# (pull request #2605)
IO-3389 Parts Scanning with Part # Update

Approved-by: Patrick Fic
2025-10-03 17:54:21 +00:00
Allan Carr
4afafef98f Merged in feature/IO-3368-QB-Bill-Accumulator (pull request #2607)
Feature/IO-3368 QB Bill Accumulator

Approved-by: Patrick Fic
2025-10-03 17:53:38 +00:00
Allan Carr
48a3220c49 Merged in feature/IO-3365-Bills-Filtering-Server-Side (pull request #2608)
IO-3365 Bills Filtering Server Side

Approved-by: Patrick Fic
2025-10-03 17:51:22 +00:00
Allan Carr
7bc105ea46 IO-3368 QB Bill Accumulator
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-02 21:26:41 -07:00
Allan Carr
ac4fcf1694 IO-3368 QB Bill Accumulator
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-02 21:22:35 -07:00
Allan Carr
678a87f55d Merge branch 'release/2025-10-17' into feature/IO-3386-carfax-rps
Signed-off-by: Allan Carr <allan@imexsystems.ca>

# Conflicts:
#	hasura/metadata/cron_triggers.yaml
2025-10-02 18:36:42 -07:00
Allan Carr
9e977d9a58 IO-3386 CARFAX RPS
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-02 18:34:40 -07:00
Allan Carr
43f822e6ad Merge branch 'master-AIO' into feature/IO-3386-carfax-rps 2025-10-02 18:32:46 -07:00
Allan Carr
7239698a21 IO-3389 Parts Scanning with Part # Update
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-02 15:49:31 -07:00
Patrick Fic
5e308f13d3 IO-3386 2025-10-01 14:47:32 -07:00
20 changed files with 821 additions and 138 deletions

View File

@@ -1,17 +1,17 @@
import Icon, { SyncOutlined } from "@ant-design/icons";
import { cloneDeep } from "lodash";
import { useMutation, useQuery } from "@apollo/client";
import { Button, Dropdown, Space } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import { useMemo, useState } from "react";
import { useMemo, useState, useEffect } from "react";
import { Responsive, WidthProvider } from "react-grid-layout";
import { useTranslation } from "react-i18next";
import { MdClose } from "react-icons/md";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils";
import { UPDATE_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { UPDATE_DASHBOARD_LAYOUT, QUERY_USER_DASHBOARD_LAYOUT } from "../../graphql/user.queries";
import { QUERY_DASHBOARD_BODYSHOP } from "../../graphql/bodyshop.queries";
import { selectCurrentUser } from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import { GenerateDashboardData } from "./dashboard-grid.utils";
@@ -24,122 +24,173 @@ import "./dashboard-grid.styles.scss";
const ResponsiveReactGridLayout = WidthProvider(Responsive);
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser,
bodyshop: selectBodyshop
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export function DashboardGridComponent({ currentUser, bodyshop }) {
export function DashboardGridComponent({ currentUser }) {
const { t } = useTranslation();
const [state, setState] = useState(() => {
const persisted = bodyshop.associations[0].user.dashboardlayout;
// Normalize persisted structure to avoid malformed shapes that can cause recursive layout recalculations
if (persisted) {
return {
items: Array.isArray(persisted.items) ? persisted.items : [],
layout: Array.isArray(persisted.layout) ? persisted.layout : [],
layouts: typeof persisted.layouts === "object" && !Array.isArray(persisted.layouts) ? persisted.layouts : {},
cols: persisted.cols
};
}
return { items: [], layout: [], layouts: {}, cols: 12 };
});
const notification = useNotification();
// Memoize the query document so Apollo doesn't treat each render as a brand-new query causing continuous re-fetches
const dashboardQueryDoc = useMemo(() => createDashboardQuery(state.items), [state.items]);
// Constants for layout defaults
const DEFAULT_COLS = 12;
const DEFAULT_Y_POSITION = 1000;
const GRID_BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
const GRID_COLS = { lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 };
const { loading, error, data, refetch } = useQuery(dashboardQueryDoc, {
// Fetch dashboard layout data
const { data: layoutData } = useQuery(QUERY_USER_DASHBOARD_LAYOUT, {
variables: { email: currentUser.email },
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !currentUser?.email
});
// Fetch minimal bodyshop data for components
const {
loading,
error,
data: bodyshopData
} = useQuery(QUERY_DASHBOARD_BODYSHOP, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const [updateLayout] = useMutation(UPDATE_DASHBOARD_LAYOUT);
const handleLayoutChange = async (layout, layouts) => {
// Memoize layout state initialization
const initialState = useMemo(() => {
const persisted = layoutData?.users?.[0]?.dashboardlayout;
if (persisted) {
const { items = [], layout = [], layouts = {}, cols = DEFAULT_COLS } = persisted;
return {
items: Array.isArray(items) ? items : [],
layout: Array.isArray(layout) ? layout : [],
layouts: typeof layouts === "object" && !Array.isArray(layouts) ? layouts : {},
cols
};
}
return { items: [], layout: [], layouts: {}, cols: DEFAULT_COLS };
}, [layoutData]);
const [state, setState] = useState(initialState);
// Update state when layout data changes
useEffect(() => {
if (layoutData?.users?.[0]?.dashboardlayout) {
const { items = [], layout = [], layouts = {}, cols = DEFAULT_COLS } = layoutData.users[0].dashboardlayout;
setState({
items: Array.isArray(items) ? items : [],
layout: Array.isArray(layout) ? layout : [],
layouts: typeof layouts === "object" && !Array.isArray(layouts) ? layouts : {},
cols
});
}
}, [layoutData]);
// Get bodyshop data for components
const bodyshop = bodyshopData?.dashboard_bodyshops?.[0];
// DRY helper function to update layout in database and cache
const updateLayoutAndCache = async (updatedLayout, errorContext = "updating layout") => {
try {
logImEXEvent("dashboard_change_layout");
setState((prev) => ({ ...prev, layout, layouts }));
const result = await updateLayout({
variables: {
email: currentUser.email,
layout: { ...state, layout, layouts }
}
const { data: result } = await updateLayout({
variables: { email: currentUser.email, layout: updatedLayout }
});
if (result?.errors && result.errors.length) {
const errorMessages = result.errors.map((e) => e?.message || String(e));
const { errors = [] } = result?.update_users?.returning?.[0] || {};
if (errors.length) {
const errorMessages = errors.map(({ message }) => message || String(error));
notification.error({
message: t("dashboard.errors.updatinglayout", {
message: errorMessages.join("; ")
})
});
return false;
}
return true;
} catch (err) {
// Catch any unexpected errors (including potential cyclic JSON issues) so the promise never rejects unhandled
console.error("Dashboard layout update failed", err);
console.error(`Dashboard ${errorContext} failed`, err);
notification.error({
message: t("dashboard.errors.updatinglayout", {
message: err?.message || String(err)
})
});
return false;
}
};
const handleRemoveComponent = (key) => {
// Memoize the query document so Apollo doesn't treat each render as a brand-new query causing continuous re-fetches
const dashboardQueryDoc = useMemo(() => createDashboardQuery(state.items), [state.items]);
const {
loading: dashboardLoading,
error: dashboardError,
data: dashboardQueryData,
refetch
} = useQuery(dashboardQueryDoc, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
});
const dashboardData = useMemo(() => GenerateDashboardData(dashboardQueryData), [dashboardQueryData]);
// Memoize existing layout keys to prevent unnecessary recalculations
const existingLayoutKeys = useMemo(() => state.items.map(({ i }) => i), [state.items]);
// Memoize menu items to prevent unnecessary recalculations
const menuItems = useMemo(
() =>
Object.entries(componentList).map(([key, { label }]) => ({
key,
label,
value: key,
disabled: existingLayoutKeys.includes(key)
})),
[existingLayoutKeys]
);
if (loading || dashboardLoading) return <LoadingSkeleton message={t("general.labels.loading")} />;
if (error || dashboardError) return <AlertComponent message={(error || dashboardError).message} type="error" />;
const handleLayoutChange = async (layout, layouts) => {
logImEXEvent("dashboard_change_layout");
setState((prev) => ({ ...prev, layout, layouts }));
await updateLayoutAndCache({ ...state, layout, layouts }, "layout change");
};
const handleRemoveComponent = async (key) => {
logImEXEvent("dashboard_remove_component", { name: key });
const idxToRemove = state.items.findIndex((i) => i.i === key);
const items = cloneDeep(state.items);
items.splice(idxToRemove, 1);
setState({ ...state, items });
const updatedState = { ...state, items: state.items.filter((item) => item.i !== key) };
setState(updatedState);
await updateLayoutAndCache(updatedState, "component removal");
};
const handleAddComponent = (e) => {
// Avoid passing the full AntD menu click event (contains circular refs) to analytics
logImEXEvent("dashboard_add_component", { key: e.key });
const compSpec = componentList[e.key] || {};
const minW = compSpec.minW || 1;
const minH = compSpec.minH || 1;
const baseW = compSpec.w || 2;
const baseH = compSpec.h || 2;
setState((prev) => {
const nextItems = [
...prev.items,
{
i: e.key,
// Position near bottom: use a large y so RGL places it last without triggering cascading relayout loops
x: (prev.items.length * 2) % (prev.cols || 12),
y: 1000,
w: Math.max(baseW, minW),
h: Math.max(baseH, minH)
}
];
return { ...prev, items: nextItems };
});
const handleAddComponent = async ({ key }) => {
logImEXEvent("dashboard_add_component", { key });
const { minW = 1, minH = 1, w: baseW = 2, h: baseH = 2 } = componentList[key] || {};
const nextItems = [
...state.items,
{
i: key,
x: (state.items.length * 2) % (state.cols || DEFAULT_COLS),
y: DEFAULT_Y_POSITION,
w: Math.max(baseW, minW),
h: Math.max(baseH, minH)
}
];
const updatedState = { ...state, items: nextItems };
setState(updatedState);
await updateLayoutAndCache(updatedState, "component addition");
};
const dashboardData = useMemo(() => GenerateDashboardData(data), [data]);
const existingLayoutKeys = state.items.map((i) => i.i);
const menuItems = Object.keys(componentList).map((key) => ({
key: key,
label: componentList[key].label,
value: key,
disabled: existingLayoutKeys.includes(key)
}));
const menu = { items: menuItems, onClick: handleAddComponent };
if (error) return <AlertComponent message={error.message} type="error" />;
return (
<div>
<PageHeader
@@ -157,22 +208,19 @@ export function DashboardGridComponent({ currentUser, bodyshop }) {
<ResponsiveReactGridLayout
className="layout"
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
breakpoints={GRID_BREAKPOINTS}
cols={GRID_COLS}
layouts={state.layouts}
onLayoutChange={handleLayoutChange}
>
{state.items.map((item) => {
const spec = componentList[item.i] || {};
const TheComponent = spec.component;
const minW = spec.minW || 1;
const minH = spec.minH || 1;
// Ensure current width/height respect minimums to avoid react-grid-layout prop warnings
const { component: TheComponent, minW = 1, minH = 1, w: specW, h: specH } = componentList[item.i] || {};
const safeItem = {
...item,
w: Math.max(item.w || spec.w || minW, minW),
h: Math.max(item.h || spec.h || minH, minH)
w: Math.max(item.w || specW || minW, minW),
h: Math.max(item.h || specH || minH, minH)
};
return (
<div
key={safeItem.i}

View File

@@ -425,7 +425,15 @@ export function ShopInfoGeneral({ form, bodyshop }) {
]
: [])
]
: [])
: []),
<Form.Item
key="accumulatePayableLines"
name={["accountingconfig", "accumulatePayableLines"]}
label={t("bodyshop.fields.accumulatePayableLines")}
valuePropName="checked"
>
<Switch />
</Form.Item>
]}
</LayoutFormRow>
<FeatureWrapper featureName="scoreboard" noauth={() => null}>

View File

@@ -363,3 +363,25 @@ export const GET_ACTIVE_EMPLOYEES_IN_SHOP = gql`
}
}
`;
export const QUERY_MINIMAL_BODYSHOP = gql`
query QUERY_MINIMAL_BODYSHOP {
bodyshops(where: { associations: { active: { _eq: true } } }) {
id
shopname
associations(where: { active: { _eq: true } }) {
user {
email
}
}
}
}
`;
export const QUERY_DASHBOARD_BODYSHOP = gql`
query QUERY_DASHBOARD_BODYSHOP {
dashboard_bodyshops: bodyshops(where: { associations: { active: { _eq: true } } }) {
id
prodtargethrs
md_ro_statuses
}
}
`;

View File

@@ -135,3 +135,12 @@ export const UPDATE_NOTIFICATIONS_AUTOADD = gql`
}
}
`;
export const QUERY_USER_DASHBOARD_LAYOUT = gql`
query QUERY_USER_DASHBOARD_LAYOUT($email: String!) {
users(where: { email: { _eq: $email } }) {
email
dashboardlayout
}
}
`;

View File

@@ -281,6 +281,7 @@
},
"fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
"accumulatePayableLines": "Accumulate Payable Lines",
"address1": "Address 1",
"address2": "Address 2",
"appt_alt_transport": "Appointment Alternative Transportation Options",

View File

@@ -281,6 +281,7 @@
},
"fields": {
"ReceivableCustomField": "",
"accumulatePayableLines": "",
"address1": "",
"address2": "",
"appt_alt_transport": "",

View File

@@ -281,6 +281,7 @@
},
"fields": {
"ReceivableCustomField": "",
"accumulatePayableLines": "",
"address1": "",
"address2": "",
"appt_alt_transport": "",

View File

@@ -207,6 +207,9 @@ services:
aws --endpoint-url=http://localstack:4566 secretsmanager create-secret --name CHATTER_PRIVATE_KEY --secret-string file:///tmp/certs/io-ftp-test.key
aws --endpoint-url=http://localstack:4566 logs create-log-group --log-group-name development --region ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-large-log --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
"
networks:

View File

@@ -120,6 +120,8 @@ services:
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-job-totals --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket parts-estimates --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket imex-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rome-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
aws --endpoint-url=http://localstack:4566 s3api create-bucket --bucket rps-carfax-uploads --create-bucket-configuration LocationConstraint=ca-central-1
"
# Node App: The Main IMEX API
node-app:

View File

@@ -15,6 +15,15 @@
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
comment: Project Mexico
- name: CARFAX RPS Data Pump
webhook: '{{HASURA_API_URL}}/data/carfaxrps'
schedule: 15 7 * * 0
include_in_metadata: true
payload: {}
headers:
- name: x-imex-auth
value_from_env: DATAPUMP_AUTH
comment: Project Mexico
- name: Chatter Data Pump
webhook: '{{HASURA_API_URL}}/data/chatter'
schedule: 45 5 * * *

View File

@@ -205,21 +205,49 @@ async function InsertVendorRecord(oauthClient, qbo_realmId, req, bill) {
async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop) {
const { accounts, taxCodes, classes } = await QueryMetaData(oauthClient, qbo_realmId, req, bill.job.shopid);
let lines;
if (bodyshop.accountingconfig.accumulatePayableLines === true) {
lines = Object.values(
bill.billlines.reduce((acc, il) => {
const { cost_center, actual_cost, quantity = 1 } = il;
const lines = bill.billlines.map((il) =>
generateBillLine(
il,
accounts,
bill.job.class,
bodyshop.md_responsibility_centers.sales_tax_codes,
classes,
taxCodes,
bodyshop.md_responsibility_centers.costs,
bodyshop.accountingconfig,
bodyshop.region_config
)
);
if (!acc[cost_center]) {
acc[cost_center] = { ...il, actual_cost: 0, quantity: 1 };
}
acc[cost_center].actual_cost += Math.round(actual_cost * quantity * 100);
return acc;
}, {})
).map((il) => {
il.actual_cost /= 100;
return generateBillLine(
il,
accounts,
bill.job.class,
bodyshop.md_responsibility_centers.sales_tax_codes,
classes,
taxCodes,
bodyshop.md_responsibility_centers.costs,
bodyshop.accountingconfig,
bodyshop.region_config
);
});
} else {
lines = bill.billlines.map((il) =>
generateBillLine(
il,
accounts,
bill.job.class,
bodyshop.md_responsibility_centers.sales_tax_codes,
classes,
taxCodes,
bodyshop.md_responsibility_centers.costs,
bodyshop.accountingconfig,
bodyshop.region_config
)
);
}
//QB USA with GST
//This was required for the No. 1 Collision Group.
if (
@@ -241,7 +269,7 @@ async function InsertBill(oauthClient, qbo_realmId, req, bill, vendor, bodyshop)
Amount: Dinero({
amount: Math.round(
bill.billlines.reduce((acc, val) => {
return acc + (val.applicable_taxes?.federal ? (val.actual_cost * val.quantity ?? 0) : 0);
return acc + (val.applicable_taxes?.federal ? val.actual_cost * val.quantity || 0 : 0);
}, 0) * 100
)
})

View File

@@ -46,6 +46,28 @@ exports.default = async (req, res) => {
};
const generateBill = (bill, bodyshop) => {
let lines;
if (bodyshop.accountingconfig.accumulatePayableLines === true) {
lines = Object.values(
bill.billlines.reduce((acc, il) => {
const { cost_center, actual_cost, quantity = 1 } = il;
if (!acc[cost_center]) {
acc[cost_center] = { ...il, actual_cost: 0, quantity: 1 };
}
acc[cost_center].actual_cost += Math.round(actual_cost * quantity * 100);
return acc;
}, {})
).map((il) => {
il.actual_cost /= 100;
return generateBillLine(il, bodyshop.md_responsibility_centers, bill.job.class);
});
} else {
lines = bill.billlines.map((il) => generateBillLine(il, bodyshop.md_responsibility_centers, bill.job.class));
}
const billQbxmlObj = {
QBXML: {
QBXMLMsgsRq: {
@@ -67,9 +89,7 @@ const generateBill = (bill, bodyshop) => {
}),
RefNumber: bill.invoice_number,
Memo: `RO ${bill.job.ro_number || ""}`,
ExpenseLineAdd: bill.billlines.map((il) =>
generateBillLine(il, bodyshop.md_responsibility_centers, bill.job.class)
)
ExpenseLineAdd: lines
}
}
}

441
server/data/carfax-rps.js Normal file
View File

@@ -0,0 +1,441 @@
const queries = require("../graphql-client/queries");
const moment = require("moment-timezone");
const logger = require("../utils/logger");
const fs = require("fs");
const client = require("../graphql-client/graphql-client").rpsClient;
const { sendServerEmail, sendMexicoBillingEmail } = require("../email/sendemail");
const crypto = require("crypto");
const { ftpSetup, uploadToS3 } = require("./carfax");
let Client = require("ssh2-sftp-client");
const AHDateFormat = "YYYY-MM-DD";
const NON_ASCII_REGEX = /[^\x20-\x7E]/g;
const S3_BUCKET_NAME = "rps-carfax-uploads";
const carfaxExportRps = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
return res.sendStatus(403);
}
// Only process if the appropriate token is provided.
if (req.headers["x-imex-auth"] !== process.env.AUTOHOUSE_AUTH_TOKEN) {
return res.sendStatus(401);
}
// Send immediate response and continue processing.
res.status(202).json({
success: true,
message: "Processing request ...",
timestamp: new Date().toISOString()
});
try {
logger.log("CARFAX-RPS-start", "DEBUG", "api", null, null);
const allJSONResults = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_CARFAX_RPS_SHOPS); //Query for the List of Bodyshop Clients.
const specificShopIds = req.body.bodyshopIds; // ['uuid];
const { start, end, skipUpload, ignoreDateFilter } = req.body; //YYYY-MM-DD
const shopsToProcess =
specificShopIds?.length > 0 ? bodyshops.filter((shop) => specificShopIds.includes(shop.id)) : bodyshops;
logger.log("CARFAX-RPS-shopsToProcess-generated", "DEBUG", "api", null, null);
if (shopsToProcess.length === 0) {
logger.log("CARFAX-RPS-shopsToProcess-empty", "DEBUG", "api", null, null);
return;
}
await processShopData(shopsToProcess, start, end, skipUpload, ignoreDateFilter, allJSONResults, allErrors);
await sendServerEmail({
subject: `Project Mexico RPS Report ${moment().format("MM-DD-YY")}`,
text: `Total Count: ${allJSONResults.reduce((a, v) => a + v.count, 0)}\nErrors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allJSONResults.map((x) => ({
imexshopid: x.imexshopid,
filename: x.filename,
count: x.count,
result: x.result
})),
null,
2
)}`,
to: ["bradley.rhoades@convenient-brands.com"]
});
logger.log("CARFAX-RPS-end", "DEBUG", "api", null, null);
} catch (error) {
logger.log("CARFAX-RPS-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
}
};
async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDateFilter, allJSONResults, allErrors) {
for (const bodyshop of shopsToProcess) {
const shopid = bodyshop.shopname.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
const erroredJobs = [];
try {
logger.log("CARFAX-RPS-start-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
const { jobs, bodyshops_by_pk } = await client.request(queries.CARFAX_RPS_QUERY, {
bodyshopid: bodyshop.id,
...(ignoreDateFilter
? {}
: {
starttz: start ? moment(start).startOf("day") : moment().subtract(7, "days").startOf("day"),
...(end && { endtz: moment(end).endOf("day") }),
start: start
? moment(start).startOf("day").format(AHDateFormat)
: moment().subtract(7, "days").startOf("day").format(AHDateFormat),
...(end && { endtz: moment(end).endOf("day").format(AHDateFormat) })
})
});
const carfaxObject = {
shopid: shopid,
shop_name: bodyshop.shopname,
job: jobs.map((j) =>
CreateRepairOrderTag({ ...j, bodyshop: bodyshops_by_pk }, function ({ job, error }) {
erroredJobs.push({ job: job, error: error.toString() });
})
)
};
if (erroredJobs.length > 0) {
logger.log("CARFAX-RPS-failed-jobs", "ERROR", "api", bodyshop.id, {
count: erroredJobs.length,
jobs: JSON.stringify(erroredJobs.map((j) => j.job.id))
});
}
const jsonObj = {
bodyshopid: bodyshop.id,
imexshopid: shopid,
json: JSON.stringify(carfaxObject, null, 2),
filename: `${shopid}_${moment().format("DDMMYYYY_HHMMss")}.json`,
count: carfaxObject.job.length
};
if (skipUpload) {
fs.writeFileSync(`./logs/${jsonObj.filename}`, jsonObj.json);
uploadToS3(jsonObj, S3_BUCKET_NAME);
} else {
await uploadViaSFTP(jsonObj);
await sendMexicoBillingEmail({
subject: `${shopid.replace(/_/g, "").toUpperCase()}_MexicoRPS_${moment().format("MMDDYYYY")} ROs ${jsonObj.count} Error ${errorCode(jsonObj)}`,
text: `Errors:\n${JSON.stringify(
erroredJobs.map((ej) => ({
jobid: ej.job?.id,
error: ej.error
})),
null,
2
)}\n\nUploaded:\n${JSON.stringify(
{
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
},
null,
2
)}`
});
}
allJSONResults.push({
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
filename: jsonObj.filename,
result: jsonObj.result
});
logger.log("CARFAX-RPS-end-shop-extract", "DEBUG", "api", bodyshop.id, {
shopname: bodyshop.shopname
});
} catch (error) {
//Error at the shop level.
logger.log("CARFAX-RPS-error-shop", "ERROR", "api", bodyshop.id, { error: error.message, stack: error.stack });
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: shopid,
CARFAXid: bodyshop.CARFAXid,
fatal: true,
errors: [error.toString()]
});
} finally {
allErrors.push({
bodyshopid: bodyshop.id,
imexshopid: shopid,
CARFAXid: bodyshop.CARFAXid,
errors: erroredJobs.map((ej) => ({
jobid: ej.job?.id,
error: ej.error
}))
});
}
}
}
async function uploadViaSFTP(jsonObj) {
const sftp = new Client();
sftp.on("error", (errors) =>
logger.log("CARFAX-RPS-sftp-connection-error", "ERROR", "api", jsonObj.bodyshopid, {
error: errors.message,
stack: errors.stack
})
);
try {
// Upload to S3 first.
uploadToS3(jsonObj, S3_BUCKET_NAME);
//Connect to the FTP and upload all.
await sftp.connect(ftpSetup);
try {
jsonObj.result = await sftp.put(Buffer.from(jsonObj.json), `${jsonObj.filename}`);
logger.log("CARFAX-RPS-sftp-upload", "DEBUG", "api", jsonObj.bodyshopid, {
imexshopid: jsonObj.imexshopid,
filename: jsonObj.filename,
result: jsonObj.result
});
} catch (error) {
logger.log("CARFAX-RPS-sftp-upload-error", "ERROR", "api", jsonObj.bodyshopid, {
filename: jsonObj.filename,
error: error.message,
stack: error.stack
});
throw error;
}
} catch (error) {
logger.log("CARFAX-RPS-sftp-error", "ERROR", "api", jsonObj.bodyshopid, {
error: error.message,
stack: error.stack
});
throw error;
} finally {
sftp.end();
}
}
const CreateRepairOrderTag = (job, errorCallback) => {
try {
const subtotalEntry = job.totals.find((total) => total.TTL_TYPECD === "");
const subtotal = subtotalEntry ? subtotalEntry.T_AMT : 0;
const ret = {
ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"),
v_vin: job.v_vin || "",
v_year: job.v_model_yr
? parseInt(job.v_model_yr.match(/\d/g))
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10)
: ""
: "",
v_make: job.v_makedesc || "",
v_model: job.v_model || "",
date_estimated: moment(job.created_at).tz("America/Winnipeg").format(AHDateFormat) || "",
data_opened: moment(job.created_at).tz("America/Winnipeg").format(AHDateFormat) || "",
date_invoiced: [job.close_date, job.created_at].find((date) => date)
? moment([job.close_date, job.created_at].find((date) => date))
.tz("America/Winnipeg")
.format(AHDateFormat)
: "",
loss_date: job.loss_date ? moment(job.loss_date).format(AHDateFormat) : "",
ins_co_nm: job.ins_co_nm || "",
loss_desc: job.loss_desc || "",
theft_ind: job.theft_ind,
tloss_ind: job.tlos_ind,
subtotal: subtotal,
areaofdamage: {
impact1: generateAreaOfDamage(job.impact_1 || ""),
impact2: generateAreaOfDamage(job.impact_2 || "")
},
jobLines: job.joblines.length > 0 ? job.joblines.map((jl) => GenerateDetailLines(jl)) : [generateNullDetailLine()]
};
return ret;
} catch (error) {
logger.log("CARFAX-RPS-job-data-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
errorCallback({ jobid: job.id, error });
}
};
const GenerateDetailLines = (line) => {
const ret = {
line_desc: line.line_desc ? line.line_desc.replace(NON_ASCII_REGEX, "") : null,
oem_partno: line.oem_partno ? line.oem_partno.replace(NON_ASCII_REGEX, "") : null,
alt_partno: line.alt_partno ? line.alt_partno.replace(NON_ASCII_REGEX, "") : null,
op_code_desc: generateOpCodeDescription(line.lbr_op),
lbr_ty: generateLaborType(line.mod_lbr_ty),
lbr_hrs: line.mod_lb_hrs || 0,
part_qty: line.part_qty || 0,
part_type: generatePartType(line.part_type),
act_price: line.act_price || 0
};
return ret;
};
const generateNullDetailLine = () => {
return {
line_desc: null,
oem_partno: null,
alt_partno: null,
lbr_ty: null,
part_qty: 0,
part_type: null,
act_price: 0
};
};
const generateAreaOfDamage = (loc) => {
const areaMap = {
"01": "Right Front Corner",
"02": "Right Front Side",
"03": "Right Side",
"04": "Right Rear Side",
"05": "Right Rear Corner",
"06": "Rear",
"07": "Left Rear Corner",
"08": "Left Rear Side",
"09": "Left Side",
10: "Left Front Side",
11: "Left Front Corner",
12: "Front",
13: "Rollover",
14: "Uknown",
15: "Total Loss",
16: "Non-Collision",
19: "All Over",
25: "Hood",
26: "Deck Lid",
27: "Roof",
28: "Undercarriage",
34: "All Over"
};
return areaMap[loc] || null;
};
const generateLaborType = (type) => {
const laborTypeMap = {
laa: "Aluminum",
lab: "Body",
lad: "Diagnostic",
lae: "Electrical",
laf: "Frame",
lag: "Glass",
lam: "Mechanical",
lar: "Refinish",
las: "Structural",
lau: "Other - LAU",
la1: "Other - LA1",
la2: "Other - LA2",
la3: "Other - LA3",
la4: "Other - LA4",
null: "Other",
mapa: "Paint Materials",
mash: "Shop Materials",
rates_subtotal: "Labor Total",
"timetickets.labels.shift": "Shift",
"timetickets.labels.amshift": "Morning Shift",
"timetickets.labels.ambreak": "Morning Break",
"timetickets.labels.pmshift": "Afternoon Shift",
"timetickets.labels.pmbreak": "Afternoon Break",
"timetickets.labels.lunch": "Lunch"
};
return laborTypeMap[type?.toLowerCase()] || null;
};
const generatePartType = (type) => {
const partTypeMap = {
paa: "Aftermarket",
pae: "Existing",
pag: "Glass",
pal: "LKQ",
pan: "OEM",
pao: "Other",
pas: "Sublet",
pasl: "Sublet",
ccc: "CC Cleaning",
ccd: "CC Damage Waiver",
ccdr: "CC Daily Rate",
ccf: "CC Refuel",
ccm: "CC Mileage",
prt_dsmk_total: "Line Item Adjustment"
};
return partTypeMap[type?.toLowerCase()] || null;
};
const generateOpCodeDescription = (type) => {
const opCodeMap = {
OP0: "REMOVE / REPLACE PARTIAL",
OP1: "REFINISH / REPAIR",
OP10: "REPAIR , PARTIAL",
OP100: "REPLACE PRE-PRICED",
OP101: "REMOVE/REPLACE RECYCLED PART",
OP103: "REMOVE / REPLACE PARTIAL",
OP104: "REMOVE / REPLACE PARTIAL LABOUR",
OP105: "!!ADJUST MANUALLY!!",
OP106: "REPAIR , PARTIAL",
OP107: "CHIPGUARD",
OP108: "MULTI TONE",
OP109: "REPLACE PRE-PRICED",
OP11: "REMOVE / REPLACE",
OP110: "REFINISH / REPAIR",
OP111: "REMOVE / REPLACE",
OP112: "REMOVE / REPLACE",
OP113: "REPLACE PRE-PRICED",
OP114: "REPLACE PRE-PRICED",
OP12: "REMOVE / REPLACE PARTIAL",
OP120: "REPAIR , PARTIAL",
OP13: "ADDITIONAL COSTS",
OP14: "ADDITIONAL OPERATIONS",
OP15: "BLEND",
OP16: "SUBLET",
OP17: "POLICY LIMIT ADJUSTMENT",
OP18: "APPEAR ALLOWANCE",
OP2: "REMOVE / INSTALL",
OP24: "CHIPGUARD",
OP25: "TWO TONE",
OP26: "PAINTLESS DENT REPAIR",
OP260: "SUBLET",
OP3: "ADDITIONAL LABOR",
OP4: "ALIGNMENT",
OP5: "OVERHAUL",
OP6: "REFINISH",
OP7: "INSPECT",
OP8: "CHECK / ADJUST",
OP9: "REPAIR"
};
return opCodeMap[type?.toUpperCase()] || null;
};
const errorCode = ({ count, filename, results }) => {
if (count === 0) return 1;
if (!filename) return 3;
const sftpErrorCode = results?.sftpError?.code;
if (sftpErrorCode && ["ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "ECONNRESET"].includes(sftpErrorCode)) {
return 4;
}
if (sftpErrorCode) return 7;
return 0;
};
module.exports = {
default: carfaxExportRps,
ftpSetup
};

View File

@@ -37,12 +37,12 @@ const S3_BUCKET_NAME = InstanceManager({
const region = InstanceManager.InstanceRegion;
const isLocal = isString(process.env?.LOCALSTACK_HOSTNAME) && !isEmpty(process.env?.LOCALSTACK_HOSTNAME);
const uploadToS3 = (jsonObj) => {
const uploadToS3 = (jsonObj, bucketName = S3_BUCKET_NAME) => {
const webPath = isLocal
? `https://${S3_BUCKET_NAME}.s3.localhost.localstack.cloud:4566/${jsonObj.filename}`
: `https://${S3_BUCKET_NAME}.s3.${region}.amazonaws.com/${jsonObj.filename}`;
? `https://${bucketName}.s3.localhost.localstack.cloud:4566/${jsonObj.filename}`
: `https://${bucketName}.s3.${region}.amazonaws.com/${jsonObj.filename}`;
uploadFileToS3({ bucketName: S3_BUCKET_NAME, key: jsonObj.filename, content: jsonObj.json })
uploadFileToS3({ bucketName: bucketName, key: jsonObj.filename, content: jsonObj.json })
.then(() => {
logger.log("CARFAX-s3-upload", "DEBUG", "api", jsonObj.bodyshopid, {
imexshopid: jsonObj.imexshopid,
@@ -61,7 +61,7 @@ const uploadToS3 = (jsonObj) => {
});
};
exports.default = async (req, res) => {
const carfaxExport = async (req, res) => {
// Only process if in production environment.
if (process.env.NODE_ENV !== "production") {
return res.sendStatus(403);
@@ -80,7 +80,7 @@ exports.default = async (req, res) => {
try {
logger.log("CARFAX-start", "DEBUG", "api", null, null);
const allXMLResults = [];
const allJSONResults = [];
const allErrors = [];
const { bodyshops } = await client.request(queries.GET_CARFAX_SHOPS); //Query for the List of Bodyshop Clients.
@@ -96,12 +96,12 @@ exports.default = async (req, res) => {
return;
}
await processShopData(shopsToProcess, start, end, skipUpload, ignoreDateFilter, allXMLResults, allErrors);
await processShopData(shopsToProcess, start, end, skipUpload, ignoreDateFilter, allJSONResults, allErrors);
await sendServerEmail({
subject: `Project Mexico Report ${moment().format("MM-DD-YY")}`,
text: `Errors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allXMLResults.map((x) => ({
text: `Total Count: ${allJSONResults.reduce((a, v) => a + v.count, 0)}\nErrors:\n${JSON.stringify(allErrors, null, 2)}\n\nUploaded:\n${JSON.stringify(
allJSONResults.map((x) => ({
imexshopid: x.imexshopid,
filename: x.filename,
count: x.count,
@@ -109,7 +109,8 @@ exports.default = async (req, res) => {
})),
null,
2
)}`
)}`,
to: ["bradley.rhoades@convenient-brands.com"]
});
logger.log("CARFAX-end", "DEBUG", "api", null, null);
@@ -118,7 +119,7 @@ exports.default = async (req, res) => {
}
};
async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDateFilter, allXMLResults, allErrors) {
async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDateFilter, allJSONResults, allErrors) {
for (const bodyshop of shopsToProcess) {
const shopid = bodyshop.imexshopid?.toLowerCase() || bodyshop.shopname.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
const erroredJobs = [];
@@ -195,7 +196,7 @@ async function processShopData(shopsToProcess, start, end, skipUpload, ignoreDat
});
}
allXMLResults.push({
allJSONResults.push({
bodyshopid: bodyshop.id,
imexshopid: shopid,
count: jsonObj.count,
@@ -447,3 +448,9 @@ const errorCode = ({ count, filename, results }) => {
if (sftpErrorCode) return 7;
return 0;
};
module.exports = {
default: carfaxExport,
ftpSetup,
uploadToS3
};

View File

@@ -7,4 +7,5 @@ exports.usageReport = require("./usageReport").default;
exports.podium = require("./podium").default;
exports.emsUpload = require("./emsUpload").default;
exports.carfax = require("./carfax").default;
exports.carfaxRps = require("./carfax-rps").default;
exports.vehicletype = require("./vehicletype/vehicletype").default;

View File

@@ -44,8 +44,14 @@ const logEmail = async (req, email) => {
}
};
const sendServerEmail = async ({ subject, text }) => {
const sendServerEmail = async ({ subject, text, to = [] }) => {
if (process.env.NODE_ENV === undefined) return;
let sentTo = ["support@imexsystems.ca"];
if (to?.length) {
sentTo = [...sentTo, ...to];
}
try {
mailer.sendMail(
{
@@ -53,7 +59,7 @@ const sendServerEmail = async ({ subject, text }) => {
imex: `ImEX Online API - ${process.env.NODE_ENV} <noreply@imex.online>`,
rome: `Rome Online API - ${process.env.NODE_ENV} <noreply@romeonline.io>`
}),
to: ["support@thinkimex.com"],
to: sentTo,
subject: subject,
text: text,
ses: {
@@ -68,7 +74,7 @@ const sendServerEmail = async ({ subject, text }) => {
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
logger.log("server-email-send", err ? "error" : "debug", null, null, {
message: err?.message,
stack: err?.stack
});
@@ -103,7 +109,7 @@ const sendMexicoBillingEmail = async ({ subject, text }) => {
},
// eslint-disable-next-line no-unused-vars
(err, info) => {
logger.log("server-email-failure", err ? "error" : "debug", null, null, {
logger.log("server-email-send", err ? "error" : "debug", null, null, {
message: err?.message,
stack: err?.stack
});
@@ -258,7 +264,10 @@ const sendTaskEmail = async ({ to, subject, type = "text", html, text, attachmen
// eslint-disable-next-line no-unused-vars
(err, info) => {
// (message, type, user, record, meta
logger.log("server-email", err ? "error" : "debug", null, null, { message: err?.message, stack: err?.stack });
logger.log("server-email-send", err ? "error" : "debug", null, null, {
message: err?.message,
stack: err?.stack
});
}
);
} catch (error) {

View File

@@ -1,3 +1,5 @@
const logger = require("../utils/logger");
const GraphQLClient = require("graphql-request").GraphQLClient;
//New bug introduced with Graphql Request.
@@ -11,9 +13,24 @@ const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
}
});
const rpsClient =
process.env.RPS_GRAPHQL_ENDPOINT && process.env.RPS_HASURA_ADMIN_SECRET ?
new GraphQLClient(process.env.RPS_GRAPHQL_ENDPOINT, {
headers: {
"x-hasura-admin-secret": process.env.RPS_HASURA_ADMIN_SECRET
}
}) : null;
if (!rpsClient) {
//System log to disable RPS functions
logger.log(`RPS secrets are not set. Client is not configured.`, "WARN", "redis", "api", {
});
}
const unauthorizedClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT);
module.exports = {
client,
rpsClient,
unauthorizedClient
};

View File

@@ -921,6 +921,41 @@ exports.CARFAX_QUERY = `query CARFAX_EXPORT($start: timestamptz, $bodyshopid: uu
}
}`;
exports.CARFAX_RPS_QUERY = `query CARFAX_RPS_EXPORT($starttz: timestamptz, $endtz: timestamptz,$start: date, $end: date, $bodyshopid: uuid!) {
bodyshops_by_pk(id: $bodyshopid) {
id
shopname
}
jobs(where: {_and: [{_or: [{close_date: {_gt: $start, _lte: $end}}, {created_at: {_gt: $starttz, _lte: $endtz}, close_date: {_is_null: true}}]}, {_not: {_and: [{close_date: {_is_null: true}}, {created_at: {_is_null: true}}]}}, {bodyshopid: {_eq: $bodyshopid}}, {v_vin: {_is_null: false}}]}) {
close_date
created_at
id
ins_co_nm
impact_1
impact_2
joblines {
act_price
alt_partno
line_desc
mod_lb_hrs
mod_lbr_ty
oem_partno
lbr_op
part_type
part_qty
}
loss_date
loss_desc
theft_ind
tlos_ind
totals
v_makedesc
v_model
v_model_yr
v_vin
}
}`;
exports.CLAIMSCORP_QUERY = `query CLAIMSCORP_EXPORT($start: timestamptz, $bodyshopid: uuid!, $end: timestamptz) {
bodyshops_by_pk(id: $bodyshopid){
id
@@ -1867,6 +1902,13 @@ exports.GET_CARFAX_SHOPS = `query GET_CARFAX_SHOPS {
}
}`;
exports.GET_CARFAX_RPS_SHOPS = `query GET_CARFAX_RPS_SHOPS {
bodyshops(where: {carfax_exclude: {_neq: "true"}}){
id
shopname
}
}`;
exports.GET_CLAIMSCORP_SHOPS = `query GET_CLAIMSCORP_SHOPS {
bodyshops(where: {claimscorpid: {_is_null: false}, _or: {claimscorpid: {_neq: ""}}}){
id

View File

@@ -82,22 +82,35 @@ exports.partsScan = async function (req, res) {
criticalIds.add(jobline.id);
}
if (update_field && update_value) {
const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_JOB_LINE, {
lineId: jobline.id,
line: { [update_field]: update_value, manual_line: true }
});
const auditResult = await client
.setHeaders({ Authorization: BearerToken })
.request(queries.INSERT_AUDIT_TRAIL, {
auditObj: {
bodyshopid: data.jobs_by_pk.bodyshop.id,
jobid,
operation: `Jobline (#${jobline.line_no} ${jobline.line_desc}/${jobline.id}) ${update_field} updated from ${jobline[update_field]} to ${update_value}. Lined marked as manual line.`,
type: "partscanupdate",
useremail: req.user.email
let actualUpdateField = update_field;
if (update_field === "part_number") {
// Determine which part number field to update based on the match
if (!jobline.oem_partno) {
actualUpdateField = "oem_partno";
} else {
if (regex) {
actualUpdateField = regex.test(jobline.oem_partno || "") ? "oem_partno" : "alt_partno";
} else {
actualUpdateField = jobline.oem_partno === value ? "oem_partno" : "alt_partno";
}
}
}
if (actualUpdateField) {
await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_JOB_LINE, {
lineId: jobline.id,
line: { [actualUpdateField]: update_value, manual_line: true }
});
}
await client.setHeaders({ Authorization: BearerToken }).request(queries.INSERT_AUDIT_TRAIL, {
auditObj: {
bodyshopid: data.jobs_by_pk.bodyshop.id,
jobid,
operation: `Jobline (#${jobline.line_no} ${jobline.line_desc}/${jobline.id}) ${update_field} updated from ${jobline[update_field]} to ${update_value}. Lined marked as manual line.`,
type: "partscanupdate",
useremail: req.user.email
}
});
}
//break; // No need to evaluate further rules for this jobline

View File

@@ -1,6 +1,6 @@
const express = require("express");
const router = express.Router();
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax } = require("../data/data");
const { autohouse, claimscorp, chatter, kaizen, usageReport, podium, carfax, carfaxRps } = require("../data/data");
router.post("/ah", autohouse);
router.post("/cc", claimscorp);
@@ -9,5 +9,6 @@ router.post("/kaizen", kaizen);
router.post("/usagereport", usageReport);
router.post("/podium", podium);
router.post("/carfax", carfax);
router.post("/carfaxrps", carfaxRps);
module.exports = router;