fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned)
Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
failed deficit line rolls back the surplus corrective receipt — retries
no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
(in-transaction, clamped at 0)
High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
(Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
hours against its own balance (mirrors createLeave)
Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
(recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
@db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
/items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
settings.company|settings.system; Settings System tab no longer clobbers
Firma numbering patterns; draft invoices hide the dead PDF button;
dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
audit-log + invoice month buckets day-shift fixes
Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.
~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -73,6 +73,90 @@ function numFilter(raw: unknown): number | undefined {
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-day boundaries for created_at range filters. created_at holds
|
||||
* LOCAL-time instants, so the from-bound is the day's local midnight — a
|
||||
* bare new Date("YYYY-MM-DD") is UTC midnight (01:00/02:00 Prague), which
|
||||
* silently dropped the first morning hours. The to-bound is the NEXT day's
|
||||
* local midnight, exclusive — `lte` against UTC midnight dropped the entire
|
||||
* inclusive end day.
|
||||
*/
|
||||
function localDayStart(dateStr: string): Date {
|
||||
return new Date(`${dateStr}T00:00:00`);
|
||||
}
|
||||
function localDayEndExclusive(dateStr: string): Date {
|
||||
const d = new Date(`${dateStr}T00:00:00`);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* FK pre-validation for receipt/issue payloads (mirrors the document
|
||||
* modules): a dangling id would otherwise raise P2003 at Prisma and surface
|
||||
* as a generic 500. Returns a Czech message for a clean 400, or null when
|
||||
* all referenced rows exist.
|
||||
*/
|
||||
async function findMissingLineRef(
|
||||
items: Array<{ item_id: number; location_id?: number | null }>,
|
||||
): Promise<string | null> {
|
||||
for (const line of items) {
|
||||
const item = await prisma.sklad_items.findUnique({
|
||||
where: { id: line.item_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!item) return `Položka ID ${line.item_id} nenalezena`;
|
||||
if (line.location_id != null) {
|
||||
const location = await prisma.sklad_locations.findUnique({
|
||||
where: { id: line.location_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!location) return `Lokace ID ${line.location_id} nenalezena`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findMissingReceiptRef(body: {
|
||||
supplier_id?: number | null;
|
||||
items?: Array<{ item_id: number; location_id?: number | null }>;
|
||||
}): Promise<string | null> {
|
||||
if (body.supplier_id != null) {
|
||||
const supplier = await prisma.sklad_suppliers.findUnique({
|
||||
where: { id: body.supplier_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!supplier) return "Dodavatel nenalezen";
|
||||
}
|
||||
return findMissingLineRef(body.items ?? []);
|
||||
}
|
||||
|
||||
async function findMissingIssueRef(body: {
|
||||
project_id?: number;
|
||||
items?: Array<{
|
||||
item_id: number;
|
||||
batch_id?: number | null;
|
||||
location_id?: number | null;
|
||||
}>;
|
||||
}): Promise<string | null> {
|
||||
if (body.project_id != null) {
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: body.project_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!project) return "Projekt nenalezen";
|
||||
}
|
||||
for (const line of body.items ?? []) {
|
||||
if (line.batch_id != null) {
|
||||
const batch = await prisma.sklad_batches.findUnique({
|
||||
where: { id: line.batch_id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!batch) return `Šarže ID ${line.batch_id} nenalezena`;
|
||||
}
|
||||
}
|
||||
return findMissingLineRef(body.items ?? []);
|
||||
}
|
||||
|
||||
// NOTE: this file bundles 8 warehouse sub-entities (~2400 lines). Splitting it
|
||||
// per sub-entity (items/receipts/issues/reservations/inventory/reports) is a
|
||||
// tracked refactor, deferred — see REVIEW_FINDINGS.md.
|
||||
@@ -625,13 +709,22 @@ export default async function warehouseRoutes(
|
||||
"/items",
|
||||
{ preHandler: requirePermission("warehouse.view") },
|
||||
async (request, reply) => {
|
||||
const { page, limit, skip, search } = parsePagination(
|
||||
const { page, limit, skip, order, search } = parsePagination(
|
||||
request.query as Record<string, unknown>,
|
||||
);
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const categoryFilter = query.category_id
|
||||
? Number(query.category_id)
|
||||
: undefined;
|
||||
// Allow-listed DB sort columns (computed columns like total_quantity
|
||||
// can't be ordered server-side); anything else falls back to name.
|
||||
const ITEM_SORT_FIELDS = ["name", "item_number", "created_at"];
|
||||
const sortField = ITEM_SORT_FIELDS.includes(String(query.sort))
|
||||
? String(query.sort)
|
||||
: "name";
|
||||
// Default stays ASC (the route always sorted name asc); parsePagination
|
||||
// defaults to desc, so only honor it when the client sent one.
|
||||
const sortOrder = query.order ? order : "asc";
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
if (search || categoryFilter !== undefined) {
|
||||
@@ -659,7 +752,9 @@ export default async function warehouseRoutes(
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { name: "asc" },
|
||||
// {id} tiebreak — name/item_number are non-unique; offset
|
||||
// pagination needs a total order or rows duplicate/vanish.
|
||||
orderBy: [{ [sortField]: sortOrder }, { id: sortOrder }],
|
||||
include: {
|
||||
category: { select: { id: true, name: true } },
|
||||
item_locations: {
|
||||
@@ -1032,10 +1127,14 @@ export default async function warehouseRoutes(
|
||||
where.push({ supplier_id: supplierId });
|
||||
}
|
||||
if (query.date_from) {
|
||||
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
|
||||
where.push({
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
|
||||
where.push({
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
if (search) {
|
||||
where.push({
|
||||
@@ -1114,6 +1213,9 @@ export default async function warehouseRoutes(
|
||||
const body = parsed.data;
|
||||
const authUserId = request.authData!.userId;
|
||||
|
||||
const missingRef = await findMissingReceiptRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const receipt = await prisma.sklad_receipts.create({
|
||||
data: {
|
||||
supplier_id: body.supplier_id ?? null,
|
||||
@@ -1183,6 +1285,9 @@ export default async function warehouseRoutes(
|
||||
return error(reply, "Příjem lze upravit pouze ve stavu DRAFT", 400);
|
||||
}
|
||||
|
||||
const missingRef = await findMissingReceiptRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.supplier_id !== undefined)
|
||||
updateData.supplier_id = body.supplier_id;
|
||||
@@ -1498,10 +1603,14 @@ export default async function warehouseRoutes(
|
||||
where.push({ project_id: projectId });
|
||||
}
|
||||
if (query.date_from) {
|
||||
where.push({ created_at: { gte: new Date(String(query.date_from)) } });
|
||||
where.push({
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
where.push({ created_at: { lte: new Date(String(query.date_to)) } });
|
||||
where.push({
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
if (search) {
|
||||
where.push({
|
||||
@@ -1585,6 +1694,11 @@ export default async function warehouseRoutes(
|
||||
const body = parsed.data;
|
||||
const authUserId = request.authData!.userId;
|
||||
|
||||
// Before FIFO resolution, so a nonexistent item gets "Položka
|
||||
// nenalezena" instead of a misleading insufficient-stock message.
|
||||
const missingRef = await findMissingIssueRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
// Resolve FIFO batches for items without batch_id
|
||||
const resolvedItems: Array<{
|
||||
item_id: number;
|
||||
@@ -1704,6 +1818,9 @@ export default async function warehouseRoutes(
|
||||
return error(reply, "Výdej lze upravit pouze ve stavu DRAFT", 400);
|
||||
}
|
||||
|
||||
const missingRef = await findMissingIssueRef(body);
|
||||
if (missingRef) return error(reply, missingRef, 400);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.project_id !== undefined)
|
||||
updateData.project_id = body.project_id;
|
||||
@@ -2400,12 +2517,12 @@ export default async function warehouseRoutes(
|
||||
}
|
||||
if (query.date_from) {
|
||||
issueWhere.push({
|
||||
created_at: { gte: new Date(String(query.date_from)) },
|
||||
created_at: { gte: localDayStart(String(query.date_from)) },
|
||||
});
|
||||
}
|
||||
if (query.date_to) {
|
||||
issueWhere.push({
|
||||
created_at: { lte: new Date(String(query.date_to)) },
|
||||
created_at: { lt: localDayEndExclusive(String(query.date_to)) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2491,14 +2608,14 @@ export default async function warehouseRoutes(
|
||||
const issueWhere: Record<string, unknown>[] = [{ status: "CONFIRMED" }];
|
||||
|
||||
if (query.date_from) {
|
||||
const dateFrom = new Date(String(query.date_from));
|
||||
const dateFrom = localDayStart(String(query.date_from));
|
||||
receiptWhere.push({ created_at: { gte: dateFrom } });
|
||||
issueWhere.push({ created_at: { gte: dateFrom } });
|
||||
}
|
||||
if (query.date_to) {
|
||||
const dateTo = new Date(String(query.date_to));
|
||||
receiptWhere.push({ created_at: { lte: dateTo } });
|
||||
issueWhere.push({ created_at: { lte: dateTo } });
|
||||
const dateTo = localDayEndExclusive(String(query.date_to));
|
||||
receiptWhere.push({ created_at: { lt: dateTo } });
|
||||
issueWhere.push({ created_at: { lt: dateTo } });
|
||||
}
|
||||
|
||||
const [receipts, issues] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user