feat(issued-orders): counterparty is now a supplier (dodavatel), not a customer

Issued orders are purchase orders WE send - they must pick from suppliers
(sklad_suppliers), not from customers. Per user decision customer_id was
REPLACED (not kept alongside): migration drops issued_orders.customer_id and
adds supplier_id FK -> sklad_suppliers (existing rows lose their counterparty
- the feature is days old; re-point them in the UI).

- service: input/filters/search (suppliers.name + ico)/enrichment/detail all
  supplier-based; create validates the supplier inside the transaction and
  update before write -> Czech 400 'Dodavatel nenalezen' instead of P2003 500;
  detail returns a minimal supplier field set (no internal notes leak)
- routes: supplier_id on list + stats; new GET /issued-orders/suppliers
  lookup (orders.view/create/edit guard - orders users lack warehouse.manage
  which guards the warehouse suppliers CRUD), active suppliers only,
  name+id ordering
- PDF: Dodavatel block now renders the supplier (name, newline-split address,
  IC/DIC), layout and both language label sets unchanged
- frontend: new SupplierPicker kit component (CustomerPicker untouched),
  IssuedOrderDetail/IssuedOrders switched to supplier_id/supplier_name; the
  picker keeps a fallback option for orders whose supplier was later
  deactivated; WarehouseSuppliers CRUD now also invalidates issued-orders so
  the picker can't go stale
- tests: issued-orders suite switched to supplier fixtures + new coverage
  (lookup shape + 403, nonexistent supplier 400, PDF supplier block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 11:29:06 +02:00
parent 74ce24e3fa
commit 6a22195c7d
15 changed files with 598 additions and 112 deletions

View File

@@ -1,5 +1,6 @@
import type { $Enums } from "@prisma/client";
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
@@ -19,7 +20,7 @@ export interface IssuedOrderItemInput {
export interface IssuedOrderInput {
po_number?: string | number | null;
customer_id?: number | string | null;
supplier_id?: number | string | null;
status?: string;
currency?: string;
vat_rate?: number | string | null;
@@ -40,7 +41,7 @@ export interface IssuedOrderInput {
interface IssuedOrderFilterParams {
search?: string;
status?: string;
customer_id?: number;
supplier_id?: number;
month?: number;
year?: number;
}
@@ -66,20 +67,23 @@ export interface CurrencyAmount {
function buildIssuedOrderWhere(
params: IssuedOrderFilterParams,
): Record<string, unknown> {
const { search, status, customer_id, month, year } = params;
const { search, status, supplier_id, month, year } = params;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (supplier_id) where.supplier_id = supplier_id;
if (search) {
where.OR = [
{ po_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
{ suppliers: { name: { contains: search } } },
{ suppliers: { ico: { contains: search } } },
];
}
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
// order_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back (included prev-month's last day, dropped this month's last day).
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
where.order_date = { gte: from, lt: to };
}
return where;
@@ -150,7 +154,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
suppliers: { select: { id: true, name: true } },
issued_order_items: true,
},
}),
@@ -167,7 +171,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
return {
...rest,
items: issued_order_items,
customer_name: o.customers?.name || null,
supplier_name: o.suppliers?.name || null,
...totals,
};
});
@@ -217,7 +221,19 @@ export async function getIssuedOrder(id: number) {
const order = await prisma.issued_orders.findUnique({
where: { id },
include: {
customers: true,
// Same minimal field set as the picker lookup endpoint — the full row
// would expose internal notes/contact to any orders.view holder.
suppliers: {
select: {
id: true,
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
},
},
issued_order_items: { orderBy: { position: "asc" } },
},
});
@@ -226,8 +242,8 @@ export async function getIssuedOrder(id: number) {
return {
...rest,
items: issued_order_items,
customer: order.customers,
customer_name: order.customers?.name || null,
supplier: order.suppliers,
supplier_name: order.suppliers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
};
}
@@ -236,6 +252,17 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
return prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft";
// Validate the referenced supplier exists BEFORE the insert — a dangling
// FK would otherwise surface as a P2003 500 instead of a clean 400.
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
if (supplierId) {
const supplier = await tx.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
// Deferred numbering: a draft carries NO po_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
@@ -255,16 +282,19 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
const order = await tx.issued_orders.create({
data: {
po_number: poNumber,
customer_id: body.customer_id ? Number(body.customer_id) : null,
supplier_id: supplierId,
status: status as $Enums.issued_orders_status,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate:
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
// order_date is @db.Date (truncated to the UTC date part) — the
// "today" default must be utcMidnightOfLocalDay, or it stores
// yesterday during the 00:0002:00 Prague window.
order_date: body.order_date
? new Date(String(body.order_date))
: new Date(),
: utcMidnightOfLocalDay(),
delivery_date: body.delivery_date
? new Date(String(body.delivery_date))
: null,
@@ -328,8 +358,18 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined)
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.supplier_id !== undefined) {
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
if (supplierId) {
// Same dangling-FK guard as create: 400, not a P2003 500.
const supplier = await prisma.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
data.supplier_id = supplierId;
}
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined)
data.apply_vat =