feat(projects): auto-create NAS folder for order-spawned projects

Manual project creation already creates the 02_PROJEKTY/<number>_<name>
folder (projects.service.createProject). Projects created from an order —
both from an accepted offer (createOrderFromQuotation) and a manual
offer-less order (createOrder) — did not, so they had no NAS folder until
a user opened the project and clicked "create folder" or uploaded a file.

- orders.service: best-effort, non-fatal createProjectFolder after each
  project-creating transaction commits (mirrors createProject; gated on
  isConfigured(), logs on failure, never rolls back the order).
- projects.service: log when the existing auto-create returns false
  (was silently discarded — CLAUDE.md known-issue #4).
- nas-file-manager: optional constructor basePath seam for tests.
- tests: NasFileManager.createProjectFolder coverage — naming, idempotency,
  Czech diacritics, empty name, not-mounted (152 -> 158 tests).

Bump version to 2.0.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-08 08:43:59 +02:00
parent 9c862034ca
commit df83eb2091
5 changed files with 137 additions and 5 deletions

View File

@@ -5,6 +5,11 @@ import {
releaseSharedNumber,
isOrderNumberTaken,
} from "./numbering.service";
import { NasFileManager } from "./nas-file-manager";
// Best-effort NAS folder creation for order-spawned projects, mirroring
// projects.service. Stateless singleton; reads NAS_PATH at construction.
const nasFileManager = new NasFileManager();
interface OrderItemInput {
description?: string | null;
@@ -262,6 +267,23 @@ export async function createOrderFromQuotation(
return { order, project, orderNumber };
});
// Best-effort NAS project folder, outside the transaction (mirrors
// createProject). A project created from an accepted offer must get the same
// 02_PROJEKTY/<number>_<name> folder as a manually-created one. Non-fatal: a
// NAS outage must never roll back the order.
if (result.project.project_number && nasFileManager.isConfigured()) {
const created = nasFileManager.createProjectFolder(
result.project.project_number,
result.project.name || "",
);
if (!created) {
console.error(
"[orders.service] NAS folder not created for project",
result.project.project_number,
);
}
}
return {
data: {
order_id: result.order.id,
@@ -297,7 +319,7 @@ export async function createOrder(
attachmentName?: string | null,
) {
try {
return await prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async (tx) => {
const orderNumber =
body.order_number !== undefined && body.order_number !== null
? String(body.order_number)
@@ -362,6 +384,8 @@ export async function createOrder(
}
let projectId: number | undefined;
let createdProject: { project_number: string; name: string } | null =
null;
// 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) {
@@ -375,14 +399,42 @@ export async function createOrder(
},
});
projectId = project.id;
if (project.project_number) {
createdProject = {
project_number: project.project_number,
name: project.name || "",
};
}
}
return {
id: order.id,
order_number: order.order_number,
project_id: projectId,
createdProject,
};
});
// Best-effort NAS project folder, outside the transaction (mirrors
// createProject). Non-fatal so a NAS outage can't roll back the order.
if (result.createdProject && nasFileManager.isConfigured()) {
const created = nasFileManager.createProjectFolder(
result.createdProject.project_number,
result.createdProject.name,
);
if (!created) {
console.error(
"[orders.service] NAS folder not created for project",
result.createdProject.project_number,
);
}
}
return {
id: result.id,
order_number: result.order_number,
project_id: result.project_id,
};
} catch (err) {
if (err instanceof Error && "status" in err) {
return {