feature/IO-2968-Parts-Scanning-Extension

This commit is contained in:
Dave Richer
2024-12-10 11:15:24 -08:00
parent cec5f6e6e7
commit b8a298fc28
8 changed files with 311 additions and 119 deletions

View File

@@ -1,11 +1,4 @@
import isBetween from "dayjs/plugin/isBetween"; import {DateLocalizer} from "react-big-calendar";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import localeData from "dayjs/plugin/localeData";
import localizedFormat from "dayjs/plugin/localizedFormat";
import minMax from "dayjs/plugin/minMax";
import utc from "dayjs/plugin/utc";
import { DateLocalizer } from "react-big-calendar";
function arrayWithHoles(arr) { function arrayWithHoles(arr) {
if (Array.isArray(arr)) return arr; if (Array.isArray(arr)) return arr;
@@ -29,7 +22,9 @@ function iterableToArrayLimit(arr, i) {
try { try {
if (!_n && _i["return"] != null) _i["return"](); if (!_n && _i["return"] != null) _i["return"]();
} finally { } finally {
if (_d) throw _e; if (_d) { // noinspection ThrowInsideFinallyBlockJS
throw _e;
}
} }
} }
return _arr; return _arr;

View File

@@ -1,75 +1,188 @@
import { DeleteFilled } from "@ant-design/icons"; import {DeleteFilled} from "@ant-design/icons";
import { Button, Form, Input, Space } from "antd"; import {Button, Col, Form, Input, Row, Select, Space, Switch} from "antd";
import React from "react"; import React, {useMemo} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
export default function ShopInfoPartsScan({ form }) { const predefinedPartTypes = [
const { t } = useTranslation(); "PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG"
];
const predefinedModLbrTypes = [
"LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU",
"LA1", "LA2", "LA3", "LA4"
];
const getFieldType = (field) => {
if (["line_desc", "part_number"].includes(field)) return "string";
if (["act_price", "part_qty", "mod_lb_hrs"].includes(field)) return "number";
if (["part_type", "mod_lbr_ty"].includes(field)) return "predefined";
return null;
};
export default function ShopInfoPartsScan({form}) {
const {t} = useTranslation();
const watchedFields = Form.useWatch("md_parts_scan", form);
const operationOptions = useMemo(() => ({
string: [
{label: t("bodyshop.operations.contains"), value: "contains"},
{label: t("bodyshop.operations.equals"), value: "equals"},
{label: t("bodyshop.operations.starts_with"), value: "startsWith"},
{label: t("bodyshop.operations.ends_with"), value: "endsWith"},
],
number: [
{label: t("bodyshop.operations.equals"), value: "="},
{label: t("bodyshop.operations.greater_than"), value: ">"},
{label: t("bodyshop.operations.less_than"), value: "<"},
],
}), [t]);
return ( return (
<div> <div>
<LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}> <LayoutFormRow header={t("bodyshop.labels.md_parts_scan")}>
<Form.List name={["md_parts_scan"]}> <Form.List name={["md_parts_scan"]}>
{(fields, { add, remove, move }) => { {(fields, {add, remove, move}) => (
return ( <div>
<div> {fields.map((field, index) => {
{fields.map((field, index) => ( const selectedField = watchedFields?.[index]?.field || "line_desc";
<Form.Item key={field.key}> const fieldType = getFieldType(selectedField);
<LayoutFormRow noDivider>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.expression")}
key={`${index}expression`}
name={[field.name, "expression"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.flags")}
key={`${index}flags`}
name={[field.name, "flags"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Space wrap> return (
<DeleteFilled <Form.Item key={field.key}>
onClick={() => { <Row gutter={[16, 16]} align="middle">
remove(field.name); {/* Select Field */}
}} <Col span={6}>
/> <Form.Item
<FormListMoveArrows move={move} index={index} total={fields.length} /> label={t("bodyshop.fields.md_parts_scan.field")}
</Space> name={[field.name, "field"]}
</LayoutFormRow> rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.field"),
}),
},
]}
>
<Select
options={[
{label: t("joblines.fields.line_desc"), value: "line_desc"},
{label: t("joblines.fields.part_type"), value: "part_type"},
{label: t("joblines.fields.act_price"), value: "act_price"},
{label: t("joblines.fields.part_qty"), value: "part_qty"},
{label: t("joblines.fields.mod_lbr_ty"), value: "mod_lbr_ty"},
{label: t("joblines.fields.mod_lb_hrs"), value: "mod_lb_hrs"},
{
label: `${t("joblines.fields.oem_partno")} / ${t("joblines.fields.alt_partno")}`,
value: "part_number"
},
]}
onChange={() => {
form.setFields([
{name: ["md_parts_scan", index, "operation"], value: "contains"},
{name: ["md_parts_scan", index, "value"], value: undefined},
]);
}}
/>
</Form.Item>
</Col>
{/* Operation */}
{fieldType !== "predefined" && fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.operation")}
name={[field.name, "operation"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.operation"),
}),
},
]}
>
<Select options={operationOptions[fieldType]}/>
</Form.Item>
</Col>
)}
{/* Value */}
{fieldType && (
<Col span={6}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.value")}
name={[field.name, "value"]}
rules={[
{
required: true,
message: t("general.validation.required", {
label: t("bodyshop.fields.md_parts_scan.value"),
}),
},
]}
>
{fieldType === "predefined" ? (
<Select
options={
selectedField === "part_type"
? predefinedPartTypes.map((type) => ({
label: type,
value: type
}))
: predefinedModLbrTypes.map((type) => ({
label: type,
value: type
}))
}
/>
) : (
<Input/>
)}
</Form.Item>
</Col>
)}
{/* Case Sensitivity */}
{fieldType === "string" && (
<Col span={4}>
<Form.Item
label={t("bodyshop.fields.md_parts_scan.caseInsensitive")}
name={[field.name, "caseInsensitive"]}
valuePropName="checked"
labelCol={{span: 14}}
wrapperCol={{span: 10}}
>
<Switch defaultChecked={true}/>
</Form.Item>
</Col>
)}
{/* Actions */}
<Col span={2}>
<Space>
<DeleteFilled onClick={() => remove(field.name)}/>
<FormListMoveArrows move={move} index={index} total={fields.length}/>
</Space>
</Col>
</Row>
</Form.Item> </Form.Item>
))} );
<Form.Item> })}
<Button
type="dashed" <Form.Item>
onClick={() => { <Button
add(); type="dashed"
}} onClick={() => add({field: "line_desc", operation: "contains"})}
style={{ width: "100%" }} style={{width: "100%"}}
> >
{t("bodyshop.actions.addpartsrule")} {t("bodyshop.actions.addpartsrule")}
</Button> </Button>
</Form.Item> </Form.Item>
</div> </div>
); )}
}}
</Form.List> </Form.List>
</LayoutFormRow> </LayoutFormRow>
</div> </div>

View File

@@ -1,31 +1,32 @@
import { Alert, Form, InputNumber, Switch } from "antd"; import {Alert, Form, Switch} from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay); export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoIntellipay);
export function ShopInfoIntellipay({ bodyshop, form }) { // noinspection JSUnusedLocalSymbols
const { t } = useTranslation(); export function ShopInfoIntellipay({bodyshop, form}) {
const {t} = useTranslation();
return ( return (
<> <>
<Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}> <Form.Item dependencies={[["intellipay_config", "enable_cash_discount"]]}>
{() => { {() => {
const { intellipay_config } = form.getFieldsValue(); const {intellipay_config} = form.getFieldsValue();
if (intellipay_config?.enable_cash_discount) if (intellipay_config?.enable_cash_discount)
return <Alert message={t("bodyshop.labels.intellipay_cash_discount")} />; return <Alert message={t("bodyshop.labels.intellipay_cash_discount")}/>;
}} }}
</Form.Item> </Form.Item>
@@ -35,7 +36,7 @@ export function ShopInfoIntellipay({ bodyshop, form }) {
valuePropName="checked" valuePropName="checked"
name={["intellipay_config", "enable_cash_discount"]} name={["intellipay_config", "enable_cash_discount"]}
> >
<Switch /> <Switch/>
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
</> </>

View File

@@ -256,6 +256,15 @@
} }
}, },
"bodyshop": { "bodyshop": {
"operations": {
"starts_with": "Starts With",
"contains": "Contains",
"ends_with": "Ends With",
"equals": "Equals",
"not_equals": "Not Equals",
"greater_than": "Greater Than",
"less_than": "Less Than"
},
"actions": { "actions": {
"add_task_preset": "Add Task Preset", "add_task_preset": "Add Task Preset",
"addapptcolor": "Add Appointment Color", "addapptcolor": "Add Appointment Color",
@@ -379,8 +388,10 @@
"md_lost_sale_reasons": "Lost Sale Reasons", "md_lost_sale_reasons": "Lost Sale Reasons",
"md_parts_order_comment": "Parts Orders Comments", "md_parts_order_comment": "Parts Orders Comments",
"md_parts_scan": { "md_parts_scan": {
"expression": "RegEX Expression", "field": "Field",
"flags": "Flags" "operation": "Operation",
"value": "Value",
"caseInsensitive": "Case Insensitive"
}, },
"md_payment_types": "Payment Types", "md_payment_types": "Payment Types",
"md_referral_sources": "Referral Sources", "md_referral_sources": "Referral Sources",
@@ -1451,6 +1462,7 @@
"mod_lbr_ty": "Labor Type", "mod_lbr_ty": "Labor Type",
"notes": "Notes", "notes": "Notes",
"oem_partno": "OEM Part #", "oem_partno": "OEM Part #",
"alt_partno": "Alt Part #",
"op_code_desc": "Op Code Description", "op_code_desc": "Op Code Description",
"part_qty": "Qty.", "part_qty": "Qty.",
"part_type": "Part Type", "part_type": "Part Type",

View File

@@ -256,6 +256,15 @@
} }
}, },
"bodyshop": { "bodyshop": {
"operations": {
"starts_with": "",
"contains": "",
"ends_with": "",
"equals": "",
"not_equals": "",
"greater_than": "",
"less_than": ""
},
"actions": { "actions": {
"add_task_preset": "", "add_task_preset": "",
"addapptcolor": "", "addapptcolor": "",
@@ -379,8 +388,10 @@
"md_lost_sale_reasons": "", "md_lost_sale_reasons": "",
"md_parts_order_comment": "", "md_parts_order_comment": "",
"md_parts_scan": { "md_parts_scan": {
"expression": "", "field": "",
"flags": "" "operation": "",
"value": "",
"caseInsensitive": ""
}, },
"md_payment_types": "", "md_payment_types": "",
"md_referral_sources": "", "md_referral_sources": "",
@@ -1451,6 +1462,7 @@
"mod_lbr_ty": "Tipo de trabajo", "mod_lbr_ty": "Tipo de trabajo",
"notes": "", "notes": "",
"oem_partno": "OEM parte #", "oem_partno": "OEM parte #",
"alt_partno": "",
"op_code_desc": "", "op_code_desc": "",
"part_qty": "", "part_qty": "",
"part_type": "Tipo de parte", "part_type": "Tipo de parte",

View File

@@ -256,6 +256,15 @@
} }
}, },
"bodyshop": { "bodyshop": {
"operations": {
"starts_with": "",
"contains": "",
"ends_with": "",
"equals": "",
"not_equals": "",
"greater_than": "",
"less_than": ""
},
"actions": { "actions": {
"add_task_preset": "", "add_task_preset": "",
"addapptcolor": "", "addapptcolor": "",
@@ -379,8 +388,10 @@
"md_lost_sale_reasons": "", "md_lost_sale_reasons": "",
"md_parts_order_comment": "", "md_parts_order_comment": "",
"md_parts_scan": { "md_parts_scan": {
"expression": "", "field": "",
"flags": "" "operation": "",
"value": "",
"caseInsensitive": ""
}, },
"md_payment_types": "", "md_payment_types": "",
"md_referral_sources": "", "md_referral_sources": "",
@@ -1451,6 +1462,7 @@
"mod_lbr_ty": "Type de travail", "mod_lbr_ty": "Type de travail",
"notes": "", "notes": "",
"oem_partno": "Pièce OEM #", "oem_partno": "Pièce OEM #",
"alt_partno": "",
"op_code_desc": "", "op_code_desc": "",
"part_qty": "", "part_qty": "",
"part_type": "Type de pièce", "part_type": "Type de pièce",

View File

@@ -2234,18 +2234,25 @@ exports.QUERY_PARTS_SCAN = `query QUERY_PARTS_SCAN ($id: uuid!) {
id id
line_desc line_desc
critical critical
part_type
act_price
part_qty
mod_lbr_ty
mod_lb_hrs
oem_partno
alt_partno
} }
} }
}`; }`;
exports.UPDATE_PARTS_CRITICAL = `mutation UPDATE_PARTS_CRITICAL ($IdsToMarkCritical:[uuid!]!, $jobid: uuid!){ exports.UPDATE_PARTS_CRITICAL = `mutation UPDATE_PARTS_CRITICAL ($IdsToMarkCritical:[uuid!]!, $jobid: uuid!) {
critical: update_joblines(where:{id:{_in:$IdsToMarkCritical}}, _set:{critical: true}){ critical: update_joblines(where: {id: {_in: $IdsToMarkCritical}}, _set: {critical: true}) {
affected_rows affected_rows
} }
notcritical: update_joblines(where:{id:{_nin:$IdsToMarkCritical}, jobid: {_eq: $jobid}}, _set:{critical: false}){ notcritical: update_joblines(where: {id: {_nin: $IdsToMarkCritical}, jobid: {_eq: $jobid}}, _set: {critical: false}) {
affected_rows affected_rows
} }
}`; }`
exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) { exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) {
associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) { associations(where: {active: {_eq: true}, useremail: {_eq: $user}}) {

View File

@@ -1,50 +1,90 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { job } = require("../scheduling/scheduling-job");
const _ = require("lodash");
// Dinero.defaultCurrency = "USD"; const predefinedPartTypes = [
// Dinero.globalLocale = "en-CA"; "PAN", "PAC", "PAR", "PAL", "PAA", "PAM", "PAP", "PAS", "PASL", "PAG",
];
const predefinedModLbrTypes = [
"LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU",
"LA1", "LA2", "LA3", "LA4",
];
exports.partsScan = async function (req, res) { exports.partsScan = async function (req, res) {
console.log('hello')
const { jobid } = req.body; const { jobid } = req.body;
const BearerToken = req.BearerToken; const BearerToken = req.BearerToken;
const client = req.userGraphQLClient; const client = req.userGraphQLClient;
logger.log("job-parts-scan", "DEBUG", req.user?.email, jobid, null); logger.log("job-parts-scan", "DEBUG", req.user?.email, jobid, null);
try { try {
//Query all jobline data using the user's authorization. const data = await client.setHeaders({ Authorization: BearerToken }).request(
const data = await client.setHeaders({ Authorization: BearerToken }).request(queries.QUERY_PARTS_SCAN, { queries.QUERY_PARTS_SCAN,
id: jobid { id: jobid }
}); );
//Create RegExps once for better performance. const rules = data.jobs_by_pk.bodyshop.md_parts_scan || [];
const IdsToMarkCritical = []; if (!Array.isArray(rules)) {
const RegExpressions = data.jobs_by_pk.bodyshop.md_parts_scan.map((r) => new RegExp(r.expression, r.flags)); // noinspection ExceptionCaughtLocallyJS
throw new Error("Invalid md_parts_scan format. Expected an array of rules.");
}
//Check each line against each regex rule. const compiledRules = rules.map((rule) => ({
data.jobs_by_pk.joblines.forEach((jobline) => { ...rule,
RegExpressions.forEach((rExp) => { regex:
if (jobline.line_desc.match(rExp)) { typeof rule.value === "string" && rule.caseInsensitive
IdsToMarkCritical.push(jobline); ? new RegExp(rule.value, "i")
: typeof rule.value === "string"
? new RegExp(rule.value)
: null,
}));
const criticalIds = new Set();
for (const jobline of data.jobs_by_pk.joblines) {
for (const { field, regex, operation, value } of compiledRules) {
if (criticalIds.has(jobline.id)) break; // Skip further evaluation if already critical
let jobValue = jobline[field];
let match = false;
if (field === "part_number") {
match = regex
? regex.test(jobline.oem_partno || '') || regex.test(jobline.alt_partno || '')
: jobline.oem_partno === value || jobline.alt_partno === value;
} else if (field === "part_type") {
match = predefinedPartTypes.includes(value) && value === jobValue;
} else if (field === "mod_lbr_ty") {
match = predefinedModLbrTypes.includes(value) && value === jobValue;
} else if (regex && typeof jobValue === "string") {
if (operation === "contains") match = regex.test(jobValue);
if (operation === "startsWith") match = new RegExp(`^${value}`).test(jobValue);
if (operation === "endsWith") match = new RegExp(`${value}$`).test(jobValue);
if (operation === "equals") match = jobValue === value;
} else if (typeof jobValue === "number") {
if (operation === ">") match = jobValue > value;
if (operation === "<") match = jobValue < value;
if (operation === "=") match = jobValue === value;
} }
});
});
const result = await client.setHeaders({ Authorization: BearerToken }).request(queries.UPDATE_PARTS_CRITICAL, { if (match) {
IdsToMarkCritical: _.uniqBy(IdsToMarkCritical, "id").map((i) => i.id), criticalIds.add(jobline.id);
jobid: jobid break; // No need to evaluate further rules for this jobline
}); }
}
}
const result = await client.setHeaders({ Authorization: BearerToken }).request(
queries.UPDATE_PARTS_CRITICAL,
{ IdsToMarkCritical: Array.from(criticalIds), jobid }
);
res.status(200).json(result); res.status(200).json(result);
} catch (error) { } catch (error) {
logger.log("job-parts-scan-error", "ERROR", req.user.email, jobid, { logger.log("job-parts-scan-error", "ERROR", req.user?.email, jobid, {
jobid, jobid,
error error: error.message,
}); });
res.status(400).json(JSON.stringify(error)); res.status(400).json(JSON.stringify({ message: error?.message }));
} }
}; };