IO-836 Server Side Job Costing
This commit is contained in:
@@ -1,194 +1,20 @@
|
|||||||
import { Typography } from "antd";
|
import { Typography } from "antd";
|
||||||
import Dinero from "dinero.js";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
|
||||||
import { createStructuredSelector } from "reselect";
|
|
||||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
|
||||||
import JobCostingPartsTable from "../job-costing-parts-table/job-costing-parts-table.component";
|
import JobCostingPartsTable from "../job-costing-parts-table/job-costing-parts-table.component";
|
||||||
import JobCostingStatistics from "../job-costing-statistics/job-costing-statistics.component";
|
import JobCostingStatistics from "../job-costing-statistics/job-costing-statistics.component";
|
||||||
import JobCostingPie from "./job-costing-modal.pie.component";
|
import JobCostingPie from "./job-costing-modal.pie.component";
|
||||||
import _ from "lodash";
|
|
||||||
const mapStateToProps = createStructuredSelector({
|
|
||||||
bodyshop: selectBodyshop,
|
|
||||||
});
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
//setUserLanguage: language => dispatch(setUserLanguage(language))
|
|
||||||
});
|
|
||||||
|
|
||||||
export function JobCostingModalComponent({ bodyshop, job }) {
|
export default function JobCostingModalComponent({
|
||||||
const defaultProfits = bodyshop.md_responsibility_centers.defaults.profits;
|
summaryData,
|
||||||
const allProfitCenters = _.union(
|
costCenterData,
|
||||||
bodyshop.md_responsibility_centers.profits.map((p) => p.name),
|
}) {
|
||||||
bodyshop.md_responsibility_centers.costs.map((p) => p.name)
|
|
||||||
);
|
|
||||||
|
|
||||||
// const defaultCosts = bodyshop.md_responsibility_centers.defaults.costs;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const jobLineTotalsByProfitCenter =
|
|
||||||
job &&
|
|
||||||
job.joblines.reduce(
|
|
||||||
(acc, val) => {
|
|
||||||
const laborProfitCenter = defaultProfits[val.mod_lbr_ty] || "?";
|
|
||||||
|
|
||||||
const rateName = `rate_${(val.mod_lbr_ty || "").toLowerCase()}`;
|
|
||||||
const laborAmount = Dinero({
|
|
||||||
amount: Math.round((job[rateName] || 0) * 100),
|
|
||||||
}).multiply(val.mod_lb_hrs || 0);
|
|
||||||
if (!!!acc.labor[laborProfitCenter])
|
|
||||||
acc.labor[laborProfitCenter] = Dinero();
|
|
||||||
acc.labor[laborProfitCenter] = acc.labor[laborProfitCenter].add(
|
|
||||||
laborAmount
|
|
||||||
);
|
|
||||||
|
|
||||||
const partsProfitCenter = defaultProfits[val.part_type] || "?";
|
|
||||||
if (!!!partsProfitCenter)
|
|
||||||
console.log(
|
|
||||||
"Unknown cost/profit center mapping for parts.",
|
|
||||||
val.part_type
|
|
||||||
);
|
|
||||||
const partsAmount = Dinero({
|
|
||||||
amount: Math.round((val.act_price || 0) * 100),
|
|
||||||
}).multiply(val.part_qty || 1);
|
|
||||||
if (!!!acc.parts[partsProfitCenter])
|
|
||||||
acc.parts[partsProfitCenter] = Dinero();
|
|
||||||
acc.parts[partsProfitCenter] = acc.parts[partsProfitCenter].add(
|
|
||||||
partsAmount
|
|
||||||
);
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ parts: {}, labor: {} }
|
|
||||||
);
|
|
||||||
|
|
||||||
const billTotalsByProfitCenter = job.bills.reduce((bill_acc, bill_val) => {
|
|
||||||
//At the invoice level.
|
|
||||||
bill_val.billlines.map((line_val) => {
|
|
||||||
//At the invoice line level.
|
|
||||||
//console.log("JobCostingPartsTable -> line_val", line_val);
|
|
||||||
if (!!!bill_acc[line_val.cost_center])
|
|
||||||
bill_acc[line_val.cost_center] = Dinero();
|
|
||||||
|
|
||||||
bill_acc[line_val.cost_center] = bill_acc[line_val.cost_center].add(
|
|
||||||
Dinero({
|
|
||||||
amount: Math.round((line_val.actual_cost || 0) * 100),
|
|
||||||
})
|
|
||||||
.multiply(line_val.quantity)
|
|
||||||
.multiply(bill_val.is_credit_memo ? -1 : 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
return bill_acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const ticketTotalsByProfitCenter = job.timetickets.reduce(
|
|
||||||
(ticket_acc, ticket_val) => {
|
|
||||||
//At the invoice level.
|
|
||||||
if (!!!ticket_acc[ticket_val.cost_center])
|
|
||||||
ticket_acc[ticket_val.cost_center] = Dinero();
|
|
||||||
|
|
||||||
ticket_acc[ticket_val.cost_center] = ticket_acc[
|
|
||||||
ticket_val.cost_center
|
|
||||||
].add(
|
|
||||||
Dinero({
|
|
||||||
amount: Math.round((ticket_val.rate || 0) * 100),
|
|
||||||
}).multiply(ticket_val.actualhrs || ticket_val.productivehrs || 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
return ticket_acc;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const summaryData = {
|
|
||||||
totalLaborSales: Dinero({ amount: 0 }),
|
|
||||||
totalPartsSales: Dinero({ amount: 0 }),
|
|
||||||
totalSales: Dinero({ amount: 0 }),
|
|
||||||
totalLaborCost: Dinero({ amount: 0 }),
|
|
||||||
totalPartsCost: Dinero({ amount: 0 }),
|
|
||||||
totalCost: Dinero({ amount: 0 }),
|
|
||||||
gpdollars: Dinero({ amount: 0 }),
|
|
||||||
gppercent: null,
|
|
||||||
gppercentFormatted: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const costCenterData = allProfitCenters.map((key, idx) => {
|
|
||||||
const ccVal = key; // defaultProfits[key];
|
|
||||||
const sale_labor =
|
|
||||||
jobLineTotalsByProfitCenter.labor[ccVal] || Dinero({ amount: 0 });
|
|
||||||
const sale_parts =
|
|
||||||
jobLineTotalsByProfitCenter.parts[ccVal] || Dinero({ amount: 0 });
|
|
||||||
|
|
||||||
const cost_labor =
|
|
||||||
ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 });
|
|
||||||
const cost_parts = billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 });
|
|
||||||
|
|
||||||
const costs = (
|
|
||||||
billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 })
|
|
||||||
).add(ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }));
|
|
||||||
const totalSales = sale_labor.add(sale_parts);
|
|
||||||
const gpdollars = totalSales.subtract(costs);
|
|
||||||
const gppercent = (
|
|
||||||
(gpdollars.getAmount() / totalSales.getAmount()) *
|
|
||||||
100
|
|
||||||
).toFixed(2);
|
|
||||||
|
|
||||||
let gppercentFormatted;
|
|
||||||
if (isNaN(gppercent)) gppercentFormatted = "0%";
|
|
||||||
else if (!isFinite(gppercent)) gppercentFormatted = "- ∞";
|
|
||||||
else {
|
|
||||||
gppercentFormatted = `${gppercent}%`;
|
|
||||||
}
|
|
||||||
//Push summary data to avoid extra loop.
|
|
||||||
summaryData.totalLaborSales = summaryData.totalLaborSales.add(sale_labor);
|
|
||||||
summaryData.totalPartsSales = summaryData.totalPartsSales.add(sale_parts);
|
|
||||||
summaryData.totalSales = summaryData.totalSales
|
|
||||||
.add(sale_labor)
|
|
||||||
.add(sale_parts);
|
|
||||||
summaryData.totalLaborCost = summaryData.totalLaborCost.add(cost_labor);
|
|
||||||
summaryData.totalPartsCost = summaryData.totalPartsCost.add(cost_parts);
|
|
||||||
summaryData.totalCost = summaryData.totalCost.add(costs);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: idx,
|
|
||||||
cost_center: ccVal,
|
|
||||||
sale_labor: sale_labor && sale_labor.toFormat(),
|
|
||||||
sale_parts: sale_parts && sale_parts.toFormat(),
|
|
||||||
sales: sale_labor.add(sale_parts).toFormat(),
|
|
||||||
sales_dinero: sale_labor.add(sale_parts),
|
|
||||||
cost_parts: cost_parts && cost_parts.toFormat(),
|
|
||||||
cost_labor: cost_labor && cost_labor.toFormat(),
|
|
||||||
costs: cost_parts.add(cost_labor).toFormat(),
|
|
||||||
costs_dinero: cost_parts.add(cost_labor),
|
|
||||||
gpdollars: gpdollars.toFormat(),
|
|
||||||
gppercent: gppercentFormatted,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
//Final summary data massaging.
|
|
||||||
summaryData.gpdollars = summaryData.totalSales.subtract(
|
|
||||||
summaryData.totalCost
|
|
||||||
);
|
|
||||||
summaryData.gppercent = (
|
|
||||||
(summaryData.gpdollars.getAmount() / summaryData.totalSales.getAmount()) *
|
|
||||||
100
|
|
||||||
).toFixed(2);
|
|
||||||
if (isNaN(summaryData.gppercent)) summaryData.gppercentFormatted = 0;
|
|
||||||
else if (!isFinite(summaryData.gppercent))
|
|
||||||
summaryData.gppercentFormatted = "- ∞";
|
|
||||||
else {
|
|
||||||
summaryData.gppercentFormatted = summaryData.gppercent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<JobCostingStatistics job={job} summaryData={summaryData} />
|
<JobCostingStatistics summaryData={summaryData} />
|
||||||
<JobCostingPartsTable
|
<JobCostingPartsTable data={costCenterData} summaryData={summaryData} />
|
||||||
job={job}
|
|
||||||
data={costCenterData}
|
|
||||||
summaryData={summaryData}
|
|
||||||
/>
|
|
||||||
<div className="imex-flex-row">
|
<div className="imex-flex-row">
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Typography.Title level={4}>
|
<Typography.Title level={4}>
|
||||||
@@ -204,7 +30,3 @@ export function JobCostingModalComponent({ bodyshop, job }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default connect(
|
|
||||||
mapStateToProps,
|
|
||||||
mapDispatchToProps
|
|
||||||
)(JobCostingModalComponent);
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useQuery } from "@apollo/client";
|
|
||||||
import { Modal } from "antd";
|
import { Modal } from "antd";
|
||||||
import React from "react";
|
import axios from "axios";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { createStructuredSelector } from "reselect";
|
import { createStructuredSelector } from "reselect";
|
||||||
import { QUERY_JOB_COSTING_DETAILS } from "../../graphql/jobs.queries";
|
|
||||||
import { toggleModalVisible } from "../../redux/modals/modals.actions";
|
import { toggleModalVisible } from "../../redux/modals/modals.actions";
|
||||||
import { selectJobCosting } from "../../redux/modals/modals.selectors";
|
import { selectJobCosting } from "../../redux/modals/modals.selectors";
|
||||||
import AlertComponent from "../alert/alert.component";
|
|
||||||
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
|
||||||
import JobCostingModalComponent from "./job-costing-modal.component";
|
import JobCostingModalComponent from "./job-costing-modal.component";
|
||||||
|
|
||||||
@@ -24,14 +22,25 @@ export function JobCostingModalContainer({
|
|||||||
toggleModalVisible,
|
toggleModalVisible,
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [costingData, setCostingData] = useState(null);
|
||||||
const { visible, context } = jobCostingModal;
|
const { visible, context } = jobCostingModal;
|
||||||
const { jobId } = context;
|
const { jobId } = context;
|
||||||
|
|
||||||
const { loading, error, data } = useQuery(QUERY_JOB_COSTING_DETAILS, {
|
// const { loading, error, data } = useQuery(QUERY_JOB_COSTING_DETAILS, {
|
||||||
variables: { id: jobId },
|
// variables: { id: jobId },
|
||||||
skip: !jobId,
|
// skip: !jobId,
|
||||||
});
|
// });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getData() {
|
||||||
|
if (jobId && visible) {
|
||||||
|
const { data } = await axios.post("/job/costing", { jobid: jobId });
|
||||||
|
console.log(data);
|
||||||
|
setCostingData(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getData();
|
||||||
|
}, [jobId, visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -43,11 +52,13 @@ export function JobCostingModalContainer({
|
|||||||
width="90%"
|
width="90%"
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
{error ? <AlertComponent message={error.message} type="error" /> : null}
|
{!costingData ? (
|
||||||
{loading ? (
|
<LoadingSpinner loading={true} />
|
||||||
<LoadingSpinner loading={loading} />
|
|
||||||
) : (
|
) : (
|
||||||
<JobCostingModalComponent job={data && data.jobs_by_pk} />
|
<JobCostingModalComponent
|
||||||
|
costCenterData={costingData.costCenterData}
|
||||||
|
summaryData={costingData.summaryData}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
|
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
|
||||||
|
import Dinero from "dinero.js";
|
||||||
export default function JobCostingPieComponent({
|
export default function JobCostingPieComponent({
|
||||||
type = "sales",
|
type = "sales",
|
||||||
costCenterData,
|
costCenterData,
|
||||||
@@ -11,8 +11,8 @@ export default function JobCostingPieComponent({
|
|||||||
return data.reduce((acc, i) => {
|
return data.reduce((acc, i) => {
|
||||||
const value =
|
const value =
|
||||||
type === "sales"
|
type === "sales"
|
||||||
? i.sales_dinero.getAmount()
|
? Dinero(i.sales_dinero).getAmount()
|
||||||
: i.costs_dinero.getAmount();
|
: Dinero(i.costs_dinero).getAmount();
|
||||||
|
|
||||||
if (value > 0) {
|
if (value > 0) {
|
||||||
acc.push({
|
acc.push({
|
||||||
@@ -21,13 +21,13 @@ export default function JobCostingPieComponent({
|
|||||||
|
|
||||||
label: `${i.cost_center} - ${
|
label: `${i.cost_center} - ${
|
||||||
type === "sales"
|
type === "sales"
|
||||||
? i.sales_dinero.toFormat()
|
? Dinero(i.sales_dinero).toFormat()
|
||||||
: i.costs_dinero.toFormat()
|
: Dinero(i.costs_dinero).toFormat()
|
||||||
}`,
|
}`,
|
||||||
value:
|
value:
|
||||||
type === "sales"
|
type === "sales"
|
||||||
? i.sales_dinero.getAmount()
|
? Dinero(i.sales_dinero).getAmount()
|
||||||
: i.costs_dinero.getAmount(),
|
: Dinero(i.costs_dinero).getAmount(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Input, Table, Typography } from "antd";
|
import { Input, Space, Table, Typography } from "antd";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { alphaSort } from "../../utils/sorters";
|
import { alphaSort } from "../../utils/sorters";
|
||||||
|
import Dinero from "dinero.js";
|
||||||
export default function JobCostingPartsTable({ job, data, summaryData }) {
|
export default function JobCostingPartsTable({ data, summaryData }) {
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
sortedInfo: {},
|
sortedInfo: {},
|
||||||
@@ -75,18 +75,16 @@ export default function JobCostingPartsTable({ job, data, summaryData }) {
|
|||||||
<Table
|
<Table
|
||||||
title={() => {
|
title={() => {
|
||||||
return (
|
return (
|
||||||
<div className="imex-table-header">
|
<Space wrap>
|
||||||
<div className="imex-table-header__search">
|
<Input.Search
|
||||||
<Input.Search
|
placeholder={t("general.labels.search")}
|
||||||
placeholder={t("general.labels.search")}
|
value={searchText}
|
||||||
value={searchText}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
e.preventDefault();
|
||||||
e.preventDefault();
|
setSearchText(e.target.value);
|
||||||
setSearchText(e.target.value);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</Space>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
scroll={{ x: "50%", y: "40rem" }}
|
scroll={{ x: "50%", y: "40rem" }}
|
||||||
@@ -103,13 +101,13 @@ export default function JobCostingPartsTable({ job, data, summaryData }) {
|
|||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell>
|
<Table.Summary.Cell>
|
||||||
{summaryData.totalSales.toFormat()}
|
{Dinero(summaryData.totalSales).toFormat()}
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell>
|
<Table.Summary.Cell>
|
||||||
{summaryData.totalCost.toFormat()}
|
{Dinero(summaryData.totalCost).toFormat()}
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell>
|
<Table.Summary.Cell>
|
||||||
{summaryData.gpdollars.toFormat()}
|
{Dinero(summaryData.gpdollars).toFormat()}
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
<Table.Summary.Cell></Table.Summary.Cell>
|
<Table.Summary.Cell></Table.Summary.Cell>
|
||||||
</Table.Summary.Row>
|
</Table.Summary.Row>
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
import { Statistic } from "antd";
|
import { Statistic } from "antd";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Dinero from "dinero.js";
|
||||||
export default function JobCostingStatistics({ job, summaryData }) {
|
export default function JobCostingStatistics({ summaryData }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="imex-flex-row imex-flex-row__flex-space-around">
|
<div className="imex-flex-row imex-flex-row__flex-space-around">
|
||||||
<Statistic
|
<Statistic
|
||||||
value={summaryData.totalLaborSales.toFormat()}
|
value={Dinero(summaryData.totalLaborSales).toFormat()}
|
||||||
title={t("jobs.labels.sale_labor")}
|
title={t("jobs.labels.sale_labor")}
|
||||||
/>
|
/>
|
||||||
<Statistic
|
<Statistic
|
||||||
value={summaryData.totalPartsSales.toFormat()}
|
value={Dinero(summaryData.totalPartsSales).toFormat()}
|
||||||
title={t("jobs.labels.sale_parts")}
|
title={t("jobs.labels.sale_parts")}
|
||||||
/>
|
/>
|
||||||
<Statistic
|
<Statistic
|
||||||
value={summaryData.totalSales.toFormat()}
|
value={Dinero(summaryData.totalSales).toFormat()}
|
||||||
title={t("jobs.labels.total_sales")}
|
title={t("jobs.labels.total_sales")}
|
||||||
/>
|
/>
|
||||||
<Statistic
|
<Statistic
|
||||||
value={summaryData.totalLaborCost.toFormat()}
|
value={Dinero(summaryData.totalLaborCost).toFormat()}
|
||||||
title={t("jobs.labels.cost_labor")}
|
title={t("jobs.labels.cost_labor")}
|
||||||
/>
|
/>
|
||||||
<Statistic
|
<Statistic
|
||||||
value={summaryData.totalPartsCost.toFormat()}
|
value={Dinero(summaryData.totalPartsCost).toFormat()}
|
||||||
title={t("jobs.labels.cost_parts")}
|
title={t("jobs.labels.cost_parts")}
|
||||||
/>
|
/>
|
||||||
<Statistic
|
<Statistic
|
||||||
value={summaryData.totalCost.toFormat()}
|
value={Dinero(summaryData.totalCost).toFormat()}
|
||||||
title={t("jobs.labels.total_cost")}
|
title={t("jobs.labels.total_cost")}
|
||||||
/>
|
/>
|
||||||
<Statistic
|
<Statistic
|
||||||
value={summaryData.gpdollars.toFormat()}
|
value={Dinero(summaryData.gpdollars).toFormat()}
|
||||||
title={t("jobs.labels.gpdollars")}
|
title={t("jobs.labels.gpdollars")}
|
||||||
/>
|
/>
|
||||||
<Statistic
|
<Statistic
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ app.post(
|
|||||||
var job = require("./server/job/job");
|
var job = require("./server/job/job");
|
||||||
app.post("/job/totals", fb.validateFirebaseIdToken, job.totals);
|
app.post("/job/totals", fb.validateFirebaseIdToken, job.totals);
|
||||||
app.post("/job/totalsssu", fb.validateFirebaseIdToken, job.totalsSsu);
|
app.post("/job/totalsssu", fb.validateFirebaseIdToken, job.totalsSsu);
|
||||||
|
app.post("/job/costing", fb.validateFirebaseIdToken, job.costing);
|
||||||
|
app.post("/job/costingmulti", fb.validateFirebaseIdToken, job.costingmulti);
|
||||||
|
|
||||||
//Scheduling
|
//Scheduling
|
||||||
var scheduling = require("./server/scheduling/scheduling-job");
|
var scheduling = require("./server/scheduling/scheduling-job");
|
||||||
|
|||||||
@@ -579,3 +579,198 @@ exports.GET_JOB_BY_PK = ` query GET_JOB_BY_PK($id: uuid!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
exports.QUERY_JOB_COSTING_DETAILS = ` query QUERY_JOB_COSTING_DETAILS($id: uuid!) {
|
||||||
|
jobs_by_pk(id: $id) {
|
||||||
|
ro_number
|
||||||
|
clm_total
|
||||||
|
id
|
||||||
|
ded_amt
|
||||||
|
ded_status
|
||||||
|
depreciation_taxes
|
||||||
|
other_amount_payable
|
||||||
|
towing_payable
|
||||||
|
storage_payable
|
||||||
|
adjustment_bottom_line
|
||||||
|
federal_tax_rate
|
||||||
|
state_tax_rate
|
||||||
|
local_tax_rate
|
||||||
|
tax_tow_rt
|
||||||
|
tax_str_rt
|
||||||
|
tax_paint_mat_rt
|
||||||
|
tax_sub_rt
|
||||||
|
tax_lbr_rt
|
||||||
|
tax_levies_rt
|
||||||
|
parts_tax_rates
|
||||||
|
job_totals
|
||||||
|
labor_rate_desc
|
||||||
|
rate_la1
|
||||||
|
rate_la2
|
||||||
|
rate_la3
|
||||||
|
rate_la4
|
||||||
|
rate_laa
|
||||||
|
rate_lab
|
||||||
|
rate_lad
|
||||||
|
rate_lae
|
||||||
|
rate_laf
|
||||||
|
rate_lag
|
||||||
|
rate_lam
|
||||||
|
rate_lar
|
||||||
|
rate_las
|
||||||
|
rate_lau
|
||||||
|
rate_ma2s
|
||||||
|
rate_ma2t
|
||||||
|
rate_ma3s
|
||||||
|
rate_mabl
|
||||||
|
rate_macs
|
||||||
|
rate_mahw
|
||||||
|
rate_mapa
|
||||||
|
rate_mash
|
||||||
|
rate_matd
|
||||||
|
actual_in
|
||||||
|
status
|
||||||
|
ca_bc_pvrt
|
||||||
|
joblines(where: { removed: { _eq: false } }) {
|
||||||
|
id
|
||||||
|
unq_seq
|
||||||
|
line_ind
|
||||||
|
tax_part
|
||||||
|
line_desc
|
||||||
|
prt_dsmk_p
|
||||||
|
prt_dsmk_m
|
||||||
|
part_type
|
||||||
|
oem_partno
|
||||||
|
db_price
|
||||||
|
act_price
|
||||||
|
part_qty
|
||||||
|
mod_lbr_ty
|
||||||
|
db_hrs
|
||||||
|
mod_lb_hrs
|
||||||
|
lbr_op
|
||||||
|
lbr_amt
|
||||||
|
op_code_desc
|
||||||
|
}
|
||||||
|
bills {
|
||||||
|
id
|
||||||
|
federal_tax_rate
|
||||||
|
local_tax_rate
|
||||||
|
state_tax_rate
|
||||||
|
is_credit_memo
|
||||||
|
billlines {
|
||||||
|
actual_cost
|
||||||
|
cost_center
|
||||||
|
id
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timetickets {
|
||||||
|
id
|
||||||
|
rate
|
||||||
|
cost_center
|
||||||
|
actualhrs
|
||||||
|
productivehrs
|
||||||
|
}
|
||||||
|
bodyshop{
|
||||||
|
id
|
||||||
|
md_responsibility_centers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
exports.QUERY_JOB_COSTING_DETAILS_MULTI = ` query QUERY_JOB_COSTING_DETAILS_MULTI($ids: [uuid!]!) {
|
||||||
|
jobs( where: {id: {_in: $ids}}) {
|
||||||
|
ro_number
|
||||||
|
clm_total
|
||||||
|
id
|
||||||
|
ded_amt
|
||||||
|
ded_status
|
||||||
|
depreciation_taxes
|
||||||
|
other_amount_payable
|
||||||
|
towing_payable
|
||||||
|
storage_payable
|
||||||
|
adjustment_bottom_line
|
||||||
|
federal_tax_rate
|
||||||
|
state_tax_rate
|
||||||
|
local_tax_rate
|
||||||
|
tax_tow_rt
|
||||||
|
tax_str_rt
|
||||||
|
tax_paint_mat_rt
|
||||||
|
tax_sub_rt
|
||||||
|
tax_lbr_rt
|
||||||
|
tax_levies_rt
|
||||||
|
parts_tax_rates
|
||||||
|
job_totals
|
||||||
|
labor_rate_desc
|
||||||
|
rate_la1
|
||||||
|
rate_la2
|
||||||
|
rate_la3
|
||||||
|
rate_la4
|
||||||
|
rate_laa
|
||||||
|
rate_lab
|
||||||
|
rate_lad
|
||||||
|
rate_lae
|
||||||
|
rate_laf
|
||||||
|
rate_lag
|
||||||
|
rate_lam
|
||||||
|
rate_lar
|
||||||
|
rate_las
|
||||||
|
rate_lau
|
||||||
|
rate_ma2s
|
||||||
|
rate_ma2t
|
||||||
|
rate_ma3s
|
||||||
|
rate_mabl
|
||||||
|
rate_macs
|
||||||
|
rate_mahw
|
||||||
|
rate_mapa
|
||||||
|
rate_mash
|
||||||
|
rate_matd
|
||||||
|
actual_in
|
||||||
|
status
|
||||||
|
ca_bc_pvrt
|
||||||
|
joblines(where: {removed: {_eq: false}}) {
|
||||||
|
id
|
||||||
|
unq_seq
|
||||||
|
line_ind
|
||||||
|
tax_part
|
||||||
|
line_desc
|
||||||
|
prt_dsmk_p
|
||||||
|
prt_dsmk_m
|
||||||
|
part_type
|
||||||
|
oem_partno
|
||||||
|
db_price
|
||||||
|
act_price
|
||||||
|
part_qty
|
||||||
|
mod_lbr_ty
|
||||||
|
db_hrs
|
||||||
|
mod_lb_hrs
|
||||||
|
lbr_op
|
||||||
|
lbr_amt
|
||||||
|
op_code_desc
|
||||||
|
}
|
||||||
|
bills {
|
||||||
|
id
|
||||||
|
federal_tax_rate
|
||||||
|
local_tax_rate
|
||||||
|
state_tax_rate
|
||||||
|
is_credit_memo
|
||||||
|
billlines {
|
||||||
|
actual_cost
|
||||||
|
cost_center
|
||||||
|
id
|
||||||
|
quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timetickets {
|
||||||
|
id
|
||||||
|
rate
|
||||||
|
cost_center
|
||||||
|
actualhrs
|
||||||
|
productivehrs
|
||||||
|
}
|
||||||
|
bodyshop {
|
||||||
|
id
|
||||||
|
md_responsibility_centers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
241
server/job/job-costing.js
Normal file
241
server/job/job-costing.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
const Dinero = require("dinero.js");
|
||||||
|
const queries = require("../graphql-client/queries");
|
||||||
|
//const client = require("../graphql-client/graphql-client").client;
|
||||||
|
const _ = require("lodash");
|
||||||
|
const GraphQLClient = require("graphql-request").GraphQLClient;
|
||||||
|
const {
|
||||||
|
ExportCustomJobInstance,
|
||||||
|
} = require("twilio/lib/rest/bulkexports/v1/export/exportCustomJob");
|
||||||
|
|
||||||
|
// Dinero.defaultCurrency = "USD";
|
||||||
|
// Dinero.globalLocale = "en-CA";
|
||||||
|
Dinero.globalRoundingMode = "HALF_EVEN";
|
||||||
|
|
||||||
|
async function JobCosting(req, res) {
|
||||||
|
const { jobid } = req.body;
|
||||||
|
console.log("🚀 ~ file: job-costing.js ~ line 13 ~ jobid", jobid);
|
||||||
|
const BearerToken = req.headers.authorization;
|
||||||
|
|
||||||
|
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
|
||||||
|
headers: {
|
||||||
|
Authorization: BearerToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await client
|
||||||
|
.setHeaders({ Authorization: BearerToken })
|
||||||
|
.request(queries.QUERY_JOB_COSTING_DETAILS, {
|
||||||
|
id: jobid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ret = GenerateCostingData(resp.jobs_by_pk);
|
||||||
|
|
||||||
|
res.status(200).json(ret);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
|
res.status(400).send(JSON.stringify(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function JobCostingMulti(req, res) {
|
||||||
|
const { jobids } = req.body;
|
||||||
|
console.log("🚀 ~ file: job-costing.js ~ line 13 ~ jobids", jobids);
|
||||||
|
const BearerToken = req.headers.authorization;
|
||||||
|
|
||||||
|
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {
|
||||||
|
headers: {
|
||||||
|
Authorization: BearerToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await client
|
||||||
|
.setHeaders({ Authorization: BearerToken })
|
||||||
|
.request(queries.QUERY_JOB_COSTING_DETAILS_MULTI, {
|
||||||
|
ids: jobids,
|
||||||
|
});
|
||||||
|
|
||||||
|
//for Each!***************
|
||||||
|
const ret = {};
|
||||||
|
resp.jobs.map((job) => {
|
||||||
|
ret[job.id] = GenerateCostingData(job);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json(ret);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
|
res.status(400).send(JSON.stringify(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenerateCostingData(job) {
|
||||||
|
const defaultProfits =
|
||||||
|
job.bodyshop.md_responsibility_centers.defaults.profits;
|
||||||
|
const allProfitCenters = _.union(
|
||||||
|
job.bodyshop.md_responsibility_centers.profits.map((p) => p.name),
|
||||||
|
job.bodyshop.md_responsibility_centers.costs.map((p) => p.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
//Massage the data.
|
||||||
|
const jobLineTotalsByProfitCenter =
|
||||||
|
job &&
|
||||||
|
job.joblines.reduce(
|
||||||
|
(acc, val) => {
|
||||||
|
const laborProfitCenter = defaultProfits[val.mod_lbr_ty] || "?";
|
||||||
|
|
||||||
|
const rateName = `rate_${(val.mod_lbr_ty || "").toLowerCase()}`;
|
||||||
|
const laborAmount = Dinero({
|
||||||
|
amount: Math.round((job[rateName] || 0) * 100),
|
||||||
|
}).multiply(val.mod_lb_hrs || 0);
|
||||||
|
if (!acc.labor[laborProfitCenter])
|
||||||
|
acc.labor[laborProfitCenter] = Dinero();
|
||||||
|
acc.labor[laborProfitCenter] = acc.labor[laborProfitCenter].add(
|
||||||
|
laborAmount
|
||||||
|
);
|
||||||
|
|
||||||
|
const partsProfitCenter = defaultProfits[val.part_type] || "?";
|
||||||
|
if (!partsProfitCenter)
|
||||||
|
console.log(
|
||||||
|
"Unknown cost/profit center mapping for parts.",
|
||||||
|
val.part_type
|
||||||
|
);
|
||||||
|
const partsAmount = Dinero({
|
||||||
|
amount: Math.round((val.act_price || 0) * 100),
|
||||||
|
}).multiply(val.part_qty || 1);
|
||||||
|
if (!acc.parts[partsProfitCenter])
|
||||||
|
acc.parts[partsProfitCenter] = Dinero();
|
||||||
|
acc.parts[partsProfitCenter] = acc.parts[partsProfitCenter].add(
|
||||||
|
partsAmount
|
||||||
|
);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ parts: {}, labor: {} }
|
||||||
|
);
|
||||||
|
|
||||||
|
const billTotalsByProfitCenter = job.bills.reduce((bill_acc, bill_val) => {
|
||||||
|
//At the invoice level.
|
||||||
|
bill_val.billlines.map((line_val) => {
|
||||||
|
//At the invoice line level.
|
||||||
|
//console.log("JobCostingPartsTable -> line_val", line_val);
|
||||||
|
if (!bill_acc[line_val.cost_center])
|
||||||
|
bill_acc[line_val.cost_center] = Dinero();
|
||||||
|
|
||||||
|
bill_acc[line_val.cost_center] = bill_acc[line_val.cost_center].add(
|
||||||
|
Dinero({
|
||||||
|
amount: Math.round((line_val.actual_cost || 0) * 100),
|
||||||
|
})
|
||||||
|
.multiply(line_val.quantity)
|
||||||
|
.multiply(bill_val.is_credit_memo ? -1 : 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return bill_acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const ticketTotalsByProfitCenter = job.timetickets.reduce(
|
||||||
|
(ticket_acc, ticket_val) => {
|
||||||
|
//At the invoice level.
|
||||||
|
if (!ticket_acc[ticket_val.cost_center])
|
||||||
|
ticket_acc[ticket_val.cost_center] = Dinero();
|
||||||
|
|
||||||
|
ticket_acc[ticket_val.cost_center] = ticket_acc[
|
||||||
|
ticket_val.cost_center
|
||||||
|
].add(
|
||||||
|
Dinero({
|
||||||
|
amount: Math.round((ticket_val.rate || 0) * 100),
|
||||||
|
}).multiply(ticket_val.actualhrs || ticket_val.productivehrs || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return ticket_acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryData = {
|
||||||
|
totalLaborSales: Dinero({ amount: 0 }),
|
||||||
|
totalPartsSales: Dinero({ amount: 0 }),
|
||||||
|
totalSales: Dinero({ amount: 0 }),
|
||||||
|
totalLaborCost: Dinero({ amount: 0 }),
|
||||||
|
totalPartsCost: Dinero({ amount: 0 }),
|
||||||
|
totalCost: Dinero({ amount: 0 }),
|
||||||
|
gpdollars: Dinero({ amount: 0 }),
|
||||||
|
gppercent: null,
|
||||||
|
gppercentFormatted: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const costCenterData = allProfitCenters.map((key, idx) => {
|
||||||
|
const ccVal = key; // defaultProfits[key];
|
||||||
|
const sale_labor =
|
||||||
|
jobLineTotalsByProfitCenter.labor[ccVal] || Dinero({ amount: 0 });
|
||||||
|
const sale_parts =
|
||||||
|
jobLineTotalsByProfitCenter.parts[ccVal] || Dinero({ amount: 0 });
|
||||||
|
|
||||||
|
const cost_labor =
|
||||||
|
ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 });
|
||||||
|
const cost_parts = billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 });
|
||||||
|
|
||||||
|
const costs = (
|
||||||
|
billTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 })
|
||||||
|
).add(ticketTotalsByProfitCenter[ccVal] || Dinero({ amount: 0 }));
|
||||||
|
const totalSales = sale_labor.add(sale_parts);
|
||||||
|
const gpdollars = totalSales.subtract(costs);
|
||||||
|
const gppercent = (
|
||||||
|
(gpdollars.getAmount() / totalSales.getAmount()) *
|
||||||
|
100
|
||||||
|
).toFixed(2);
|
||||||
|
|
||||||
|
let gppercentFormatted;
|
||||||
|
if (isNaN(gppercent)) gppercentFormatted = "0%";
|
||||||
|
else if (!isFinite(gppercent)) gppercentFormatted = "- ∞";
|
||||||
|
else {
|
||||||
|
gppercentFormatted = `${gppercent}%`;
|
||||||
|
}
|
||||||
|
//Push summary data to avoid extra loop.
|
||||||
|
summaryData.totalLaborSales = summaryData.totalLaborSales.add(sale_labor);
|
||||||
|
summaryData.totalPartsSales = summaryData.totalPartsSales.add(sale_parts);
|
||||||
|
summaryData.totalSales = summaryData.totalSales
|
||||||
|
.add(sale_labor)
|
||||||
|
.add(sale_parts);
|
||||||
|
summaryData.totalLaborCost = summaryData.totalLaborCost.add(cost_labor);
|
||||||
|
summaryData.totalPartsCost = summaryData.totalPartsCost.add(cost_parts);
|
||||||
|
summaryData.totalCost = summaryData.totalCost.add(costs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: idx,
|
||||||
|
cost_center: ccVal,
|
||||||
|
sale_labor: sale_labor && sale_labor.toFormat(),
|
||||||
|
sale_parts: sale_parts && sale_parts.toFormat(),
|
||||||
|
sales: sale_labor.add(sale_parts).toFormat(),
|
||||||
|
sales_dinero: sale_labor.add(sale_parts),
|
||||||
|
cost_parts: cost_parts && cost_parts.toFormat(),
|
||||||
|
cost_labor: cost_labor && cost_labor.toFormat(),
|
||||||
|
costs: cost_parts.add(cost_labor).toFormat(),
|
||||||
|
costs_dinero: cost_parts.add(cost_labor),
|
||||||
|
gpdollars: gpdollars.toFormat(),
|
||||||
|
gppercent: gppercentFormatted,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
//Final summary data massaging.
|
||||||
|
summaryData.gpdollars = summaryData.totalSales.subtract(
|
||||||
|
summaryData.totalCost
|
||||||
|
);
|
||||||
|
summaryData.gppercent = (
|
||||||
|
(summaryData.gpdollars.getAmount() / summaryData.totalSales.getAmount()) *
|
||||||
|
100
|
||||||
|
).toFixed(2);
|
||||||
|
if (isNaN(summaryData.gppercent)) summaryData.gppercentFormatted = 0;
|
||||||
|
else if (!isFinite(summaryData.gppercent))
|
||||||
|
summaryData.gppercentFormatted = "- ∞";
|
||||||
|
else {
|
||||||
|
summaryData.gppercentFormatted = summaryData.gppercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { summaryData, costCenterData };
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.JobCosting = JobCosting;
|
||||||
|
exports.JobCostingMulti = JobCostingMulti;
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
exports.totals = require("./job-totals").default;
|
exports.totals = require("./job-totals").default;
|
||||||
exports.totalsSsu = require("./job-totals").totalsSsu;
|
exports.totalsSsu = require("./job-totals").totalsSsu;
|
||||||
|
exports.costing = require("./job-costing").JobCosting;
|
||||||
|
exports.costingmulti = require("./job-costing").JobCostingMulti;
|
||||||
|
|||||||
Reference in New Issue
Block a user