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:
42
src/__tests__/manual-create.test.ts
Normal file
42
src/__tests__/manual-create.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user