- agentic chat loop (max 6 round-trips, budget re-checked between iterations) over 10 read-only, permission-delegated tools in src/services/ai-tools.ts - persist assistant tool trace in ai_chat_messages.content_json (+ migration) - consulted-tools chips in OdinThread; header now shows month spend only (budget cap hidden from the UI, server-side 402 guard unchanged) - seed: ai.use permission; Settings exposes the ai permission module - docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 KiB
10 KiB
Manual project & order creation — design
Date: 2026-06-06 Status: approved (pending spec review) Author: Claude + BOHA
Problem
Today both entities are only ever derived from a parent:
- A project is created solely as a side-effect of
createOrderFromQuotation(orders.service.ts). There is noPOST /projects, nocreateProjectservice, noCreateProjectSchema. A manual-create feature existed but was removed in commit82919d3(2026-04-28) — the note: "Projects can only be created through orders (shared numbering sequence)." The old code pulledproject_numberfrom the shared order/project pool, which consumed an order number and left gaps in order numbering. That is the bug we must not repeat. - An order is normally created from an offer. A manual
createOrder(orders.service.ts, offer-less) already exists and the route already dispatches to it, but there is no UI, and it does not create a project.
We want: (1) manually create projects on /projects, (2) manually create
orders without an offer on /orders.
Decisions (locked with the user)
- Project numbering: shared pool, with proper tracking. Standalone
projects take the next shared number via
generateSharedNumber()— the same pool as orders — but we add the tracking/transparency that was missing before (see "Tracking" below). Auto-assigned; no manual number entry. - Manual order → project: optional checkbox, default ON. The order form has a pre-checked "Vytvořit propojený projekt"; when set, the order and its project share the order number (identical to the from-offer flow → no gap).
- Order form scope: header only. Customer, customer order number, currency/VAT, scope title/description, notes. No line-item editor.
- Customer: optional on both forms (matches the existing nullable schema).
- Match existing house design —
FormModal+FormField, theVehicles.tsxcreate/edit-modal pattern, theOffersCustomers.tsxheader-button + customer-selector pattern, existing list/badge styling.
Why the shared pool is safe here (the tracking)
The shared sequence is effectively a single "zakázkové číslo" pool. An order and its project share one number; a standalone project simply takes the next number in that pool and has no order. The mechanics that make this coherent:
- Collision-safe:
isSharedNumberTaken()already checks bothorders.order_numberandprojects.project_number, so the same number can never be issued twice across the pool (numbering.service.ts:161-167). - Atomic:
createProjectwrapsgenerateSharedNumber(tx)inprisma.$transaction— the existingSELECT … FOR UPDATElock (getNextSequence) serialises concurrent consumers. - Release on delete:
deleteProjectalready callsreleaseSharedNumber, which decrements the sequence only if the deleted number is the current highest — so deleting a standalone project doesn't leave a permanent tail-gap (numbering.service.ts:116-158, 319-328). - Audit trail:
logAuditon create/delete records the number innewValues/oldValues, so every consumed pool number has a "who/when/what" trail showing it went to a project (not a lost order). - UI transparency: the Projects list shows a badge — "z objednávky"
(order-linked,
order_id != null) vs "samostatný" (standalone) — so a pool number with no order reads as intentional. The create form previews the number it is about to assign (previewSharedNumber()).
Part A — Manual projects
Backend
src/schemas/projects.schema.ts— addCreateProjectSchema(z.strictObject, shared coercers fromschemas/common.ts, Czech messages):name— required,z.string().min(1).customer_id— optional number (coerced, NaN-guarded).responsible_user_id— optional number.status— optional, default"aktivni".start_date,end_date— optional date strings.notes— optional string.- No
project_numberfield (auto-assigned from the pool).
src/services/projects.service.ts— addcreateProject(data):prisma.$transaction(async (tx) => …):project_number = await generateSharedNumber(tx);tx.projects.create({ … order_id: null, quotation_id: null, status: data.status ?? "aktivni", … }).- Validate
customer_id/responsible_user_idexist (when provided) → return{ error, status: 400 }(Czech) if not. - After commit:
nasFileManager.createProjectFolder(number, name)whenisConfigured()— non-fatal, logged on failure (mirrors the old code). - Return
{ data: project }(or{ error, status }).
src/routes/admin/projects.ts:POST /guarded byrequirePermission("projects.create")→parseBody(CreateProjectSchema, …)→createProject→logAudit(action: "create",entityType: "project",newValues) →success(reply, project, 201, "Projekt vytvořen").GET /next-numberguarded byprojects.create→previewSharedNumber()→success(reply, { number }). (Preview only; does not consume.)
Frontend (src/admin/pages/Projects.tsx + new modal)
- Header "Přidat projekt" button, gated on
hasPermission("projects.create"), matching theOffersCustomers.tsx"Přidat zákazníka" header-button pattern. - A project create
FormModal(house pattern fromVehicles.tsx):FormFieldrows for name (required), customer (select from customers list), responsible user (select fromuserListOptions), status, start/end dates, notes, plus a read-only "Číslo projektu:<preview>(přiděleno automaticky)" line fetched fromGET /projects/next-numberwhen the modal opens. useApiMutationPOST/api/admin/projects; on success invalidate["projects"](broad domain key, per the invalidation convention) and close modal; surface server errors viauseAlert.- List: add the "z objednávky" / "samostatný" badge (derive from
order_id). Update the empty-state text (currently "Projekt se vytvoří automaticky při vytvoření objednávky") to mention manual creation.
Part B — Manual orders (header only, optional project)
Backend
src/schemas/orders.schema.ts— addcreate_projecttoCreateOrderSchema: boolean via the existingpreprocess(v => v === true || v === 1 || v === "1")idiom,.optional().default(true). (Only the manual path uses this schema; the from-quotation path usesCreateOrderFromQuotationSchema.)src/services/orders.service.tscreateOrder— inside the existing transaction, after the order is created, whenbody.create_projectis true and there is no quotation link:tx.projects.create({ project_number: orderNumber, order_id: order.id, customer_id: order.customer_id, name: scope_title ?? customer_order_number ?? orderNumber, status: "aktivni" })— mirrorscreateOrderFromQuotation's project block, so order + project share the number. Return the project id so the route can audit it.src/routes/admin/orders.ts— the manualPOST /branch already exists (orders.create); add a secondlogAuditfor the created project when present.
Frontend (src/admin/pages/Orders.tsx + new modal)
- Header "Vytvořit objednávku" button, gated on
orders.create. - Header-only
FormModal: customer (select), customer order number, currency, VAT rate + apply-VAT, language, scope title, scope description, notes, and a pre-checked "Vytvořit propojený projekt" checkbox. Read-only next order-number preview via the existingGET /api/admin/orders/next-number. - POST
/api/admin/orders(noquotationId); on success invalidate["orders"]and["projects"]; errors viauseAlert.
Part C — Permissions
projects.create was removed from the permission set and must come back the
migration way (per CLAUDE.md — never seed-only for prod data):
- New migration
prisma/migrations/<ts>_add_projects_create_permission/thatINSERTs theprojects.createpermission row and grants it to the admin role (mirror20260603230000_add_warehouse_permissions/migration.sql). UseINSERT … ON DUPLICATE KEY UPDATE/INSERT IGNOREstyle so re-runs are safe. - Add
projects.createto thePERMISSIONSarray inprisma/seed.ts(dev). orders.createalready exists — no change.
Part D — Tests (src/__tests__/)
Real-DB Vitest (no Prisma mocks), following numbering.test.ts style:
createProjectassigns the next shared number, increments thesharedsequence by exactly 1, setsorder_id = null, writes an audit row.createOrderwithcreate_project: truecreates an order and a project sharing oneorder_number/project_number.- Interleaved coherence: order → standalone project → order produces three consecutive shared numbers with no duplicates (the tracking guarantee).
createOrderwithcreate_project: falsecreates only the order.
Error handling
- Service returns
{ error, status }(Czech) for bad customer/user FK and numbering exhaustion (thegenerateSharedNumber100-retry throw → mapped to a 500 with a Czech message by the route, logged viaapp.log.error). - NAS folder creation is non-fatal and logged (never blocks creation).
Out of scope (v1) — noted for later
- Editing order line-items after creation.
- Attaching a standalone project to an order later (conflicts with current
order_idimmutability + thehas_orderdelete guard). - Manual project-number override (user chose auto-from-pool).
- A unified order+project number lookup/search.
Affected files (summary)
src/schemas/projects.schema.ts(addCreateProjectSchema)src/schemas/orders.schema.ts(addcreate_project)src/services/projects.service.ts(addcreateProject)src/services/orders.service.ts(createOrderoptional project)src/routes/admin/projects.ts(POST /,GET /next-number)src/routes/admin/orders.ts(audit project on manual order)src/admin/pages/Projects.tsx(+ create modal, badge, empty-state)src/admin/pages/Orders.tsx(+ create modal)src/admin/lib/queries/projects.ts,orders.ts(mutations/preview queries)prisma/migrations/<ts>_add_projects_create_permission/migration.sqlprisma/seed.ts(+projects.create)src/__tests__/manual-create.test.ts(new)