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>
839 lines
28 KiB
TypeScript
839 lines
28 KiB
TypeScript
import prisma from "../config/database";
|
|
import { Prisma } from "@prisma/client";
|
|
import {
|
|
generateSharedNumber,
|
|
previewSharedNumber,
|
|
releaseSharedNumber,
|
|
isOrderNumberTaken,
|
|
generateProjectNumber,
|
|
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.
|
|
const nasFileManager = new NasFileManager();
|
|
|
|
interface OrderItemInput {
|
|
description?: string | null;
|
|
item_description?: string | null;
|
|
quantity?: number;
|
|
unit?: string | null;
|
|
unit_price?: number;
|
|
is_included_in_total?: boolean;
|
|
position?: number;
|
|
}
|
|
interface OrderSectionInput {
|
|
title?: string | null;
|
|
title_cz?: string | null;
|
|
content?: string | null;
|
|
position?: number;
|
|
}
|
|
|
|
// Status transition rules matching PHP
|
|
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
|
prijata: ["v_realizaci", "stornovana"],
|
|
v_realizaci: ["dokoncena", "stornovana"],
|
|
dokoncena: [],
|
|
stornovana: [],
|
|
};
|
|
|
|
const ORDER_ALLOWED_SORT_FIELDS = [
|
|
"id",
|
|
"order_number",
|
|
"status",
|
|
"currency",
|
|
"created_at",
|
|
];
|
|
|
|
// Order status -> linked-project status (matching PHP).
|
|
const ORDER_TO_PROJECT_STATUS: Record<string, string> = {
|
|
v_realizaci: "aktivni",
|
|
dokoncena: "dokonceny",
|
|
stornovana: "zruseny",
|
|
};
|
|
|
|
/**
|
|
* Propagate an order status change onto its linked project(s). No-op when the
|
|
* new status has no project-status mapping. Accepts a Prisma client (the tx
|
|
* client inside a transaction, or the base client otherwise) so both update
|
|
* branches share one implementation.
|
|
*/
|
|
async function syncProjectStatus(
|
|
client: Prisma.TransactionClient,
|
|
orderId: number,
|
|
newStatus: string,
|
|
): Promise<void> {
|
|
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
|
|
if (!projectStatus) return;
|
|
await client.projects.updateMany({
|
|
where: { order_id: orderId },
|
|
data: { status: projectStatus },
|
|
});
|
|
}
|
|
|
|
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
|
|
// quantity/unit_price/is_included_in_total). If you read a NEW order/item
|
|
// field here, add it to that select — a field missing from the select is
|
|
// silently 0 in the per-currency totals.
|
|
//
|
|
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
|
|
function enrichOrder(o: any) {
|
|
const total = o.order_items
|
|
.filter((i: any) => i.is_included_in_total !== false)
|
|
.reduce(
|
|
(s: number, i: any) =>
|
|
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
|
0,
|
|
);
|
|
const { order_items, order_sections, ...rest } = o;
|
|
const invoice = o.invoices?.[0] || null;
|
|
return {
|
|
...rest,
|
|
items: order_items,
|
|
sections: order_sections,
|
|
customer_name: o.customers?.name || null,
|
|
quotation_number: o.quotations?.quotation_number || null,
|
|
project_code: o.quotations?.project_code || null,
|
|
invoice_id: invoice?.id || null,
|
|
invoice_number: invoice?.invoice_number || null,
|
|
total: Math.round(total * 100) / 100,
|
|
};
|
|
}
|
|
|
|
interface OrderFilterParams {
|
|
status?: string;
|
|
customer_id?: number;
|
|
search?: string;
|
|
month?: number;
|
|
year?: number;
|
|
}
|
|
|
|
interface ListOrdersParams extends OrderFilterParams {
|
|
page: number;
|
|
limit: number;
|
|
skip: number;
|
|
sort: string;
|
|
order: "asc" | "desc";
|
|
}
|
|
|
|
/**
|
|
* Shared `where` builder for the orders list and the per-currency stats
|
|
* aggregation, so both stay in sync (any new filter applies to both). month/year
|
|
* filters on created_at (matching the list).
|
|
*/
|
|
function buildOrderWhere(params: OrderFilterParams): Record<string, unknown> {
|
|
const { status, customer_id, search, month, year } = params;
|
|
const where: Record<string, unknown> = {};
|
|
if (status) where.status = status;
|
|
if (customer_id) where.customer_id = customer_id;
|
|
if (search) {
|
|
where.OR = [
|
|
{ order_number: { contains: search } },
|
|
{ customer_order_number: { contains: search } },
|
|
{ customers: { name: { contains: search } } },
|
|
];
|
|
}
|
|
if (month && year) {
|
|
const from = new Date(year, month - 1, 1);
|
|
const to = new Date(year, month, 1);
|
|
where.created_at = { gte: from, lt: to };
|
|
}
|
|
return where;
|
|
}
|
|
|
|
export async function listOrders(params: ListOrdersParams) {
|
|
const { page, limit, skip, order } = params;
|
|
const sortField = ORDER_ALLOWED_SORT_FIELDS.includes(params.sort)
|
|
? params.sort
|
|
: "id";
|
|
|
|
const where = buildOrderWhere(params);
|
|
|
|
const [orders, total] = await Promise.all([
|
|
prisma.orders.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
// Tiebreak on id so same-second created_at rows sort deterministically.
|
|
orderBy: [{ [sortField]: order }, { id: order }],
|
|
// The PO attachment blob is served ONLY by GET /:id/attachment. Never
|
|
// pull it into list payloads — a single 1MB PDF would serialize into
|
|
// megabytes of JSON per row. attachment_name stays as the existence
|
|
// signal the frontend uses.
|
|
omit: { attachment_data: true },
|
|
include: {
|
|
customers: { select: { id: true, name: true } },
|
|
order_items: { orderBy: { position: "asc" } },
|
|
order_sections: { orderBy: { position: "asc" } },
|
|
quotations: { select: { quotation_number: true, project_code: true } },
|
|
invoices: {
|
|
select: { id: true, invoice_number: true },
|
|
take: 1,
|
|
orderBy: { id: "desc" },
|
|
},
|
|
},
|
|
}),
|
|
prisma.orders.count({ where }),
|
|
]);
|
|
|
|
const enriched = orders.map(enrichOrder);
|
|
return { data: enriched, total, page, limit };
|
|
}
|
|
|
|
export interface CurrencyAmount {
|
|
amount: number;
|
|
currency: string;
|
|
}
|
|
|
|
/**
|
|
* Sum order NET total per currency across the WHOLE filtered set (not a
|
|
* single page). Reuses `buildOrderWhere` so the filters track the list, and
|
|
* `enrichOrder` so the per-order math matches the list/detail exactly. Returns
|
|
* one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
|
*/
|
|
export async function getOrderTotals(
|
|
params: OrderFilterParams,
|
|
): Promise<{ totals: CurrencyAmount[] }> {
|
|
const where = buildOrderWhere(params);
|
|
|
|
// Select ONLY what the math needs (currency + item numbers).
|
|
// This runs over the WHOLE filtered set, so pulling attachment blobs,
|
|
// sections HTML or relations here would multiply the query size for nothing.
|
|
const orders = await prisma.orders.findMany({
|
|
where,
|
|
select: {
|
|
currency: true,
|
|
order_items: {
|
|
select: {
|
|
quantity: true,
|
|
unit_price: true,
|
|
is_included_in_total: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const byCurrency: Record<string, number> = {};
|
|
for (const o of orders) {
|
|
const { total, currency } = enrichOrder(o);
|
|
const cur = currency || "CZK";
|
|
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
|
|
}
|
|
|
|
const totals: CurrencyAmount[] = Object.entries(byCurrency)
|
|
.map(([currency, amount]) => ({
|
|
currency,
|
|
amount: Math.round(amount * 100) / 100,
|
|
}))
|
|
.filter((t) => t.amount > 0);
|
|
|
|
return { totals };
|
|
}
|
|
|
|
export async function getOrder(id: number) {
|
|
const order = await prisma.orders.findUnique({
|
|
where: { id },
|
|
// The blob belongs only to GET /:id/attachment — the detail payload
|
|
// carries attachment_name as the existence signal.
|
|
omit: { attachment_data: true },
|
|
include: {
|
|
customers: true,
|
|
order_items: { orderBy: { position: "asc" } },
|
|
order_sections: { orderBy: { position: "asc" } },
|
|
quotations: {
|
|
select: { id: true, quotation_number: true, project_code: true },
|
|
},
|
|
projects: {
|
|
select: { id: true, project_number: true, name: true, status: true },
|
|
},
|
|
invoices: {
|
|
select: { id: true, invoice_number: true, status: true },
|
|
orderBy: { id: "desc" },
|
|
},
|
|
},
|
|
});
|
|
if (!order) return null;
|
|
const { order_items, order_sections, ...rest } = order;
|
|
const invoice = order.invoices?.[0] || null;
|
|
return {
|
|
...rest,
|
|
items: order_items,
|
|
sections: order_sections,
|
|
customer: order.customers,
|
|
customer_name: order.customers?.name || null,
|
|
quotation_number: order.quotations?.quotation_number || null,
|
|
project_code: order.quotations?.project_code || null,
|
|
project: order.projects?.[0] || null,
|
|
invoice: invoice,
|
|
invoice_id: invoice?.id || null,
|
|
invoice_number: invoice?.invoice_number || null,
|
|
valid_transitions: VALID_TRANSITIONS[(order.status as string) || ""] || [],
|
|
};
|
|
}
|
|
|
|
export async function getOrderAttachment(id: number) {
|
|
const order = await prisma.orders.findUnique({
|
|
where: { id },
|
|
select: { attachment_data: true, attachment_name: true },
|
|
});
|
|
if (!order?.attachment_data) return null;
|
|
return {
|
|
data: Buffer.from(order.attachment_data),
|
|
filename: order.attachment_name || `order-${id}.pdf`,
|
|
};
|
|
}
|
|
|
|
interface CreateOrderFromQuotationData {
|
|
quotationId: number;
|
|
customerOrderNumber?: string;
|
|
attachmentBuffer?: Buffer | null;
|
|
attachmentName?: string | null;
|
|
}
|
|
|
|
export async function createOrderFromQuotation(
|
|
data: CreateOrderFromQuotationData,
|
|
) {
|
|
const { quotationId, customerOrderNumber, attachmentBuffer, attachmentName } =
|
|
data;
|
|
|
|
const quotation = await prisma.quotations.findUnique({
|
|
where: { id: quotationId },
|
|
include: {
|
|
quotation_items: { orderBy: { position: "asc" } },
|
|
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
|
},
|
|
});
|
|
|
|
if (!quotation) return { error: "Nabídka nenalezena", status: 404 } as const;
|
|
if (quotation.order_id)
|
|
return {
|
|
error: "Z této nabídky již byla vytvořena objednávka",
|
|
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
|
|
// gets its OWN number from the dedicated project sequence. Both are
|
|
// assigned inside this transaction so they're allocated atomically.
|
|
const projectNumber = await generateProjectNumber(tx);
|
|
|
|
const order = await tx.orders.create({
|
|
data: {
|
|
order_number: orderNumber,
|
|
customer_order_number: customerOrderNumber || null,
|
|
quotation_id: quotationId,
|
|
customer_id: quotation.customer_id,
|
|
status: "prijata",
|
|
currency: quotation.currency || "CZK",
|
|
language: quotation.language || "cs",
|
|
scope_title: quotation.scope_title,
|
|
scope_description: quotation.scope_description,
|
|
attachment_data: attachmentBuffer
|
|
? new Uint8Array(attachmentBuffer)
|
|
: null,
|
|
attachment_name: attachmentName || null,
|
|
},
|
|
});
|
|
|
|
if (quotation.quotation_items.length > 0) {
|
|
await tx.order_items.createMany({
|
|
data: quotation.quotation_items.map((item) => ({
|
|
order_id: order.id,
|
|
description: item.description,
|
|
item_description: item.item_description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
is_included_in_total: item.is_included_in_total,
|
|
position: item.position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (quotation.scope_sections.length > 0) {
|
|
await tx.order_sections.createMany({
|
|
data: quotation.scope_sections.map((s) => ({
|
|
order_id: order.id,
|
|
title: s.title,
|
|
title_cz: s.title_cz,
|
|
content: s.content,
|
|
position: s.position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
await tx.quotations.update({
|
|
where: { id: quotationId },
|
|
data: { order_id: order.id, status: "ordered", modified_at: new Date() },
|
|
});
|
|
|
|
const project = await tx.projects.create({
|
|
data: {
|
|
project_number: projectNumber,
|
|
name:
|
|
quotation.project_code || quotation.quotation_number || projectNumber,
|
|
customer_id: quotation.customer_id,
|
|
quotation_id: quotationId,
|
|
order_id: order.id,
|
|
status: "aktivni",
|
|
},
|
|
});
|
|
|
|
return { order, project, orderNumber };
|
|
});
|
|
|
|
// Best-effort NAS project folder, outside the transaction (mirrors
|
|
// createProject). A project created from an accepted offer must get the same
|
|
// 02_PROJEKTY/<number>_<name> folder as a manually-created one. Non-fatal: a
|
|
// NAS outage must never roll back the order.
|
|
if (result.project.project_number && nasFileManager.isConfigured()) {
|
|
const created = nasFileManager.createProjectFolder(
|
|
result.project.project_number,
|
|
result.project.name || "",
|
|
);
|
|
if (!created) {
|
|
console.error(
|
|
"[orders.service] NAS folder not created for project",
|
|
result.project.project_number,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: {
|
|
order_id: result.order.id,
|
|
id: result.order.id,
|
|
order_number: result.orderNumber,
|
|
quotationId,
|
|
},
|
|
};
|
|
}
|
|
|
|
interface CreateOrderData {
|
|
order_number?: string | null;
|
|
customer_order_number?: string | null;
|
|
quotation_id?: number | null;
|
|
customer_id?: number | null;
|
|
status: string;
|
|
currency: string;
|
|
language: string;
|
|
exchange_rate?: number;
|
|
scope_title?: string | null;
|
|
scope_description?: string | null;
|
|
notes?: string | null;
|
|
items?: OrderItemInput[];
|
|
sections?: OrderSectionInput[];
|
|
create_project?: boolean;
|
|
}
|
|
|
|
export async function createOrder(
|
|
body: CreateOrderData,
|
|
attachmentBuffer?: Buffer | null,
|
|
attachmentName?: string | null,
|
|
) {
|
|
try {
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
const orderNumber =
|
|
body.order_number !== undefined && body.order_number !== null
|
|
? String(body.order_number)
|
|
: await generateSharedNumber(tx);
|
|
|
|
if (body.order_number !== undefined && body.order_number !== null) {
|
|
const taken = await isOrderNumberTaken(String(body.order_number));
|
|
if (taken) {
|
|
throw Object.assign(new Error("Číslo objednávky je již použito"), {
|
|
status: 400,
|
|
});
|
|
}
|
|
}
|
|
|
|
const order = await tx.orders.create({
|
|
data: {
|
|
order_number: orderNumber,
|
|
customer_order_number: body.customer_order_number ?? null,
|
|
quotation_id: body.quotation_id ?? null,
|
|
customer_id: body.customer_id ?? null,
|
|
status: body.status,
|
|
currency: body.currency,
|
|
language: body.language,
|
|
exchange_rate: body.exchange_rate,
|
|
scope_title: body.scope_title ?? null,
|
|
scope_description: body.scope_description ?? null,
|
|
notes: body.notes ?? null,
|
|
attachment_data: attachmentBuffer
|
|
? new Uint8Array(attachmentBuffer)
|
|
: null,
|
|
attachment_name: attachmentName || null,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(body.items)) {
|
|
await tx.order_items.createMany({
|
|
data: body.items.map((item, i) => ({
|
|
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,
|
|
is_included_in_total: item.is_included_in_total !== false,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(body.sections)) {
|
|
await tx.order_sections.createMany({
|
|
data: body.sections.map((s, i) => ({
|
|
order_id: order.id,
|
|
title: s.title ?? null,
|
|
title_cz: s.title_cz ?? null,
|
|
content: s.content ?? null,
|
|
position: s.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
|
|
let projectId: number | undefined;
|
|
let createdProject: { project_number: string; name: string } | null =
|
|
null;
|
|
// Only auto-create a project for genuine offer-less orders. The project
|
|
// gets its OWN number from the dedicated project sequence (not the
|
|
// order's shared number), exactly like the from-quotation flow.
|
|
if (body.create_project !== false && body.quotation_id == null) {
|
|
const projectNumber = await generateProjectNumber(tx);
|
|
const project = await tx.projects.create({
|
|
data: {
|
|
project_number: projectNumber,
|
|
name:
|
|
body.scope_title || body.customer_order_number || projectNumber,
|
|
customer_id: order.customer_id,
|
|
order_id: order.id,
|
|
status: "aktivni",
|
|
},
|
|
});
|
|
projectId = project.id;
|
|
if (project.project_number) {
|
|
createdProject = {
|
|
project_number: project.project_number,
|
|
name: project.name || "",
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: order.id,
|
|
order_number: order.order_number,
|
|
project_id: projectId,
|
|
createdProject,
|
|
};
|
|
});
|
|
|
|
// Best-effort NAS project folder, outside the transaction (mirrors
|
|
// createProject). Non-fatal so a NAS outage can't roll back the order.
|
|
if (result.createdProject && nasFileManager.isConfigured()) {
|
|
const created = nasFileManager.createProjectFolder(
|
|
result.createdProject.project_number,
|
|
result.createdProject.name,
|
|
);
|
|
if (!created) {
|
|
console.error(
|
|
"[orders.service] NAS folder not created for project",
|
|
result.createdProject.project_number,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: result.id,
|
|
order_number: result.order_number,
|
|
project_id: result.project_id,
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof Error && "status" in err) {
|
|
return {
|
|
error: err.message,
|
|
status: (err as Error & { status: number }).status,
|
|
};
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
interface UpdateOrderData {
|
|
[key: string]: unknown;
|
|
customer_id?: number | string | null;
|
|
items?: OrderItemInput[];
|
|
sections?: OrderSectionInput[];
|
|
}
|
|
|
|
export async function updateOrder(id: number, body: UpdateOrderData) {
|
|
// Pre-read only the fields the guards below check — never the PDF blob.
|
|
const existing = await prisma.orders.findUnique({
|
|
where: { id },
|
|
select: { status: true, order_number: true },
|
|
});
|
|
if (!existing)
|
|
return { error: "Objednávka nenalezena", status: 404 } as const;
|
|
|
|
const currentStatus = existing.status as string;
|
|
|
|
if (
|
|
body.order_number !== undefined &&
|
|
String(body.order_number) !== existing.order_number
|
|
) {
|
|
return {
|
|
error: "Číslo objednávky nelze změnit",
|
|
status: 400,
|
|
} as const;
|
|
}
|
|
|
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
|
const newStatus = String(body.status);
|
|
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
|
if (!allowed.includes(newStatus)) {
|
|
return {
|
|
error: `Neplatný přechod stavu z "${currentStatus}" na "${newStatus}"`,
|
|
status: 400,
|
|
} as const;
|
|
}
|
|
}
|
|
|
|
const data: Record<string, unknown> = { modified_at: new Date() };
|
|
const strFields = [
|
|
"customer_order_number",
|
|
"status",
|
|
"currency",
|
|
"language",
|
|
"scope_title",
|
|
"scope_description",
|
|
"notes",
|
|
];
|
|
for (const f of strFields) {
|
|
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
|
|
}
|
|
if (body.customer_id !== undefined)
|
|
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
|
|
|
|
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
|
|
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
|
|
return {
|
|
error: "Nelze upravit položky dokončené/stornované objednávky",
|
|
status: 400,
|
|
} as const;
|
|
}
|
|
if (
|
|
body.status !== undefined &&
|
|
(String(body.status) === "dokoncena" ||
|
|
String(body.status) === "stornovana")
|
|
) {
|
|
return {
|
|
error: "Nelze upravit položky při změně stavu na dokončeno/storno",
|
|
status: 400,
|
|
} as const;
|
|
}
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.orders.update({ where: { id }, data });
|
|
|
|
// Sync project status when order status changes (matching PHP)
|
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
|
await syncProjectStatus(tx, id, String(body.status));
|
|
}
|
|
|
|
if (Array.isArray(body.items)) {
|
|
await tx.order_items.deleteMany({ where: { order_id: id } });
|
|
await tx.order_items.createMany({
|
|
data: (body.items as OrderItemInput[]).map((item, i) => ({
|
|
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,
|
|
is_included_in_total: item.is_included_in_total !== false,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
if (Array.isArray(body.sections)) {
|
|
await tx.order_sections.deleteMany({ where: { order_id: id } });
|
|
await tx.order_sections.createMany({
|
|
data: (body.sections as OrderSectionInput[]).map((s, i) => ({
|
|
order_id: id,
|
|
title: s.title ?? null,
|
|
title_cz: s.title_cz ?? null,
|
|
content: s.content ?? null,
|
|
position: s.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
await prisma.orders.update({ where: { id }, data });
|
|
|
|
// Sync project status when order status changes (matching PHP)
|
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
|
await syncProjectStatus(prisma, id, String(body.status));
|
|
}
|
|
}
|
|
|
|
return { data: { id, order_number: existing.order_number } };
|
|
}
|
|
|
|
export async function deleteOrder(id: number, deleteFiles = false) {
|
|
// Pre-read only what the delete needs (number release + year) — not the blob.
|
|
const existing = await prisma.orders.findUnique({
|
|
where: { id },
|
|
select: { order_number: true, created_at: true },
|
|
});
|
|
if (!existing)
|
|
return { error: "Objednávka nenalezena", status: 404 } as const;
|
|
|
|
// Fetch linked projects before the transaction for number release and
|
|
// (optional) NAS folder cleanup later. project_number is needed to locate
|
|
// the folder on the share.
|
|
const linkedProjects = await prisma.projects.findMany({
|
|
where: { order_id: id },
|
|
select: { id: true, created_at: true, project_number: true },
|
|
});
|
|
|
|
try {
|
|
await prisma.$transaction(async (tx) => {
|
|
// Guard: projects may have non-cascaded warehouse refs (sklad_issues,
|
|
// sklad_reservations, attendance_project_logs all use project_id as a
|
|
// non-nullable FK with no onDelete). Surface as 409 (resource conflict)
|
|
// rather than letting Prisma throw P2003 -> 500. The count check runs
|
|
// INSIDE the transaction so a row can't be re-introduced between the
|
|
// guard and the project delete (closes the narrow re-intro window).
|
|
//
|
|
// Only ACTIVE records count: a CANCELLED issue/restored batch or
|
|
// CANCELLED reservation is audit-trail-only (cancelIssue() has already
|
|
// restored the batch qty; cancelReservation() zeroes remaining_qty).
|
|
// Attendance project logs are time-records — they always block, since
|
|
// deleting the project would orphan the time record. The user must
|
|
// re-assign the attendance log to a different project first.
|
|
if (linkedProjects.length > 0) {
|
|
const projectIds = linkedProjects.map((p) => p.id);
|
|
const [activeIssuesCount, activeReservationsCount, attendanceLogCount] =
|
|
await Promise.all([
|
|
tx.sklad_issues.count({
|
|
where: {
|
|
project_id: { in: projectIds },
|
|
status: { not: "CANCELLED" },
|
|
},
|
|
}),
|
|
tx.sklad_reservations.count({
|
|
where: {
|
|
project_id: { in: projectIds },
|
|
status: { not: "CANCELLED" },
|
|
},
|
|
}),
|
|
tx.attendance_project_logs.count({
|
|
where: { project_id: { in: projectIds } },
|
|
}),
|
|
]);
|
|
if (
|
|
activeIssuesCount + activeReservationsCount + attendanceLogCount >
|
|
0
|
|
) {
|
|
throw Object.assign(
|
|
new Error(
|
|
"Nelze smazat objednávku, protože navázaný projekt má aktivní skladové výdeje, rezervace nebo docházkové záznamy. Zrušte je nebo přeřaďte docházku na jiný projekt.",
|
|
),
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Clear quotation back-reference (matching PHP)
|
|
await tx.quotations.updateMany({
|
|
where: { order_id: id },
|
|
data: { order_id: null },
|
|
});
|
|
|
|
// Delete linked project and its notes (matching PHP)
|
|
if (linkedProjects.length > 0) {
|
|
const projectIds = linkedProjects.map((p) => p.id);
|
|
await tx.project_notes.deleteMany({
|
|
where: { project_id: { in: projectIds } },
|
|
});
|
|
await tx.projects.deleteMany({ where: { order_id: id } });
|
|
}
|
|
|
|
// Explicitly clean up child rows
|
|
await tx.order_items.deleteMany({ where: { order_id: id } });
|
|
await tx.order_sections.deleteMany({ where: { order_id: id } });
|
|
|
|
await tx.orders.delete({ where: { id } });
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof Error && "status" in err) {
|
|
return {
|
|
error: err.message,
|
|
status: (err as Error & { status: number }).status,
|
|
} as const;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Best-effort NAS folder cleanup for the order's project(s), outside the
|
|
// transaction and only when the user ticked the "delete folder" checkbox in
|
|
// the order delete modal. Non-fatal: a NAS error must not undo the
|
|
// already-committed DB delete. deleteProjectFolder is idempotent — it returns
|
|
// true (no-op) when the folder is already absent.
|
|
if (deleteFiles && nasFileManager.isConfigured()) {
|
|
for (const p of linkedProjects) {
|
|
if (!p.project_number) continue;
|
|
const ok = await nasFileManager.deleteProjectFolder(p.project_number);
|
|
if (!ok) {
|
|
console.error(
|
|
"[orders.service] NAS folder not deleted for project",
|
|
p.project_number,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const year = existing.created_at
|
|
? new Date(existing.created_at).getFullYear()
|
|
: new Date().getFullYear();
|
|
await releaseSharedNumber(year, existing.order_number ?? undefined);
|
|
|
|
// Release each deleted project's OWN number from the dedicated `project`
|
|
// sequence. Auto-created projects now draw a distinct number from the
|
|
// `project` pool (not the order's `shared` number), so deleting the order
|
|
// must release the project number too — otherwise the highest project number
|
|
// leaves a permanent gap. Use each project's OWN created_at year: project
|
|
// numbers embed {YY} and the sequence is (type, year)-keyed, so the order's
|
|
// year could decrement the wrong row. Best-effort, post-commit, mirroring the
|
|
// shared release above — releaseSequence row-locks and only decrements when
|
|
// the deleted number is the current highest, so this is safe/idempotent.
|
|
for (const p of linkedProjects) {
|
|
if (!p.project_number) continue;
|
|
const projYear = p.created_at ? new Date(p.created_at).getFullYear() : year;
|
|
await releaseProjectNumber(projYear, p.project_number);
|
|
}
|
|
|
|
return { data: { id, order_number: existing.order_number } };
|
|
}
|
|
|
|
export async function getNextOrderNumber() {
|
|
return previewSharedNumber();
|
|
}
|