Files
app/src/services/orders.service.ts
BOHA 1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00

873 lines
29 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).
// Structural shape for enrichOrder — like enrichQuotation it runs over several
// different selects (full include vs. the minimal totals select), so only the
// fields actually read are pinned; everything else is spread through.
type EnrichableLineItem = {
is_included_in_total?: unknown;
quantity?: unknown;
unit_price?: unknown;
};
type EnrichableOrder = {
order_items: EnrichableLineItem[];
order_sections?: unknown;
invoices?: Array<{
id?: number | null;
invoice_number?: string | null;
}> | null;
customers?: { name?: string | null } | null;
quotations?: {
quotation_number?: string | null;
project_code?: string | null;
} | null;
[key: string]: unknown;
};
// Generic over the concrete payload so `...rest` keeps every field the caller's
// select actually returned (the return type must stay as rich as the input).
function enrichOrder<T extends EnrichableOrder>(o: T) {
const total = o.order_items
.filter((i) => i.is_included_in_total !== false)
.reduce(
(s: number, i) =>
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 the quotation back-reference AND revert its status: an
// `ordered` offer with no order is a dead end (the transition table
// only allows ordered -> invalidated), so it goes back to `active`,
// making it orderable again.
await tx.quotations.updateMany({
where: { order_id: id, status: "ordered" },
data: { order_id: null, status: "active", modified_at: new Date() },
});
// Backstop for rows in any other status (shouldn't exist — only an
// ordered offer can hold an order_id).
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();
}