feat(documents)!: unify offers and issued orders end to end

One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).

Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
  the PO template's red style (shared DocumentSectionSchema, one-transaction
  create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
  3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
  readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
  /file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
  number previews (also invoice previews), PUT returns assigned po_number

Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
  follows the table; order creation only from active offers) +
  valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
  issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
  linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
  DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
  dates, ints for positions); audit old/new values + koncept fallbacks;
  list id tiebreaks; stats include trimmed; NAS delete dedupe

Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
  document number (form field removed), one form layout/readonly convention,
  view-permission opens read-only everywhere (issued's editable-for-viewers
  hole closed), server-driven transition buttons, dirty guard + Enter submit
  on both, useApiMutation everywhere (new opt-in envelope mode), fixed
  infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
  numbers on offers, shared hardened per-row PDF flow (spinner, double-click
  guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
  states, query-lib cleanups (["offers","customers"] key, typed list rows,
  shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
  formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
  monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
  PDF language now comes from the document column

+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 16:22:39 +02:00
parent 1c1b9dca17
commit d1533ffc4d
35 changed files with 4762 additions and 2835 deletions

View File

@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
generateOfferNumber,
previewOfferNumber,
releaseOfferNumber,
isOfferNumberTaken,
assignOfferNumber,
@@ -27,6 +27,44 @@ interface ScopeSectionInput {
// Re-export for convenience
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
/**
* Offer status lifecycle (mirrors issued orders' table): a draft must be
* finalized to `active` first (that's when the number is assigned), an active
* offer can be ordered or invalidated, an ordered one only invalidated, and
* `invalidated` is terminal. The create-order flow (orders.service
* createOrderFromQuotation) consults this table too — an offer can only become
* `ordered` from a status that allows the transition.
*/
export const VALID_TRANSITIONS: Record<string, string[]> = {
draft: ["active"],
active: ["ordered", "invalidated"],
ordered: ["invalidated"],
invalidated: [],
};
/** Fields (non-status) are editable only while the offer is draft/active. */
function isEditableStatus(status: string): boolean {
return status === "draft" || status === "active";
}
/**
* Non-status update fields — used by the editable-state guard so a field edit
* on an ordered/invalidated offer is rejected EXPLICITLY (not silently
* dropped). `quotation_number` is stripped by the schema and is not listed.
*/
const NON_STATUS_UPDATE_FIELDS = [
"project_code",
"customer_id",
"created_at",
"valid_until",
"currency",
"language",
"scope_title",
"scope_description",
"items",
"sections",
] as const;
const ALLOWED_SORT_FIELDS = [
"id",
"quotation_number",
@@ -102,16 +140,24 @@ export async function listOffers(params: ListOffersParams) {
const where = buildOfferWhere(params);
// Tiebreak on id: created_at is second-precision, so same-second rows would
// otherwise paginate non-deterministically (project ordering rule).
const orderBy: Record<string, "asc" | "desc">[] = [
{ [sortField]: order },
{ id: order },
];
const [quotations, total] = await Promise.all([
prisma.quotations.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
orderBy,
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
}),
prisma.quotations.count({ where }),
@@ -152,12 +198,12 @@ export async function getOfferTotals(
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildOfferWhere(params);
// Only quotation_items — the per-currency NET math needs nothing else
// (customers/scope_sections would just inflate the full-set scan).
const quotations = await prisma.quotations.findMany({
where,
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
quotation_items: true,
},
});
@@ -184,7 +230,8 @@ export async function getOffer(id: number) {
include: {
customers: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
if (!quotation) return null;
@@ -207,6 +254,9 @@ export async function getOffer(id: number) {
customer: quotation.customers,
customer_name: quotation.customers?.name || null,
order: orderInfo,
// Computed server-side so the frontend renders only legal status buttons
// (same contract as getIssuedOrder).
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
};
}
@@ -219,6 +269,18 @@ export async function createOffer(body: Record<string, any>) {
? String(body.quotation_number)
: null;
// Validate the referenced customer exists BEFORE the insert — a dangling
// FK would otherwise surface as a P2003 500 instead of a clean 400
// (mirrors createIssuedOrder's supplier check).
const customerId = body.customer_id ? Number(body.customer_id) : null;
if (customerId) {
const customer = await tx.customers.findUnique({
where: { id: customerId },
select: { id: true },
});
if (!customer) return { error: "customer_not_found" as const };
}
// Deferred numbering: a draft carries NO quotation_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignOfferNumber on draft->active).
@@ -232,19 +294,15 @@ export async function createOffer(body: Record<string, any>) {
// generateOfferNumber already verifies its own generated numbers, so this
// guards the caller-supplied path.
if (explicitNumber !== null) {
const taken = await isOfferNumberTaken(explicitNumber);
if (taken) {
throw Object.assign(new Error("Číslo nabídky je již použito"), {
status: 400,
});
}
const taken = await isOfferNumberTaken(explicitNumber, tx);
if (taken) return { error: "number_taken" as const };
}
const quotation = await tx.quotations.create({
data: {
quotation_number: quotationNumber,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
customer_id: customerId,
created_at: body.created_at
? new Date(String(body.created_at))
: undefined,
@@ -291,11 +349,14 @@ export async function createOffer(body: Record<string, any>) {
return quotation;
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
} as const;
// P2002 on the quotation_number unique index = a concurrent insert won the
// race between the in-tx check and our insert — surface the same clean
// conflict token instead of a 500 (mirrors createIssuedOrder).
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return { error: "number_taken" as const };
}
throw err;
}
@@ -304,31 +365,53 @@ export async function createOffer(body: Record<string, any>) {
export async function updateOffer(id: number, body: Record<string, any>) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const };
if (existing.status === "invalidated")
return { error: "invalidated" as const };
// A finalized offer's number is immutable. A draft has no number yet
// (quotation_number === null) — it is assigned on finalize, not editable here,
// so an empty/absent submitted value on a draft is not a "change" and must not
// block the draft->active finalize.
const currentStatus = existing.status;
// Status changes must follow the transition table (issued-orders parity).
if (body.status !== undefined && String(body.status) !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return { error: "invalid_transition" as const, currentStatus, newStatus };
}
}
// Editable-state guard: non-status fields may change only in draft/active.
// In ordered/invalidated a field edit is REJECTED explicitly (it used to be
// possible to rewrite everything in any status except invalidated).
// Status-only transition payloads (e.g. ordered -> invalidated) still work.
const editable = isEditableStatus(currentStatus);
if (
existing.quotation_number !== null &&
body.quotation_number !== undefined &&
String(body.quotation_number) !== existing.quotation_number
!editable &&
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
) {
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
return { error: "not_editable" as const, currentStatus };
}
// Explicit null CLEARS the customer; a set id must exist (dangling FK would
// surface as a P2003 500). The old `Number(null)` coerced null to 0.
let customerId: number | null | undefined = undefined;
if (body.customer_id !== undefined) {
customerId = body.customer_id ? Number(body.customer_id) : null;
if (customerId) {
const customer = await prisma.customers.findUnique({
where: { id: customerId },
select: { id: true },
});
if (!customer) return { error: "customer_not_found" as const };
}
}
// Finalize transition draft -> active: assign the official number ATOMICALLY
// with the status change. assignOfferNumber is idempotent.
const finalizing =
existing.status === "draft" &&
currentStatus === "draft" &&
body.status !== undefined &&
String(body.status) === "active";
const data = {
customer_id:
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
customer_id: customerId,
created_at:
body.created_at !== undefined
? body.created_at
@@ -402,19 +485,39 @@ export async function updateOffer(id: number, body: Record<string, any>) {
await prisma.quotations.update({ where: { id }, data });
}
// newValues for the audit diff: the header fields actually written plus the
// replaced collections (JSON.stringify drops the undefined = untouched keys).
const newValues: Record<string, unknown> = { ...data };
if (Array.isArray(body.items)) newValues.items = body.items;
if (Array.isArray(body.sections)) newValues.sections = body.sections;
// Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its quotation_number is still the pre-finalize null).
return {
id,
quotation_number: assignedNumber ?? existing.quotation_number,
oldValues: existing as unknown as Record<string, unknown>,
newValues,
};
}
export async function deleteOffer(id: number) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return null;
if (!existing) return { error: "not_found" as const };
await prisma.quotations.delete({ where: { id } });
try {
await prisma.quotations.delete({ where: { id } });
} catch (err) {
// P2003 = the offer is referenced by an order/project via a Restrict FK —
// surface a clean conflict token instead of a 500.
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2003"
) {
return { error: "linked" as const };
}
throw err;
}
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
@@ -422,6 +525,8 @@ export async function deleteOffer(id: number) {
await releaseOfferNumber(year, existing.quotation_number ?? undefined);
// Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
// The service is the SINGLE owner of this cleanup (the route used to repeat
// it and always logged a spurious second-delete failure).
// Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
// file is already gone, so a NAS outage / missing file never throws here.
if (existing.quotation_number && nasOffersManager.isConfigured()) {
@@ -440,7 +545,7 @@ export async function deleteOffer(id: number) {
}
}
return existing;
return { data: existing };
}
export async function duplicateOffer(id: number) {
@@ -448,7 +553,8 @@ export async function duplicateOffer(id: number) {
where: { id },
include: {
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
if (!original) return null;
@@ -503,11 +609,23 @@ export async function duplicateOffer(id: number) {
export async function invalidateOffer(id: number) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return null;
if (!existing) return { error: "not_found" as const };
// The dedicated endpoint must respect the same transition table as PUT:
// only active/ordered offers can be invalidated (a draft is deleted instead,
// invalidated is terminal).
const allowed = VALID_TRANSITIONS[existing.status] || [];
if (!allowed.includes("invalidated")) {
return {
error: "invalid_transition" as const,
currentStatus: existing.status,
newStatus: "invalidated",
};
}
await prisma.quotations.update({
where: { id },
data: { status: "invalidated", modified_at: new Date() },
});
return existing;
return { data: existing };
}