- 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>
192 lines
10 KiB
Markdown
192 lines
10 KiB
Markdown
# 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 **no** `POST /projects`, no `createProject`
|
|
service, no `CreateProjectSchema`. A manual-create feature existed but was
|
|
**removed in commit `82919d3`** (2026-04-28) — the note: _"Projects can only
|
|
be created through orders (shared numbering sequence)."_ The old code pulled
|
|
`project_number` from 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)
|
|
|
|
1. **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.
|
|
2. **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).
|
|
3. **Order form scope: header only.** Customer, customer order number,
|
|
currency/VAT, scope title/description, notes. No line-item editor.
|
|
4. **Customer: optional** on both forms (matches the existing nullable schema).
|
|
5. **Match existing house design** — `FormModal` + `FormField`, the
|
|
`Vehicles.tsx` create/edit-modal pattern, the `OffersCustomers.tsx`
|
|
header-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 **both**
|
|
`orders.order_number` and `projects.project_number`, so the same number can
|
|
never be issued twice across the pool (`numbering.service.ts:161-167`).
|
|
- **Atomic:** `createProject` wraps `generateSharedNumber(tx)` in
|
|
`prisma.$transaction` — the existing `SELECT … FOR UPDATE` lock
|
|
(`getNextSequence`) serialises concurrent consumers.
|
|
- **Release on delete:** `deleteProject` already calls `releaseSharedNumber`,
|
|
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:** `logAudit` on create/delete records the number in
|
|
`newValues`/`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`** — add `CreateProjectSchema`
|
|
(`z.strictObject`, shared coercers from `schemas/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_number` field (auto-assigned from the pool).
|
|
- **`src/services/projects.service.ts`** — add `createProject(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_id` exist (when provided) →
|
|
return `{ error, status: 400 }` (Czech) if not.
|
|
- After commit: `nasFileManager.createProjectFolder(number, name)` when
|
|
`isConfigured()` — non-fatal, logged on failure (mirrors the old code).
|
|
- Return `{ data: project }` (or `{ error, status }`).
|
|
- **`src/routes/admin/projects.ts`**:
|
|
- `POST /` guarded by `requirePermission("projects.create")` →
|
|
`parseBody(CreateProjectSchema, …)` → `createProject` → `logAudit`
|
|
(`action: "create"`, `entityType: "project"`, `newValues`) →
|
|
`success(reply, project, 201, "Projekt vytvořen")`.
|
|
- `GET /next-number` guarded by `projects.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 the `OffersCustomers.tsx` "Přidat zákazníka" header-button pattern.
|
|
- A project create `FormModal` (house pattern from `Vehicles.tsx`): `FormField`
|
|
rows for name (required), customer (select from customers list), responsible
|
|
user (select from `userListOptions`), status, start/end dates, notes, plus a
|
|
read-only "Číslo projektu: `<preview>` (přiděleno automaticky)" line fetched
|
|
from `GET /projects/next-number` when the modal opens.
|
|
- `useApiMutation` POST `/api/admin/projects`; on success invalidate
|
|
**`["projects"]`** (broad domain key, per the invalidation convention) and
|
|
close modal; surface server errors via `useAlert`.
|
|
- 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`** — add `create_project` to
|
|
`CreateOrderSchema`: boolean via the existing `preprocess(v => v === true || v
|
|
=== 1 || v === "1")` idiom, `.optional().default(true)`. (Only the manual path
|
|
uses this schema; the from-quotation path uses `CreateOrderFromQuotationSchema`.)
|
|
- **`src/services/orders.service.ts` `createOrder`** — inside the existing
|
|
transaction, after the order is created, when `body.create_project` is 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" })` — mirrors
|
|
`createOrderFromQuotation`'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 manual `POST /` branch already exists
|
|
(`orders.create`); add a second `logAudit` for 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 existing `GET /api/admin/orders/next-number`.
|
|
- POST `/api/admin/orders` (no `quotationId`); on success invalidate
|
|
**`["orders"]`** and **`["projects"]`**; errors via `useAlert`.
|
|
|
|
## 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/` that
|
|
`INSERT`s the `projects.create` permission row and grants it to the **admin**
|
|
role (mirror `20260603230000_add_warehouse_permissions/migration.sql`). Use
|
|
`INSERT … ON DUPLICATE KEY UPDATE` / `INSERT IGNORE` style so re-runs are safe.
|
|
- Add `projects.create` to the `PERMISSIONS` array in `prisma/seed.ts` (dev).
|
|
- `orders.create` already exists — no change.
|
|
|
|
## Part D — Tests (`src/__tests__/`)
|
|
|
|
Real-DB Vitest (no Prisma mocks), following `numbering.test.ts` style:
|
|
|
|
- `createProject` assigns the next shared number, increments the `shared`
|
|
sequence by exactly 1, sets `order_id = null`, writes an audit row.
|
|
- `createOrder` with `create_project: true` creates an order **and** a project
|
|
sharing one `order_number`/`project_number`.
|
|
- Interleaved coherence: order → standalone project → order produces three
|
|
consecutive shared numbers with no duplicates (the tracking guarantee).
|
|
- `createOrder` with `create_project: false` creates only the order.
|
|
|
|
## Error handling
|
|
|
|
- Service returns `{ error, status }` (Czech) for bad customer/user FK and
|
|
numbering exhaustion (the `generateSharedNumber` 100-retry throw → mapped to a
|
|
500 with a Czech message by the route, logged via `app.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_id` immutability + the `has_order` delete 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` (add `CreateProjectSchema`)
|
|
- `src/schemas/orders.schema.ts` (add `create_project`)
|
|
- `src/services/projects.service.ts` (add `createProject`)
|
|
- `src/services/orders.service.ts` (`createOrder` optional 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.sql`
|
|
- `prisma/seed.ts` (+ `projects.create`)
|
|
- `src/__tests__/manual-create.test.ts` (new)
|