feat(projects/orders): project status machine + bidirectional project-order sync; order reopen
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,31 @@ describe("order attachment payload hygiene", () => {
|
|||||||
expect(withoutDetail!.attachment_name).toBeNull();
|
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 () => {
|
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
|
||||||
const { withId, withoutId } = await makeOrderPair();
|
const { withId, withoutId } = await makeOrderPair();
|
||||||
const headers = { authorization: `Bearer ${adminToken}` };
|
const headers = { authorization: `Bearer ${adminToken}` };
|
||||||
|
|||||||
301
src/__tests__/project-order-status-sync.test.ts
Normal file
301
src/__tests__/project-order-status-sync.test.ts
Normal file
@@ -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<T>(
|
||||||
|
res: T,
|
||||||
|
): asserts res is Exclude<T, null | { error: unknown }> {
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -331,6 +331,26 @@ export default async function ordersRoutes(
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
description: `Upravena objednávka ${result.data.order_number}`,
|
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");
|
return success(reply, { id }, 200, "Objednávka byla uložena");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -131,6 +131,12 @@ export default async function projectsRoutes(
|
|||||||
const result = await updateProject(id, parsed.data);
|
const result = await updateProject(id, parsed.data);
|
||||||
if (!result) return error(reply, "Projekt nenalezen", 404);
|
if (!result) return error(reply, "Projekt nenalezen", 404);
|
||||||
if ("error" in result) {
|
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(
|
return error(
|
||||||
reply,
|
reply,
|
||||||
result.error,
|
result.error,
|
||||||
@@ -138,6 +144,7 @@ export default async function projectsRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusChanged = result.old_status !== result.status;
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
@@ -145,7 +152,26 @@ export default async function projectsRoutes(
|
|||||||
entityType: "project",
|
entityType: "project",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
description: `Upraven projekt ${result.name}`,
|
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");
|
return success(reply, { id }, 200, "Projekt byl uložen");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const CreateOrderSchema = z.object({
|
|||||||
quotation_id: nullableIntIdFromForm.nullish(),
|
quotation_id: nullableIntIdFromForm.nullish(),
|
||||||
customer_id: nullableIntIdFromForm.nullish(),
|
customer_id: nullableIntIdFromForm.nullish(),
|
||||||
status: z
|
status: z
|
||||||
.enum(["prijata", "v_realizaci", "dokoncena", "zrusena"])
|
.enum(["prijata", "v_realizaci", "dokoncena", "stornovana"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("prijata"),
|
.default("prijata"),
|
||||||
currency: z.string().max(10).optional().default("CZK"),
|
currency: z.string().max(10).optional().default("CZK"),
|
||||||
@@ -52,7 +52,9 @@ export const CreateOrderSchema = z.object({
|
|||||||
|
|
||||||
export const UpdateOrderSchema = z.object({
|
export const UpdateOrderSchema = z.object({
|
||||||
customer_order_number: z.string().max(100).nullish(),
|
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(),
|
currency: z.string().max(10).optional(),
|
||||||
language: z.string().max(5).optional(),
|
language: z.string().max(5).optional(),
|
||||||
scope_title: z.string().max(255).nullish(),
|
scope_title: z.string().max(255).nullish(),
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ interface OrderSectionInput {
|
|||||||
position?: number;
|
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<string, string[]> = {
|
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
prijata: ["v_realizaci", "stornovana"],
|
prijata: ["v_realizaci", "stornovana"],
|
||||||
v_realizaci: ["dokoncena", "stornovana"],
|
v_realizaci: ["dokoncena", "stornovana"],
|
||||||
dokoncena: [],
|
dokoncena: ["v_realizaci"],
|
||||||
stornovana: [],
|
stornovana: ["v_realizaci"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ORDER_ALLOWED_SORT_FIELDS = [
|
const ORDER_ALLOWED_SORT_FIELDS = [
|
||||||
@@ -54,23 +55,53 @@ const ORDER_TO_PROJECT_STATUS: Record<string, string> = {
|
|||||||
stornovana: "zruseny",
|
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
|
* 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
|
* new status has no project-status mapping, or when the change is a reopen
|
||||||
* client inside a transaction, or the base client otherwise) so both update
|
* (previous status terminal — reopening never cascades, spec 2026-07-04).
|
||||||
* branches share one implementation.
|
* 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(
|
async function syncProjectStatus(
|
||||||
client: Prisma.TransactionClient,
|
client: Prisma.TransactionClient,
|
||||||
orderId: number,
|
orderId: number,
|
||||||
|
previousStatus: string,
|
||||||
newStatus: string,
|
newStatus: string,
|
||||||
): Promise<void> {
|
): Promise<SyncedProject[]> {
|
||||||
|
if (TERMINAL_ORDER_STATUSES.includes(previousStatus)) return [];
|
||||||
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
|
const projectStatus = ORDER_TO_PROJECT_STATUS[newStatus];
|
||||||
if (!projectStatus) return;
|
if (!projectStatus) return [];
|
||||||
await client.projects.updateMany({
|
const linked = await client.projects.findMany({
|
||||||
where: { order_id: orderId },
|
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 },
|
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
|
// ⚠ 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<string, unknown> = { modified_at: new Date() };
|
const data: Record<string, unknown> = { modified_at: new Date() };
|
||||||
const strFields = [
|
const strFields = [
|
||||||
"customer_order_number",
|
"customer_order_number",
|
||||||
@@ -678,7 +713,12 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
|
|||||||
|
|
||||||
// Sync project status when order status changes (matching PHP)
|
// Sync project status when order status changes (matching PHP)
|
||||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
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)) {
|
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)
|
// Sync project status when order status changes (matching PHP)
|
||||||
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
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) {
|
export async function deleteOrder(id: number, deleteFiles = false) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "./numbering.service";
|
} from "./numbering.service";
|
||||||
import { NasFileManager } from "./nas-file-manager";
|
import { NasFileManager } from "./nas-file-manager";
|
||||||
import type { CreateProjectInput } from "../schemas/projects.schema";
|
import type { CreateProjectInput } from "../schemas/projects.schema";
|
||||||
|
import type { projects } from "../types";
|
||||||
|
|
||||||
const nasFileManager = new NasFileManager();
|
const nasFileManager = new NasFileManager();
|
||||||
|
|
||||||
@@ -17,6 +18,29 @@ const ALLOWED_SORT_FIELDS = [
|
|||||||
"created_at",
|
"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<string, string[]> = {
|
||||||
|
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 {
|
interface ListProjectsParams {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -91,13 +115,39 @@ export async function getProject(id: number) {
|
|||||||
order_number: orders?.order_number ?? null,
|
order_number: orders?.order_number ?? null,
|
||||||
order_status: orders?.status ?? null,
|
order_status: orders?.status ?? null,
|
||||||
quotation_number: quotations?.quotation_number ?? 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
|
has_nas_folder: project.project_number
|
||||||
? nasFileManager.projectFolderExists(project.project_number)
|
? nasFileManager.projectFolderExists(project.project_number)
|
||||||
: false,
|
: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProject(id: number, body: Record<string, unknown>) {
|
/** 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<string, unknown>,
|
||||||
|
): Promise<UpdateProjectResult> {
|
||||||
const existing = await prisma.projects.findUnique({ where: { id } });
|
const existing = await prisma.projects.findUnique({ where: { id } });
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
|
|
||||||
@@ -108,6 +158,25 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
|
|||||||
return { error: "Číslo projektu nelze změnit", status: 400 };
|
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
|
// FK pre-validation (mirrors createProject): a dangling id would raise
|
||||||
// P2003 at Prisma and surface as a generic 500 — return the same Czech
|
// P2003 at Prisma and surface as a generic 500 — return the same Czech
|
||||||
// 400s as the create path. null still clears the FK.
|
// 400s as the create path. null still clears the FK.
|
||||||
@@ -164,7 +233,45 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
|
|||||||
if (body.end_date !== undefined)
|
if (body.end_date !== undefined)
|
||||||
data.end_date = body.end_date ? new Date(String(body.end_date)) : null;
|
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 (
|
if (
|
||||||
body.name !== undefined &&
|
body.name !== undefined &&
|
||||||
@@ -184,7 +291,9 @@ export async function updateProject(id: number, body: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user