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:
@@ -61,6 +61,14 @@ export const positiveNumberFromForm = z
|
||||
message: "Číslo musí být kladné",
|
||||
});
|
||||
|
||||
/** number|string → non-negative integer (e.g. list positions). */
|
||||
export const nonNegativeIntFromForm = z
|
||||
.union([z.number(), z.string()])
|
||||
.transform(coerceNum)
|
||||
.refine((n) => Number.isInteger(n) && n >= 0, {
|
||||
message: "Číslo musí být nezáporné celé číslo",
|
||||
});
|
||||
|
||||
/** number|string → positive integer id; rejects NaN/≤0/non-integer. */
|
||||
export const intIdFromForm = z
|
||||
.union([z.number(), z.string()])
|
||||
@@ -154,3 +162,17 @@ export const emailOrEmpty = z.union([
|
||||
z.literal(""),
|
||||
z.string().max(255).email("Neplatný email"),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shared rich-text document section — offers' scope_sections and issued
|
||||
* orders' issued_order_sections are 1:1 mirror tables, so both schemas use
|
||||
* this. Limits are DB-aligned: title/title_cz are VarChar(500) (the old
|
||||
* offers-local copy under-shot at 255), content is Text with the app-level
|
||||
* 8000 cap used by the other rich-text fields.
|
||||
*/
|
||||
export const DocumentSectionSchema = z.object({
|
||||
title: z.string().max(500).nullish(),
|
||||
title_cz: z.string().max(500).nullish(),
|
||||
content: z.string().max(8000).nullish(),
|
||||
position: nonNegativeIntFromForm.optional(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
isoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
export const IssuedOrderItemSchema = z.object({
|
||||
@@ -41,6 +42,14 @@ export const CreateIssuedOrderSchema = z.object({
|
||||
notes: z.string().nullish(),
|
||||
internal_notes: z.string().nullish(),
|
||||
items: z.array(IssuedOrderItemSchema).optional(),
|
||||
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
||||
// scope_sections — shared DocumentSectionSchema, DB-aligned limits).
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial();
|
||||
// Update = partial create MINUS the number: PO numbers are immutable
|
||||
// (assigned by the sequence on finalize), so the field is stripped before it
|
||||
// can ever reach the service (which never reads it on update anyway).
|
||||
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial().omit({
|
||||
po_number: true,
|
||||
});
|
||||
|
||||
@@ -1,52 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
numberFromForm,
|
||||
nonNegativeIntFromForm,
|
||||
nonNegativeNumberFromForm,
|
||||
positiveNumberFromForm,
|
||||
nullableIntIdFromForm,
|
||||
booleanFromForm,
|
||||
isoDateString,
|
||||
DocumentSectionSchema,
|
||||
} from "./common";
|
||||
|
||||
// Limits are DB-aligned: description VarChar(500), unit VarChar(20);
|
||||
// item_description is Text with the app-level 8000 cap used elsewhere.
|
||||
const QuotationItemSchema = z.object({
|
||||
description: z.string().max(8000).nullish(),
|
||||
description: z.string().max(500).nullish(),
|
||||
item_description: z.string().max(8000).nullish(),
|
||||
quantity: positiveNumberFromForm.default(1),
|
||||
unit: z.string().max(255).nullish(),
|
||||
unit_price: numberFromForm.default(0),
|
||||
unit: z.string().max(20).nullish(),
|
||||
unit_price: nonNegativeNumberFromForm.default(0),
|
||||
is_included_in_total: booleanFromForm.optional().default(true),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
// Int column — a float position would 500 at Prisma.
|
||||
position: nonNegativeIntFromForm.optional(),
|
||||
});
|
||||
|
||||
const ScopeSectionSchema = z.object({
|
||||
title: z.string().max(255).nullish(),
|
||||
title_cz: z.string().max(255).nullish(),
|
||||
content: z.string().max(8000).nullish(),
|
||||
position: nonNegativeNumberFromForm.optional(),
|
||||
});
|
||||
// Shared with issued orders (scope_sections ≡ issued_order_sections).
|
||||
const ScopeSectionSchema = DocumentSectionSchema;
|
||||
|
||||
// NOTE: no top-level `.default()`s here — `UpdateQuotationSchema` derives from
|
||||
// this schema via `.partial()`, and Zod 4 still applies a field default when
|
||||
// the key is absent, which would silently inject status/currency/language
|
||||
// resets into every update payload. Create-time defaults live in
|
||||
// `createOffer` (service) instead.
|
||||
export const CreateQuotationSchema = z.object({
|
||||
quotation_number: z.string().max(255).nullish(),
|
||||
project_code: z.string().max(255).nullish(),
|
||||
// DB: quotations.quotation_number VarChar(50).
|
||||
quotation_number: z.string().max(50).nullish(),
|
||||
// DB column is VarChar(100) — a longer value would 500 at Prisma.
|
||||
project_code: z.string().max(100).nullish(),
|
||||
customer_id: nullableIntIdFromForm.nullish(),
|
||||
created_at: z.string().max(255).nullish(),
|
||||
valid_until: z.string().max(255).nullish(),
|
||||
currency: z.string().max(20).optional().default("CZK"),
|
||||
language: z.string().max(20).optional().default("cs"),
|
||||
status: z
|
||||
.enum(["draft", "active", "ordered", "invalidated"])
|
||||
.optional()
|
||||
.default("draft"),
|
||||
scope_title: z.string().max(255).nullish(),
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
items: z.array(QuotationItemSchema).optional(),
|
||||
sections: z.array(ScopeSectionSchema).optional(),
|
||||
});
|
||||
|
||||
export const UpdateQuotationSchema = z.object({
|
||||
project_code: z.string().max(255).nullish(),
|
||||
customer_id: nullableIntIdFromForm.optional(),
|
||||
created_at: z.union([z.string().max(255), z.null()]).optional(),
|
||||
valid_until: z.union([z.string().max(255), z.null()]).optional(),
|
||||
created_at: isoDateString.nullish(),
|
||||
valid_until: isoDateString.nullish(),
|
||||
currency: z.string().max(20).optional(),
|
||||
language: z.string().max(20).optional(),
|
||||
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
|
||||
@@ -56,5 +47,12 @@ export const UpdateQuotationSchema = z.object({
|
||||
sections: z.array(ScopeSectionSchema).optional(),
|
||||
});
|
||||
|
||||
// Update = partial create MINUS the number: quotation numbers are immutable
|
||||
// (assigned by the sequence on finalize), so the field is stripped before it
|
||||
// can ever reach the service.
|
||||
export const UpdateQuotationSchema = CreateQuotationSchema.partial().omit({
|
||||
quotation_number: true,
|
||||
});
|
||||
|
||||
export type CreateQuotationInput = z.infer<typeof CreateQuotationSchema>;
|
||||
export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>;
|
||||
|
||||
Reference in New Issue
Block a user