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:
@@ -1,4 +1,4 @@
|
||||
import type { $Enums } from "@prisma/client";
|
||||
import { Prisma, type $Enums } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||
import {
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
previewIssuedOrderNumber,
|
||||
releaseIssuedOrderNumber,
|
||||
assignIssuedOrderNumber,
|
||||
isIssuedOrderNumberTaken,
|
||||
} from "./numbering.service";
|
||||
import { nasOrdersManager } from "./nas-financials-manager";
|
||||
|
||||
export interface IssuedOrderItemInput {
|
||||
description?: string | null;
|
||||
@@ -17,6 +19,13 @@ export interface IssuedOrderItemInput {
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface IssuedOrderSectionInput {
|
||||
title?: string | null;
|
||||
title_cz?: string | null;
|
||||
content?: string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface IssuedOrderInput {
|
||||
po_number?: string | number | null;
|
||||
supplier_id?: number | string | null;
|
||||
@@ -33,6 +42,7 @@ export interface IssuedOrderInput {
|
||||
notes?: string | null;
|
||||
internal_notes?: string | null;
|
||||
items?: IssuedOrderItemInput[];
|
||||
sections?: IssuedOrderSectionInput[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -100,9 +110,33 @@ const ALLOWED_SORT_FIELDS = [
|
||||
"po_number",
|
||||
"status",
|
||||
"order_date",
|
||||
"created_at",
|
||||
"currency",
|
||||
];
|
||||
|
||||
/**
|
||||
* Non-status update fields — used by the editable-state guard so a field edit
|
||||
* on a confirmed/completed/cancelled order is rejected EXPLICITLY (it used to
|
||||
* be silently dropped). `po_number` is stripped by the schema and the service
|
||||
* never reads it on update.
|
||||
*/
|
||||
const NON_STATUS_UPDATE_FIELDS = [
|
||||
"supplier_id",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"order_date",
|
||||
"delivery_date",
|
||||
"language",
|
||||
"delivery_terms",
|
||||
"payment_terms",
|
||||
"issued_by",
|
||||
"order_text",
|
||||
"notes",
|
||||
"internal_notes",
|
||||
"items",
|
||||
"sections",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* NET-only total: issued orders (PO) are not tax documents — VAT never appears
|
||||
* on them. The total is the plain sum of qty × unit_price, rounded to 2dp.
|
||||
@@ -211,13 +245,18 @@ export async function getIssuedOrder(id: number) {
|
||||
},
|
||||
},
|
||||
issued_order_items: { orderBy: { position: "asc" } },
|
||||
// id tiebreak: position can repeat, keep the order deterministic.
|
||||
issued_order_sections: {
|
||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!order) return null;
|
||||
const { issued_order_items, ...rest } = order;
|
||||
const { issued_order_items, issued_order_sections, ...rest } = order;
|
||||
return {
|
||||
...rest,
|
||||
items: issued_order_items,
|
||||
sections: issued_order_sections,
|
||||
supplier: order.suppliers,
|
||||
supplier_name: order.suppliers?.name || null,
|
||||
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
||||
@@ -225,83 +264,117 @@ export async function getIssuedOrder(id: number) {
|
||||
}
|
||||
|
||||
export async function createIssuedOrder(body: IssuedOrderInput) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const status = body.status ? String(body.status) : "draft";
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const status = body.status ? String(body.status) : "draft";
|
||||
|
||||
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||
if (supplierId) {
|
||||
const supplier = await tx.sklad_suppliers.findUnique({
|
||||
where: { id: supplierId },
|
||||
select: { id: true },
|
||||
// Validate the referenced supplier exists BEFORE the insert — a dangling
|
||||
// FK would otherwise surface as a P2003 500 instead of a clean 400.
|
||||
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
|
||||
if (supplierId) {
|
||||
const supplier = await tx.sklad_suppliers.findUnique({
|
||||
where: { id: supplierId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!supplier) return { error: "supplier_not_found" as const };
|
||||
}
|
||||
|
||||
// Deferred numbering: a draft carries NO po_number (the column is
|
||||
// nullable-unique so many drafts coexist). The official number is consumed
|
||||
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
||||
// A caller-supplied number, or a doc created already-finalized, numbers now.
|
||||
const explicit =
|
||||
body.po_number !== undefined &&
|
||||
body.po_number !== null &&
|
||||
body.po_number !== ""
|
||||
? String(body.po_number)
|
||||
: null;
|
||||
|
||||
// Re-check caller-supplied uniqueness INSIDE the transaction so the
|
||||
// check-then-insert window can't race a concurrent create (mirrors
|
||||
// createOffer). generateIssuedOrderNumber verifies its own numbers,
|
||||
// so this guards only the explicit path.
|
||||
if (explicit !== null && (await isIssuedOrderNumberTaken(explicit, tx))) {
|
||||
return { error: "po_number_taken" as const };
|
||||
}
|
||||
|
||||
const poNumber =
|
||||
explicit ??
|
||||
(status === "draft"
|
||||
? null
|
||||
: (await generateIssuedOrderNumber(undefined, tx)).number);
|
||||
|
||||
const order = await tx.issued_orders.create({
|
||||
data: {
|
||||
po_number: poNumber,
|
||||
supplier_id: supplierId,
|
||||
status: status as $Enums.issued_orders_status,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
exchange_rate:
|
||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||
// "today" default must be utcMidnightOfLocalDay, or it stores
|
||||
// yesterday during the 00:00–02:00 Prague window.
|
||||
order_date: body.order_date
|
||||
? new Date(String(body.order_date))
|
||||
: utcMidnightOfLocalDay(),
|
||||
delivery_date: body.delivery_date
|
||||
? new Date(String(body.delivery_date))
|
||||
: null,
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
delivery_terms: body.delivery_terms
|
||||
? String(body.delivery_terms)
|
||||
: null,
|
||||
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||
order_text: body.order_text ? String(body.order_text) : null,
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
internal_notes: body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
if (!supplier) return { error: "supplier_not_found" as const };
|
||||
}
|
||||
|
||||
// Deferred numbering: a draft carries NO po_number (the column is
|
||||
// nullable-unique so many drafts coexist). The official number is consumed
|
||||
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
|
||||
// A caller-supplied number, or a doc created already-finalized, numbers now.
|
||||
const explicit =
|
||||
body.po_number !== undefined &&
|
||||
body.po_number !== null &&
|
||||
body.po_number !== ""
|
||||
? String(body.po_number)
|
||||
: null;
|
||||
const poNumber =
|
||||
explicit ??
|
||||
(status === "draft"
|
||||
? null
|
||||
: (await generateIssuedOrderNumber(undefined, tx)).number);
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
issued_order_id: order.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const order = await tx.issued_orders.create({
|
||||
data: {
|
||||
po_number: poNumber,
|
||||
supplier_id: supplierId,
|
||||
status: status as $Enums.issued_orders_status,
|
||||
currency: body.currency ? String(body.currency) : "CZK",
|
||||
exchange_rate:
|
||||
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
|
||||
// order_date is @db.Date (truncated to the UTC date part) — the
|
||||
// "today" default must be utcMidnightOfLocalDay, or it stores
|
||||
// yesterday during the 00:00–02:00 Prague window.
|
||||
order_date: body.order_date
|
||||
? new Date(String(body.order_date))
|
||||
: utcMidnightOfLocalDay(),
|
||||
delivery_date: body.delivery_date
|
||||
? new Date(String(body.delivery_date))
|
||||
: null,
|
||||
language: body.language ? String(body.language) : "cs",
|
||||
delivery_terms: body.delivery_terms
|
||||
? String(body.delivery_terms)
|
||||
: null,
|
||||
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
|
||||
issued_by: body.issued_by ? String(body.issued_by) : null,
|
||||
order_text: body.order_text ? String(body.order_text) : null,
|
||||
notes: body.notes ? String(body.notes) : null,
|
||||
internal_notes: body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null,
|
||||
},
|
||||
if (Array.isArray(body.sections)) {
|
||||
await tx.issued_order_sections.createMany({
|
||||
data: body.sections.map((s, i) => ({
|
||||
issued_order_id: order.id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
|
||||
if (Array.isArray(body.items)) {
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items.map((item, i) => ({
|
||||
issued_order_id: order.id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
// P2002 on the po_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 offers).
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
return { error: "po_number_taken" as const };
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
@@ -318,7 +391,18 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// Editable-state guard: non-status fields may change only in draft/sent.
|
||||
// In confirmed/completed/cancelled a field edit is REJECTED explicitly
|
||||
// (offers parity — it used to be silently ignored). Status-only transition
|
||||
// payloads (e.g. confirmed -> completed) still work.
|
||||
const editable = currentStatus === "draft" || currentStatus === "sent";
|
||||
if (
|
||||
!editable &&
|
||||
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
|
||||
) {
|
||||
return { error: "not_editable" as const, currentStatus };
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||
|
||||
if (editable) {
|
||||
@@ -375,46 +459,67 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
body.status !== undefined &&
|
||||
String(body.status) === "sent";
|
||||
|
||||
if (finalizing) {
|
||||
// ONE transaction for the header write, the finalize numbering AND the
|
||||
// items/sections full-replace. The items replace used to run in a SECOND
|
||||
// transaction after the header tx — a failure between the two left a torn
|
||||
// write (new header, old items). Now either everything lands or nothing.
|
||||
const replaceItems = editable && Array.isArray(body.items);
|
||||
const replaceSections = editable && Array.isArray(body.sections);
|
||||
|
||||
let assignedNumber: string | null = null;
|
||||
if (finalizing || replaceItems || replaceSections) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.issued_orders.update({ where: { id }, data });
|
||||
await assignIssuedOrderNumber(id, tx);
|
||||
if (finalizing) assignedNumber = await assignIssuedOrderNumber(id, tx);
|
||||
if (replaceItems) {
|
||||
await tx.issued_order_items.deleteMany({
|
||||
where: { issued_order_id: id },
|
||||
});
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items!.map((item, i) => ({
|
||||
issued_order_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (replaceSections) {
|
||||
await tx.issued_order_sections.deleteMany({
|
||||
where: { issued_order_id: id },
|
||||
});
|
||||
await tx.issued_order_sections.createMany({
|
||||
data: body.sections!.map((s, i) => ({
|
||||
issued_order_id: id,
|
||||
title: s.title ?? null,
|
||||
title_cz: s.title_cz ?? null,
|
||||
content: s.content ?? null,
|
||||
position: s.position ?? i,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.issued_orders.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
if (editable && Array.isArray(body.items)) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.issued_order_items.deleteMany({
|
||||
where: { issued_order_id: id },
|
||||
});
|
||||
await tx.issued_order_items.createMany({
|
||||
data: body.items!.map((item, i) => ({
|
||||
issued_order_id: id,
|
||||
description: item.description ?? null,
|
||||
item_description: item.item_description ?? null,
|
||||
quantity: item.quantity ?? 1,
|
||||
unit: item.unit ?? null,
|
||||
unit_price: item.unit_price ?? 0,
|
||||
position: item.position ?? i,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
// 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 (replaceItems) newValues.items = body.items;
|
||||
if (replaceSections) newValues.sections = body.sections;
|
||||
|
||||
// Reflect the number the finalize transition just assigned (existing was read
|
||||
// before the assign, so its po_number is still the pre-finalize null).
|
||||
const poNumber = finalizing
|
||||
? ((
|
||||
await prisma.issued_orders.findUnique({
|
||||
where: { id },
|
||||
select: { po_number: true },
|
||||
})
|
||||
)?.po_number ?? existing.po_number)
|
||||
: existing.po_number;
|
||||
|
||||
return { id, po_number: poNumber };
|
||||
return {
|
||||
id,
|
||||
po_number: assignedNumber ?? existing.po_number,
|
||||
oldValues: existing as unknown as Record<string, unknown>,
|
||||
newValues,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteIssuedOrder(id: number) {
|
||||
@@ -428,6 +533,23 @@ export async function deleteIssuedOrder(id: number) {
|
||||
? new Date(existing.created_at).getFullYear()
|
||||
: new Date().getFullYear();
|
||||
await releaseIssuedOrderNumber(year, existing.po_number ?? undefined);
|
||||
|
||||
// Remove the archived PDF from the NAS so the DB delete doesn't orphan it
|
||||
// (mirrors deleteOffer). cleanIssued sweeps every Vydané/YYYY/MM folder, so
|
||||
// an order whose order_date moved after archiving is still found.
|
||||
// Best-effort: log, never throw.
|
||||
if (existing.po_number && nasOrdersManager.isConfigured()) {
|
||||
try {
|
||||
nasOrdersManager.cleanIssued(existing.po_number);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"[issued-orders.service] NAS issued-order PDF delete failed for",
|
||||
existing.po_number,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,54 @@ export class NasDocumentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and read the archived PDF for an issued document by its number
|
||||
* alone, sweeping every Vydané/YYYY/MM folder (the same walk cleanIssued
|
||||
* uses). Needed because the archive folder is derived from the document
|
||||
* date at save time and the date can be edited afterwards — a fixed
|
||||
* expected-path read would miss a file that does exist in another month
|
||||
* folder. Returns null when not found (callers fall back to a live render).
|
||||
*/
|
||||
readIssuedByNumber(
|
||||
documentNumber: string,
|
||||
): { data: Buffer; fileName: string } | null {
|
||||
if (!this.basePath) return null;
|
||||
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
|
||||
const issuedDir = path.join(this.basePath, DIR_ISSUED);
|
||||
try {
|
||||
if (!fs.existsSync(issuedDir)) return null;
|
||||
for (const yearDir of fs.readdirSync(issuedDir)) {
|
||||
const yearPath = path.join(issuedDir, yearDir);
|
||||
if (!fs.statSync(yearPath).isDirectory()) continue;
|
||||
for (const monthDir of fs.readdirSync(yearPath)) {
|
||||
const monthPath = path.join(yearPath, monthDir);
|
||||
try {
|
||||
if (!fs.statSync(monthPath).isDirectory()) continue;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[nas-financials-manager] stat failed for monthPath:",
|
||||
monthPath,
|
||||
err,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(monthPath, safeName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return { data: fs.readFileSync(filePath), fileName: safeName };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[nas-financials-manager] readIssuedByNumber failed for:",
|
||||
documentNumber,
|
||||
err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
readIssued(relativePath: string): { data: Buffer; fileName: string } | null {
|
||||
const fullPath = this.resolveSafePath(relativePath);
|
||||
if (!fullPath) return null;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
releaseProjectNumber,
|
||||
} from "./numbering.service";
|
||||
import { NasFileManager } from "./nas-file-manager";
|
||||
import { VALID_TRANSITIONS as OFFER_VALID_TRANSITIONS } from "./offers.service";
|
||||
|
||||
// Best-effort NAS folder creation for order-spawned projects, mirroring
|
||||
// projects.service. Stateless singleton; reads NAS_PATH at construction.
|
||||
@@ -301,7 +302,7 @@ export async function createOrderFromQuotation(
|
||||
where: { id: quotationId },
|
||||
include: {
|
||||
quotation_items: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: { position: "asc" } },
|
||||
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,6 +313,19 @@ export async function createOrderFromQuotation(
|
||||
status: 400,
|
||||
} as const;
|
||||
|
||||
// The flow sets the offer's status to `ordered`, so it must respect the
|
||||
// offers transition table: only an offer whose current status allows the
|
||||
// `ordered` transition qualifies. A draft must be finalized first (it has no
|
||||
// number yet — an ordered offer without a number must never exist) and an
|
||||
// invalidated offer is terminal.
|
||||
const allowedFromStatus = OFFER_VALID_TRANSITIONS[quotation.status] || [];
|
||||
if (!allowedFromStatus.includes("ordered")) {
|
||||
return {
|
||||
error: `Objednávku nelze vytvořit z nabídky ve stavu "${quotation.status}"`,
|
||||
status: 400,
|
||||
} as const;
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const orderNumber = await generateSharedNumber(tx);
|
||||
// The order keeps the shared (order) sequence; the auto-created project
|
||||
|
||||
Reference in New Issue
Block a user