From f6cdfb9801015826363ca64a8f31422aa1ccae92 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 6 Jun 2026 11:06:25 +0200 Subject: [PATCH] feat(orders): manual order optionally creates a linked project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `create_project` flag (default true) to `CreateOrderSchema` and `CreateOrderData`. When true and `quotation_id` is absent, `createOrder` creates a `projects` row sharing the order's shared-pool number and `order_id` link — mirroring the from-quotation flow. The route audits the new project creation when it occurs. Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/manual-create.test.ts | 57 +++++++++++++++++++++++++++++ src/routes/admin/orders.ts | 10 +++++ src/schemas/orders.schema.ts | 4 ++ src/services/orders.service.ts | 23 +++++++++++- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/__tests__/manual-create.test.ts b/src/__tests__/manual-create.test.ts index de03fae..35a7820 100644 --- a/src/__tests__/manual-create.test.ts +++ b/src/__tests__/manual-create.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach } from "vitest"; import prisma from "../config/database"; import { createProject } from "../services/projects.service"; +import { createOrder } from "../services/orders.service"; const createdProjectIds: number[] = []; const createdOrderIds: number[] = []; @@ -15,6 +16,62 @@ afterEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); }); +const baseOrder = { + status: "prijata" as const, + currency: "CZK", + language: "cs", + vat_rate: 21, + apply_vat: true, + exchange_rate: 1, +}; + +describe("createOrder (manual, optional linked project)", () => { + it("creates an order AND a project sharing one number when create_project=true", async () => { + const res = await createOrder({ + ...baseOrder, + scope_title: "Ruční zakázka", + create_project: true, + }); + expect("id" in res).toBe(true); + if (!("id" in res)) return; + createdOrderIds.push(res.id); + expect(res.project_id).toBeTruthy(); + const project = await prisma.projects.findUnique({ + where: { id: res.project_id! }, + }); + if (project) createdProjectIds.push(project.id); + expect(project?.project_number).toBe(res.order_number); + expect(project?.order_id).toBe(res.id); + }); + + it("creates only the order when create_project=false", async () => { + const res = await createOrder({ ...baseOrder, create_project: false }); + expect("id" in res).toBe(true); + if (!("id" in res)) return; + createdOrderIds.push(res.id); + expect(res.project_id).toBeUndefined(); + }); +}); + +describe("shared pool coherence (tracking)", () => { + it("order → standalone project → order produce three distinct shared numbers", async () => { + const o1 = await createOrder({ ...baseOrder, create_project: true }); + if ("id" in o1) { + createdOrderIds.push(o1.id); + if (o1.project_id) createdProjectIds.push(o1.project_id); + } + const p = await createProject({ name: "Mezi-projekt", status: "aktivni" }); + if ("project_number" in p) createdProjectIds.push(p.id); + const o2 = await createOrder({ ...baseOrder, create_project: false }); + if ("id" in o2) createdOrderIds.push(o2.id); + + const n1 = "id" in o1 ? o1.order_number : null; + const np = "project_number" in p ? p.project_number : null; + const n2 = "id" in o2 ? o2.order_number : null; + expect(new Set([n1, np, n2]).size).toBe(3); + }); +}); + describe("createProject (manual, standalone)", () => { it("assigns a shared number and is not linked to an order", async () => { const result = await createProject({ diff --git a/src/routes/admin/orders.ts b/src/routes/admin/orders.ts index 75e36d2..2a35e7b 100644 --- a/src/routes/admin/orders.ts +++ b/src/routes/admin/orders.ts @@ -216,6 +216,16 @@ export default async function ordersRoutes( entityId: result.id, description: `Vytvořena objednávka ${result.order_number}`, }); + if (result.project_id) { + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "project", + entityId: result.project_id, + description: `Vytvořen projekt k objednávce ${result.order_number}`, + }); + } return success( reply, { id: result.id }, diff --git a/src/schemas/orders.schema.ts b/src/schemas/orders.schema.ts index 0f676f2..6134a6b 100644 --- a/src/schemas/orders.schema.ts +++ b/src/schemas/orders.schema.ts @@ -86,6 +86,10 @@ export const CreateOrderSchema = z.object({ notes: z.string().nullish(), items: z.array(OrderItemSchema).optional(), sections: z.array(OrderSectionSchema).optional(), + create_project: z + .preprocess((v) => v === true || v === 1 || v === "1", z.boolean()) + .optional() + .default(true), }); export const UpdateOrderSchema = z.object({ diff --git a/src/services/orders.service.ts b/src/services/orders.service.ts index 1e3c440..8c07e9e 100644 --- a/src/services/orders.service.ts +++ b/src/services/orders.service.ts @@ -288,6 +288,7 @@ interface CreateOrderData { notes?: string | null; items?: OrderItemInput[]; sections?: OrderSectionInput[]; + create_project?: boolean; } export async function createOrder(body: CreateOrderData) { @@ -352,7 +353,27 @@ export async function createOrder(body: CreateOrderData) { }); } - return { id: order.id, order_number: order.order_number }; + let projectId: number | undefined; + // Only auto-create a project for genuine offer-less orders; share the + // order's number (same shared pool) exactly like the from-quotation flow. + if (body.create_project !== false && body.quotation_id == null) { + const project = await tx.projects.create({ + data: { + project_number: orderNumber, + name: body.scope_title || body.customer_order_number || orderNumber, + customer_id: order.customer_id, + order_id: order.id, + status: "aktivni", + }, + }); + projectId = project.id; + } + + return { + id: order.id, + order_number: order.order_number, + project_id: projectId, + }; }); } catch (err) { if (err instanceof Error && "status" in err) {