From 0c5d0ee2f774cbca428787f892c79893557e66a8 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sat, 6 Jun 2026 11:01:10 +0200 Subject: [PATCH] feat(projects): add createProject service (manual, shared numbering) Introduces CreateProjectSchema (Zod 4, NaN-guarded coercions), the createProject() service function (atomic shared-number generation inside a $transaction), getNextProjectNumber() preview helper, and two Vitest tests that verify standalone project creation and distinct sequential numbers. Routes are deferred to a subsequent task. Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/manual-create.test.ts | 42 ++++++++++++++++++++ src/schemas/projects.schema.ts | 23 +++++++++++ src/services/projects.service.ts | 61 ++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/manual-create.test.ts diff --git a/src/__tests__/manual-create.test.ts b/src/__tests__/manual-create.test.ts new file mode 100644 index 0000000..de03fae --- /dev/null +++ b/src/__tests__/manual-create.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from "vitest"; +import prisma from "../config/database"; +import { createProject } from "../services/projects.service"; + +const createdProjectIds: number[] = []; +const createdOrderIds: number[] = []; + +afterEach(async () => { + for (const id of createdProjectIds) + await prisma.projects.deleteMany({ where: { id } }); + for (const id of createdOrderIds) + await prisma.orders.deleteMany({ where: { id } }); + createdProjectIds.length = 0; + createdOrderIds.length = 0; + await prisma.number_sequences.deleteMany({ where: { type: "shared" } }); +}); + +describe("createProject (manual, standalone)", () => { + it("assigns a shared number and is not linked to an order", async () => { + const result = await createProject({ + name: "Test projekt", + status: "aktivni", + }); + expect("project_number" in result).toBe(true); + if (!("project_number" in result)) return; + createdProjectIds.push(result.id); + expect(typeof result.project_number).toBe("string"); + expect(result.project_number!.length).toBeGreaterThan(0); + expect(result.order_id).toBeNull(); + }); + + it("gives consecutive standalone projects distinct numbers", async () => { + const a = await createProject({ name: "Projekt A", status: "aktivni" }); + const b = await createProject({ name: "Projekt B", status: "aktivni" }); + if ("project_number" in a) createdProjectIds.push(a.id); + if ("project_number" in b) createdProjectIds.push(b.id); + expect("project_number" in a && "project_number" in b).toBe(true); + if ("project_number" in a && "project_number" in b) { + expect(a.project_number).not.toBe(b.project_number); + } + }); +}); diff --git a/src/schemas/projects.schema.ts b/src/schemas/projects.schema.ts index 04ee1bb..eeeb522 100644 --- a/src/schemas/projects.schema.ts +++ b/src/schemas/projects.schema.ts @@ -1,5 +1,27 @@ import { z } from "zod"; +export const CreateProjectSchema = z.object({ + name: z.string().min(1, "Zadejte název projektu"), + customer_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null || v === "" ? null : Number(v))) + .refine((v) => v === null || !Number.isNaN(v), { + message: "Neplatný zákazník", + }) + .optional(), + responsible_user_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null || v === "" ? null : Number(v))) + .refine((v) => v === null || !Number.isNaN(v), { + message: "Neplatná zodpovědná osoba", + }) + .optional(), + status: z.string().optional().default("aktivni"), + start_date: z.union([z.string(), z.null()]).optional(), + end_date: z.union([z.string(), z.null()]).optional(), + notes: z.string().nullish(), +}); + export const UpdateProjectSchema = z.object({ name: z.string().nullish(), status: z.string().optional(), @@ -28,5 +50,6 @@ export const CreateProjectNoteSchema = z.object({ content: z.string().nullish(), }); +export type CreateProjectInput = z.infer; export type UpdateProjectInput = z.infer; export type CreateProjectNoteInput = z.infer; diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts index 530512f..33fbcf1 100644 --- a/src/services/projects.service.ts +++ b/src/services/projects.service.ts @@ -1,6 +1,11 @@ import prisma from "../config/database"; -import { releaseSharedNumber } from "./numbering.service"; +import { + generateSharedNumber, + previewSharedNumber, + releaseSharedNumber, +} from "./numbering.service"; import { NasFileManager } from "./nas-file-manager"; +import type { CreateProjectInput } from "../schemas/projects.schema"; const nasFileManager = new NasFileManager(); @@ -144,6 +149,60 @@ export async function updateProject(id: number, body: Record) { return updated; } +/** + * Create a standalone (manual) project. Takes the next SHARED number — the + * same pool orders/projects draw from — generated atomically inside the + * transaction (SELECT … FOR UPDATE via getNextSequence). order_id stays null, + * which is what marks it "samostatný" in the UI. NAS folder creation is + * best-effort (the manager self-guards isConfigured() and swallows fs errors). + */ +export async function createProject(data: CreateProjectInput) { + if (data.customer_id != null) { + const customer = await prisma.customers.findUnique({ + where: { id: data.customer_id }, + }); + if (!customer) return { error: "Zákazník nenalezen", status: 400 }; + } + if (data.responsible_user_id != null) { + const user = await prisma.users.findUnique({ + where: { id: data.responsible_user_id }, + }); + if (!user) return { error: "Zodpovědná osoba nenalezena", status: 400 }; + } + + const project = await prisma.$transaction(async (tx) => { + const project_number = await generateSharedNumber(tx); + return tx.projects.create({ + data: { + project_number, + name: data.name, + customer_id: data.customer_id ?? null, + responsible_user_id: data.responsible_user_id ?? null, + status: data.status || "aktivni", + start_date: data.start_date ? new Date(data.start_date) : null, + end_date: data.end_date ? new Date(data.end_date) : null, + notes: data.notes ?? null, + order_id: null, + quotation_id: null, + }, + }); + }); + + if (project.project_number && nasFileManager.isConfigured()) { + nasFileManager.createProjectFolder( + project.project_number, + project.name || "", + ); + } + + return project; +} + +/** Preview the next shared number for the create form (does NOT consume it). */ +export async function getNextProjectNumber(): Promise { + return previewSharedNumber(); +} + export async function deleteProject(id: number, deleteFiles: boolean = false) { const existing = await prisma.projects.findUnique({ where: { id } }); if (!existing) return { error: "not_found" as const };