From 3ec512faf140293a20ac6a3f629810bf90f2b4ee Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 4 Jul 2026 04:33:58 +0200 Subject: [PATCH] feat(projects/orders): project status machine + bidirectional project-order sync; order reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projects.service: VALID_TRANSITIONS (aktivni->dokonceny/zruseny, terminal->aktivni reopen), tolerant of legacy statuses; getProject returns valid_transitions; updateProject validates transitions (invalid_transition token -> Czech 400 in the route). - NEW project->order cascade, transactional with the project update: finishing/ cancelling a project completes/cancels its linked received order — only while the order is still open (never resurrects storno, never cancels completed); reopen never cascades. Direct tx write, no service recursion; cascaded order change gets its own audit row. - orders.service: reopen edges (dokoncena/stornovana -> v_realizaci); syncProjectStatus is reopen-aware (reopen skips project sync) and returns the cascaded projects for per-project audit rows in the route. - orders.schema: fix phantom 'zrusena' enum token -> canonical 'stornovana' (every storno PUT through the route 400ed with a raw English Zod message; latent until now because no UI sent it) + route-level regression test. - NEW project-order-status-sync.test.ts: 15 tests — both cascade directions, guards, reopen-no-cascade, legacy tolerance, forward-sync regression. Co-Authored-By: Claude Fable 5 --- src/__tests__/orders-list.test.ts | 25 ++ .../project-order-status-sync.test.ts | 301 ++++++++++++++++++ src/routes/admin/orders.ts | 20 ++ src/routes/admin/projects.ts | 26 ++ src/schemas/orders.schema.ts | 6 +- src/services/orders.service.ts | 72 ++++- src/services/projects.service.ts | 115 ++++++- 7 files changed, 548 insertions(+), 17 deletions(-) create mode 100644 src/__tests__/project-order-status-sync.test.ts diff --git a/src/__tests__/orders-list.test.ts b/src/__tests__/orders-list.test.ts index a92a391..d7b7313 100644 --- a/src/__tests__/orders-list.test.ts +++ b/src/__tests__/orders-list.test.ts @@ -196,6 +196,31 @@ describe("order attachment payload hygiene", () => { expect(withoutDetail!.attachment_name).toBeNull(); }); + it("PUT /:id accepts the canonical storno token through the Zod layer", async () => { + // Regression: UpdateOrderSchema carried a phantom "zrusena" enum member + // until 2026-07, so { status: "stornovana" } 400ed at parseBody with a + // raw English Zod message. Service-level tests bypass the schema — this + // must stay a route-level test. + const customer = await makeCustomer(); + const res = await createOrder({ + ...baseOrder, + customer_id: customer.id, + create_project: false, + }); + if (!("id" in res)) failResult(res); + createdOrderIds.push(res.id!); + + const put = await app.inject({ + method: "PUT", + url: `/api/admin/orders/${res.id}`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { status: "stornovana" }, + }); + expect(put.statusCode).toBe(200); + const row = await prisma.orders.findUnique({ where: { id: res.id! } }); + expect(row!.status).toBe("stornovana"); + }); + it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => { const { withId, withoutId } = await makeOrderPair(); const headers = { authorization: `Bearer ${adminToken}` }; diff --git a/src/__tests__/project-order-status-sync.test.ts b/src/__tests__/project-order-status-sync.test.ts new file mode 100644 index 0000000..3a261b8 --- /dev/null +++ b/src/__tests__/project-order-status-sync.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, afterEach, afterAll } from "vitest"; +import prisma from "../config/database"; +import { + updateProject, + getProject, + VALID_TRANSITIONS as PROJECT_VALID_TRANSITIONS, +} from "../services/projects.service"; +import { updateOrder } from "../services/orders.service"; + +/** + * Bidirectional project⇄order status sync (spec 2026-07-04): + * - projects gain a status machine (aktivni ⇄ dokonceny/zruseny) with + * legacy-status tolerance, + * - finishing/cancelling a project cascades onto its linked OPEN order + * (prijata/v_realizaci) — terminal orders are never touched, + * - reopen (project → aktivni, order → v_realizaci) is now a legal + * transition and NEVER cascades, + * - regression: the existing order→project forward sync stays intact. + * + * Real app_test DB via the service layer (suite convention). Fixtures use a + * unique prefix; projects are created without project_number so getProject's + * NAS folder probe is skipped. + */ + +const N = "posync_"; + +const createdProjectIds: number[] = []; +const createdOrderIds: number[] = []; +let seq = 0; + +async function mkOrder(status: string) { + const order = await prisma.orders.create({ + data: { order_number: `${N}${Date.now()}_${seq++}`, status }, + }); + createdOrderIds.push(order.id); + return order; +} + +async function mkProject(status: string, orderId?: number) { + const project = await prisma.projects.create({ + data: { name: `${N}project_${seq++}`, status, order_id: orderId ?? null }, + }); + createdProjectIds.push(project.id); + return project; +} + +// FK-safe order: projects reference orders (Restrict), so projects go first. +afterEach(async () => { + await prisma.projects.deleteMany({ + where: { id: { in: createdProjectIds } }, + }); + await prisma.orders.deleteMany({ where: { id: { in: createdOrderIds } } }); + createdProjectIds.length = 0; + createdOrderIds.length = 0; +}); + +afterAll(async () => { + await prisma.$disconnect(); +}); + +function assertOk( + res: T, +): asserts res is Exclude { + if (!res || (typeof res === "object" && "error" in res)) { + throw new Error(`expected success, got ${JSON.stringify(res)}`); + } +} + +describe("project → order cascade (updateProject)", () => { + it("aktivni→dokonceny completes a linked v_realizaci order and returns synced_order", async () => { + const order = await mkOrder("v_realizaci"); + const project = await mkProject("aktivni", order.id); + + const res = await updateProject(project.id, { status: "dokonceny" }); + assertOk(res); + expect(res.synced_order).toEqual({ + id: order.id, + order_number: order.order_number, + from: "v_realizaci", + to: "dokoncena", + }); + expect(res.old_status).toBe("aktivni"); + + const freshOrder = await prisma.orders.findUnique({ + where: { id: order.id }, + }); + expect(freshOrder?.status).toBe("dokoncena"); + const freshProject = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(freshProject?.status).toBe("dokonceny"); + }); + + it("aktivni→zruseny cancels a linked prijata order", async () => { + const order = await mkOrder("prijata"); + const project = await mkProject("aktivni", order.id); + + const res = await updateProject(project.id, { status: "zruseny" }); + assertOk(res); + expect(res.synced_order).toEqual({ + id: order.id, + order_number: order.order_number, + from: "prijata", + to: "stornovana", + }); + + const freshOrder = await prisma.orders.findUnique({ + where: { id: order.id }, + }); + expect(freshOrder?.status).toBe("stornovana"); + }); + + it("guard: never resurrects a terminal order (stornovana stays stornovana)", async () => { + const order = await mkOrder("stornovana"); + const project = await mkProject("aktivni", order.id); + + const res = await updateProject(project.id, { status: "dokonceny" }); + assertOk(res); + expect(res.synced_order).toBeNull(); + + const freshOrder = await prisma.orders.findUnique({ + where: { id: order.id }, + }); + expect(freshOrder?.status).toBe("stornovana"); + const freshProject = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(freshProject?.status).toBe("dokonceny"); + }); + + it("reopen (dokonceny→aktivni) never touches the linked order", async () => { + const order = await mkOrder("dokoncena"); + const project = await mkProject("dokonceny", order.id); + + const res = await updateProject(project.id, { status: "aktivni" }); + assertOk(res); + expect(res.synced_order).toBeNull(); + + const freshOrder = await prisma.orders.findUnique({ + where: { id: order.id }, + }); + expect(freshOrder?.status).toBe("dokoncena"); + const freshProject = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(freshProject?.status).toBe("aktivni"); + }); + + it("no linked order → no cascade, synced_order null", async () => { + const project = await mkProject("aktivni"); + const res = await updateProject(project.id, { status: "dokonceny" }); + assertOk(res); + expect(res.synced_order).toBeNull(); + }); +}); + +describe("project status machine (updateProject validation)", () => { + it("rejects dokonceny→zruseny with the invalid_transition token", async () => { + const project = await mkProject("dokonceny"); + const res = await updateProject(project.id, { status: "zruseny" }); + expect(res && "error" in res && res.error).toBe("invalid_transition"); + expect(res).toMatchObject({ + currentStatus: "dokonceny", + newStatus: "zruseny", + }); + + // Nothing was written. + const fresh = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(fresh?.status).toBe("dokonceny"); + }); + + it("legacy tolerance: an unknown current status may move to any canonical target", async () => { + const project = await mkProject("stary"); + const res = await updateProject(project.id, { status: "dokonceny" }); + assertOk(res); + const fresh = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(fresh?.status).toBe("dokonceny"); + }); + + it("legacy tolerance still rejects a non-canonical target", async () => { + const project = await mkProject("stary"); + const res = await updateProject(project.id, { status: "nesmysl" }); + expect(res && "error" in res && res.error).toBe("invalid_transition"); + }); + + it("status-unchanged payloads pass without transition validation", async () => { + const project = await mkProject("dokonceny"); + const res = await updateProject(project.id, { + status: "dokonceny", + notes: `${N}note`, + }); + assertOk(res); + }); +}); + +describe("order reopen + order → project sync (updateOrder)", () => { + it("dokoncena→v_realizaci is now valid and skips project sync (reopen)", async () => { + const order = await mkOrder("dokoncena"); + const project = await mkProject("dokonceny", order.id); + + const res = await updateOrder(order.id, { status: "v_realizaci" }); + expect("error" in res).toBe(false); + if ("error" in res) throw new Error(res.error); + expect(res.synced_projects).toEqual([]); + + const freshOrder = await prisma.orders.findUnique({ + where: { id: order.id }, + }); + expect(freshOrder?.status).toBe("v_realizaci"); + // Reopen never cascades — the project keeps its terminal status. + const freshProject = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(freshProject?.status).toBe("dokonceny"); + }); + + it("stornovana→v_realizaci is valid too; other exits from terminal stay rejected", async () => { + const order = await mkOrder("stornovana"); + const res = await updateOrder(order.id, { status: "v_realizaci" }); + expect("error" in res).toBe(false); + + const back = await mkOrder("dokoncena"); + const rejected = await updateOrder(back.id, { status: "prijata" }); + expect("error" in rejected).toBe(true); + if ("error" in rejected) { + expect(rejected.status).toBe(400); + expect(rejected.error).toContain("Neplatný přechod stavu"); + } + }); + + it("regression: v_realizaci→dokoncena still completes the linked project and reports it", async () => { + const order = await mkOrder("v_realizaci"); + const project = await mkProject("aktivni", order.id); + + const res = await updateOrder(order.id, { status: "dokoncena" }); + expect("error" in res).toBe(false); + if ("error" in res) throw new Error(res.error); + expect(res.synced_projects).toEqual([ + { + id: project.id, + project_number: null, + from: "aktivni", + to: "dokonceny", + }, + ]); + + const freshProject = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(freshProject?.status).toBe("dokonceny"); + }); + + it("regression: prijata→stornovana still cancels the linked project", async () => { + const order = await mkOrder("prijata"); + const project = await mkProject("aktivni", order.id); + + const res = await updateOrder(order.id, { status: "stornovana" }); + expect("error" in res).toBe(false); + if ("error" in res) throw new Error(res.error); + expect(res.synced_projects).toHaveLength(1); + + const freshProject = await prisma.projects.findUnique({ + where: { id: project.id }, + }); + expect(freshProject?.status).toBe("zruseny"); + }); +}); + +describe("getProject valid_transitions", () => { + it("matches the machine for canonical statuses", async () => { + const active = await mkProject("aktivni"); + expect((await getProject(active.id))?.valid_transitions).toEqual( + PROJECT_VALID_TRANSITIONS["aktivni"], + ); + expect((await getProject(active.id))?.valid_transitions).toEqual([ + "dokonceny", + "zruseny", + ]); + + const done = await mkProject("dokonceny"); + expect((await getProject(done.id))?.valid_transitions).toEqual(["aktivni"]); + + const cancelled = await mkProject("zruseny"); + expect((await getProject(cancelled.id))?.valid_transitions).toEqual([ + "aktivni", + ]); + }); + + it("offers all canonical targets for a legacy/unknown status", async () => { + const legacy = await mkProject("stary"); + expect((await getProject(legacy.id))?.valid_transitions).toEqual([ + "aktivni", + "dokonceny", + "zruseny", + ]); + }); +}); diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index 86a3699..0bb593d 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -331,6 +331,26 @@ export default async function ordersRoutes( entityId: id, description: `Upravena objednávka ${result.data.order_number}`, }); + // The order→project cascade updated linked project(s) — give each its + // own audit trail entry. + for (const p of result.synced_projects ?? []) { + const verb = + p.to === "dokonceny" + ? "dokončen" + : p.to === "zruseny" + ? "zrušen" + : "aktivován"; + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "project", + entityId: p.id, + description: `Projekt ${p.project_number ?? `#${p.id}`} automaticky ${verb} (synchronizace s objednávkou)`, + oldValues: { status: p.from }, + newValues: { status: p.to }, + }); + } return success(reply, { id }, 200, "Objednávka byla uložena"); }, ); diff --git a/src/routes/admin/projects.ts b/src/routes/admin/projects.ts index 10c8792..279dee1 100644 --- a/src/routes/admin/projects.ts +++ b/src/routes/admin/projects.ts @@ -131,6 +131,12 @@ export default async function projectsRoutes( const result = await updateProject(id, parsed.data); if (!result) return error(reply, "Projekt nenalezen", 404); if ("error" in result) { + if (result.error === "invalid_transition" && "currentStatus" in result) + return error( + reply, + `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, + 400, + ); return error( reply, result.error, @@ -138,6 +144,7 @@ export default async function projectsRoutes( ); } + const statusChanged = result.old_status !== result.status; await logAudit({ request, authData: request.authData, @@ -145,7 +152,26 @@ export default async function projectsRoutes( entityType: "project", entityId: id, description: `Upraven projekt ${result.name}`, + oldValues: statusChanged ? { status: result.old_status } : undefined, + newValues: statusChanged ? { status: result.status } : undefined, }); + // The project→order cascade updated the linked order too — give the + // order its own audit trail entry. + if (result.synced_order) { + const so = result.synced_order; + await logAudit({ + request, + authData: request.authData, + action: "update", + entityType: "order", + entityId: so.id, + description: `Objednávka ${so.order_number ?? `#${so.id}`} automaticky ${ + so.to === "dokoncena" ? "dokončena" : "stornována" + } (synchronizace s projektem)`, + oldValues: { status: so.from }, + newValues: { status: so.to }, + }); + } return success(reply, { id }, 200, "Projekt byl uložen"); }, ); diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts index f2af13a..13f5460 100644 --- a/src/schemas/orders.schema.ts +++ b/src/schemas/orders.schema.ts @@ -36,7 +36,7 @@ export const CreateOrderSchema = z.object({ quotation_id: nullableIntIdFromForm.nullish(), customer_id: nullableIntIdFromForm.nullish(), status: z - .enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]) + .enum(["prijata", "v_realizaci", "dokoncena", "stornovana"]) .optional() .default("prijata"), currency: z.string().max(10).optional().default("CZK"), @@ -52,7 +52,9 @@ export const CreateOrderSchema = z.object({ export const UpdateOrderSchema = z.object({ customer_order_number: z.string().max(100).nullish(), - status: z.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"]).optional(), + status: z + .enum(["prijata", "v_realizaci", "dokoncena", "stornovana"]) + .optional(), currency: z.string().max(10).optional(), language: z.string().max(5).optional(), scope_title: z.string().max(255).nullish(), diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index b6794d2..4b61a7b 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -31,12 +31,13 @@ interface OrderSectionInput { position?: number; } -// Status transition rules matching PHP +// Status transition rules matching PHP, plus the deliberate reopen edges +// dokoncena/stornovana → v_realizaci (spec 2026-07-04 — status quick actions). export const VALID_TRANSITIONS: Record = { prijata: ["v_realizaci", "stornovana"], v_realizaci: ["dokoncena", "stornovana"], - dokoncena: [], - stornovana: [], + dokoncena: ["v_realizaci"], + stornovana: ["v_realizaci"], }; const ORDER_ALLOWED_SORT_FIELDS = [ @@ -54,23 +55,53 @@ const ORDER_TO_PROJECT_STATUS: Record = { stornovana: "zruseny", }; +// Order statuses that count as terminal — a transition OUT of one is a REOPEN, +// which deliberately never cascades to the linked project(s) (spec 2026-07-04). +const TERMINAL_ORDER_STATUSES = ["dokoncena", "stornovana"]; + +/** Project change performed by syncProjectStatus, returned for auditing. */ +export interface SyncedProject { + id: number; + project_number: string | null; + from: string | null; + to: string; +} + /** * 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. + * new status has no project-status mapping, or when the change is a reopen + * (previous status terminal — reopening never cascades, spec 2026-07-04). + * Accepts a Prisma client (the tx client inside a transaction, or the base + * client otherwise) so both update branches share one implementation. + * Returns the projects it actually changed so the route can audit them. */ async function syncProjectStatus( client: Prisma.TransactionClient, orderId: number, + previousStatus: string, newStatus: string, -): Promise { +): Promise { + if (TERMINAL_ORDER_STATUSES.includes(previousStatus)) return []; const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus]; - if (!projectStatus) return; - await client.projects.updateMany({ + if (!projectStatus) return []; + const linked = await client.projects.findMany({ where: { order_id: orderId }, + select: { id: true, project_number: true, status: true }, + }); + // Filter in JS (not via `status: { not: … }`) so NULL-status projects are + // still picked up — SQL `<>` would exclude them. + const changed = linked.filter((p) => p.status !== projectStatus); + if (changed.length === 0) return []; + await client.projects.updateMany({ + where: { id: { in: changed.map((p) => p.id) } }, data: { status: projectStatus }, }); + return changed.map((p) => ({ + id: p.id, + project_number: p.project_number, + from: p.status, + to: projectStatus, + })); } // ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item @@ -640,6 +671,10 @@ export async function updateOrder(id: number, body: UpdateOrderData) { } } + // Projects cascaded onto by a status change — surfaced to the route for + // per-project audit rows. + let syncedProjects: SyncedProject[] = []; + const data: Record = { modified_at: new Date() }; const strFields = [ "customer_order_number", @@ -678,7 +713,12 @@ export async function updateOrder(id: number, body: UpdateOrderData) { // Sync project status when order status changes (matching PHP) if (body.status !== undefined && String(body.status) !== currentStatus) { - await syncProjectStatus(tx, id, String(body.status)); + syncedProjects = await syncProjectStatus( + tx, + id, + currentStatus, + String(body.status), + ); } if (Array.isArray(body.items)) { @@ -714,11 +754,19 @@ export async function updateOrder(id: number, body: UpdateOrderData) { // Sync project status when order status changes (matching PHP) if (body.status !== undefined && String(body.status) !== currentStatus) { - await syncProjectStatus(prisma, id, String(body.status)); + syncedProjects = await syncProjectStatus( + prisma, + id, + currentStatus, + String(body.status), + ); } } - return { data: { id, order_number: existing.order_number } }; + return { + data: { id, order_number: existing.order_number }, + synced_projects: syncedProjects, + }; } export async function deleteOrder(id: number, deleteFiles = false) { diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index c2f695e..644e409 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -6,6 +6,7 @@ import { } from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; import type { CreateProjectInput } from "../schemas/projects.schema"; +import type { projects } from "../types"; const nasFileManager = new NasFileManager(); @@ -17,6 +18,29 @@ const ALLOWED_SORT_FIELDS = [ "created_at", ]; +// Status transition rules (spec 2026-07-04 — projects gain a status machine; +// dokonceny/zruseny → aktivni is a deliberate reopen edge). +export const VALID_TRANSITIONS: Record = { + aktivni: ["dokonceny", "zruseny"], + dokonceny: ["aktivni"], + zruseny: ["aktivni"], +}; + +const CANONICAL_STATUSES = Object.keys(VALID_TRANSITIONS); + +/** + * Legal next statuses for a project. The status column is a legacy free-text + * string, so an unknown/legacy current status may move to any canonical value + * (minus itself) — tolerant, matching the updateProject validation. + */ +function validTransitionsFor(status: string | null): string[] { + const current = status ?? ""; + if (Object.prototype.hasOwnProperty.call(VALID_TRANSITIONS, current)) { + return VALID_TRANSITIONS[current]; + } + return CANONICAL_STATUSES.filter((s) => s !== current); +} + interface ListProjectsParams { page: number; limit: number; @@ -91,13 +115,39 @@ export async function getProject(id: number) { order_number: orders?.order_number ?? null, order_status: orders?.status ?? null, quotation_number: quotations?.quotation_number ?? null, + // Computed server-side so the frontend renders only legal status buttons + // (same contract as offers/orders/issued orders). + valid_transitions: validTransitionsFor(project.status), has_nas_folder: project.project_number ? nasFileManager.projectFolderExists(project.project_number) : false, }; } -export async function updateProject(id: number, body: Record) { +/** Order change performed by the project→order cascade, returned for auditing. */ +export interface SyncedOrder { + id: number; + order_number: string | null; + from: string; + to: string; +} + +// Explicit union — inference would normalize the members with `?: undefined` +// props (and narrow synced_order to `null`, its value at the return +// statement), breaking the route's `"error" in result` narrowing. +type UpdateProjectResult = + | null + | { error: string; status: number } + | { error: "invalid_transition"; currentStatus: string; newStatus: string } + | (projects & { + old_status: string | null; + synced_order: SyncedOrder | null; + }); + +export async function updateProject( + id: number, + body: Record, +): Promise { const existing = await prisma.projects.findUnique({ where: { id } }); if (!existing) return null; @@ -108,6 +158,25 @@ export async function updateProject(id: number, body: Record) { return { error: "Číslo projektu nelze změnit", status: 400 }; } + // Status changes must follow the transition table (offers/orders parity). + // Legacy tolerance: the column is historic free text, so an unknown current + // status may move to any canonical value. + const currentStatus = existing.status ?? ""; + const statusChanges = + body.status !== undefined && String(body.status) !== currentStatus; + const newStatus = statusChanges ? String(body.status) : null; + if (statusChanges && newStatus !== null) { + const allowed = Object.prototype.hasOwnProperty.call( + VALID_TRANSITIONS, + currentStatus, + ) + ? VALID_TRANSITIONS[currentStatus] + : CANONICAL_STATUSES; + if (!allowed.includes(newStatus)) { + return { error: "invalid_transition" as const, currentStatus, newStatus }; + } + } + // FK pre-validation (mirrors createProject): a dangling id would raise // P2003 at Prisma and surface as a generic 500 — return the same Czech // 400s as the create path. null still clears the FK. @@ -164,7 +233,45 @@ export async function updateProject(id: number, body: Record) { if (body.end_date !== undefined) data.end_date = body.end_date ? new Date(String(body.end_date)) : null; - const updated = await prisma.projects.update({ where: { id }, data }); + // Project → order cascade (spec 2026-07-04): finishing/cancelling a project + // completes/cancels its linked order iff the order is still open + // (prijata/v_realizaci). Reopen (→aktivni) never touches the order, and + // completed/cancelled orders are never resurrected. Direct tx write — no + // updateOrder recursion — in the SAME transaction as the project update. + let syncedOrder: SyncedOrder | null = null; + + const wantsCascade = + statusChanges && + (newStatus === "dokonceny" || newStatus === "zruseny") && + existing.order_id != null; + + const updated = wantsCascade + ? await prisma.$transaction(async (tx) => { + const row = await tx.projects.update({ where: { id }, data }); + const order = await tx.orders.findUnique({ + where: { id: existing.order_id! }, + select: { id: true, order_number: true, status: true }, + }); + const orderStatus = order?.status ?? ""; + if ( + order && + (orderStatus === "prijata" || orderStatus === "v_realizaci") + ) { + const to = newStatus === "dokonceny" ? "dokoncena" : "stornovana"; + await tx.orders.update({ + where: { id: order.id }, + data: { status: to, modified_at: new Date() }, + }); + syncedOrder = { + id: order.id, + order_number: order.order_number, + from: orderStatus, + to, + }; + } + return row; + }) + : await prisma.projects.update({ where: { id }, data }); if ( body.name !== undefined && @@ -184,7 +291,9 @@ export async function updateProject(id: number, body: Record) { } } - return updated; + // old_status + synced_order let the route audit the status change and the + // cascaded order update. + return { ...updated, old_status: existing.status, synced_order: syncedOrder }; } /**