One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).
Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
the PO template's red style (shared DocumentSectionSchema, one-transaction
create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
/file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
number previews (also invoice previews), PUT returns assigned po_number
Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
follows the table; order creation only from active offers) +
valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
dates, ints for positions); audit old/new values + koncept fallbacks;
list id tiebreaks; stats include trimmed; NAS delete dedupe
Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
document number (form field removed), one form layout/readonly convention,
view-permission opens read-only everywhere (issued's editable-for-viewers
hole closed), server-driven transition buttons, dirty guard + Enter submit
on both, useApiMutation everywhere (new opt-in envelope mode), fixed
infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
numbers on offers, shared hardened per-row PDF flow (spinner, double-click
guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
states, query-lib cleanups (["offers","customers"] key, typed list rows,
shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
PDF language now comes from the document column
+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
632 lines
21 KiB
TypeScript
632 lines
21 KiB
TypeScript
import { Prisma } from "@prisma/client";
|
|
import prisma from "../config/database";
|
|
import {
|
|
generateOfferNumber,
|
|
releaseOfferNumber,
|
|
isOfferNumberTaken,
|
|
assignOfferNumber,
|
|
} from "./numbering.service";
|
|
import { nasOffersManager } from "./nas-offers-manager";
|
|
|
|
interface QuotationItemInput {
|
|
description?: string;
|
|
item_description?: string;
|
|
quantity?: number;
|
|
unit?: string;
|
|
unit_price?: number;
|
|
is_included_in_total?: boolean;
|
|
position?: number;
|
|
}
|
|
interface ScopeSectionInput {
|
|
title?: string;
|
|
title_cz?: string;
|
|
content?: string;
|
|
position?: number;
|
|
}
|
|
|
|
// Re-export for convenience
|
|
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
|
|
|
|
/**
|
|
* Offer status lifecycle (mirrors issued orders' table): a draft must be
|
|
* finalized to `active` first (that's when the number is assigned), an active
|
|
* offer can be ordered or invalidated, an ordered one only invalidated, and
|
|
* `invalidated` is terminal. The create-order flow (orders.service
|
|
* createOrderFromQuotation) consults this table too — an offer can only become
|
|
* `ordered` from a status that allows the transition.
|
|
*/
|
|
export const VALID_TRANSITIONS: Record<string, string[]> = {
|
|
draft: ["active"],
|
|
active: ["ordered", "invalidated"],
|
|
ordered: ["invalidated"],
|
|
invalidated: [],
|
|
};
|
|
|
|
/** Fields (non-status) are editable only while the offer is draft/active. */
|
|
function isEditableStatus(status: string): boolean {
|
|
return status === "draft" || status === "active";
|
|
}
|
|
|
|
/**
|
|
* Non-status update fields — used by the editable-state guard so a field edit
|
|
* on an ordered/invalidated offer is rejected EXPLICITLY (not silently
|
|
* dropped). `quotation_number` is stripped by the schema and is not listed.
|
|
*/
|
|
const NON_STATUS_UPDATE_FIELDS = [
|
|
"project_code",
|
|
"customer_id",
|
|
"created_at",
|
|
"valid_until",
|
|
"currency",
|
|
"language",
|
|
"scope_title",
|
|
"scope_description",
|
|
"items",
|
|
"sections",
|
|
] as const;
|
|
|
|
const ALLOWED_SORT_FIELDS = [
|
|
"id",
|
|
"quotation_number",
|
|
"project_code",
|
|
"created_at",
|
|
"valid_until",
|
|
"currency",
|
|
"status",
|
|
];
|
|
|
|
interface OfferFilterParams {
|
|
search?: string;
|
|
status?: string;
|
|
customer_id?: number;
|
|
}
|
|
|
|
interface ListOffersParams extends OfferFilterParams {
|
|
page: number;
|
|
limit: number;
|
|
skip: number;
|
|
sort: string;
|
|
order: "asc" | "desc";
|
|
search: string;
|
|
}
|
|
|
|
export interface CurrencyAmount {
|
|
amount: number;
|
|
currency: string;
|
|
}
|
|
|
|
/**
|
|
* Shared `where` builder for the offers list and the per-currency stats
|
|
* aggregation, so both stay in sync (any new filter applies to both). Offers
|
|
* have NO month/year filter — the list filters by search + status + customer_id.
|
|
*/
|
|
function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
|
|
const { search, status, customer_id } = params;
|
|
const where: Record<string, unknown> = {};
|
|
if (status) where.status = status;
|
|
if (customer_id) where.customer_id = customer_id;
|
|
if (search) {
|
|
where.OR = [
|
|
{ quotation_number: { contains: search } },
|
|
{ project_code: { contains: search } },
|
|
{ customers: { name: { contains: search } } },
|
|
];
|
|
}
|
|
return where;
|
|
}
|
|
|
|
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
|
|
function enrichQuotation(q: any) {
|
|
const total = q.quotation_items
|
|
.filter((i: any) => i.is_included_in_total !== false)
|
|
.reduce(
|
|
(s: number, i: any) =>
|
|
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
|
0,
|
|
);
|
|
const { quotation_items, scope_sections, ...rest } = q;
|
|
return {
|
|
...rest,
|
|
items: quotation_items,
|
|
sections: scope_sections,
|
|
customer_name: q.customers?.name || null,
|
|
total: Math.round(total * 100) / 100,
|
|
};
|
|
}
|
|
|
|
export async function listOffers(params: ListOffersParams) {
|
|
const { page, limit, skip, sort, order } = params;
|
|
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
|
|
|
|
const where = buildOfferWhere(params);
|
|
|
|
// Tiebreak on id: created_at is second-precision, so same-second rows would
|
|
// otherwise paginate non-deterministically (project ordering rule).
|
|
const orderBy: Record<string, "asc" | "desc">[] = [
|
|
{ [sortField]: order },
|
|
{ id: order },
|
|
];
|
|
|
|
const [quotations, total] = await Promise.all([
|
|
prisma.quotations.findMany({
|
|
where,
|
|
skip,
|
|
take: limit,
|
|
orderBy,
|
|
include: {
|
|
customers: { select: { id: true, name: true } },
|
|
quotation_items: { orderBy: { position: "asc" } },
|
|
// id tiebreak: position can repeat, keep the order deterministic.
|
|
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
|
},
|
|
}),
|
|
prisma.quotations.count({ where }),
|
|
]);
|
|
|
|
// Batch-fetch linked order statuses
|
|
const orderIds = quotations
|
|
.map((q) => q.order_id)
|
|
.filter((id): id is number => id != null);
|
|
const orderStatusMap = new Map<number, string>();
|
|
if (orderIds.length > 0) {
|
|
const orders = await prisma.orders.findMany({
|
|
where: { id: { in: orderIds } },
|
|
select: { id: true, status: true },
|
|
});
|
|
for (const o of orders) {
|
|
orderStatusMap.set(o.id, o.status || "");
|
|
}
|
|
}
|
|
|
|
const enriched = quotations.map((q) => ({
|
|
...enrichQuotation(q),
|
|
order_status:
|
|
q.order_id != null ? orderStatusMap.get(q.order_id) || null : null,
|
|
}));
|
|
|
|
return { data: enriched, total, page, limit };
|
|
}
|
|
|
|
/**
|
|
* Sum offer NET total per currency across the WHOLE filtered set (not a
|
|
* single page). Reuses `buildOfferWhere` so the filters track the list, and
|
|
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
|
|
* Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
|
|
*/
|
|
export async function getOfferTotals(
|
|
params: OfferFilterParams,
|
|
): Promise<{ totals: CurrencyAmount[] }> {
|
|
const where = buildOfferWhere(params);
|
|
|
|
// Only quotation_items — the per-currency NET math needs nothing else
|
|
// (customers/scope_sections would just inflate the full-set scan).
|
|
const quotations = await prisma.quotations.findMany({
|
|
where,
|
|
include: {
|
|
quotation_items: true,
|
|
},
|
|
});
|
|
|
|
const byCurrency: Record<string, number> = {};
|
|
for (const q of quotations) {
|
|
const { total, currency } = enrichQuotation(q);
|
|
const cur = currency || "CZK";
|
|
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
|
|
}
|
|
|
|
const totals: CurrencyAmount[] = Object.entries(byCurrency)
|
|
.map(([currency, amount]) => ({
|
|
currency,
|
|
amount: Math.round(amount * 100) / 100,
|
|
}))
|
|
.filter((t) => t.amount > 0);
|
|
|
|
return { totals };
|
|
}
|
|
|
|
export async function getOffer(id: number) {
|
|
const quotation = await prisma.quotations.findUnique({
|
|
where: { id },
|
|
include: {
|
|
customers: true,
|
|
quotation_items: { orderBy: { position: "asc" } },
|
|
// id tiebreak: position can repeat, keep the order deterministic.
|
|
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
|
},
|
|
});
|
|
if (!quotation) return null;
|
|
|
|
// Fetch linked order if exists
|
|
let orderInfo = null;
|
|
if (quotation.order_id) {
|
|
const order = await prisma.orders.findUnique({
|
|
where: { id: quotation.order_id },
|
|
select: { id: true, order_number: true, status: true },
|
|
});
|
|
orderInfo = order;
|
|
}
|
|
|
|
const { quotation_items, scope_sections, ...rest } = quotation;
|
|
return {
|
|
...rest,
|
|
items: quotation_items,
|
|
sections: scope_sections,
|
|
customer: quotation.customers,
|
|
customer_name: quotation.customers?.name || null,
|
|
order: orderInfo,
|
|
// Computed server-side so the frontend renders only legal status buttons
|
|
// (same contract as getIssuedOrder).
|
|
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
|
|
};
|
|
}
|
|
|
|
export async function createOffer(body: Record<string, any>) {
|
|
try {
|
|
return await prisma.$transaction(async (tx) => {
|
|
const status = body.status ? String(body.status) : "draft";
|
|
const explicitNumber =
|
|
body.quotation_number !== undefined && body.quotation_number !== null
|
|
? String(body.quotation_number)
|
|
: null;
|
|
|
|
// Validate the referenced customer exists BEFORE the insert — a dangling
|
|
// FK would otherwise surface as a P2003 500 instead of a clean 400
|
|
// (mirrors createIssuedOrder's supplier check).
|
|
const customerId = body.customer_id ? Number(body.customer_id) : null;
|
|
if (customerId) {
|
|
const customer = await tx.customers.findUnique({
|
|
where: { id: customerId },
|
|
select: { id: true },
|
|
});
|
|
if (!customer) return { error: "customer_not_found" as const };
|
|
}
|
|
|
|
// Deferred numbering: a draft carries NO quotation_number (the column is
|
|
// nullable-unique so many drafts coexist). The official number is consumed
|
|
// only when the draft is finalized (assignOfferNumber on draft->active).
|
|
// A caller-supplied number, or an offer created already-finalized, numbers now.
|
|
const quotationNumber =
|
|
explicitNumber ??
|
|
(status === "draft" ? null : await generateOfferNumber(tx));
|
|
|
|
// Re-check uniqueness INSIDE the transaction to close the TOCTOU window
|
|
// between a pre-transaction check and the insert (mirrors createOrder).
|
|
// generateOfferNumber already verifies its own generated numbers, so this
|
|
// guards the caller-supplied path.
|
|
if (explicitNumber !== null) {
|
|
const taken = await isOfferNumberTaken(explicitNumber, tx);
|
|
if (taken) return { error: "number_taken" as const };
|
|
}
|
|
|
|
const quotation = await tx.quotations.create({
|
|
data: {
|
|
quotation_number: quotationNumber,
|
|
project_code: body.project_code ? String(body.project_code) : null,
|
|
customer_id: customerId,
|
|
created_at: body.created_at
|
|
? new Date(String(body.created_at))
|
|
: undefined,
|
|
valid_until: body.valid_until
|
|
? new Date(String(body.valid_until))
|
|
: null,
|
|
currency: body.currency ? String(body.currency) : "CZK",
|
|
language: body.language ? String(body.language) : "cs",
|
|
status,
|
|
scope_title: body.scope_title ? String(body.scope_title) : null,
|
|
scope_description: body.scope_description
|
|
? String(body.scope_description)
|
|
: null,
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(body.items)) {
|
|
await tx.quotation_items.createMany({
|
|
data: (body.items as QuotationItemInput[]).map((item, i) => ({
|
|
quotation_id: quotation.id,
|
|
description: item.description ?? null,
|
|
item_description: item.item_description ?? null,
|
|
quantity: item.quantity ?? 1,
|
|
unit: item.unit ?? null,
|
|
unit_price: item.unit_price ?? 0,
|
|
is_included_in_total: item.is_included_in_total !== false,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (Array.isArray(body.sections)) {
|
|
await tx.scope_sections.createMany({
|
|
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
|
quotation_id: quotation.id,
|
|
title: s.title ?? null,
|
|
title_cz: s.title_cz ?? null,
|
|
content: s.content ?? null,
|
|
position: s.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
|
|
return quotation;
|
|
});
|
|
} catch (err) {
|
|
// P2002 on the quotation_number unique index = a concurrent insert won the
|
|
// race between the in-tx check and our insert — surface the same clean
|
|
// conflict token instead of a 500 (mirrors createIssuedOrder).
|
|
if (
|
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
err.code === "P2002"
|
|
) {
|
|
return { error: "number_taken" as const };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function updateOffer(id: number, body: Record<string, any>) {
|
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
|
if (!existing) return { error: "not_found" as const };
|
|
|
|
const currentStatus = existing.status;
|
|
|
|
// Status changes must follow the transition table (issued-orders parity).
|
|
if (body.status !== undefined && String(body.status) !== currentStatus) {
|
|
const newStatus = String(body.status);
|
|
const allowed = VALID_TRANSITIONS[currentStatus] || [];
|
|
if (!allowed.includes(newStatus)) {
|
|
return { error: "invalid_transition" as const, currentStatus, newStatus };
|
|
}
|
|
}
|
|
|
|
// Editable-state guard: non-status fields may change only in draft/active.
|
|
// In ordered/invalidated a field edit is REJECTED explicitly (it used to be
|
|
// possible to rewrite everything in any status except invalidated).
|
|
// Status-only transition payloads (e.g. ordered -> invalidated) still work.
|
|
const editable = isEditableStatus(currentStatus);
|
|
if (
|
|
!editable &&
|
|
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
|
|
) {
|
|
return { error: "not_editable" as const, currentStatus };
|
|
}
|
|
|
|
// Explicit null CLEARS the customer; a set id must exist (dangling FK would
|
|
// surface as a P2003 500). The old `Number(null)` coerced null to 0.
|
|
let customerId: number | null | undefined = undefined;
|
|
if (body.customer_id !== undefined) {
|
|
customerId = body.customer_id ? Number(body.customer_id) : null;
|
|
if (customerId) {
|
|
const customer = await prisma.customers.findUnique({
|
|
where: { id: customerId },
|
|
select: { id: true },
|
|
});
|
|
if (!customer) return { error: "customer_not_found" as const };
|
|
}
|
|
}
|
|
|
|
// Finalize transition draft -> active: assign the official number ATOMICALLY
|
|
// with the status change. assignOfferNumber is idempotent.
|
|
const finalizing =
|
|
currentStatus === "draft" &&
|
|
body.status !== undefined &&
|
|
String(body.status) === "active";
|
|
|
|
const data = {
|
|
customer_id: customerId,
|
|
created_at:
|
|
body.created_at !== undefined
|
|
? body.created_at
|
|
? new Date(String(body.created_at))
|
|
: null
|
|
: undefined,
|
|
valid_until:
|
|
body.valid_until !== undefined
|
|
? body.valid_until
|
|
? new Date(String(body.valid_until))
|
|
: null
|
|
: undefined,
|
|
currency: body.currency !== undefined ? String(body.currency) : undefined,
|
|
language: body.language !== undefined ? String(body.language) : undefined,
|
|
status: body.status !== undefined ? String(body.status) : undefined,
|
|
project_code:
|
|
body.project_code !== undefined
|
|
? body.project_code
|
|
? String(body.project_code)
|
|
: null
|
|
: undefined,
|
|
scope_title:
|
|
body.scope_title !== undefined
|
|
? body.scope_title
|
|
? String(body.scope_title)
|
|
: null
|
|
: undefined,
|
|
scope_description:
|
|
body.scope_description !== undefined
|
|
? body.scope_description
|
|
? String(body.scope_description)
|
|
: null
|
|
: undefined,
|
|
modified_at: new Date(),
|
|
};
|
|
|
|
let assignedNumber: string | null = null;
|
|
if (Array.isArray(body.items) || Array.isArray(body.sections) || finalizing) {
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.quotations.update({ where: { id }, data });
|
|
if (finalizing) assignedNumber = await assignOfferNumber(id, tx);
|
|
if (Array.isArray(body.items)) {
|
|
await tx.quotation_items.deleteMany({ where: { quotation_id: id } });
|
|
await tx.quotation_items.createMany({
|
|
data: (body.items as QuotationItemInput[]).map((item, i) => ({
|
|
quotation_id: id,
|
|
description: item.description ?? null,
|
|
item_description: item.item_description ?? null,
|
|
quantity: item.quantity ?? 1,
|
|
unit: item.unit ?? null,
|
|
unit_price: item.unit_price ?? 0,
|
|
is_included_in_total: item.is_included_in_total !== false,
|
|
position: item.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
if (Array.isArray(body.sections)) {
|
|
await tx.scope_sections.deleteMany({ where: { quotation_id: id } });
|
|
await tx.scope_sections.createMany({
|
|
data: (body.sections as ScopeSectionInput[]).map((s, i) => ({
|
|
quotation_id: id,
|
|
title: s.title ?? null,
|
|
title_cz: s.title_cz ?? null,
|
|
content: s.content ?? null,
|
|
position: s.position ?? i,
|
|
})),
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
await prisma.quotations.update({ where: { id }, data });
|
|
}
|
|
|
|
// newValues for the audit diff: the header fields actually written plus the
|
|
// replaced collections (JSON.stringify drops the undefined = untouched keys).
|
|
const newValues: Record<string, unknown> = { ...data };
|
|
if (Array.isArray(body.items)) newValues.items = body.items;
|
|
if (Array.isArray(body.sections)) newValues.sections = body.sections;
|
|
|
|
// Reflect the number the finalize transition just assigned (existing was read
|
|
// before the assign, so its quotation_number is still the pre-finalize null).
|
|
return {
|
|
id,
|
|
quotation_number: assignedNumber ?? existing.quotation_number,
|
|
oldValues: existing as unknown as Record<string, unknown>,
|
|
newValues,
|
|
};
|
|
}
|
|
|
|
export async function deleteOffer(id: number) {
|
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
|
if (!existing) return { error: "not_found" as const };
|
|
|
|
try {
|
|
await prisma.quotations.delete({ where: { id } });
|
|
} catch (err) {
|
|
// P2003 = the offer is referenced by an order/project via a Restrict FK —
|
|
// surface a clean conflict token instead of a 500.
|
|
if (
|
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
|
err.code === "P2003"
|
|
) {
|
|
return { error: "linked" as const };
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const year = existing.created_at
|
|
? new Date(existing.created_at).getFullYear()
|
|
: new Date().getFullYear();
|
|
await releaseOfferNumber(year, existing.quotation_number ?? undefined);
|
|
|
|
// Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
|
|
// The service is the SINGLE owner of this cleanup (the route used to repeat
|
|
// it and always logged a spurious second-delete failure).
|
|
// Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
|
|
// file is already gone, so a NAS outage / missing file never throws here.
|
|
if (existing.quotation_number && nasOffersManager.isConfigured()) {
|
|
try {
|
|
const relPath = nasOffersManager.buildRelativePath(
|
|
existing.quotation_number,
|
|
year,
|
|
);
|
|
nasOffersManager.deleteOfferPdf(relPath);
|
|
} catch (e) {
|
|
console.error(
|
|
"[offers.service] NAS offer PDF delete failed for",
|
|
existing.quotation_number,
|
|
e,
|
|
);
|
|
}
|
|
}
|
|
|
|
return { data: existing };
|
|
}
|
|
|
|
export async function duplicateOffer(id: number) {
|
|
const original = await prisma.quotations.findUnique({
|
|
where: { id },
|
|
include: {
|
|
quotation_items: { orderBy: { position: "asc" } },
|
|
// id tiebreak: position can repeat, keep the order deterministic.
|
|
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
|
},
|
|
});
|
|
if (!original) return null;
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const nextOfferNumber = await generateOfferNumber(tx);
|
|
|
|
const copy = await tx.quotations.create({
|
|
data: {
|
|
quotation_number: nextOfferNumber,
|
|
project_code: original.project_code,
|
|
customer_id: original.customer_id,
|
|
valid_until: null,
|
|
currency: original.currency,
|
|
language: original.language,
|
|
status: "active",
|
|
scope_title: original.scope_title,
|
|
scope_description: original.scope_description,
|
|
},
|
|
});
|
|
|
|
if (original.quotation_items.length > 0) {
|
|
await tx.quotation_items.createMany({
|
|
data: original.quotation_items.map((item) => ({
|
|
quotation_id: copy.id,
|
|
description: item.description,
|
|
item_description: item.item_description,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
unit_price: item.unit_price,
|
|
is_included_in_total: item.is_included_in_total,
|
|
position: item.position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (original.scope_sections.length > 0) {
|
|
await tx.scope_sections.createMany({
|
|
data: original.scope_sections.map((s) => ({
|
|
quotation_id: copy.id,
|
|
title: s.title,
|
|
title_cz: s.title_cz,
|
|
content: s.content,
|
|
position: s.position,
|
|
})),
|
|
});
|
|
}
|
|
|
|
return { copy, original };
|
|
});
|
|
}
|
|
|
|
export async function invalidateOffer(id: number) {
|
|
const existing = await prisma.quotations.findUnique({ where: { id } });
|
|
if (!existing) return { error: "not_found" as const };
|
|
|
|
// The dedicated endpoint must respect the same transition table as PUT:
|
|
// only active/ordered offers can be invalidated (a draft is deleted instead,
|
|
// invalidated is terminal).
|
|
const allowed = VALID_TRANSITIONS[existing.status] || [];
|
|
if (!allowed.includes("invalidated")) {
|
|
return {
|
|
error: "invalid_transition" as const,
|
|
currentStatus: existing.status,
|
|
newStatus: "invalidated",
|
|
};
|
|
}
|
|
|
|
await prisma.quotations.update({
|
|
where: { id },
|
|
data: { status: "invalidated", modified_at: new Date() },
|
|
});
|
|
return { data: existing };
|
|
}
|