feature/IO-3587-Commision-Cut - rebuild from master-AIO

This commit is contained in:
Dave
2026-03-17 10:50:22 -04:00
parent 8af8c8039c
commit 73ba95a240
21 changed files with 1473 additions and 579 deletions

View File

@@ -10,8 +10,13 @@ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const toFiniteNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (!value) return null;
if (value === null || value === undefined || value === "") return null;
switch (type) {
case "employee": {
const emp = bodyshop.employees.find((e) => e.id === value);
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
case "text":
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
case "currency":
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
case "currency": {
const numericValue = toFiniteNumber(value);
if (numericValue === null) {
return null;
}
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
}
default:
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
}

View File

@@ -1,7 +1,7 @@
import { DownOutlined, UpOutlined } from "@ant-design/icons";
import { Space } from "antd";
export default function FormListMoveArrows({ move, index, total }) {
export default function FormListMoveArrows({ move, index, total, orientation = "vertical" }) {
const upDisabled = index === 0;
const downDisabled = index === total - 1;
@@ -14,7 +14,7 @@ export default function FormListMoveArrows({ move, index, total }) {
};
return (
<Space orientation="vertical">
<Space orientation={orientation}>
<UpOutlined disabled={upDisabled} onClick={handleUp} />
<DownOutlined disabled={downDisabled} onClick={handleDown} />
</Space>

View File

@@ -21,6 +21,8 @@ const mapStateToProps = createStructuredSelector({
technician: selectTechnician
});
const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || "";
export function PayrollLaborAllocationsTable({
jobId,
joblines,
@@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({
});
const notification = useNotification();
useEffect(() => {
async function CalculateTotals() {
const loadTotals = async () => {
try {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId
});
setTotals(data);
} catch (error) {
setTotals([]);
notification.error({
title: getRequestErrorMessage(error)
});
}
};
useEffect(() => {
if (!!joblines && !!timetickets && !!bodyshop) {
CalculateTotals();
loadTotals();
}
if (!jobId) setTotals([]);
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
@@ -210,6 +219,7 @@ export function PayrollLaborAllocationsTable({
<Button
disabled={!hasTimeTicketAccess}
onClick={async () => {
try {
const response = await axios.post("/payroll/payall", {
jobid: jobId
});
@@ -235,16 +245,20 @@ export function PayrollLaborAllocationsTable({
})
});
}
} catch (error) {
notification.error({
title: t("timetickets.errors.payall", {
error: getRequestErrorMessage(error)
})
});
}
}}
>
<LockWrapperComponent featureName="timetickets">{t("timetickets.actions.payall")}</LockWrapperComponent>
</Button>
<Button
onClick={async () => {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId
});
setTotals(data);
await loadTotals();
refetch();
}}
icon={<SyncOutlined />}

View File

@@ -16,6 +16,43 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoTaskPresets);
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
const getTaskPresetAllocationErrors = (presets = [], t) => {
const totalsByLaborType = {};
presets.forEach((preset) => {
const percent = normalizePercent(preset?.percent);
if (!percent) {
return;
}
const laborTypes = Array.isArray(preset?.hourstype) ? preset.hourstype : [];
laborTypes.forEach((laborType) => {
if (!laborType) {
return;
}
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
});
});
return Object.entries(totalsByLaborType)
.filter(([, total]) => total > 100)
.map(([laborType, total]) => {
const translatedLaborType = t(`joblines.fields.lbr_types.${laborType}`);
const laborTypeLabel =
translatedLaborType === `joblines.fields.lbr_types.${laborType}` ? laborType : translatedLaborType;
return t("bodyshop.errors.task_preset_allocation_exceeded", {
laborType: laborTypeLabel,
total
});
});
};
export function ShopInfoTaskPresets({ bodyshop }) {
const { t } = useTranslation();
@@ -39,8 +76,21 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
<Form.List name={["md_tasks_presets", "presets"]}>
{(fields, { add, remove, move }) => {
<Form.List
name={["md_tasks_presets", "presets"]}
rules={[
{
validator: async (_, presets) => {
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
if (allocationErrors.length > 0) {
throw new Error(allocationErrors.join(" "));
}
}
}
]}
>
{(fields, { add, remove, move }, { errors }) => {
return (
<div>
{fields.map((field, index) => (
@@ -189,6 +239,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow>
</Form.Item>
))}
<Form.ErrorList errors={errors} />
<Form.Item>
<Button
type="dashed"

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Form, Input, InputNumber, Space, Switch } from "antd";
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch, Tag, Typography } from "antd";
import querystring from "query-string";
import { useEffect } from "react";
@@ -26,10 +26,59 @@ import { useNotification } from "../../contexts/Notifications/notificationContex
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
const mapDispatchToProps = () => ({});
const LABOR_TYPES = ["LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"];
const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
];
const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
employee: { xs: 24, lg: 13, xxl: 14 },
allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
};
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
const normalizeTeamMember = (teamMember = {}) => ({
...teamMember,
payout_method: teamMember.payout_method || "hourly",
labor_rates: teamMember.labor_rates || {},
commission_rates: teamMember.commission_rates || {}
});
const normalizeEmployeeTeam = (employeeTeam = {}) => ({
...employeeTeam,
employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember)
});
const getSplitTotal = (teamMembers = []) =>
teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0);
const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
const getEmployeeDisplayName = (employees = [], employeeId) => {
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
if (!employee) return null;
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ").trim();
return fullName || employee.employee_number || null;
};
const formatAllocationPercentage = (percentage) => {
if (percentage === null || percentage === undefined || percentage === "") return null;
const numericValue = Number(percentage);
if (!Number.isFinite(numericValue)) return null;
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
};
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const { t } = useTranslation();
const [form] = Form.useForm();
@@ -45,38 +94,100 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
useEffect(() => {
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk);
else {
if (data?.employee_teams_by_pk) {
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
} else {
form.resetFields();
}
}, [form, data, search.employeeTeamId]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
const payoutMethodOptions = PAYOUT_METHOD_OPTIONS.map(({ labelKey, value }) => ({
label: t(labelKey),
value
}));
const teamName = Form.useWatch("name", form);
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
const teamCardTitle = teamName?.trim() || t("employee_teams.fields.name");
const getTeamMemberTitle = (teamMember = {}) => {
const employeeName =
getEmployeeDisplayName(bodyshop.employees, teamMember.employeeid) || t("employee_teams.fields.employeeid");
const allocation = formatAllocationPercentage(teamMember.percentage);
const payoutMethod =
teamMember.payout_method === "commission"
? t("employee_teams.options.commission")
: t("employee_teams.options.hourly");
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
<Typography.Text strong>{employeeName}</Typography.Text>
<Tag bordered={false} color="geekblue">
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
</Tag>
<Tag bordered={false} color={getPayoutMethodTagColor(teamMember.payout_method)}>
{payoutMethod}
</Tag>
</div>
);
};
const handleFinish = async ({ employee_team_members = [], ...values }) => {
const normalizedTeamMembers = employee_team_members.map((teamMember) => {
const nextTeamMember = normalizeTeamMember({ ...teamMember });
delete nextTeamMember.__typename;
return nextTeamMember;
});
if (normalizedTeamMembers.length === 0) {
notification.error({
title: t("employee_teams.errors.minimum_one_member")
});
return;
}
const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean);
const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index);
if (duplicateEmployeeIds.length > 0) {
notification.error({
title: t("employee_teams.errors.duplicate_member")
});
return;
}
if (!hasExactSplitTotal(normalizedTeamMembers)) {
notification.error({
title: t("employee_teams.errors.allocation_total_exact")
});
return;
}
const handleFinish = async ({ employee_team_members, ...values }) => {
if (search.employeeTeamId && search.employeeTeamId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update");
const result = await updateEmployeeTeam({
variables: {
employeeTeamId: search.employeeTeamId,
employeeTeam: values,
teamMemberUpdates: employee_team_members
.filter((e) => e.id)
.map((e) => {
delete e.__typename;
return { where: { id: { _eq: e.id } }, _set: e };
}),
teamMemberInserts: employee_team_members
.filter((e) => e.id === null || e.id === undefined)
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter(
(e) => !employee_team_members.find((etm) => etm.id === e.id)
teamMemberUpdates: normalizedTeamMembers
.filter((teamMember) => teamMember.id)
.map((teamMember) => ({
where: { id: { _eq: teamMember.id } },
_set: teamMember
})),
teamMemberInserts: normalizedTeamMembers
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
.filter(
(teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id)
)
.map((teamMember) => teamMember.id)
}
});
if (!result.errors) {
notification.success({
title: t("employees.successes.save")
@@ -89,20 +200,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
}
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployeeTeam({
variables: {
employeeTeam: {
...values,
employee_team_members: { data: employee_team_members },
employee_team_members: { data: normalizedTeamMembers },
bodyshopid: bodyshop.id
}
},
refetchQueries: ["QUERY_TEAMS"]
}).then((r) => {
search.employeeTeamId = r.data.insert_employee_teams_one.id;
}).then((response) => {
search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) });
notification.success({
title: t("employees.successes.save")
@@ -116,6 +226,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
return (
<Card
title={teamCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()}>
{t("general.actions.save")}
@@ -130,7 +241,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -145,7 +255,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -156,12 +265,38 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
{fields.map((field, index) => {
const teamMember = normalizeTeamMember(teamMembers[field.name]);
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow grow>
<LayoutFormRow
grow
title={getTeamMemberTitle(teamMember)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
>
<div>
<Row gutter={[16, 0]}>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
<Form.Item
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
@@ -169,230 +304,110 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
<Form.Item
label={t("employee_teams.fields.percentage")}
label={t("employee_teams.fields.allocation_percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
<Form.Item
label={t("joblines.fields.lbr_types.LAA")}
key={`${index}`}
name={[field.name, "labor_rates", "LAA"]}
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAB")}
key={`${index}`}
name={[field.name, "labor_rates", "LAB"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAD")}
key={`${index}`}
name={[field.name, "labor_rates", "LAD"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAE")}
key={`${index}`}
name={[field.name, "labor_rates", "LAE"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
<Select options={payoutMethodOptions} />
</Form.Item>
</Col>
</Row>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
return (
<Row gutter={[16, 0]}>
{LABOR_TYPES.map((laborType) => (
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
<Form.Item
label={t("joblines.fields.lbr_types.LAF")}
key={`${index}`}
name={[field.name, "labor_rates", "LAF"]}
label={
t(`joblines.fields.lbr_types.${laborType}`)
}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAG")}
key={`${index}`}
name={[field.name, "labor_rates", "LAG"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAM")}
key={`${index}`}
name={[field.name, "labor_rates", "LAM"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAR")}
key={`${index}`}
name={[field.name, "labor_rates", "LAR"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAS")}
key={`${index}`}
name={[field.name, "labor_rates", "LAS"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAU")}
key={`${index}`}
name={[field.name, "labor_rates", "LAU"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA1")}
key={`${index}`}
name={[field.name, "labor_rates", "LA1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA2")}
key={`${index}`}
name={[field.name, "labor_rates", "LA2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA3")}
key={`${index}`}
name={[field.name, "labor_rates", "LA3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA4")}
key={`${index}`}
name={[field.name, "labor_rates", "LA4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Space align="center">
<DeleteFilled
onClick={() => {
remove(field.name);
</Col>
))}
</Row>
);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</Form.Item>
</div>
</LayoutFormRow>
</Form.Item>
))}
);
})}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
add({
percentage: 0,
payout_method: "hourly",
labor_rates: {},
commission_rates: {}
});
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => {
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
const splitTotal = getSplitTotal(teamMembers);
return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: splitTotal.toFixed(2)
})}
</Typography.Text>
);
}}
</Form.Item>
</div>
);
}}

View File

@@ -15,6 +15,18 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(TimeTicketTaskModalComponent);
const getPayoutMethodLabel = (payoutMethod, t) => {
if (!payoutMethod) {
return "";
}
if (payoutMethod === "hourly" || payoutMethod === "commission") {
return t(`timetickets.labels.payout_methods.${payoutMethod}`);
}
return payoutMethod;
};
export function TimeTicketTaskModalComponent({ bodyshop, form, loading, completedTasks, unassignedHours }) {
const { t } = useTranslation();
@@ -35,7 +47,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
</Form.Item>
<Space wrap>
<Form.Item name="task" label={t("timetickets.labels.task")}>
<Form.Item
name="task"
label={t("timetickets.labels.task")}
rules={[
{
required: true
}
]}
>
{loading ? (
<Spin />
) : (
@@ -93,10 +113,16 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<th>{t("timetickets.fields.cost_center")}</th>
<th>{t("timetickets.fields.ciecacode")}</th>
<th>{t("timetickets.fields.productivehrs")}</th>
<th>{t("timetickets.fields.payout_method")}</th>
<th>{t("timetickets.fields.rate")}</th>
<th>{t("timetickets.fields.amount")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
{fields.map((field, index) => {
const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]);
return (
<tr key={field.key}>
<td>
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
@@ -118,8 +144,20 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>{getPayoutMethodLabel(payoutMethod, t)}</td>
<td>
<Form.Item key={`${index}rate`} name={[field.name, "rate"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
</tr>
))}
);
})}
</tbody>
</table>
<Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} />

View File

@@ -25,6 +25,22 @@ const mapDispatchToProps = (dispatch) => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer);
const toFiniteNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const getPreviewPayoutAmount = (ticket) => {
const productiveHours = toFiniteNumber(ticket?.productivehrs);
const rate = toFiniteNumber(ticket?.rate);
if (productiveHours === null || rate === null) {
return null;
}
return productiveHours * rate;
};
export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) {
const [form] = Form.useForm();
const { context, open, actions } = timeTicketTasksModal;
@@ -90,7 +106,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
if (actions?.refetch) actions.refetch();
toggleModalVisible();
} else if (handleFinish === false) {
form.setFieldsValue({ timetickets: data.ticketsToInsert });
form.setFieldsValue({
timetickets: (data.ticketsToInsert || []).map((ticket) => ({
...ticket,
payoutamount: getPreviewPayoutAmount(ticket)
}))
});
setUnassignedHours(data.unassignedHours);
} else {
notification.error({
@@ -101,7 +122,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
}
} catch (error) {
notification.error({
title: t("timetickets.errors.creating", { message: error.message })
title: t("timetickets.errors.creating", {
message: error.response?.data?.error || error.message
})
});
} finally {
setLoading(false);

View File

@@ -130,7 +130,15 @@ export function TtApprovalsListComponent({
key: "memo",
sorter: (a, b) => alphaSort(a.memo, b.memo),
sortOrder: state.sortedInfo.columnKey === "memo" && state.sortedInfo.order,
render: (text, record) => (record.clockon || record.clockoff ? t(record.memo) : record.memo)
render: (text, record) => (record.memo?.startsWith("timetickets.labels") ? t(record.memo) : record.memo)
},
{
title: t("timetickets.fields.task_name"),
dataIndex: "task_name",
key: "task_name",
sorter: (a, b) => alphaSort(a.task_name, b.task_name),
sortOrder: state.sortedInfo.columnKey === "task_name" && state.sortedInfo.order,
render: (text, record) => record.task_name || ""
},
{
title: t("timetickets.fields.clockon"),
@@ -140,12 +148,12 @@ export function TtApprovalsListComponent({
render: (text, record) => <DateTimeFormatter>{record.clockon}</DateTimeFormatter>
},
{
title: "Pay",
title: t("timetickets.fields.pay"),
dataIndex: "pay",
key: "pay",
render: (text, record) =>
Dinero({ amount: Math.round(record.rate * 100) })
.multiply(record.flat_rate ? record.productivehrs : record.actualhrs)
Dinero({ amount: Math.round((record.rate || 0) * 100) })
.multiply(record.flat_rate ? record.productivehrs || 0 : record.actualhrs || 0)
.toFormat("$0.00")
}
];
@@ -184,7 +192,7 @@ export function TtApprovalsListComponent({
<ResponsiveTable
loading={loading}
columns={columns}
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center"]}
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center", "task_name"]}
rowKey="id"
scroll={{
x: true

View File

@@ -18,7 +18,15 @@ const mapStateToProps = createStructuredSelector({
authLevel: selectAuthLevel
});
export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabled, authLevel }) {
export function TtApproveButton({
bodyshop,
currentUser,
selectedTickets,
disabled,
authLevel,
completedCallback,
refetch
}) {
const { t } = useTranslation();
const client = useApolloClient();
const notification = useNotification();
@@ -54,6 +62,12 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
})
});
} else {
if (typeof completedCallback === "function") {
completedCallback([]);
}
if (typeof refetch === "function") {
refetch();
}
notification.success({
title: t("timetickets.successes.created")
});
@@ -68,8 +82,6 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
setLoading(false);
}
// if (!!completedCallback) completedCallback([]);
// if (!!loadingCallback) loadingCallback(false);
};
return (

View File

@@ -152,6 +152,8 @@ export const QUERY_BODYSHOP = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -285,6 +287,8 @@ export const UPDATE_SHOP = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}

View File

@@ -10,6 +10,8 @@ export const QUERY_TEAMS = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -29,6 +31,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -40,6 +44,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -52,6 +58,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -69,6 +77,8 @@ export const INSERT_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -86,6 +96,8 @@ export const QUERY_EMPLOYEE_TEAM_BY_ID = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}

View File

@@ -260,6 +260,7 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
id
clockon
clockoff
created_by
employeeid
productivehrs
actualhrs
@@ -267,6 +268,9 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
date
memo
flat_rate
task_name
payout_context
ttapprovalqueueid
commited_by
committed_at
}

View File

@@ -23,7 +23,14 @@ export const QUERY_ALL_TT_APPROVALS_PAGINATED = gql`
ciecacode
cost_center
date
memo
flat_rate
clockon
clockoff
rate
created_by
task_name
payout_context
}
tt_approval_queue_aggregate {
aggregate {
@@ -42,9 +49,16 @@ export const INSERT_NEW_TT_APPROVALS = gql`
productivehrs
actualhrs
ciecacode
cost_center
date
memo
flat_rate
rate
clockon
clockoff
created_by
task_name
payout_context
}
}
}
@@ -65,6 +79,11 @@ export const QUERY_TT_APPROVALS_BY_IDS = gql`
ciecacode
bodyshopid
cost_center
clockon
clockoff
created_by
task_name
payout_context
}
}
`;

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "Error creating default view.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}"
"saving": "Error encountered while saving. {{message}}",
"task_preset_allocation_exceeded": "{{laborType}} task preset total is {{total}}% and cannot exceed 100%."
},
"fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -1175,12 +1176,28 @@
"new": "New Team",
"newmember": "New Team Member"
},
"errors": {
"allocation_total_exact": "Team allocation must total exactly 100%.",
"duplicate_member": "Each employee can only appear once per team.",
"minimum_one_member": "Add at least one team member."
},
"fields": {
"active": "Active",
"allocation": "Allocation",
"allocation_percentage": "Allocation %",
"employeeid": "Employee",
"max_load": "Max Load",
"name": "Team Name",
"payout_method": "Payout Method",
"percentage": "Percent"
},
"labels": {
"allocation_total": "Allocation Total: {{total}}%"
},
"options": {
"commission": "Commission",
"commission_percentage": "Commission %",
"hourly": "Hourly"
}
},
"employees": {
@@ -3594,6 +3611,7 @@
},
"fields": {
"actualhrs": "Actual Hours",
"amount": "Amount",
"ciecacode": "CIECA Code",
"clockhours": "Clock Hours",
"clockoff": "Clock Off",
@@ -3608,7 +3626,10 @@
"employee_team": "Employee Team",
"flat_rate": "Flat Rate?",
"memo": "Memo",
"pay": "Pay",
"payout_method": "Payout Method",
"productivehrs": "Productive Hours",
"rate": "Rate",
"ro_number": "Job to Post Against",
"task_name": "Task"
},
@@ -3627,6 +3648,10 @@
"lunch": "Lunch",
"new": "New Time Ticket",
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
"payout_methods": {
"commission": "Commission",
"hourly": "Hourly"
},
"pmbreak": "PM Break",
"pmshift": "PM Shift",
"shift": "Shift",

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1175,12 +1176,28 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission": "",
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -3594,6 +3611,7 @@
},
"fields": {
"actualhrs": "",
"amount": "",
"ciecacode": "",
"clockhours": "",
"clockoff": "",
@@ -3608,7 +3626,10 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"payout_method": "",
"productivehrs": "",
"rate": "",
"ro_number": "",
"task_name": ""
},
@@ -3627,6 +3648,10 @@
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"pmbreak": "",
"pmshift": "",
"shift": "",

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1175,12 +1176,28 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission": "",
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -3594,6 +3611,7 @@
},
"fields": {
"actualhrs": "",
"amount": "",
"ciecacode": "",
"clockhours": "",
"clockoff": "",
@@ -3608,7 +3626,10 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"payout_method": "",
"productivehrs": "",
"rate": "",
"ro_number": "",
"task_name": ""
},
@@ -3627,6 +3648,10 @@
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"pmbreak": "",
"pmshift": "",
"shift": "",

View File

@@ -2463,6 +2463,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2473,6 +2475,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number
@@ -2564,6 +2567,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2574,6 +2579,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number

View File

@@ -1,20 +1,9 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob, CalculateTicketsHoursForJob } = require("./pay-all");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const get = (obj, key) => {
return key.split(".").reduce((o, x) => {
return typeof o == "undefined" || o === null ? o : o[x];
}, obj);
};
exports.calculatelabor = async function (req, res) {
const { jobid, calculateOnly } = req.body;
const { jobid } = req.body;
logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey];
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
const expected = employeeHash[employeeIdKey][laborTypeKey];
const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey];
if (claimed) {
delete ticketHash[employeeIdKey][laborTypeKey];
}
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
rate: expected.rate,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0
});
expectedHours: expected.hours,
claimedHours: claimed?.hours || 0
});
});
});
@@ -65,23 +50,14 @@ exports.calculatelabor = async function (req, res) {
Object.keys(ticketHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(ticketHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = 0;
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
}
const claimed = ticketHash[employeeIdKey][laborTypeKey];
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
rate: claimed.rate,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0
});
expectedHours: 0,
claimedHours: claimed.hours || 0
});
});
});
@@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) {
jobid: jobid,
error
});
res.status(503).send();
res.status(400).json({ error: error.message });
}
};

View File

@@ -1,11 +1,42 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob } = require("./pay-all");
const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all");
const moment = require("moment");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
const getTaskPresetAllocationError = (taskPresets = []) => {
const totalsByLaborType = {};
taskPresets.forEach((taskPreset) => {
const percent = normalizePercent(taskPreset?.percent);
if (!percent) {
return;
}
const laborTypes = Array.isArray(taskPreset?.hourstype) ? taskPreset.hourstype : [];
laborTypes.forEach((laborType) => {
if (!laborType) {
return;
}
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
});
});
const overAllocatedType = Object.entries(totalsByLaborType).find(([, total]) => total > 100);
if (!overAllocatedType) {
return null;
}
const [laborType, total] = overAllocatedType;
return `Task preset percentages for labor type ${laborType} total ${total}% and cannot exceed 100%.`;
};
exports.GetTaskPresetAllocationError = getTaskPresetAllocationError;
exports.claimtask = async function (req, res) {
const { jobid, task, calculateOnly, employee } = req.body;
@@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) {
id: jobid
});
const theTaskPreset = job.bodyshop.md_tasks_presets.presets.find((tp) => tp.name === task);
const taskPresets = job.bodyshop?.md_tasks_presets?.presets || [];
const taskPresetAllocationError = getTaskPresetAllocationError(taskPresets);
if (taskPresetAllocationError) {
res.status(400).json({ success: false, error: taskPresetAllocationError });
return;
}
const theTaskPreset = taskPresets.find((tp) => tp.name === task);
if (!theTaskPreset) {
res.status(400).json({ success: false, error: "Provided task preset not found." });
return;
}
const taskAlreadyCompleted = (job.completed_tasks || []).some((completedTask) => completedTask?.name === task);
if (taskAlreadyCompleted) {
res.status(400).json({ success: false, error: "Provided task preset has already been completed for this job." });
return;
}
//Get all of the assignments that are filtered.
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
const ticketsToInsert = [];
@@ -35,10 +79,8 @@ exports.claimtask = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100);
const expected = employeeHash[employeeIdKey][laborTypeKey];
const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100));
ticketsToInsert.push({
task_name: task,
@@ -46,21 +88,28 @@ exports.claimtask = async function (req, res) {
bodyshopid: job.bodyshop.id,
employeeid: employeeIdKey,
productivehrs: expectedHours,
rate: rateKey,
rate: expected.rate,
ciecacode: laborTypeKey,
flat_rate: true,
created_by: employee?.name || req.user.email,
payout_context: {
...(expected.payoutContext || {}),
generated_by: req.user.email,
generated_at: new Date().toISOString(),
generated_from: "claimtask",
task_name: task
},
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
memo: `*Flagged Task* ${theTaskPreset.memo}`
});
});
});
});
if (!calculateOnly) {
//Insert the time ticekts if we're not just calculating them.
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
});
const updateResult = await client.request(queries.UPDATE_JOB, {
await client.request(queries.UPDATE_JOB, {
jobId: job.id,
job: {
status: theTaskPreset.nextstatus,
@@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) {
jobid: jobid,
error
});
res.status(503).send();
res.status(400).json({ success: false, error: error.message });
}
};

View File

@@ -1,15 +1,196 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const rdiff = require("recursive-diff");
const logger = require("../utils/logger");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
Dinero.globalFormatRoundingMode = "HALF_EVEN";
const PAYOUT_METHODS = {
hourly: "hourly",
commission: "commission"
};
const CURRENCY_PRECISION = 2;
const HOURS_PRECISION = 5;
const toNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const normalizeNumericString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
const asString = value.toString();
if (!asString.toLowerCase().includes("e")) {
return asString;
}
return value.toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
}
return `${value ?? ""}`.trim();
};
const decimalToDinero = (value, errorMessage = "Invalid numeric value.") => {
const normalizedValue = normalizeNumericString(value);
const parsedValue = Number(normalizedValue);
if (!Number.isFinite(parsedValue)) {
throw new Error(errorMessage);
}
const isNegative = normalizedValue.startsWith("-");
const unsignedValue = normalizedValue.replace(/^[+-]/, "");
const [wholePart = "0", fractionPartRaw = ""] = unsignedValue.split(".");
const wholeDigits = wholePart.replace(/\D/g, "") || "0";
const fractionDigits = fractionPartRaw.replace(/\D/g, "");
const amount = Number(`${wholeDigits}${fractionDigits}` || "0") * (isNegative ? -1 : 1);
return Dinero({
amount,
precision: fractionDigits.length
});
};
const roundValueWithDinero = (value, precision, errorMessage) =>
decimalToDinero(value, errorMessage).convertPrecision(precision, Dinero.globalRoundingMode).toUnit();
const roundCurrency = (value, errorMessage = "Invalid currency value.") =>
roundValueWithDinero(value, CURRENCY_PRECISION, errorMessage);
const roundHours = (value, errorMessage = "Invalid hours value.") => roundValueWithDinero(value, HOURS_PRECISION, errorMessage);
const normalizePayoutMethod = (value) =>
value === PAYOUT_METHODS.commission ? PAYOUT_METHODS.commission : PAYOUT_METHODS.hourly;
const hasOwnValue = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key);
const getJobSaleRateField = (laborType) => `rate_${String(laborType || "").toLowerCase()}`;
const getTeamMemberLabel = (teamMember) => {
const fullName = `${teamMember?.employee?.first_name || ""} ${teamMember?.employee?.last_name || ""}`.trim();
return fullName || teamMember?.employee?.id || teamMember?.employeeid || "unknown employee";
};
const parseRequiredNumber = (value, errorMessage) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(errorMessage);
}
return parsed;
};
const buildFallbackPayoutContext = ({ laborType, rate }) => ({
payout_type: "legacy",
payout_method: "legacy",
cut_percent_applied: null,
source_labor_rate: null,
source_labor_type: laborType,
effective_rate: roundCurrency(rate)
});
function BuildPayoutDetails(job, teamMember, laborType) {
const payoutMethod = normalizePayoutMethod(teamMember?.payout_method);
const teamMemberLabel = getTeamMemberLabel(teamMember);
const sourceLaborRateField = getJobSaleRateField(laborType);
if (payoutMethod === PAYOUT_METHODS.hourly && !hasOwnValue(teamMember?.labor_rates, laborType)) {
throw new Error(`Missing hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(teamMember?.commission_rates, laborType)) {
throw new Error(`Missing commission percent for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(job, sourceLaborRateField)) {
throw new Error(`Missing sale rate ${sourceLaborRateField} for labor type ${laborType}.`);
}
const hourlyRate =
payoutMethod === PAYOUT_METHODS.hourly
? roundCurrency(
parseRequiredNumber(
teamMember?.labor_rates?.[laborType],
`Invalid hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
const commissionPercent =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(
teamMember?.commission_rates?.[laborType],
`Invalid commission percent for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
if (commissionPercent !== null && (commissionPercent < 0 || commissionPercent > 100)) {
throw new Error(`Commission percent for ${teamMemberLabel} on labor type ${laborType} must be between 0 and 100.`);
}
const sourceLaborRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(job?.[sourceLaborRateField], `Invalid sale rate ${sourceLaborRateField} for labor type ${laborType}.`)
)
: null;
const effectiveRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency((sourceLaborRate * toNumber(commissionPercent)) / 100)
: hourlyRate;
return {
effectiveRate,
payoutContext: {
payout_type: payoutMethod === PAYOUT_METHODS.commission ? "cut" : "hourly",
payout_method: payoutMethod,
cut_percent_applied: commissionPercent,
source_labor_rate: sourceLaborRate,
source_labor_type: laborType,
effective_rate: effectiveRate
}
};
}
function BuildGeneratedPayoutContext({ baseContext, generatedBy, generatedFrom, taskName, usedTicketFallback }) {
return {
...(baseContext || {}),
generated_by: generatedBy,
generated_at: new Date().toISOString(),
generated_from: generatedFrom,
task_name: taskName,
used_ticket_fallback: Boolean(usedTicketFallback)
};
}
function getAllKeys(...objects) {
return [...new Set(objects.flatMap((obj) => (obj ? Object.keys(obj) : [])))];
}
function buildPayAllMemo({ deltaHours, hasExpected, hasClaimed, userEmail }) {
if (!hasClaimed && deltaHours > 0) {
return `Add unflagged hours. (${userEmail})`;
}
if (!hasExpected && deltaHours < 0) {
return `Remove flagged hours per assignment. (${userEmail})`;
}
return `Adjust flagged hours per assignment. (${userEmail})`;
}
exports.payall = async function (req, res) {
const { jobid, calculateOnly } = req.body;
const { jobid } = req.body;
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -22,253 +203,183 @@ exports.payall = async function (req, res) {
id: jobid
});
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
const ticketHash = CalculateTicketsHoursForJob(job);
if (assignmentHash.unassigned > 0) {
res.json({ success: false, error: "Not all hours have been assigned." });
return;
}
//Calculate how much time each tech should have by labor type.
//Doing this order creates a diff of changes on the ticket hash to make it the same as the employee hash.
const recursiveDiff = rdiff.getDiff(ticketHash, employeeHash, true);
const ticketsToInsert = [];
const employeeIds = getAllKeys(employeeHash, ticketHash);
recursiveDiff.forEach((diff) => {
//Every iteration is what we would need to insert into the time ticket hash
//so that it would match the employee hash exactly.
const path = diffParser(diff);
employeeIds.forEach((employeeId) => {
const expectedByLabor = employeeHash[employeeId] || {};
const claimedByLabor = ticketHash[employeeId] || {};
getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => {
const expected = expectedByLabor[laborType];
const claimed = claimedByLabor[laborType];
const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0));
if (deltaHours === 0) {
return;
}
const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate);
const payoutContext = BuildGeneratedPayoutContext({
baseContext:
expected?.payoutContext ||
claimed?.payoutContext ||
buildFallbackPayoutContext({ laborType, rate: effectiveRate }),
generatedBy: req.user.email,
generatedFrom: "payall",
taskName: "Pay All",
usedTicketFallback: !expected && Boolean(claimed)
});
if (diff.op === "add") {
// console.log(Object.keys(diff.val));
if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) {
//Multiple values to add.
Object.keys(diff.val).forEach((key) => {
// console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]);
// console.log("Rate", Object.keys(diff.val[key])[0]);
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val[key][Object.keys(diff.val[key])[0]],
rate: Object.keys(diff.val[key])[0],
ciecacode: key,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
employeeid: employeeId,
productivehrs: deltaHours,
rate: effectiveRate,
ciecacode: laborType,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
flat_rate: true,
memo: `Add unflagged hours. (${req.user.email})`
created_by: req.user.email,
payout_context: payoutContext,
memo: buildPayAllMemo({
deltaHours,
hasExpected: Boolean(expected),
hasClaimed: Boolean(claimed),
userEmail: req.user.email
})
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
memo: `Add unflagged hours. (${req.user.email})`
});
const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
if (filteredTickets.length > 0) {
await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: filteredTickets
});
}
} else if (diff.op === "update") {
//An old ticket amount isn't sufficient
//We can't modify the existing ticket, it might already be committed. So let's add a new one instead.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val - diff.oldVal,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
memo: `Adjust flagged hours per assignment. (${req.user.email})`
});
} else {
//Has to be a delete
if (typeof diff.oldVal === "object" && Object.keys(diff.oldVal).length > 1) {
//Multiple oldValues to add.
Object.keys(diff.oldVal).forEach((key) => {
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.oldVal[key][Object.keys(diff.oldVal[key])[0]] * -1,
rate: Object.keys(diff.oldVal[key])[0],
ciecacode: key,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
flat_rate: true,
memo: `Remove flagged hours per assignment. (${req.user.email})`
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours * -1,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
flat_rate: true,
memo: `Remove flagged hours per assignment. (${req.user.email})`
});
}
}
});
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
});
res.json(ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0));
res.json(filteredTickets);
} catch (error) {
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
jobid: jobid,
jobid,
error: JSON.stringify(error)
});
res.status(400).json({ error: error.message });
}
};
function diffParser(diff) {
const type = typeof diff.oldVal;
let mod_lbr_ty, rate, hours;
if (diff.path.length === 1) {
if (diff.op === "add") {
mod_lbr_ty = Object.keys(diff.val)[0];
rate = Object.keys(diff.val[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
} else {
mod_lbr_ty = Object.keys(diff.oldVal)[0];
rate = Object.keys(diff.oldVal[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
}
} else if (diff.path.length === 2) {
mod_lbr_ty = diff.path[1];
if (diff.op === "add") {
rate = Object.keys(diff.val)[0];
} else {
rate = Object.keys(diff.oldVal)[0];
}
} else if (diff.path.length === 3) {
mod_lbr_ty = diff.path[1];
rate = diff.path[2];
//hours = 0;
}
//Set the hours
if (typeof diff.val === "number" && diff.val !== null && diff.val !== undefined) {
hours = diff.val;
} else if (diff.val !== null && diff.val !== undefined) {
if (diff.path.length === 1) {
hours = diff.val[Object.keys(diff.val)[0]][Object.keys(diff.val[Object.keys(diff.val)[0]])];
} else {
hours = diff.val[Object.keys(diff.val)[0]];
}
} else if (typeof diff.oldVal === "number" && diff.oldVal !== null && diff.oldVal !== undefined) {
hours = diff.oldVal;
} else {
hours = diff.oldVal[Object.keys(diff.oldVal)[0]];
}
const ret = {
multiVal: false,
employeeid: diff.path[0], // Always True
mod_lbr_ty,
rate,
hours
};
return ret;
}
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
const assignmentHash = { unassigned: 0 };
const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid.
const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null;
job.joblines
.filter((jobline) => {
if (!filterToLbrTypes) return true;
else {
return (
filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
(jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
);
if (!laborTypeFilter) {
return true;
}
const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null;
return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType));
})
.forEach((jobline) => {
if (jobline.convertedtolbr) {
// Line has been converte to labor. Temporarily re-assign the hours.
jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty;
jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs;
}
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;
const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty;
const laborHours = roundHours(
toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0)
);
if (laborHours === 0) {
return;
}
if (jobline.assigned_team === null) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
//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.
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;
if (!theTeam) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100;
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;
assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours);
theTeam.employee_team_members.forEach((teamMember) => {
const employeeId = teamMember.employee.id;
const { effectiveRate, payoutContext } = BuildPayoutDetails(job, teamMember, laborType);
if (!employeeHash[employeeId]) {
employeeHash[employeeId] = {};
}
if (!employeeHash[employeeId][laborType]) {
employeeHash[employeeId][laborType] = {
hours: 0,
rate: effectiveRate,
payoutContext
};
}
const hoursOwed = roundHours((toNumber(teamMember.percentage) * laborHours) / 100);
employeeHash[employeeId][laborType].hours = roundHours(employeeHash[employeeId][laborType].hours + hoursOwed);
employeeHash[employeeId][laborType].rate = effectiveRate;
employeeHash[employeeId][laborType].payoutContext = payoutContext;
});
}
}
});
return { assignmentHash, employeeHash };
}
function CalculateTicketsHoursForJob(job) {
const ticketHash = {}; // employeeid => Cieca labor type => rate => hours.
//Calculate how much each employee has been paid so far.
const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
job.timetickets.forEach((ticket) => {
if (!ticket?.employeeid || !ticket?.ciecacode) {
return;
}
if (!ticketHash[ticket.employeeid]) {
ticketHash[ticket.employeeid] = {};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) {
ticketHash[ticket.employeeid][ticket.ciecacode] = {};
ticketHash[ticket.employeeid][ticket.ciecacode] = {
hours: 0,
rate: roundCurrency(ticket.rate),
payoutContext: ticket.payout_context || null
};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) {
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0;
ticketHash[ticket.employeeid][ticket.ciecacode].hours = roundHours(
ticketHash[ticket.employeeid][ticket.ciecacode].hours + toNumber(ticket.productivehrs)
);
if (ticket.rate !== null && ticket.rate !== undefined) {
ticketHash[ticket.employeeid][ticket.ciecacode].rate = roundCurrency(ticket.rate);
}
if (ticket.payout_context) {
ticketHash[ticket.employeeid][ticket.ciecacode].payoutContext = ticket.payout_context;
}
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] =
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] + ticket.productivehrs;
});
return ticketHash;
}
exports.BuildPayoutDetails = BuildPayoutDetails;
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
exports.RoundPayrollHours = roundHours;

View File

@@ -0,0 +1,465 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import mockRequire from "mock-require";
const logMock = vi.fn();
let payAllModule;
let claimTaskModule;
const buildBaseJob = (overrides = {}) => ({
id: "job-1",
completed_tasks: [],
rate_laa: 100,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: []
},
joblines: [],
timetickets: [],
...overrides
});
const buildReqRes = ({ job, body = {}, userEmail = "payroll@example.com" }) => {
const client = {
setHeaders: vi.fn().mockReturnThis(),
request: vi.fn().mockResolvedValueOnce({ jobs_by_pk: job })
};
const req = {
body: {
jobid: job.id,
...body
},
user: {
email: userEmail
},
BearerToken: "Bearer test",
userGraphQLClient: client
};
const res = {
json: vi.fn(),
status: vi.fn().mockReturnThis()
};
return { client, req, res };
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockRequire.stopAll();
mockRequire("../utils/logger", { log: logMock });
payAllModule = require("./pay-all");
claimTaskModule = require("./claim-task");
});
describe("payroll payout helpers", () => {
it("defaults team members to hourly payout when no payout method is stored", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {
LAA: 27.5
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(27.5);
expect(payoutContext).toEqual(
expect.objectContaining({
payout_type: "hourly",
payout_method: "hourly",
cut_percent_applied: null,
source_labor_rate: null,
source_labor_type: "LAA",
effective_rate: 27.5
})
);
});
it("calculates commission payout rates from the raw job labor sale rate", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{
rate_laa: 120
},
{
payout_method: "commission",
commission_rates: {
LAA: 35
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(42);
expect(payoutContext).toEqual(
expect.objectContaining({
payout_type: "cut",
payout_method: "commission",
cut_percent_applied: 35,
source_labor_rate: 120,
source_labor_type: "LAA",
effective_rate: 42
})
);
});
it("uses Dinero half-even rounding for stored hourly rates", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {
LAA: 10.005
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(10);
expect(payoutContext.effective_rate).toBe(10);
});
it("throws a useful error when commission configuration is incomplete", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{
rate_laa: 100
},
{
payout_method: "commission",
commission_rates: {},
employee: {
first_name: "Jane",
last_name: "Doe"
}
},
"LAA"
)
).toThrow("Missing commission percent for Jane Doe on labor type LAA.");
});
it("throws a useful error when an hourly payout rate is missing", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {},
employee: {
first_name: "John",
last_name: "Smith"
}
},
"LAB"
)
).toThrow("Missing hourly payout rate for John Smith on labor type LAB.");
});
});
describe("payroll routes", () => {
it("aggregates claimed hours across prior ticket rates and inserts the remaining delta at the current rate", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
labor_rates: {
LAA: 30
},
employee: {
id: "emp-1",
first_name: "Jane",
last_name: "Doe"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 10,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 2,
rate: 30,
payout_context: {
payout_method: "hourly"
}
},
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 3,
rate: 35,
payout_context: {
payout_method: "commission"
}
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
await payAllModule.payall(req, res);
expect(client.request).toHaveBeenCalledTimes(2);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(1);
expect(insertedTickets[0]).toEqual(
expect.objectContaining({
task_name: "Pay All",
employeeid: "emp-1",
productivehrs: 5,
rate: 40,
ciecacode: "LAA",
cost_center: "Body",
created_by: "payroll@example.com"
})
);
expect(insertedTickets[0].payout_context).toEqual(
expect.objectContaining({
payout_method: "commission",
cut_percent_applied: 40,
source_labor_rate: 100,
generated_from: "payall",
task_name: "Pay All",
used_ticket_fallback: false
})
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("rejects duplicate claim-task submissions for completed presets", async () => {
const job = buildBaseJob({
completed_tasks: [{ name: "Disassembly" }],
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Disassembly",
hourstype: ["LAA"],
percent: 50,
nextstatus: "In Progress",
memo: "Flag disassembly"
}
]
},
employee_teams: []
}
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Disassembly",
calculateOnly: false,
employee: {
name: "Jane Doe",
employeeid: "emp-1"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Provided task preset has already been completed for this job."
});
});
it("rejects claim-task when task presets over-allocate the same labor type", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Body Prep",
hourstype: ["LAA"],
percent: 60,
nextstatus: "Prep",
memo: "Prep body work"
},
{
name: "Body Prime",
hourstype: ["LAA"],
percent: 50,
nextstatus: "Prime",
memo: "Prime body work"
}
]
},
employee_teams: []
}
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Body Prep",
calculateOnly: true,
employee: {
name: "Jane Doe",
employeeid: "emp-1"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%."
});
});
it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAB: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Teardown",
hourstype: ["LAB"],
percent: 100,
nextstatus: "In Progress",
memo: "Teardown"
}
]
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 50,
labor_rates: {
LAB: 45
},
employee: {
id: "emp-1",
first_name: "Configured",
last_name: "Tech"
}
},
{
percentage: 50,
labor_rates: {},
employee: {
id: "emp-2",
first_name: "Missing",
last_name: "Rate"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAB",
mod_lb_hrs: 4.4,
assigned_team: "team-1",
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Teardown",
calculateOnly: true,
employee: {
name: "Dave",
email: "dave@rome.test"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Missing hourly payout rate for Missing Rate on labor type LAB."
});
});
});