Files
app/docs/superpowers/specs/2026-06-06-manual-project-order-creation-design.md
BOHA 674b44d047 feat(odin): Phase 2a read-only agentic assistant + tool-trace UI
- 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>
2026-06-10 23:31:50 +02:00

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)