- projects.service: VALID_TRANSITIONS (aktivni->dokonceny/zruseny, terminal->aktivni reopen), tolerant of legacy statuses; getProject returns valid_transitions; updateProject validates transitions (invalid_transition token -> Czech 400 in the route). - NEW project->order cascade, transactional with the project update: finishing/ cancelling a project completes/cancels its linked received order — only while the order is still open (never resurrects storno, never cancels completed); reopen never cascades. Direct tx write, no service recursion; cascaded order change gets its own audit row. - orders.service: reopen edges (dokoncena/stornovana -> v_realizaci); syncProjectStatus is reopen-aware (reopen skips project sync) and returns the cascaded projects for per-project audit rows in the route. - orders.schema: fix phantom 'zrusena' enum token -> canonical 'stornovana' (every storno PUT through the route 400ed with a raw English Zod message; latent until now because no UI sent it) + route-level regression test. - NEW project-order-status-sync.test.ts: 15 tests — both cascade directions, guards, reopen-no-cascade, legacy tolerance, forward-sync regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
73 lines
2.5 KiB
TypeScript
73 lines
2.5 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
numberFromForm,
|
|
nonNegativeNumberFromForm,
|
|
positiveNumberFromForm,
|
|
intIdFromForm,
|
|
nullableIntIdFromForm,
|
|
booleanFromForm,
|
|
} from "./common";
|
|
|
|
const OrderItemSchema = z.object({
|
|
description: z.string().max(500).nullish(),
|
|
item_description: z.string().max(8000).nullish(),
|
|
quantity: positiveNumberFromForm.default(1),
|
|
unit: z.string().max(20).nullish(),
|
|
unit_price: numberFromForm.default(0),
|
|
is_included_in_total: booleanFromForm.optional().default(true),
|
|
position: nonNegativeNumberFromForm.optional(),
|
|
});
|
|
|
|
const OrderSectionSchema = z.object({
|
|
title: z.string().max(255).nullish(),
|
|
title_cz: z.string().max(255).nullish(),
|
|
content: z.string().max(8000).nullish(),
|
|
position: nonNegativeNumberFromForm.optional(),
|
|
});
|
|
|
|
export const CreateOrderFromQuotationSchema = z.object({
|
|
quotationId: intIdFromForm,
|
|
customerOrderNumber: z.string().max(100).optional().default(""),
|
|
});
|
|
|
|
export const CreateOrderSchema = z.object({
|
|
order_number: z.string().max(50).nullish(),
|
|
customer_order_number: z.string().max(100).nullish(),
|
|
quotation_id: nullableIntIdFromForm.nullish(),
|
|
customer_id: nullableIntIdFromForm.nullish(),
|
|
status: z
|
|
.enum(["prijata", "v_realizaci", "dokoncena", "stornovana"])
|
|
.optional()
|
|
.default("prijata"),
|
|
currency: z.string().max(10).optional().default("CZK"),
|
|
language: z.string().max(5).optional().default("cs"),
|
|
exchange_rate: positiveNumberFromForm.optional().default(1.0),
|
|
scope_title: z.string().max(255).nullish(),
|
|
scope_description: z.string().max(8000).nullish(),
|
|
notes: z.string().max(8000).nullish(),
|
|
items: z.array(OrderItemSchema).optional(),
|
|
sections: z.array(OrderSectionSchema).optional(),
|
|
create_project: booleanFromForm.optional().default(true),
|
|
});
|
|
|
|
export const UpdateOrderSchema = z.object({
|
|
customer_order_number: z.string().max(100).nullish(),
|
|
status: z
|
|
.enum(["prijata", "v_realizaci", "dokoncena", "stornovana"])
|
|
.optional(),
|
|
currency: z.string().max(10).optional(),
|
|
language: z.string().max(5).optional(),
|
|
scope_title: z.string().max(255).nullish(),
|
|
scope_description: z.string().max(8000).nullish(),
|
|
notes: z.string().max(8000).nullish(),
|
|
customer_id: nullableIntIdFromForm.optional(),
|
|
items: z.array(OrderItemSchema).optional(),
|
|
sections: z.array(OrderSectionSchema).optional(),
|
|
});
|
|
|
|
export type CreateOrderFromQuotationInput = z.infer<
|
|
typeof CreateOrderFromQuotationSchema
|
|
>;
|
|
export type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
|
|
export type UpdateOrderInput = z.infer<typeof UpdateOrderSchema>;
|