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:
@@ -228,18 +228,30 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/** Verify an issued-order (PO) number is not already used. */
|
||||
async function isIssuedOrderNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.issued_orders.findFirst({
|
||||
/** Verify an issued-order (PO) number is not already used. Pass the tx client
|
||||
* when the check must be part of a transaction (otherwise it runs on a
|
||||
* separate connection and is only a best-effort pre-check). */
|
||||
export async function isIssuedOrderNumberTaken(
|
||||
number: string,
|
||||
client: Prisma.TransactionClient | PrismaClient = prisma,
|
||||
): Promise<boolean> {
|
||||
const existing = await client.issued_orders.findFirst({
|
||||
where: { po_number: number },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
|
||||
/** Verify an offer/quotation number is not already used. */
|
||||
export async function isOfferNumberTaken(number: string): Promise<boolean> {
|
||||
const existing = await prisma.quotations.findFirst({
|
||||
/** Verify an offer/quotation number is not already used. Pass the tx client
|
||||
* when the check must be part of a transaction (otherwise it runs on a
|
||||
* separate connection and is only a best-effort pre-check). */
|
||||
export async function isOfferNumberTaken(
|
||||
number: string,
|
||||
client: Prisma.TransactionClient | PrismaClient = prisma,
|
||||
): Promise<boolean> {
|
||||
const existing = await client.quotations.findFirst({
|
||||
where: { quotation_number: number },
|
||||
select: { id: true },
|
||||
});
|
||||
return !!existing;
|
||||
}
|
||||
@@ -440,7 +452,18 @@ export async function previewInvoiceNumber(
|
||||
const code = settings?.invoice_type_code || "81";
|
||||
const year = _year || new Date().getFullYear();
|
||||
|
||||
const seq = await previewNextSequence("invoice", year);
|
||||
// Mirror generateInvoiceNumber's collision check (without consuming the
|
||||
// sequence). The number_sequences counter can lag behind real data, so the
|
||||
// raw next sequence may already be taken — advance past any number already
|
||||
// used by an invoice so the preview matches what generateInvoiceNumber
|
||||
// would actually assign instead of showing a stale, already-used number.
|
||||
let seq = await previewNextSequence("invoice", year);
|
||||
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
if (!(await isInvoiceNumberTaken(number)))
|
||||
return { number, next_number: number };
|
||||
seq++;
|
||||
}
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
return { number, next_number: number };
|
||||
}
|
||||
@@ -482,7 +505,18 @@ export async function previewIssuedOrderNumber(
|
||||
const code = settings?.issued_order_type_code || "72";
|
||||
const year = _year || new Date().getFullYear();
|
||||
|
||||
const seq = await previewNextSequence("issued_order", year);
|
||||
// Mirror generateIssuedOrderNumber's collision check (without consuming the
|
||||
// sequence). The number_sequences counter can lag behind real data, so the
|
||||
// raw next sequence may already be taken — advance past any number already
|
||||
// used by an issued order so the preview matches what
|
||||
// generateIssuedOrderNumber would actually assign.
|
||||
let seq = await previewNextSequence("issued_order", year);
|
||||
for (let attempt = 0; attempt < 1000; attempt++) {
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
if (!(await isIssuedOrderNumberTaken(number)))
|
||||
return { number, next_number: number };
|
||||
seq++;
|
||||
}
|
||||
const number = applyPattern(pattern, { year, prefix: "", code, seq });
|
||||
return { number, next_number: number };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user