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();
|
||||
});
|
||||
|
||||
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}` };
|
||||
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user