Bỏ qua để đến nội dung

Vendor Employee Detail — Wages & Attendance Payloads

Nội dung này hiện chưa có sẵn bằng ngôn ngữ của bạn.

PUT /api/v1/vendor/vendor-employees/:id/wages and PUT /api/v1/vendor/vendor-employees/:id/attendance are the two write paths exposed by the vendor module’s employee-detail surface. Both accept numeric-string fields that the database stores in UNSIGNED decimal(...) columns. Without a server-side guard, a negative value (e.g. -20 typed into the wage-rate input) slips past IsNumberString (which only checks format) and MySQL rejects it at INSERT time with errno 1264 — “Out of range value for column ‘wage_rate’ at row 1” — producing a 500 from the controller.

This page documents the shared IsNonNegativeNumberString validator that fixes the issue at the DTO layer and the service-layer defensive guard.

Every field below is paired with @IsOptional() + @IsNumberString() + the new @IsNonNegativeNumberString():

DTO fieldDB columnDB typeSource
UpsertVendorEmployeeWageDto.wage_ratevendor_employee_wages.wage_ratedecimal(12,2) UNSIGNED NULLmigration 2000000000165-CreateVendorEmployeeDetailAuxTables
UpsertVendorEmployeeAttendanceMetricDto.on_time_ratevendor_employee_attendance_metrics.on_time_ratedecimal(5,2) UNSIGNED NULLsame migration
UpsertVendorEmployeeAttendanceMetricDto.avg_hours_per_week…avg_hours_per_weekdecimal(6,2) UNSIGNED NULLsame migration
UpsertVendorEmployeeAttendanceMetricDto.average_shift_rating…average_shift_ratingdecimal(3,2) UNSIGNED NULLsame migration
  • Empty / undefined values are treated as not provided — they bypass validation and the column is stored as NULL (where nullable). Pair with @IsOptional().
  • Numeric strings >= 0 are accepted. "0", "0.00", "12", "12.50", "9999999999.99" all pass.
  • Negative strings ("-20", "-0.01", …) are rejected with HTTP 400 carrying the validator’s message field. The NestJS ValidationPipe returns this as { message: [...], error: "Bad Request", statusCode: 400 }.
  • Non-numeric strings ("abc", "-") are rejected by the underlying @IsNumberString() decorator before the new one runs.

Defence-in-depth: VendorEmployeeDetailService.replaceWages

Section titled “Defence-in-depth: VendorEmployeeDetailService.replaceWages”

Even with the DTO validator wired in, the service re-checks every wage_rate before it calls repo.save(). If a payload still contains a negative value (for example because the global ValidationPipe is misconfigured, or a custom client bypassed it), the service throws:

{
"success": false,
"errors": [
{
"code": "invalid_wage_rate",
"message": "wage_rate must be a non-negative number (>= 0)",
"field": "wage_rate",
"index": 1,
"value": "-20"
}
]
}

HTTP 400 Bad Request. No INSERT is issued for any row in the batch (the existing rows were already deleted by repo.delete({ employeeId }); on error we surface the failure rather than silently re-save the partial list — the client should retry with a corrected payload).

The “Access, roles & wages” edit form (EmployeeAccessRolesWagesEditForm) wires the same rule at the Zod layer so the user sees a field-level error before submitting:

apps/vendor-web/src/app/dashboard/employees/v2/components/lib/access-roles-wages-schema.ts
rate: z
.union([z.string(), z.number()])
.transform((val) => (val === "" || val == null ? undefined : String(val)))
.pipe(
z.string().optional().refine(
(val) => val === undefined || val === "" ||
(!Number.isNaN(Number(val)) && Number(val) >= 0),
{ message: t("employees.detail.accessRolesWages.validation.wageRateNonNegative", { defaultValue: "Wage rate must be 0 or greater" }) },
),
);

The <input type="number" min="0" step="0.01" /> widget also blocks browser spinner negatives, but the Zod check is the source of truth (it covers paste, autofill, and screen-reader input).

  • Validator unit tests: apps/backend/src/modules/vendor/dto/validators/is-non-negative-number-string.validator.spec.ts (6 tests).
  • Service guard tests: apps/backend/src/modules/vendor/vendor-employee-detail.service.spec.ts → describe('replaceWages — wage_rate validation') (4 tests: rejects -20, includes field/index/value in the 400 body, accepts 0/positive, treats undefined/'' as not-provided).
  • Frontend schema tests: apps/vendor-web/src/app/dashboard/employees/v2/components/lib/access-roles-wages-schema.spec.ts (6 tests).

A wage rate is, by definition, a non-negative quantity of currency per unit time. Allowing -20 into vendor_employee_wages.wage_rate would corrupt downstream payroll calculations (totals, averages, tax deductions). The clean fix is to reject at the boundary, not to migrate the column to signed decimal — that would let bad data through and force every payroll consumer to defend against rate < 0.

If a future feature ever needs signed values (e.g. a “deduction” column), add a new decimal(...) column without UNSIGNED rather than relaxing this contract.