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:
BOHA
2026-06-12 23:00:19 +02:00
parent 8f74efba97
commit e11765bf0e
56 changed files with 3968 additions and 265 deletions

View File

@@ -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([