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:
@@ -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:00–02: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 =
|
||||
|
||||
Reference in New Issue
Block a user