feat(orders): manual order PO attachment + price items (multipart)
Extend createOrder service to accept optional attachmentBuffer/attachmentName parameters and persist them. Restructure the POST / multipart branch in the orders route to dispatch on quotationId presence: from-quotation path is unchanged, new manual-multipart path validates with CreateOrderSchema and forwards the attachment. Add two TDD tests covering price line items and PO file attachment storage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,3 +97,46 @@ describe("createProject (manual, standalone)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createOrder with price line item + attachment", () => {
|
||||||
|
it("stores a single line item with the entered price", async () => {
|
||||||
|
const res = await createOrder({
|
||||||
|
...baseOrder,
|
||||||
|
create_project: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "Práce",
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 12345,
|
||||||
|
is_included_in_total: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
const items = await prisma.order_items.findMany({
|
||||||
|
where: { order_id: res.id },
|
||||||
|
});
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
expect(Number(items[0].unit_price)).toBe(12345);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores an uploaded PO attachment", async () => {
|
||||||
|
const buf = Buffer.from("%PDF-1.4 fake");
|
||||||
|
const res = await createOrder(
|
||||||
|
{ ...baseOrder, create_project: false },
|
||||||
|
buf,
|
||||||
|
"po.pdf",
|
||||||
|
);
|
||||||
|
expect("id" in res).toBe(true);
|
||||||
|
if (!("id" in res)) return;
|
||||||
|
createdOrderIds.push(res.id);
|
||||||
|
const order = await prisma.orders.findUnique({
|
||||||
|
where: { id: res.id },
|
||||||
|
select: { attachment_name: true, attachment_data: true },
|
||||||
|
});
|
||||||
|
expect(order?.attachment_name).toBe("po.pdf");
|
||||||
|
expect(order?.attachment_data).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export default async function ordersRoutes(
|
|||||||
request.headers["content-type"]?.includes("multipart");
|
request.headers["content-type"]?.includes("multipart");
|
||||||
|
|
||||||
if (isMultipart) {
|
if (isMultipart) {
|
||||||
// === Order from quotation flow (multipart) ===
|
|
||||||
const fields: Record<string, string> = {};
|
const fields: Record<string, string> = {};
|
||||||
let attachmentBuffer: Buffer | null = null;
|
let attachmentBuffer: Buffer | null = null;
|
||||||
let attachmentName: string | null = null;
|
let attachmentName: string | null = null;
|
||||||
@@ -119,13 +118,13 @@ export default async function ordersRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From-quotation flow (multipart with attachment)
|
||||||
|
if (fields.quotationId) {
|
||||||
const quotationId = parseInt(fields.quotationId, 10);
|
const quotationId = parseInt(fields.quotationId, 10);
|
||||||
const customerOrderNumber = fields.customerOrderNumber || "";
|
const customerOrderNumber = fields.customerOrderNumber || "";
|
||||||
|
|
||||||
if (!quotationId || isNaN(quotationId)) {
|
if (!quotationId || isNaN(quotationId)) {
|
||||||
return error(reply, "Chybí ID nabídky", 400);
|
return error(reply, "Chybí ID nabídky", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await createOrderFromQuotation({
|
const result = await createOrderFromQuotation({
|
||||||
quotationId,
|
quotationId,
|
||||||
customerOrderNumber,
|
customerOrderNumber,
|
||||||
@@ -134,7 +133,6 @@ export default async function ordersRoutes(
|
|||||||
});
|
});
|
||||||
if ("error" in result)
|
if ("error" in result)
|
||||||
return error(reply, result.error!, result.status!);
|
return error(reply, result.error!, result.status!);
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
request,
|
request,
|
||||||
authData: request.authData,
|
authData: request.authData,
|
||||||
@@ -155,6 +153,52 @@ export default async function ordersRoutes(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual order (multipart, with optional PO attachment)
|
||||||
|
const rawManual: Record<string, unknown> = { ...fields };
|
||||||
|
if (typeof fields.items === "string" && fields.items.length > 0) {
|
||||||
|
try {
|
||||||
|
rawManual.items = JSON.parse(fields.items);
|
||||||
|
} catch {
|
||||||
|
return error(reply, "Neplatná data položek", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const manualParsed = parseBody(CreateOrderSchema, rawManual);
|
||||||
|
if ("error" in manualParsed)
|
||||||
|
return error(reply, manualParsed.error, 400);
|
||||||
|
|
||||||
|
const result = await createOrder(
|
||||||
|
manualParsed.data,
|
||||||
|
attachmentBuffer,
|
||||||
|
attachmentName,
|
||||||
|
);
|
||||||
|
if ("error" in result)
|
||||||
|
return error(reply, result.error!, result.status!);
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
authData: request.authData,
|
||||||
|
action: "create",
|
||||||
|
entityType: "order",
|
||||||
|
entityId: result.id,
|
||||||
|
description: `Vytvořena objednávka ${result.order_number}`,
|
||||||
|
});
|
||||||
|
if (result.project_id) {
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
authData: request.authData,
|
||||||
|
action: "create",
|
||||||
|
entityType: "project",
|
||||||
|
entityId: result.project_id,
|
||||||
|
description: `Vytvořen projekt k objednávce ${result.order_number}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return success(
|
||||||
|
reply,
|
||||||
|
{ id: result.id },
|
||||||
|
201,
|
||||||
|
"Objednávka byla vytvořena",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// === JSON body — either from-quotation (no attachment) or manual order ===
|
// === JSON body — either from-quotation (no attachment) or manual order ===
|
||||||
const rawBody = request.body as Record<string, unknown>;
|
const rawBody = request.body as Record<string, unknown>;
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,11 @@ interface CreateOrderData {
|
|||||||
create_project?: boolean;
|
create_project?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrder(body: CreateOrderData) {
|
export async function createOrder(
|
||||||
|
body: CreateOrderData,
|
||||||
|
attachmentBuffer?: Buffer | null,
|
||||||
|
attachmentName?: string | null,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
return await prisma.$transaction(async (tx) => {
|
return await prisma.$transaction(async (tx) => {
|
||||||
const orderNumber =
|
const orderNumber =
|
||||||
@@ -323,6 +327,10 @@ export async function createOrder(body: CreateOrderData) {
|
|||||||
scope_title: body.scope_title ?? null,
|
scope_title: body.scope_title ?? null,
|
||||||
scope_description: body.scope_description ?? null,
|
scope_description: body.scope_description ?? null,
|
||||||
notes: body.notes ?? null,
|
notes: body.notes ?? null,
|
||||||
|
attachment_data: attachmentBuffer
|
||||||
|
? new Uint8Array(attachmentBuffer)
|
||||||
|
: null,
|
||||||
|
attachment_name: attachmentName || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user