diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 36ec1a0c9..86da3ae37 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -5305,6 +5305,27 @@ + + ro_posting + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + sendmaterialscosting false diff --git a/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx b/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx index 0c950fba9..1cd3281f7 100644 --- a/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx +++ b/client/src/components/dms-allocations-summary/dms-allocations-summary.component.jsx @@ -113,6 +113,7 @@ export function DmsAllocationsSummary({ socket, bodyshop, jobId, title }) { rowKey="center" dataSource={allocationsSummary} locale={{ emptyText: t("dms.labels.refreshallocations") }} + scroll={{ x: true }} summary={() => { const totals = allocationsSummary && diff --git a/client/src/components/job-detail-cards/job-detail-cards.parts.component.jsx b/client/src/components/job-detail-cards/job-detail-cards.parts.component.jsx index 9f3c32820..949d42eae 100644 --- a/client/src/components/job-detail-cards/job-detail-cards.parts.component.jsx +++ b/client/src/components/job-detail-cards/job-detail-cards.parts.component.jsx @@ -90,7 +90,7 @@ export function JobDetailCardsPartsComponent({ loading, data, jobRO }) { .filter(onlyUnique) .map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; })) || @@ -103,7 +103,7 @@ export function JobDetailCardsPartsComponent({ loading, data, jobRO }) {
- +
); diff --git a/client/src/components/job-detail-lines/job-lines.component.jsx b/client/src/components/job-detail-lines/job-lines.component.jsx index 2cadcf4e8..02dd2cda6 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -318,7 +318,7 @@ export function JobLinesComponent({ .filter(onlyUnique) .map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; })) || diff --git a/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx b/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx index 8fbee411c..c7cc3a115 100644 --- a/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx +++ b/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import { Col, Row, Tag, Tooltip } from "antd"; +import { Tag, Tooltip } from "antd"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop } from "../../redux/user/user.selectors"; +import { useTranslation } from "react-i18next"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop @@ -11,65 +12,67 @@ const mapDispatchToProps = () => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) }); -export const DEFAULT_COL_LAYOUT = { xs: 24, sm: 24, md: 8, lg: 4, xl: 4, xxl: 4 }; - export default connect(mapStateToProps, mapDispatchToProps)(JobPartsQueueCount); -export function JobPartsQueueCount({ bodyshop, parts, defaultColLayout = DEFAULT_COL_LAYOUT }) { +export function JobPartsQueueCount({ bodyshop, parts }) { + const { t } = useTranslation(); const partsStatus = useMemo(() => { if (!parts) return null; + const statusKeys = ["default_bo", "default_ordered", "default_received", "default_returned"]; return parts.reduce( (acc, val) => { if (val.part_type === "PAS" || val.part_type === "PASL") return acc; acc.total = acc.total + val.count; acc[val.status] = acc[val.status] + val.count; - return acc; }, { total: 0, null: 0, - [bodyshop.md_order_statuses.default_bo]: 0, - [bodyshop.md_order_statuses.default_ordered]: 0, - [bodyshop.md_order_statuses.default_received]: 0, - [bodyshop.md_order_statuses.default_returned]: 0 + ...Object.fromEntries(statusKeys.map((key) => [bodyshop.md_order_statuses[key], 0])) } ); }, [bodyshop, parts]); if (!parts) return null; return ( - - - - {partsStatus.total} - - - - - {partsStatus["null"]} - - - - - {partsStatus[bodyshop.md_order_statuses.default_ordered]} - - - - - {partsStatus[bodyshop.md_order_statuses.default_received]} - - - - - {partsStatus[bodyshop.md_order_statuses.default_returned]} - - - - - {partsStatus[bodyshop.md_order_statuses.default_bo]} - - - +
+ + {partsStatus.total} + + + + {partsStatus["null"]} + + + + + {partsStatus[bodyshop.md_order_statuses.default_bo]} + + + + + {partsStatus[bodyshop.md_order_statuses.default_ordered]} + + + + + {partsStatus[bodyshop.md_order_statuses.default_received]} + + + + + {partsStatus[bodyshop.md_order_statuses.default_returned]} + + +
); } diff --git a/client/src/components/jobs-list/jobs-list.component.jsx b/client/src/components/jobs-list/jobs-list.component.jsx index 49439fa00..bde88d161 100644 --- a/client/src/components/jobs-list/jobs-list.component.jsx +++ b/client/src/components/jobs-list/jobs-list.component.jsx @@ -166,7 +166,7 @@ export function JobsList({ bodyshop }) { .filter(onlyUnique) .map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; }) diff --git a/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx b/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx index b88b713c2..b218b46f4 100644 --- a/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx +++ b/client/src/components/jobs-ready-list/jobs-ready-list.component.jsx @@ -165,7 +165,7 @@ export function JobsReadyList({ bodyshop }) { .filter(onlyUnique) .map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; }) diff --git a/client/src/components/parts-queue-card/parts-queue-job-lines.component.jsx b/client/src/components/parts-queue-card/parts-queue-job-lines.component.jsx index 7d108954c..066e937e8 100644 --- a/client/src/components/parts-queue-card/parts-queue-job-lines.component.jsx +++ b/client/src/components/parts-queue-card/parts-queue-job-lines.component.jsx @@ -145,7 +145,7 @@ export function PartsQueueJobLinesComponent({ loading, jobLines }) { .filter(onlyUnique) .map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; })) || diff --git a/client/src/components/parts-queue-list/parts-queue.list.component.jsx b/client/src/components/parts-queue-list/parts-queue.list.component.jsx index bb4b72b79..a947eaefa 100644 --- a/client/src/components/parts-queue-list/parts-queue.list.component.jsx +++ b/client/src/components/parts-queue-list/parts-queue.list.component.jsx @@ -171,7 +171,7 @@ export function PartsQueueListComponent({ bodyshop }) { filters: bodyshop.md_ro_statuses.active_statuses.map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; }) || [], diff --git a/client/src/components/production-list-columns/production-list-columns.data.jsx b/client/src/components/production-list-columns/production-list-columns.data.jsx index f26733c36..165e15c1d 100644 --- a/client/src/components/production-list-columns/production-list-columns.data.jsx +++ b/client/src/components/production-list-columns/production-list-columns.data.jsx @@ -34,8 +34,9 @@ const getEmployeeName = (employeeId, employees) => { return employee ? `${employee.first_name} ${employee.last_name}` : ""; }; -const r = ({ technician, state, activeStatuses, data, bodyshop, refetch, treatments }) => { +const productionListColumnsData = ({ technician, state, activeStatuses, data, bodyshop, refetch, treatments }) => { const { Enhanced_Payroll } = treatments; + return [ { title: i18n.t("jobs.actions.viewdetail"), @@ -313,7 +314,7 @@ const r = ({ technician, state, activeStatuses, data, bodyshop, refetch, treatme activeStatuses ?.map((s) => { return { - text: s || "No Status*", + text: s || i18n.t("dashboard.errors.status"), value: [s] }; }) @@ -584,4 +585,4 @@ const r = ({ technician, state, activeStatuses, data, bodyshop, refetch, treatme } ]; }; -export default r; +export default productionListColumnsData; diff --git a/client/src/components/shop-info/shop-info.general.component.jsx b/client/src/components/shop-info/shop-info.general.component.jsx index 165e133a2..acf553bb5 100644 --- a/client/src/components/shop-info/shop-info.general.component.jsx +++ b/client/src/components/shop-info/shop-info.general.component.jsx @@ -425,7 +425,15 @@ export function ShopInfoGeneral({ form, bodyshop }) { ] : []) ] - : []) + : []), + + + ]} null}> diff --git a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx index d0aae4ff3..53f780c6b 100644 --- a/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx +++ b/client/src/components/shop-info/shop-info.responsibilitycenters.component.jsx @@ -138,6 +138,15 @@ export function ShopInfoResponsibilityCenterComponent({ bodyshop, form }) { )} + {bodyshop.pbs_serialnumber && ( + + + + )} {bodyshop.pbs_serialnumber && ( { + filters: bodyshop?.md_ro_statuses?.parts_statuses?.map((s) => { return { text: s, value: [s] }; }), onFilter: (value, record) => value.includes(record.status) diff --git a/client/src/components/tech-lookup-jobs-list/tech-lookup-jobs-list.component.jsx b/client/src/components/tech-lookup-jobs-list/tech-lookup-jobs-list.component.jsx index 0febbb24d..dfba5a133 100644 --- a/client/src/components/tech-lookup-jobs-list/tech-lookup-jobs-list.component.jsx +++ b/client/src/components/tech-lookup-jobs-list/tech-lookup-jobs-list.component.jsx @@ -111,7 +111,7 @@ export function TechLookupJobsList({ bodyshop }) { .filter(onlyUnique) .map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; })) || diff --git a/client/src/firebase/firebase.utils.js b/client/src/firebase/firebase.utils.js index d3964eaac..8f1518504 100644 --- a/client/src/firebase/firebase.utils.js +++ b/client/src/firebase/firebase.utils.js @@ -4,7 +4,7 @@ import { getAuth, updatePassword, updateProfile } from "@firebase/auth"; import { getFirestore } from "@firebase/firestore"; import { getMessaging, getToken, onMessage } from "@firebase/messaging"; import { store } from "../redux/store"; -import * as amplitude from '@amplitude/analytics-browser'; +//import * as amplitude from '@amplitude/analytics-browser'; import posthog from 'posthog-js' const config = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG); @@ -91,14 +91,14 @@ export const logImEXEvent = (eventName, additionalParams, stateProp = null) => { // dbevent: false, // env: `master-AIO|${import.meta.env.VITE_APP_GIT_SHA_DATE}` // }); - console.log( - "%c[Analytics]", - "background-color: green ;font-weight:bold;", - eventName, - eventParams - ); + // console.log( + // "%c[Analytics]", + // "background-color: green ;font-weight:bold;", + // eventName, + // eventParams + // ); logEvent(analytics, eventName, eventParams); - amplitude.track(eventName, eventParams); + //amplitude.track(eventName, eventParams); posthog.capture(eventName, eventParams); } finally { diff --git a/client/src/graphql/bills.queries.js b/client/src/graphql/bills.queries.js index a9c3433fa..03be22e61 100644 --- a/client/src/graphql/bills.queries.js +++ b/client/src/graphql/bills.queries.js @@ -42,7 +42,7 @@ export const QUERY_ALL_BILLS_PAGINATED = gql` ro_number } } - bills_aggregate { + bills_aggregate(where: $where) { aggregate { count(distinct: true) } diff --git a/client/src/index.jsx b/client/src/index.jsx index 610aa8aad..557ac9c43 100644 --- a/client/src/index.jsx +++ b/client/src/index.jsx @@ -14,7 +14,7 @@ import { persistor, store } from "./redux/store"; import reportWebVitals from "./reportWebVitals"; import "./translations/i18n"; import "./utils/CleanAxios"; -import * as amplitude from "@amplitude/analytics-browser"; +//import * as amplitude from "@amplitude/analytics-browser"; import { PostHogProvider } from "posthog-js/react"; import posthog from "posthog-js"; @@ -26,23 +26,23 @@ registerSW({ immediate: true }); // Dinero.globalLocale = "en-CA"; Dinero.globalRoundingMode = "HALF_EVEN"; -amplitude.init(import.meta.env.VITE_APP_AMP_KEY, { - defaultTracking: true, - serverUrl: import.meta.env.VITE_APP_AMP_URL - // { - // attribution: { - // excludeReferrers: true, - // initialEmptyValue: true, - // resetSessionOnNewCampaign: true, - // }, - // fileDownloads: true, - // formInteractions: true, - // pageViews: { - // trackHistoryChanges: 'all' - // }, - // sessions: true - // } -}); +// amplitude.init(import.meta.env.VITE_APP_AMP_KEY, { +// defaultTracking: true, +// serverUrl: import.meta.env.VITE_APP_AMP_URL +// // { +// // 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, diff --git a/client/src/pages/bills/bills.page.component.jsx b/client/src/pages/bills/bills.page.component.jsx index e3d2f55b0..0fb17a978 100644 --- a/client/src/pages/bills/bills.page.component.jsx +++ b/client/src/pages/bills/bills.page.component.jsx @@ -16,7 +16,6 @@ import { DateFormatter } from "../../utils/DateFormatter"; import { TemplateList } from "../../utils/TemplateConstants"; import { pageLimit } from "../../utils/config"; import { alphaSort, dateSort } from "../../utils/sorters"; -import useLocalStorage from "../../utils/useLocalStorage"; import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; import { logImEXEvent } from "../../firebase/firebase.utils"; @@ -24,16 +23,12 @@ const mapDispatchToProps = (dispatch) => ({ setBillEnterContext: (context) => dispatch(setModalContext({ context: context, modal: "billEnter" })) }); -export function BillsListPage({ loading, data, refetch, total, setBillEnterContext }) { +export function BillsListPage({ loading, data, refetch, total, setBillEnterContext, handleTableChange, sortedInfo }) { const search = queryString.parse(useLocation().search); const [openSearchResults, setOpenSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const { page } = search; const history = useNavigate(); - const [state, setState] = useLocalStorage("bills_list_sort", { - sortedInfo: {}, - filteredInfo: { vendorname: [] } - }); const Templates = TemplateList("bill"); const { t } = useTranslation(); @@ -50,7 +45,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte }), filters: (vendorsData?.vendors || []).map((v) => ({ text: v.name, value: v.id })), filteredValue: search.vendorIds ? search.vendorIds.split(",") : null, - sortOrder: state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order, + sortOrder: sortedInfo.columnKey === "vendorname" && sortedInfo.order, render: (text, record) => {record.vendor.name} }, { @@ -58,7 +53,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte dataIndex: "invoice_number", key: "invoice_number", sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number), - sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order + sortOrder: sortedInfo.columnKey === "invoice_number" && sortedInfo.order }, { title: t("jobs.fields.ro_number"), @@ -68,7 +63,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte sortObject: (order) => ({ job: { ro_number: order === "descend" ? "desc" : "asc" } }), - sortOrder: state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order, + sortOrder: sortedInfo.columnKey === "ro_number" && sortedInfo.order, render: (text, record) => record.job && {record.job.ro_number} }, { @@ -76,7 +71,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte dataIndex: "date", key: "date", sorter: (a, b) => dateSort(a.date, b.date), - sortOrder: state.sortedInfo.columnKey === "date" && state.sortedInfo.order, + sortOrder: sortedInfo.columnKey === "date" && sortedInfo.order, render: (text, record) => {record.date} }, { @@ -84,7 +79,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte dataIndex: "total", key: "total", sorter: (a, b) => a.total - b.total, - sortOrder: state.sortedInfo.columnKey === "total" && state.sortedInfo.order, + sortOrder: sortedInfo.columnKey === "total" && sortedInfo.order, render: (text, record) => {record.total} }, { @@ -92,7 +87,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte dataIndex: "is_credit_memo", key: "is_credit_memo", sorter: (a, b) => a.is_credit_memo - b.is_credit_memo, - sortOrder: state.sortedInfo.columnKey === "is_credit_memo" && state.sortedInfo.order, + sortOrder: sortedInfo.columnKey === "is_credit_memo" && sortedInfo.order, render: (text, record) => }, { @@ -100,7 +95,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte dataIndex: "exported", key: "exported", sorter: (a, b) => a.exported - b.exported, - sortOrder: state.sortedInfo.columnKey === "exported" && state.sortedInfo.order, + sortOrder: sortedInfo.columnKey === "exported" && sortedInfo.order, render: (text, record) => }, { @@ -164,37 +159,7 @@ export function BillsListPage({ loading, data, refetch, total, setBillEnterConte } ]; - const handleTableChange = (pagination, filters, sorter) => { - setState({ - sortedInfo: sorter, - filteredInfo: { ...state.filteredInfo, vendorname: filters.vendorname || [] } - }); - - search.page = pagination.current; - if (filters.vendorname && filters.vendorname.length) { - search.vendorIds = filters.vendorname.join(","); - } else { - delete search.vendorIds; - } - if (sorter && sorter.column && sorter.column.sortObject) { - search.searchObj = JSON.stringify(sorter.column.sortObject(sorter.order)); - delete search.sortcolumn; - delete search.sortorder; - } else { - delete search.searchObj; - search.sortcolumn = sorter.order ? sorter.columnKey : null; - search.sortorder = sorter.order; - } - history({ search: queryString.stringify(search) }); - logImEXEvent("bills_list_sort_filter", { pagination, filters, sorter }); - }; - - useEffect(() => { - if (!search.vendorIds && state.filteredInfo.vendorname && state.filteredInfo.vendorname.length) { - search.vendorIds = state.filteredInfo.vendorname.join(","); - history({ search: queryString.stringify(search) }); - } - }, []); + // (State & URL handling moved to container - Option C) useEffect(() => { if (search.search && search.search.trim() !== "") { diff --git a/client/src/pages/bills/bills.page.container.jsx b/client/src/pages/bills/bills.page.container.jsx index 68c5d4c01..f4a7a1859 100644 --- a/client/src/pages/bills/bills.page.container.jsx +++ b/client/src/pages/bills/bills.page.container.jsx @@ -3,13 +3,14 @@ import queryString from "query-string"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import AlertComponent from "../../components/alert/alert.component"; import BillDetailEditContainer from "../../components/bill-detail-edit/bill-detail-edit.container"; import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component"; import { QUERY_ALL_BILLS_PAGINATED } from "../../graphql/bills.queries"; import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions"; import BillsPageComponent from "./bills.page.component"; +import useLocalStorage from "../../utils/useLocalStorage"; import { pageLimit } from "../../utils/config"; import FeatureWrapperComponent from "../../components/feature-wrapper/feature-wrapper.component"; import InstanceRenderManager from "../../utils/instanceRenderMgr"; @@ -23,7 +24,9 @@ const mapDispatchToProps = (dispatch) => ({ export function BillsPageContainer({ setBreadcrumbs, setSelectedHeader }) { const { t } = useTranslation(); - const searchParams = queryString.parse(useLocation().search); + const location = useLocation(); + const history = useNavigate(); + const searchParams = queryString.parse(location.search); const { page, sortcolumn, sortorder, searchObj } = searchParams; useEffect(() => { @@ -37,6 +40,12 @@ export function BillsPageContainer({ setBreadcrumbs, setSelectedHeader }) { setBreadcrumbs([{ link: "/manage/bills", label: t("titles.bc.bills-list") }]); }, [t, setBreadcrumbs, setSelectedHeader]); + // Persisted table state (sorting & filtering) + const [persistState, setPersistState] = useLocalStorage("bills_list_sort", { + sortedInfo: {}, + filteredInfo: { vendorname: [] } + }); + const { loading, error, data, refetch } = useQuery(QUERY_ALL_BILLS_PAGINATED, { fetchPolicy: "network-only", nextFetchPolicy: "network-only", @@ -54,6 +63,90 @@ export function BillsPageContainer({ setBreadcrumbs, setSelectedHeader }) { } }); + const handleTableChange = (pagination, filters, sorter) => { + const search = queryString.parse(location.search); + + const vendorArr = filters?.vendorname ?? []; + const newVendorIds = vendorArr.length ? vendorArr.join(",") : undefined; + const vendorFilterChanged = search.vendorIds !== newVendorIds; + + search.page = vendorFilterChanged || !search.page ? 1 : pagination.current; + newVendorIds ? (search.vendorIds = newVendorIds) : delete search.vendorIds; + + const { columnKey, order, column } = sorter || {}; + if (column?.sortObject) { + search.searchObj = JSON.stringify(column.sortObject(order)); + delete search.sortcolumn; + delete search.sortorder; + } else { + delete search.searchObj; + search.sortcolumn = order ? columnKey : null; + search.sortorder = order ?? null; // keep explicit null to mirror prior behavior + } + + setPersistState({ + sortedInfo: sorter || {}, + filteredInfo: { vendorname: vendorArr } + }); + + history({ search: queryString.stringify(search) }); + }; + + useEffect(() => { + const search = queryString.parse(location.search); + let changed = false; + + const vendorPersisted = persistState.filteredInfo.vendorname || []; + if (!search.vendorIds && vendorPersisted.length) { + search.vendorIds = vendorPersisted.join(","); + search.page = 1; // reset page when injecting filter + changed = true; + } + + const { sortedInfo } = persistState; + if (!search.searchObj && !search.sortcolumn && sortedInfo?.order) { + const { columnKey, order } = sortedInfo; + if (columnKey) { + const dir = order === "descend" ? "desc" : "asc"; + if (columnKey === "vendorname") { + search.searchObj = JSON.stringify({ vendor: { name: dir } }); + } else if (columnKey === "ro_number") { + search.searchObj = JSON.stringify({ job: { ro_number: dir } }); + } else { + search.sortcolumn = columnKey; + search.sortorder = order; + } + changed = true; + } + } + + if (changed) { + history({ search: queryString.stringify(search) }); + return; + } + + const hasPersistSort = !!sortedInfo?.order; + const hasUrlSort = !!(search.searchObj || (search.sortcolumn && search.sortorder)); + if (!hasPersistSort && hasUrlSort) { + let derived = {}; + if (search.searchObj) { + try { + const o = JSON.parse(search.searchObj); + if (o.vendor?.name) { + derived = { columnKey: "vendorname", order: o.vendor.name === "desc" ? "descend" : "ascend" }; + } else if (o.job?.ro_number) { + derived = { columnKey: "ro_number", order: o.job.ro_number === "desc" ? "descend" : "ascend" }; + } + } catch { + /* ignore parse errors */ + } + } else { + derived = { columnKey: search.sortcolumn, order: search.sortorder }; + } + if (derived.order) setPersistState((prev) => ({ ...prev, sortedInfo: derived })); + } + }, [location.search]); + if (error) return ; return ( diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index 7b68952a7..496ea81f2 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -50,7 +50,7 @@ export const socket = SocketIO( export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, insertAuditTrail }) { const { t } = useTranslation(); - const [logLevel, setLogLevel] = useState("DEBUG"); + const [logLevel, setLogLevel] = useState(determineDmsType(bodyshop) === "pbs" ? "INFO" : "DEBUG"); const history = useNavigate(); const [logs, setLogs] = useState([]); const search = queryString.parse(useLocation().search); diff --git a/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx b/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx index 1849b9b63..5cc1f308d 100644 --- a/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx +++ b/client/src/pages/tech-assigned-prod-jobs/tech-assigned-prod-jobs.component.jsx @@ -97,7 +97,7 @@ export function TechAssignedProdJobs({ setTimeTicketTaskContext, technician, bod .filter(onlyUnique) .map((s) => { return { - text: s || "No Status*", + text: s || t("dashboard.errors.status"), value: [s] }; })) || diff --git a/client/src/redux/user/user.sagas.js b/client/src/redux/user/user.sagas.js index 1c50357cc..c508bf96e 100644 --- a/client/src/redux/user/user.sagas.js +++ b/client/src/redux/user/user.sagas.js @@ -49,7 +49,7 @@ import { validatePasswordResetSuccess } from "./user.actions"; import UserActionTypes from "./user.types"; -import * as amplitude from '@amplitude/analytics-browser'; +//import * as amplitude from '@amplitude/analytics-browser'; import posthog from 'posthog-js'; const fpPromise = FingerprintJS.load(); @@ -92,7 +92,7 @@ export function* isUserAuthenticated() { } LogRocket.identify(user.email); - amplitude.setUserId(user.email); + //amplitude.setUserId(user.email); posthog.identify(user.email); const eulaQuery = yield client.query({ @@ -139,7 +139,7 @@ export function* signOutStart() { imexshopid: state.user.bodyshop.imexshopid, type: "messaging" }); - amplitude.reset(); + //amplitude.reset(); } catch { console.log("No FCM token. Skipping unsubscribe."); } @@ -365,7 +365,7 @@ export function* SetAuthLevelFromShopDetails({ payload }) { } try { - amplitude.setGroup('Shop', payload.shopname); + //amplitude.setGroup('Shop', payload.shopname); window.$crisp.push(["set", "user:company", [payload.shopname]]); if (authRecord[0] && authRecord[0].user.validemail) { window.$crisp.push(["set", "user:email", [authRecord[0].user.email]]); diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index aca3da3e2..05219fd5e 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -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", @@ -321,6 +322,7 @@ "itc_local": "Local Tax is ITC?", "itc_state": "State Tax is ITC?", "mappingname": "DMS Mapping Name", + "ro_posting": "Create $0 RO?", "sendmaterialscosting": "Materials Cost as % of Sale", "srcco": "Source Company #/Dealer #" }, @@ -996,6 +998,7 @@ "insco": "No Ins. Co.*", "refreshrequired": "You must refresh the dashboard data to see this component.", "status": "No Status*", + "status_normal": "No Status", "updatinglayout": "Error saving updated layout {{message}}" }, "labels": { diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 345712670..20610391c 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -281,6 +281,7 @@ }, "fields": { "ReceivableCustomField": "", + "accumulatePayableLines": "", "address1": "", "address2": "", "appt_alt_transport": "", @@ -321,6 +322,7 @@ "itc_local": "", "itc_state": "", "mappingname": "", + "ro_posting": "", "sendmaterialscosting": "", "srcco": "" }, @@ -996,6 +998,7 @@ "insco": "", "refreshrequired": "", "status": "", + "status_normal": "", "updatinglayout": "" }, "labels": { diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index c3f680059..76d5bbd1d 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -281,6 +281,7 @@ }, "fields": { "ReceivableCustomField": "", + "accumulatePayableLines": "", "address1": "", "address2": "", "appt_alt_transport": "", @@ -321,6 +322,7 @@ "itc_local": "", "itc_state": "", "mappingname": "", + "ro_posting": "", "sendmaterialscosting": "", "srcco": "" }, @@ -996,6 +998,7 @@ "insco": "", "refreshrequired": "", "status": "", + "status_normal": "", "updatinglayout": "" }, "labels": { diff --git a/docker-compose-cluster.yml b/docker-compose-cluster.yml index 86defaf50..bbce31dd4 100644 --- a/docker-compose-cluster.yml +++ b/docker-compose-cluster.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index ad805e272..261076522 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/hasura/metadata/cron_triggers.yaml b/hasura/metadata/cron_triggers.yaml index a9bcc3361..2c8c4c91d 100644 --- a/hasura/metadata/cron_triggers.yaml +++ b/hasura/metadata/cron_triggers.yaml @@ -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 * * * diff --git a/server/accounting/pbs/pbs-ap-allocations.js b/server/accounting/pbs/pbs-ap-allocations.js index 0fc2d0c4d..56fe0e78a 100644 --- a/server/accounting/pbs/pbs-ap-allocations.js +++ b/server/accounting/pbs/pbs-ap-allocations.js @@ -177,11 +177,9 @@ exports.PbsCalculateAllocationsAp = PbsCalculateAllocationsAp; async function QueryBillData(socket, billids) { WsLogger.createLogEvent(socket, "DEBUG", `Querying bill data for id(s) ${billids}`); const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); - const currentToken = - (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); const result = await client - .setHeaders({ Authorization: `Bearer ${currentToken}` }) + .setHeaders({ Authorization: `Bearer ${socket.handshake.auth.token}` }) .request(queries.GET_PBS_AP_ALLOCATIONS, { billids: billids }); WsLogger.createLogEvent(socket, "SILLY", `Bill data query result ${JSON.stringify(result, null, 2)}`); @@ -201,11 +199,9 @@ function getCostAccount(billline, respcenters) { async function MarkApExported(socket, billids) { WsLogger.createLogEvent(socket, "DEBUG", `Marking bills as exported for id ${billids}`); const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); - const currentToken = - (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); const result = await client - .setHeaders({ Authorization: `Bearer ${currentToken}` }) + .setHeaders({ Authorization: `Bearer ${socket.handshake.auth.token}` }) .request(queries.MARK_BILLS_EXPORTED, { billids, bill: { diff --git a/server/accounting/pbs/pbs-constants.js b/server/accounting/pbs/pbs-constants.js index c8a6d54f3..7c54f4d0e 100644 --- a/server/accounting/pbs/pbs-constants.js +++ b/server/accounting/pbs/pbs-constants.js @@ -6,10 +6,6 @@ const PBS_CREDENTIALS = { }; exports.PBS_CREDENTIALS = PBS_CREDENTIALS; -// const cdkDomain = -// process.env.NODE_ENV === "production" -// ? "https://3pa.dmotorworks.com" -// : "https://uat-3pa.dmotorworks.com"; const pbsDomain = `https://partnerhub.pbsdealers.com/json/reply`; exports.PBS_ENDPOINTS = { @@ -18,5 +14,9 @@ exports.PBS_ENDPOINTS = { VehicleGet: `${pbsDomain}/VehicleGet`, AccountingPostingChange: `${pbsDomain}/AccountingPostingChange`, ContactChange: `${pbsDomain}/ContactChange`, - VehicleChange: `${pbsDomain}/VehicleChange` + VehicleChange: `${pbsDomain}/VehicleChange`, + RepairOrderChange: `${pbsDomain}/RepairOrderChange`, //TODO: Verify that this is correct. Docs have /reply/ in path. + RepairOrderGet: `${pbsDomain}/RepairOrderGet`, + RepairOrderContactVehicleGet: `${pbsDomain}/RepairOrderContactVehicleGet`, + RepairOrderContactVehicleChange: `${pbsDomain}/RepairOrderContactVehicleChange`, }; diff --git a/server/accounting/pbs/pbs-job-export.js b/server/accounting/pbs/pbs-job-export.js index 04ee907d0..911382e15 100644 --- a/server/accounting/pbs/pbs-job-export.js +++ b/server/accounting/pbs/pbs-job-export.js @@ -3,6 +3,8 @@ const AxiosLib = require("axios").default; const queries = require("../../graphql-client/queries"); const { PBS_ENDPOINTS, PBS_CREDENTIALS } = require("./pbs-constants"); const WsLogger = require("../../web-sockets/createLogEvent"); +const fs = require("fs"); +const path = require("path"); //const { CDK_CREDENTIALS, CheckCdkResponseForError } = require("./cdk-wsdl"); const CalculateAllocations = require("../../cdk/cdk-calculate-allocations").default; @@ -22,9 +24,9 @@ axios.interceptors.request.use((x) => { const printable = `${new Date()} | Request: ${x.method.toUpperCase()} | ${ x.url } | ${JSON.stringify(x.data)} | ${JSON.stringify(headers)}`; - //console.log(printable); + //logRequestToFile(printable); - WsLogger.createJsonEvent(socket, "SILLY", `Raw Request: ${printable}`, x.data); + WsLogger.createJsonEvent(socket, "DEBUG", `Raw Request: ${printable}`, x.data); return x; }); @@ -32,23 +34,36 @@ axios.interceptors.request.use((x) => { axios.interceptors.response.use((x) => { const socket = x.config.socket; - const printable = `${new Date()} | Response: ${x.status} | ${JSON.stringify(x.data)}`; - //console.log(printable); - WsLogger.createJsonEvent(socket, "SILLY", `Raw Response: ${printable}`, x.data); + const printable = `${new Date()} | Response: ${x.status} ${x.statusText} |${JSON.stringify(x.data)}`; + //logRequestToFile(printable); + WsLogger.createJsonEvent(socket, "DEBUG", `Raw Response: ${printable}`, x.data); return x; }); +function logRequestToFile(printable) { + try { + const logDir = path.join(process.cwd(), "logs"); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); + } + const logFile = path.join(logDir, "pbs-http.log"); + fs.appendFileSync(logFile, `${printable}\n`); + } catch (err) { + console.error("Unexpected error in logRequestToFile:", err); + } +} + const defaultHandler = async (socket, { txEnvelope, jobid }) => { socket.logEvents = []; socket.recordid = jobid; socket.txEnvelope = txEnvelope; try { - WsLogger.createLogEvent(socket, "DEBUG", `Received Job export request for id ${jobid}`); + WsLogger.createLogEvent(socket, "INFO", `Received Job export request for id ${jobid}`); const JobData = await QueryJobData(socket, jobid); socket.JobData = JobData; - WsLogger.createLogEvent(socket, "DEBUG", `Querying the DMS for the Vehicle Record.`); + WsLogger.createLogEvent(socket, "INFO", `Querying the DMS for the Vehicle Record.`); //Query for the Vehicle record to get the associated customer. socket.DmsVeh = await QueryVehicleFromDms(socket); //Todo: Need to validate the lines and methods below. @@ -71,42 +86,53 @@ exports.default = defaultHandler; exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selectedCustomerId) { try { - if (socket.JobData.bodyshop.pbs_configuration.disablecontactvehicle === false) { - WsLogger.createLogEvent(socket, "DEBUG", `User selected customer ${selectedCustomerId || "NEW"}`); + socket.selectedCustomerId = selectedCustomerId; + if (socket.JobData.bodyshop.pbs_configuration.disablecontactvehicle !== true) { + WsLogger.createLogEvent(socket, "INFO", `User selected customer ${selectedCustomerId || "NEW"}`); //Upsert the contact information as per Wafaa's Email. WsLogger.createLogEvent( socket, - "DEBUG", + "INFO", `Upserting contact information to DMS for ${ socket.JobData.ownr_fn || "" } ${socket.JobData.ownr_ln || ""} ${socket.JobData.ownr_co_nm || ""}` ); const ownerRef = await UpsertContactData(socket, selectedCustomerId); + socket.ownerRef = ownerRef; - WsLogger.createLogEvent(socket, "DEBUG", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`); - await UpsertVehicleData(socket, ownerRef.ReferenceId); + WsLogger.createLogEvent(socket, "INFO", `Upserting vehicle information to DMS for ${socket.JobData.v_vin}`); + const vehicleRef = await UpsertVehicleData(socket, ownerRef.ReferenceId); + socket.vehicleRef = vehicleRef; } else { WsLogger.createLogEvent( socket, - "DEBUG", - `Contact and Vehicle updates disabled. Skipping to accounting data insert.` + "INFO", + `Contact and Vehicle updates disabled. Querying data and skipping to accounting data insert.` ); + //Must query for records to insert $0 RO. + if (!socket.ownerRef) { + const ownerRef = (await QueryCustomerBycodeFromDms(socket, selectedCustomerId))?.[0]; + socket.ownerRef = ownerRef; + } + const vehicleRef = await GetVehicleData(socket, socket.ownerRef?.ReferenceId || socket.selectedCustomerId); + socket.vehicleRef = vehicleRef; } - WsLogger.createLogEvent(socket, "DEBUG", `Inserting account data.`); - WsLogger.createLogEvent(socket, "DEBUG", `Inserting accounting posting data..`); + WsLogger.createLogEvent(socket, "INFO", `Inserting account posting data...`); const insertResponse = await InsertAccountPostingData(socket); if (insertResponse.WasSuccessful) { - WsLogger.createLogEvent(socket, "DEBUG", `Marking job as exported.`); + if (socket.JobData.bodyshop.pbs_configuration.ro_posting) { + await CreateRepairOrderInPBS(socket, socket.ownerRef, socket.vehicleRef); + } + WsLogger.createLogEvent(socket, "INFO", `Marking job as exported.`); await MarkJobExported(socket, socket.JobData.id); - socket.emit("export-success", socket.JobData.id); } else { WsLogger.createLogEvent(socket, "ERROR", `Export was not successful.`); } } catch (error) { - WsLogger.createLogEvent(socket, "ERROR", `Error encountered in CdkSelectedCustomer. ${error}`); + WsLogger.createLogEvent(socket, "ERROR", `Error encountered in PbsSelectedCustomer. ${error}`); await InsertFailedExportLog(socket, error); } }; @@ -114,26 +140,24 @@ exports.PbsSelectedCustomer = async function PbsSelectedCustomer(socket, selecte // Was Successful async function CheckForErrors(socket, response) { if (response.WasSuccessful === undefined || response.WasSuccessful === true) { - WsLogger.createLogEvent(socket, "DEBUG", `Successful response from DMS. ${response.Message || ""}`); + WsLogger.createLogEvent(socket, "INFO", `Successful response from DMS. ${response.Message || ""}`); } else { WsLogger.createLogEvent(socket, "ERROR", `Error received from DMS: ${response.Message}`); - WsLogger.createLogEvent(socket, "SILLY", `Error received from DMS: ${JSON.stringify(response)}`); + WsLogger.createLogEvent(socket, "DEBUG", `Error received from DMS: ${JSON.stringify(response)}`); } } exports.CheckForErrors = CheckForErrors; async function QueryJobData(socket, jobid) { - WsLogger.createLogEvent(socket, "DEBUG", `Querying job data for id ${jobid}`); + WsLogger.createLogEvent(socket, "INFO", `Querying job data for id ${jobid}`); const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); - const currentToken = - (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); const result = await client - .setHeaders({ Authorization: `Bearer ${currentToken}` }) + .setHeaders({ Authorization: `Bearer ${socket.handshake.auth.token}` }) .request(queries.QUERY_JOBS_FOR_PBS_EXPORT, { id: jobid }); - WsLogger.createLogEvent(socket, "SILLY", `Job data query result ${JSON.stringify(result, null, 2)}`); + WsLogger.createLogEvent(socket, "DEBUG", `Job data query result ${JSON.stringify(result, null, 2)}`); return result.jobs_by_pk; } @@ -338,7 +362,7 @@ async function UpsertVehicleData(socket, ownerRef) { //FleetNumber: "String", //Status: "String", OwnerRef: ownerRef, // "00000000000000000000000000000000", - ModelNumber: socket.JobData.vehicle?.v_makecode, + // ModelNumber: socket.JobData.vehicle?.v_makecode, Make: socket.JobData.v_make_desc, Model: socket.JobData.v_model_desc, Trim: socket.JobData.vehicle?.v_trimcode, @@ -346,7 +370,7 @@ async function UpsertVehicleData(socket, ownerRef) { Year: socket.JobData.v_model_yr, Odometer: socket.JobData.kmout, ExteriorColor: { - Code: socket.JobData.v_color, + // Code: socket.JobData.v_color, Description: socket.JobData.v_color } // InteriorColor: { Code: "String", Description: "String" }, @@ -476,6 +500,56 @@ async function UpsertVehicleData(socket, ownerRef) { } } +async function GetVehicleData(socket, ownerRef) { + try { + const { + data: { Vehicles } + } = await axios.post( + PBS_ENDPOINTS.VehicleGet, + { + SerialNumber: socket.JobData.bodyshop.pbs_serialnumber, + // "VehicleId": "00000000000000000000000000000000", + // "Year": "String", + // "YearFrom": "String", + // "YearTo": "String", + // "Make": "String", + // "Model": "String", + // "Trim": "String", + // "ModelNumber": "String", + // "StockNumber": "String", + VIN: socket.JobData.v_vin + // "LicenseNumber": "String", + // "Lot": "String", + // "Status": "String", + // "StatusList": ["String"], + // "OwnerRef": "00000000000000000000000000000000", + // "ModifiedSince": "0001-01-01T00:00:00.0000000Z", + // "ModifiedUntil": "0001-01-01T00:00:00.0000000Z", + // "LastSaleSince": "0001-01-01T00:00:00.0000000Z", + // "VehicleIDList": ["00000000000000000000000000000000"], + // "IncludeInactive": false, + // "IncludeBuildVehicles": false, + // "IncludeBlankLot": false, + // "ShortVIN": "String", + // "ResultLimit": 0, + // "LotAccessDivisions": [0], + // "OdometerTo": 0, + // "OdometerFrom": 0 + }, + { auth: PBS_CREDENTIALS, socket } + ); + CheckForErrors(socket, Vehicles); + if (Vehicles.length === 1) { + return Vehicles[0]; + } else { + WsLogger.createLogEvent(socket, "ERROR", `Error in Getting Vehicle Data - ${Vehicles.length} vehicle(s) found`); + } + } catch (error) { + WsLogger.createLogEvent(socket, "ERROR", `Error in UpsertVehicleData - ${error}`); + throw new Error(error); + } +} + async function InsertAccountPostingData(socket) { try { const allocations = await CalculateAllocations(socket, socket.JobData.id); @@ -579,13 +653,11 @@ async function InsertAccountPostingData(socket) { } async function MarkJobExported(socket, jobid) { - WsLogger.createLogEvent(socket, "DEBUG", `Marking job as exported for id ${jobid}`); + WsLogger.createLogEvent(socket, "INFO", `Marking job as exported for id ${jobid}`); const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); - const currentToken = - (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); const result = await client - .setHeaders({ Authorization: `Bearer ${currentToken}` }) + .setHeaders({ Authorization: `Bearer ${socket.handshake.auth.token}` }) .request(queries.MARK_JOB_EXPORTED, { jobId: jobid, job: { @@ -611,11 +683,9 @@ async function MarkJobExported(socket, jobid) { async function InsertFailedExportLog(socket, error) { try { const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {}); - const currentToken = - (socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token); const result = await client - .setHeaders({ Authorization: `Bearer ${currentToken}` }) + .setHeaders({ Authorization: `Bearer ${socket.handshake.auth.token}` }) .request(queries.INSERT_EXPORT_LOG, { log: { bodyshopid: socket.JobData.bodyshop.id, @@ -631,3 +701,161 @@ async function InsertFailedExportLog(socket, error) { WsLogger.createLogEvent(socket, "ERROR", `Error in InsertFailedExportLog - ${error} - ${JSON.stringify(error2)}`); } } + +async function CreateRepairOrderInPBS(socket) { + try { + const { RepairOrders } = await RepairOrderGet(socket); + if (RepairOrders.length === 0) { + const InsertedRepairOrder = await RepairOrderChange(socket); + socket.InsertedRepairOrder = InsertedRepairOrder; + WsLogger.createLogEvent(socket, "INFO", `No repair orders found for vehicle. Inserting record.`); + } else if (RepairOrders.length > 0) { + //Find out if it's a matching RO. + //This logic is used because the integration will simply add another line to an open RO if it exists. + const matchingRo = RepairOrders.find((ro) => + ro.Memo?.toLowerCase()?.includes(socket.JobData.ro_number.toLowerCase()) + ); + if (!matchingRo) { + WsLogger.createLogEvent(socket, "INFO", `ROs found for vehicle, but none match. Inserting record.`); + const InsertedRepairOrder = await RepairOrderChange(socket); + socket.InsertedRepairOrder = InsertedRepairOrder; + } else { + WsLogger.createLogEvent( + socket, + "WARN", + `Repair order appears to already exist in PBS. ${matchingRo.RepairOrderNumber}` + ); + } + } + } catch (error) { + WsLogger.createLogEvent(socket, "ERROR", `Error in CreateRepairOrderInPBS - ${error} - ${JSON.stringify(error)}`); + } +} + +async function RepairOrderGet(socket) { + try { + const { data: RepairOrderGet } = await axios.post( + PBS_ENDPOINTS.RepairOrderGet, + { + SerialNumber: socket.JobData.bodyshop.pbs_serialnumber, + //"RepairOrderId": "374728766", + //"RepairOrderNumber": "4" || socket.JobData.ro_number, + //"RawRepairOrderNumber": socket.JobData.ro_number, + // "Tag": "String", + //"ContactRef": socket.contactRef, + // "ContactRefList": ["00000000000000000000000000000000"], + VehicleRef: socket.vehicleRef?.ReferenceId || socket.vehicleRef?.VehicleId + // "VehicleRefList": ["00000000000000000000000000000000"], + // "Status": "String", + // "CashieredSince": "0001-01-01T00:00:00.0000000Z", + // "CashieredUntil": "0001-01-01T00:00:00.0000000Z", + // "OpenDateSince": "0001-01-01T00:00:00.0000000Z", + // "OpenDateUntil": "0001-01-01T00:00:00.0000000Z", + //"ModifiedSince": "2025-01-01T00:00:00.0000000Z", + // "ModifiedUntil": "0001-01-01T00:00:00.0000000Z", + // "Shop": "String" + }, + { auth: PBS_CREDENTIALS, socket } + ); + CheckForErrors(socket, RepairOrderGet); + return RepairOrderGet; + } catch (error) { + WsLogger.createLogEvent(socket, "ERROR", `Error in RepairOrderChange - ${error}`); + throw new Error(error); + } +} + +async function RepairOrderChange(socket) { + try { + const { data: RepairOrderChangeResponse } = await axios.post( + PBS_ENDPOINTS.RepairOrderChange, + { + //Additional details at https://partnerhub.pbsdealers.com/json/metadata?op=RepairOrderChange + RepairOrderInfo: { + //"Id": "string/00000000-0000-0000-0000-000000000000", + //"RepairOrderId": "00000000000000000000000000000000", + SerialNumber: socket.JobData.bodyshop.pbs_serialnumber, + RepairOrderNumber: "00000000000000000000000000000000", //This helps force a new RO. + RawRepairOrderNumber: "00000000000000000000000000000000", + // "RepairOrderNumber": socket.JobData.ro_number, //These 2 values are ignored as confirmed by PBS. + // "RawRepairOrderNumber": socket.JobData.ro_number, + DateOpened: moment(), + // "DateOpenedUTC": "0001-01-01T00:00:00.0000000Z", + // "DateCashiered": "0001-01-01T00:00:00.0000000Z", + // "DateCashieredUTC": "0001-01-01T00:00:00.0000000Z", + DatePromised: socket.JobData.scheduled_completion, + // "DatePromisedUTC": "0001-01-01T00:00:00.0000000Z", + DateVehicleCompleted: socket.JobData.actual_completion, + // "DateCustomerNotified": "0001-01-01T00:00:00.0000000Z", + // "CSR": "String", + // "CSRRef": "00000000000000000000000000000000", + // "BookingUser": "String", + // "BookingUserRef": "00000000000000000000000000000000", + ContactRef: socket.ownerRef?.ReferenceId || socket.ownerRef?.ContactId, + VehicleRef: socket.vehicleRef?.ReferenceId || socket.vehicleRef?.VehicleId, + MileageIn: socket.JobData.km_in, + Tag: "BODYSHOP", + //"Status": "CLOSED", //Values here do not impact the status. Confirmed by PBS support. + Requests: [ + { + // "RepairOrderRequestRef": "b1842ecad62c4279bbc2fef4f6bf6cde", + // "RepairOrderRequestId": 1, + // "CSR": "PBS", + // "CSRRef": "1ce12ac692564e94bda955d529ee911a", + // "Skill": "GEN", + RequestCode: "MISC", + RequestDescription: `VEHICLE REPAIRED AT BODYSHOP. PLEASE REFERENCE IMEX SHOP MANAGEMENT SYSTEM. ${socket.txEnvelope.story}`, + Status: "Completed", + // "TechRef": "00000000000000000000000000000000", + AllowedHours: 0, + EstimateLabour: 0, + EstimateParts: 0, + ComeBack: false, + AddedOperation: true, + PartLines: [], + PartRequestLines: [], + LabourLines: [], + SubletLines: [], + TimePunches: [], + Summary: { + Labour: 0, + Parts: 0, + OilGas: 0, + SubletTow: 0, + Misc: 0, + Environment: 0, + ShopSupplies: 0, + Freight: 0, + WarrantyDeductible: 0, + Discount: 0, + SubTotal: 0, + Tax1: 0, + Tax2: 0, + InvoiceTotal: 0, + CustomerDeductible: 0, + GrandTotal: 0, + LabourDiscount: 0, + PartDiscount: 0, + ServiceFeeTotal: 0, + OEMDiscount: 0 + }, + LineType: "RequestLine" + } + ], + + Memo: socket.txEnvelope.story + }, + IsAsynchronous: false + // "UserRequest": "String", + // "UserRef": "00000000000000000000000000000000" + }, + + { auth: PBS_CREDENTIALS, socket } + ); + CheckForErrors(socket, RepairOrderChangeResponse); + return RepairOrderChangeResponse; + } catch (error) { + WsLogger.createLogEvent(socket, "ERROR", `Error in RepairOrderChange - ${error}`); + throw new Error(error); + } +} diff --git a/server/accounting/qbo/qbo-payables.js b/server/accounting/qbo/qbo-payables.js index 9f0aacc42..a21e8ebc0 100644 --- a/server/accounting/qbo/qbo-payables.js +++ b/server/accounting/qbo/qbo-payables.js @@ -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 ) }) diff --git a/server/accounting/qbxml/qbxml-payables.js b/server/accounting/qbxml/qbxml-payables.js index 9e658ecea..2b5efaf18 100644 --- a/server/accounting/qbxml/qbxml-payables.js +++ b/server/accounting/qbxml/qbxml-payables.js @@ -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 } } } diff --git a/server/data/carfax-rps.js b/server/data/carfax-rps.js new file mode 100644 index 000000000..d6065df57 --- /dev/null +++ b/server/data/carfax-rps.js @@ -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 +}; diff --git a/server/data/carfax.js b/server/data/carfax.js index 34a145dda..f2ff0bac2 100644 --- a/server/data/carfax.js +++ b/server/data/carfax.js @@ -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 +}; diff --git a/server/data/data.js b/server/data/data.js index e9b80de5a..0aa7f6e34 100644 --- a/server/data/data.js +++ b/server/data/data.js @@ -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; \ No newline at end of file diff --git a/server/email/sendemail.js b/server/email/sendemail.js index c9a72c17f..864031462 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -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} `, rome: `Rome Online API - ${process.env.NODE_ENV} ` }), - 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) { diff --git a/server/graphql-client/graphql-client.js b/server/graphql-client/graphql-client.js index 79d86315b..2c42bf361 100644 --- a/server/graphql-client/graphql-client.js +++ b/server/graphql-client/graphql-client.js @@ -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 }; diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 9ff853e0f..397e90de8 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -420,6 +420,8 @@ query QUERY_JOBS_FOR_PBS_EXPORT($id: uuid!) { v_make_desc v_color ca_customer_gst + scheduled_completion + actual_completion vehicle { v_trimcode v_makecode @@ -920,6 +922,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 @@ -1870,6 +1907,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 @@ -2164,18 +2208,16 @@ exports.UPDATE_OLD_TRANSITION = `mutation UPDATE_OLD_TRANSITION($jobid: uuid!, $ exports.INSERT_NEW_TRANSITION = ( includeOldTransition -) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${ - includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : "" -}) { +) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : "" + }) { insert_transitions_one(object: $newTransition) { id } - ${ - includeOldTransition - ? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) { + ${includeOldTransition + ? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) { affected_rows }` - : "" + : "" } }`; diff --git a/server/parts-scan/parts-scan.js b/server/parts-scan/parts-scan.js index 6822bded6..79eda54d6 100644 --- a/server/parts-scan/parts-scan.js +++ b/server/parts-scan/parts-scan.js @@ -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 diff --git a/server/routes/dataRoutes.js b/server/routes/dataRoutes.js index 8e7bc04fd..c72a2a502 100644 --- a/server/routes/dataRoutes.js +++ b/server/routes/dataRoutes.js @@ -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;