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 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 11:01:10 +02:00
parent fdf180809d
commit 0c5d0ee2f7
3 changed files with 125 additions and 1 deletions

View File

@@ -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);
}
});
});

View File

@@ -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<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type CreateProjectNoteInput = z.infer<typeof CreateProjectNoteSchema>;

View File

@@ -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<string, any>) {
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<string> {
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 };