diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index 40a8c65ca..0f1df33fd 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -14067,6 +14067,48 @@ + + scheduledintoday + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + scheduledouttoday + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + @@ -19398,6 +19440,27 @@ actions + + assign_team + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + converttolabor false @@ -19555,6 +19618,27 @@ + + assigned_team + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + db_price false @@ -23928,6 +24012,27 @@ + + dms_unsold + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + dms_wip_acctnumber false @@ -43600,6 +43705,27 @@ + + jobs_scheduled_completion + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + lag_time false 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 adc9ebe67..468abb236 100644 --- a/client/src/components/job-detail-lines/job-lines.component.jsx +++ b/client/src/components/job-detail-lines/job-lines.component.jsx @@ -45,7 +45,9 @@ import JobSendPartPriceChangeComponent from "../job-send-parts-price-change/job- import PartsOrderModalContainer from "../parts-order-modal/parts-order-modal.container"; import JobLinesExpander from "./job-lines-expander.component"; import JobLinesPartPriceChange from "./job-lines-part-price-change.component"; +import JoblineTeamAssignment from "../job-line-team-assignment/job-line-team-assignmnent.component"; import JobLineDispatchButton from "../job-line-dispatch-button/job-line-dispatch-button.component"; +import JobLineBulkAssignComponent from "../job-line-bulk-assign/job-line-bulk-assign.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -287,6 +289,14 @@ export function JobLinesComponent({ state.sortedInfo.columnKey === "line_ind" && state.sortedInfo.order, responsive: ["md"], }, + { + title: t("joblines.fields.assigned_team"), + dataIndex: "assigned_team", + key: "assigned_team", + render: (text, record) => ( + + ), + }, { title: t("joblines.fields.notes"), dataIndex: "notes", @@ -405,7 +415,11 @@ export function JobLinesComponent({ setSelectedLines( _.uniq([ ...selectedLines, - ...jobLines.filter((item) => markedTypes.includes(item.part_type)), + ...jobLines.filter( + (item) => + markedTypes.includes(item.part_type) || + markedTypes.includes(item.mod_lbr_ty) + ), ]) ); } @@ -418,6 +432,10 @@ export function JobLinesComponent({ {t("joblines.fields.part_types.PAL")} {t("joblines.fields.part_types.PAS")} + {t("joblines.fields.lbr_types.LAB")} + {t("joblines.fields.lbr_types.LAR")} + {t("joblines.fields.lbr_types.LAM")} + {t("general.labels.clear")} ); @@ -446,6 +464,11 @@ export function JobLinesComponent({ setSelectedLines={setSelectedLines} job={job} /> + + + + + + ); + + return ( + + + + ); +} diff --git a/client/src/components/job-line-team-assignment/job-line-team-assignmnent.component.jsx b/client/src/components/job-line-team-assignment/job-line-team-assignmnent.component.jsx new file mode 100644 index 000000000..929ec1e5d --- /dev/null +++ b/client/src/components/job-line-team-assignment/job-line-team-assignmnent.component.jsx @@ -0,0 +1,103 @@ +import { notification, Select } from "antd"; +import React, { useEffect, useState } from "react"; +import { useMutation } from "@apollo/client"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component"; + +const mapStateToProps = createStructuredSelector({ + //currentUser: selectCurrentUser + bodyshop: selectBodyshop, +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); + +export function JoblineTeamAssignment({ bodyshop, jobline, disabled }) { + const [editing, setEditing] = useState(false); + const [loading, setLoading] = useState(false); + const [assignedTeam, setAssignedTeam] = useState(jobline.assigned_team); + const [updateJob] = useMutation(UPDATE_JOB_LINE); + const { t } = useTranslation(); + + useEffect(() => { + if (editing) setAssignedTeam(jobline.assigned_team); + }, [editing, jobline.assigned_team]); + + const handleChange = (e) => { + setAssignedTeam(e); + }; + + const handleSave = async (e) => { + setLoading(true); + const result = await updateJob({ + variables: { + lineId: jobline.id, + line: { assigned_team: assignedTeam }, + }, + }); + + if ( + assignedTeam === null || + assignedTeam === undefined || + assignedTeam === "" + ) { + alert("TODO - implement calculation to reduce assigned hours if needed."); + } + + if (!!!result.errors) { + notification["success"]({ message: t("joblines.successes.saved") }); + } else { + notification["error"]({ + message: t("joblines.errors.saving", { + error: JSON.stringify(result.errors), + }), + }); + } + setLoading(false); + setEditing(false); + }; + + if (editing) + return ( +
+ + + +
+ ); + + const team = bodyshop.employee_teams.find( + (tm) => tm.id === jobline.assigned_team + ); + + return ( +
!disabled && setEditing(true)} + > + {team?.name} +
+ ); +} +export default connect( + mapStateToProps, + mapDispatchToProps +)(JoblineTeamAssignment); diff --git a/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx b/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx index d3f4ba651..49a1d26ea 100644 --- a/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx +++ b/client/src/components/jobs-detail-labor/jobs-detail-labor.component.jsx @@ -5,6 +5,7 @@ import { createStructuredSelector } from "reselect"; import { selectJobReadOnly } from "../../redux/application/application.selectors"; import LaborAllocationsTableComponent from "../labor-allocations-table/labor-allocations-table.component"; import TimeTicketList from "../time-ticket-list/time-ticket-list.component"; +import PayrollLaborAllocationsTable from "../labor-allocations-table/labor-allocations-table.payroll.component"; const mapStateToProps = createStructuredSelector({ jobRO: selectJobReadOnly, @@ -78,6 +79,14 @@ export function JobsDetailLaborContainer({ adjustments={adjustments} /> + + + ); } diff --git a/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx new file mode 100644 index 000000000..d93e1cae8 --- /dev/null +++ b/client/src/components/labor-allocations-table/labor-allocations-table.payroll.component.jsx @@ -0,0 +1,272 @@ +import { EditFilled } from "@ant-design/icons"; +import { Button, Card, Col, Row, Space, Table, Typography } from "antd"; +import _ from "lodash"; +import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectTechnician } from "../../redux/tech/tech.selectors"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import CurrencyFormatter from "../../utils/CurrencyFormatter"; +import { alphaSort } from "../../utils/sorters"; +import LaborAllocationsAdjustmentEdit from "../labor-allocations-adjustment-edit/labor-allocations-adjustment-edit.component"; +import "./labor-allocations-table.styles.scss"; +import { CalculateAllocationsTotals } from "./labor-allocations-table.utility"; +import axios from "axios"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + technician: selectTechnician, +}); + +export function PayrollLaborAllocationsTable({ + jobId, + joblines, + timetickets, + bodyshop, + adjustments, + technician, +}) { + const { t } = useTranslation(); + const [totals, setTotals] = useState([]); + const [state, setState] = useState({ + sortedInfo: { + columnKey: "cost_center", + field: "cost_center", + order: "ascend", + }, + filteredInfo: {}, + }); + + useEffect(() => { + if (!!joblines && !!timetickets && !!bodyshop); + + setTotals( + CalculateAllocationsTotals(bodyshop, joblines, timetickets, adjustments) + ); + if (!jobId) setTotals([]); + }, [joblines, timetickets, bodyshop, adjustments, jobId]); + + const convertedLines = useMemo( + () => joblines && joblines.filter((j) => j.convertedtolbr), + [joblines] + ); + + const columns = [ + { + title: t("timetickets.fields.cost_center"), + dataIndex: "cost_center", + key: "cost_center", + defaultSortOrder: "cost_center", + sorter: (a, b) => alphaSort(a.cost_center, b.cost_center), + sortOrder: + state.sortedInfo.columnKey === "cost_center" && state.sortedInfo.order, + render: (text, record) => `${record.cost_center} (${record.mod_lbr_ty})`, + }, + { + title: t("jobs.labels.hrs_total"), + dataIndex: "total", + key: "total", + sorter: (a, b) => a.total - b.total, + sortOrder: + state.sortedInfo.columnKey === "total" && state.sortedInfo.order, + render: (text, record) => record.total.toFixed(1), + }, + { + title: t("jobs.labels.hrs_claimed"), + dataIndex: "hrs_claimed", + key: "hrs_claimed", + sorter: (a, b) => a.claimed - b.claimed, + sortOrder: + state.sortedInfo.columnKey === "claimed" && state.sortedInfo.order, + render: (text, record) => record.claimed && record.claimed.toFixed(1), + }, + { + title: t("jobs.labels.adjustments"), + dataIndex: "adjustments", + key: "adjustments", + sorter: (a, b) => a.adjustments - b.adjustments, + sortOrder: + state.sortedInfo.columnKey === "adjustments" && state.sortedInfo.order, + render: (text, record) => ( + + {record.adjustments.toFixed(1)} + {!technician && ( + + + + )} + + ), + }, + { + title: t("jobs.labels.difference"), + dataIndex: "difference", + + key: "difference", + sorter: (a, b) => a.difference - b.difference, + sortOrder: + state.sortedInfo.columnKey === "difference" && state.sortedInfo.order, + render: (text, record) => ( + = 0 ? "green" : "red", + }} + > + {_.round(record.difference, 1)} + + ), + }, + ]; + const convertedTableCols = [ + { + title: t("joblines.fields.line_desc"), + dataIndex: "line_desc", + key: "line_desc", + ellipsis: true, + }, + { + title: t("joblines.fields.op_code_desc"), + dataIndex: "op_code_desc", + key: "op_code_desc", + ellipsis: true, + render: (text, record) => + `${record.op_code_desc || ""}${ + record.alt_partm ? ` ${record.alt_partm}` : "" + }`, + }, + + { + title: t("joblines.fields.act_price"), + dataIndex: "act_price", + key: "act_price", + ellipsis: true, + render: (text, record) => ( + <> + + {record.db_ref === "900510" || record.db_ref === "900511" + ? record.prt_dsmk_m + : record.act_price} + + {record.prt_dsmk_p && record.prt_dsmk_p !== 0 ? ( + {`(${record.prt_dsmk_p}%)`} + ) : ( + <> + )} + + ), + }, + { + title: t("joblines.fields.part_qty"), + dataIndex: "part_qty", + key: "part_qty", + }, + { + title: t("joblines.fields.mod_lbr_ty"), + dataIndex: "conv_mod_lbr_ty", + key: "conv_mod_lbr_ty", + render: (text, record) => + record.convertedtolbr_data && record.convertedtolbr_data.mod_lbr_ty, + }, + { + title: t("joblines.fields.mod_lb_hrs"), + dataIndex: "conv_mod_lb_hrs", + key: "conv_mod_lb_hrs", + render: (text, record) => + record.convertedtolbr_data && + record.convertedtolbr_data.mod_lb_hrs && + record.convertedtolbr_data.mod_lb_hrs.toFixed(1), + }, + ]; + + const handleTableChange = (pagination, filters, sorter) => { + setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); + }; + + const summary = + totals && + totals.reduce( + (acc, val) => { + acc.hrs_total += val.total; + acc.hrs_claimed += val.claimed; + acc.adjustments += val.adjustments; + acc.difference += val.difference; + return acc; + }, + { hrs_total: 0, hrs_claimed: 0, adjustments: 0, difference: 0 } + ); + + return ( + + + + + `${record.cost_center} ${record.mod_lbr_ty}`} + pagination={false} + onChange={handleTableChange} + dataSource={totals} + scroll={{ + x: true, + }} + summary={() => ( + + + + {t("general.labels.totals")} + + + + {summary.hrs_total.toFixed(1)} + + + {summary.hrs_claimed.toFixed(1)} + + + {summary.adjustments.toFixed(1)} + + + {summary.difference.toFixed(1)} + + + )} + /> + + + {convertedLines && convertedLines.length > 0 && ( + + +
+ + + )} + + ); +} +export default connect(mapStateToProps, null)(PayrollLaborAllocationsTable); diff --git a/client/src/graphql/bodyshop.queries.js b/client/src/graphql/bodyshop.queries.js index ad004198a..e856fca67 100644 --- a/client/src/graphql/bodyshop.queries.js +++ b/client/src/graphql/bodyshop.queries.js @@ -119,6 +119,19 @@ export const QUERY_BODYSHOP = gql` tt_enforce_hours_for_tech_console md_tasks_presets use_paint_scale_data + employee_teams( + order_by: { name: asc } + where: { active: { _eq: true } } + ) { + id + name + employee_team_members { + id + employeeid + labor_rates + percentage + } + } employees { user_email id @@ -235,6 +248,19 @@ export const UPDATE_SHOP = gql` enforce_conversion_category tt_enforce_hours_for_tech_console md_tasks_presets + employee_teams( + order_by: { name: asc } + where: { active: { _eq: true } } + ) { + id + name + employee_team_members { + id + employeeid + labor_rates + percentage + } + } employees { id first_name diff --git a/client/src/graphql/jobs-lines.queries.js b/client/src/graphql/jobs-lines.queries.js index d94f866e8..67cc6b248 100644 --- a/client/src/graphql/jobs-lines.queries.js +++ b/client/src/graphql/jobs-lines.queries.js @@ -51,7 +51,6 @@ export const GET_LINE_TICKET_BY_PK = gql` op_code_desc convertedtolbr convertedtolbr_data - } timetickets(where: { jobid: { _eq: $id } }) { actualhrs @@ -244,6 +243,7 @@ export const UPDATE_JOB_LINE = gql` removed convertedtolbr convertedtolbr_data + assigned_team } } } @@ -348,3 +348,19 @@ export const UPDATE_LINE_PPC = gql` } } `; + +export const UPDATE_LINE_BULK_ASSIGN = gql` + mutation UPDATE_LINE_BULK_ASSIGN( + $ids: [uuid!]! + $jobline: joblines_set_input + ) { + update_joblines_many( + updates: { _set: $jobline, where: { id: { _in: $ids } } } + ) { + returning { + id + assigned_team + } + } + } +`; diff --git a/client/src/graphql/jobs.queries.js b/client/src/graphql/jobs.queries.js index 6407442eb..6509b74e6 100644 --- a/client/src/graphql/jobs.queries.js +++ b/client/src/graphql/jobs.queries.js @@ -733,6 +733,7 @@ export const GET_JOB_BY_PK = gql` employeeid } } + assigned_team billlines(limit: 1, order_by: { bill: { date: desc } }) { id quantity diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 8aad2f5d2..671ea674a 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1212,6 +1212,7 @@ }, "joblines": { "actions": { + "assign_team": "Assign Team", "converttolabor": "Convert amount to Labor.", "dispatchparts": "Dispatch Parts ({{count}})", "new": "New Line" @@ -1223,6 +1224,7 @@ "fields": { "act_price": "Retail Price", "ah_detail_line": "Mark as Detail Labor Line (Autohouse Only)", + "assigned_team": "Team", "db_price": "List Price", "lbr_types": { "LA1": "LA1", @@ -1455,8 +1457,8 @@ "cost_dms_acctnumber": "Cost DMS Acct #", "dms_make": "DMS Make", "dms_model": "DMS Model", - "dms_wip_acctnumber": "Cost WIP DMS Acct #", "dms_unsold": "New, Unsold Vehicle", + "dms_wip_acctnumber": "Cost WIP DMS Acct #", "id": "DMS ID", "inservicedate": "In Service Date", "journal": "Journal #", @@ -2590,8 +2592,8 @@ "job_costing_ro_ins_co": "Job Costing by RO Source", "jobs_completed_not_invoiced": "Jobs Completed not Invoiced", "jobs_invoiced_not_exported": "Jobs Invoiced not Exported", - "jobs_scheduled_completion": "Jobs Scheduled Completion", "jobs_reconcile": "Parts/Sublet/Labor Reconciliation", + "jobs_scheduled_completion": "Jobs Scheduled Completion", "lag_time": "Lag Time", "open_orders": "Open Orders by Date", "open_orders_csr": "Open Orders by CSR", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index e1a0a9920..3700aa408 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1212,6 +1212,7 @@ }, "joblines": { "actions": { + "assign_team": "", "converttolabor": "", "dispatchparts": "", "new": "" @@ -1223,6 +1224,7 @@ "fields": { "act_price": "Precio actual", "ah_detail_line": "", + "assigned_team": "", "db_price": "Precio de base de datos", "lbr_types": { "LA1": "", @@ -1455,8 +1457,8 @@ "cost_dms_acctnumber": "", "dms_make": "", "dms_model": "", - "dms_wip_acctnumber": "", "dms_unsold": "", + "dms_wip_acctnumber": "", "id": "", "inservicedate": "", "journal": "", @@ -2590,8 +2592,8 @@ "job_costing_ro_ins_co": "", "jobs_completed_not_invoiced": "", "jobs_invoiced_not_exported": "", - "jobs_scheduled_completion": "", "jobs_reconcile": "", + "jobs_scheduled_completion": "", "lag_time": "", "open_orders": "", "open_orders_csr": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 60498ae31..2764abafb 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1212,6 +1212,7 @@ }, "joblines": { "actions": { + "assign_team": "", "converttolabor": "", "dispatchparts": "", "new": "" @@ -1223,6 +1224,7 @@ "fields": { "act_price": "Prix actuel", "ah_detail_line": "", + "assigned_team": "", "db_price": "Prix de la base de données", "lbr_types": { "LA1": "", @@ -1455,8 +1457,8 @@ "cost_dms_acctnumber": "", "dms_make": "", "dms_model": "", - "dms_wip_acctnumber": "", "dms_unsold": "", + "dms_wip_acctnumber": "", "id": "", "inservicedate": "", "journal": "", @@ -2590,8 +2592,8 @@ "job_costing_ro_ins_co": "", "jobs_completed_not_invoiced": "", "jobs_invoiced_not_exported": "", - "jobs_scheduled_completion": "", "jobs_reconcile": "", + "jobs_scheduled_completion": "", "lag_time": "", "open_orders": "", "open_orders_csr": "", diff --git a/server.js b/server.js index 9b16722d7..ce84afa35 100644 --- a/server.js +++ b/server.js @@ -261,6 +261,14 @@ app.post( intellipay.postback ); +const payroll = require("./server/payroll/payroll"); +app.post( + "/payroll/calculatelabortotals", + fb.validateFirebaseIdToken, + payroll.calculateLaborTotals +); +app.post("/payroll/payall", fb.validateFirebaseIdToken, payroll.payall); + var ioevent = require("./server/ioevent/ioevent"); app.post("/ioevent", ioevent.default); // app.post("/newlog", (req, res) => { diff --git a/server/graphql-client/queries.js b/server/graphql-client/queries.js index 938c13a29..1013ac32d 100644 --- a/server/graphql-client/queries.js +++ b/server/graphql-client/queries.js @@ -1822,3 +1822,92 @@ exports.ACTIVE_SHOP_BY_USER = `query ACTIVE_SHOP_BY_USER($user: String) { } } `; + +exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) { + jobs_by_pk(id: $id) { + bodyshop{ + id + employee_teams{ + id + name + employee_team_members{ + id + employee{ + id + first_name + last_name + } + percentage + labor_rates + } + } + } + timetickets{ + id + employeeid + rate + productivehrs + actualhrs + ciecacode + } + lbr_adjustments + ro_number + id + job_totals + 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 + status + materials + joblines(where: { removed: { _eq: false } }){ + id + line_no + unq_seq + line_ind + line_desc + part_type + line_ref + oem_partno + db_price + act_price + part_qty + mod_lbr_ty + db_hrs + mod_lb_hrs + lbr_op + lbr_amt + op_code_desc + status + notes + location + tax_part + db_ref + manual_line + prt_dsmk_p + prt_dsmk_m + misc_amt + misc_tax + assigned_team + } + } +}`; diff --git a/server/payroll/calculate-totals.js b/server/payroll/calculate-totals.js new file mode 100644 index 000000000..4d53732c3 --- /dev/null +++ b/server/payroll/calculate-totals.js @@ -0,0 +1,59 @@ +const Dinero = require("dinero.js"); +const queries = require("../graphql-client/queries"); +const GraphQLClient = require("graphql-request").GraphQLClient; +const logger = require("../utils/logger"); +// Dinero.defaultCurrency = "USD"; +// Dinero.globalLocale = "en-CA"; +Dinero.globalRoundingMode = "HALF_EVEN"; + +exports.calculateLaborTotals = async function (req, res) { + const BearerToken = req.headers.authorization; + const { jobid } = req.body; + logger.log("job-payroll-labor-totals", "DEBUG", req.user.email, jobid, null); + const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { + headers: { + Authorization: BearerToken, + }, + }); + + try { + const { jobs_by_pk: job } = await client + .setHeaders({ Authorization: BearerToken }) + .request(queries.QUERY_JOB_PAYROLL_DATA, { + id: jobid, + }); + + //iterate over each ticket, building a hash of team -> employee to calculate total assigned hours. + + const assignmentHash = { unassigned: 0 }; + job.joblines.forEach((jobline) => { + if (jobline.mod_lb_hrs > 0) { + //Check if the line is assigned. If not, keep track of it as an unassigned line by type. + if (jobline.assigned_team === null) { + assignmentHash.unassigned[jobline.mod_lbr_ty] = + assignmentHash.unassigned[jobline.mod_lbr_ty] + jobline.mod_lb_hrs; + } else { + //Line is assigned. + if (!assignmentHash[jobline.assigned_team]) { + assignmentHash[jobline.assigned_team] = 0; + } + assignmentHash[jobline.assigned_team] = + assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs; + } + } + }); + res.json(assignmentHash); + } catch (error) { + logger.log( + "job-payroll-labor-totals-error", + "ERROR", + req.user.email, + jobid, + { + jobid: jobid, + error, + } + ); + res.status(503).send(); + } +}; diff --git a/server/payroll/pay-all.js b/server/payroll/pay-all.js new file mode 100644 index 000000000..8239557f8 --- /dev/null +++ b/server/payroll/pay-all.js @@ -0,0 +1,249 @@ +const Dinero = require("dinero.js"); +const queries = require("../graphql-client/queries"); +const GraphQLClient = require("graphql-request").GraphQLClient; +const _ = require("lodash"); +const logger = require("../utils/logger"); +// Dinero.defaultCurrency = "USD"; +// Dinero.globalLocale = "en-CA"; +Dinero.globalRoundingMode = "HALF_EVEN"; + +exports.payall = async function (req, res) { + const BearerToken = req.headers.authorization; + const { jobid } = req.body; + logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null); + const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { + headers: { + Authorization: BearerToken, + }, + }); + + try { + const { jobs_by_pk: job } = await client + .setHeaders({ Authorization: BearerToken }) + .request(queries.QUERY_JOB_PAYROLL_DATA, { + id: jobid, + }); + + //iterate over each ticket, building a hash of team -> employee to calculate total assigned hours. + + const assignmentHash = { unassigned: 0 }; + const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid. + job.joblines.forEach((jobline) => { + if (jobline.mod_lb_hrs > 0) { + //Check if the line is assigned. If not, keep track of it as an unassigned line by type. + if (jobline.assigned_team === null) { + assignmentHash.unassigned = + assignmentHash.unassigned + jobline.mod_lb_hrs; + } else { + //Line is assigned. + if (!assignmentHash[jobline.assigned_team]) { + assignmentHash[jobline.assigned_team] = 0; + } + assignmentHash[jobline.assigned_team] = + assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs; + + //Create the assignment breakdown. + const theTeam = job.bodyshop.employee_teams.find( + (team) => team.id === jobline.assigned_team + ); + + theTeam.employee_team_members.forEach((tm) => { + //Figure out how many hours they are owed at this line, and at what rate. + console.log(tm); + if (!employeeHash[tm.employee.id]) { + employeeHash[tm.employee.id] = {}; + } + if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty]) { + employeeHash[tm.employee.id][jobline.mod_lbr_ty] = {}; + } + if ( + !employeeHash[tm.employee.id][jobline.mod_lbr_ty][ + tm.labor_rates[jobline.mod_lbr_ty] + ] + ) { + employeeHash[tm.employee.id][jobline.mod_lbr_ty][ + tm.labor_rates[jobline.mod_lbr_ty] + ] = 0; + } + + const hoursOwed = (tm.percentage / 100) * jobline.mod_lb_hrs; + employeeHash[tm.employee.id][jobline.mod_lbr_ty][ + tm.labor_rates[jobline.mod_lbr_ty] + ] = + employeeHash[tm.employee.id][jobline.mod_lbr_ty][ + tm.labor_rates[jobline.mod_lbr_ty] + ] + hoursOwed; + }); + } + } + }); + + const ticketHash = {}; // employeeid => Cieca labor type => rate => hours. + //Calculate how much each employee has been paid so far. + job.timetickets.forEach((ticket) => { + if (!ticketHash[ticket.employeeid]) { + ticketHash[ticket.employeeid] = {}; + } + if (!ticketHash[ticket.employeeid][ticket.ciecacode]) { + ticketHash[ticket.employeeid][ticket.ciecacode] = {}; + } + if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) { + ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0; + } + ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = + ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] + + ticket.productivehrs; + //Add the rate + }); + + if (assignmentHash.unassigned > 0) { + res.json({ success: false, error: "Unassigned hours." }); + return; + } + + //Calculate how much time each tech should have by labor type. + const comparison = compare(employeeHash, ticketHash); + + const ticketsToInsert = []; + + //Check the ones that are different first. Source of truth will be the employee hash. + comparison.different.forEach((differentKey) => { + const empVal = employeeHash[differentKey]; + const ticketVal = ticketHash[differentKey]; + + ticketsToInsert.push({ + jobid: job.id, + employeeid: differentKey.split(".")[0], + productivehrs: empVal - ticketVal, + rate: differentKey.split(".")[2], + memo: "Adjustment between expected and entered values. ", + }); + }); + + comparison.missing_from_first + .filter((differentKey) => differentKey.split(".").length == 3) + .forEach((differentKey) => { + const empVal = employeeHash[differentKey]; + const ticketVal = ticketHash[differentKey]; + + ticketsToInsert.push({ + jobid: job.id, + employeeid: differentKey.split(".")[0], + productivehrs: empVal - ticketVal * -1, + rate: differentKey.split(".")[2], + memo: "Entered ticket reversed to match system payroll.", + }); + }); + comparison.missing_from_second + .filter((differentKey) => differentKey.split(".").length == 3) + .forEach((differentKey) => { + const empVal = employeeHash[differentKey]; + const ticketVal = ticketHash[differentKey]; + + ticketsToInsert.push({ + jobid: job.id, + employeeid: differentKey.split(".")[0], + productivehrs: empVal - ticketVal * -1, + rate: differentKey.split(".")[2], + memo: "Entered ticket reversed to match system payroll.", + }); + }); + + res.json({ + assignmentHash, + employeeHash, + diff: getObjectDiff(employeeHash, ticketHash), + compare: compare(employeeHash, ticketHash), + }); + } catch (error) { + logger.log( + "job-payroll-labor-totals-error", + "ERROR", + req.user.email, + jobid, + { + jobid: jobid, + error, + } + ); + res.status(503).send(); + } +}; + +function getObjectDiff(obj1, obj2) { + const diff = Object.keys(obj1).reduce((result, key) => { + if (!obj2.hasOwnProperty(key)) { + result.push(key); + } else if (_.isEqual(obj1[key], obj2[key])) { + const resultKeyIndex = result.indexOf(key); + result.splice(resultKeyIndex, 1); + } + return result; + }, Object.keys(obj2)); + + return diff; +} + +var compare = function (a, b) { + var result = { + different: [], + missing_from_first: [], + missing_from_second: [], + }; + + _.reduce( + a, + function (result, value, key) { + if (b.hasOwnProperty(key)) { + if (_.isEqual(value, b[key])) { + return result; + } else { + if (typeof a[key] != typeof {} || typeof b[key] != typeof {}) { + //dead end. + result.different.push(key); + return result; + } else { + var deeper = compare(a[key], b[key]); + result.different = result.different.concat( + _.map(deeper.different, (sub_path) => { + return key + "." + sub_path; + }) + ); + + result.missing_from_second = result.missing_from_second.concat( + _.map(deeper.missing_from_second, (sub_path) => { + return key + "." + sub_path; + }) + ); + + result.missing_from_first = result.missing_from_first.concat( + _.map(deeper.missing_from_first, (sub_path) => { + return key + "." + sub_path; + }) + ); + return result; + } + } + } else { + result.missing_from_second.push(key); + return result; + } + }, + result + ); + + _.reduce( + b, + function (result, value, key) { + if (a.hasOwnProperty(key)) { + return result; + } else { + result.missing_from_first.push(key); + return result; + } + }, + result + ); + + return result; +}; diff --git a/server/payroll/payroll.js b/server/payroll/payroll.js new file mode 100644 index 000000000..c069976c9 --- /dev/null +++ b/server/payroll/payroll.js @@ -0,0 +1,2 @@ +exports.calculateLaborTotals = require("./calculate-totals").calculateLaborTotals; +exports.payall = require("./pay-all").payall;