Split purchase submit from receipt finalization
This commit is contained in:
@ -130,12 +130,27 @@ export async function PUT(request: Request, context: RouteContext) {
|
|||||||
const existing = await prisma.purchase.findUnique({
|
const existing = await prisma.purchase.findUnique({
|
||||||
where: { id: parsedId },
|
where: { id: parsedId },
|
||||||
include: {
|
include: {
|
||||||
lines: true
|
lines: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
receipts: true,
|
||||||
|
lots: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (existing._count.receipts > 0 || existing._count.lots > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Purchase sudah memiliki receipt/lot, sehingga baris pembelian tidak bisa diubah dari form purchase. Gunakan koreksi stok atau buat purchase baru untuk perubahan setelah barang diterima."
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const purchaseDate = toDateOrThrow(`${payload.purchase_date}T00:00:00.000Z`, "Tanggal pembelian");
|
const purchaseDate = toDateOrThrow(`${payload.purchase_date}T00:00:00.000Z`, "Tanggal pembelian");
|
||||||
const receivedAt = toDateOrThrow(payload.received_at, "Waktu diterima");
|
const receivedAt = toDateOrThrow(payload.received_at, "Waktu diterima");
|
||||||
@ -268,6 +283,15 @@ export async function PUT(request: Request, context: RouteContext) {
|
|||||||
|
|
||||||
return NextResponse.json({ data: serializePurchaseDetail(purchase) });
|
return NextResponse.json({ data: serializePurchaseDetail(purchase) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Purchase masih terkait receipt/lot, sehingga baris pembelian tidak bisa diubah dari form purchase."
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError || error instanceof Prisma.PrismaClientValidationError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError || error instanceof Prisma.PrismaClientValidationError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
import { createAuditTrailSafe } from "@/lib/audit-trail";
|
import { createAuditTrailSafe } from "@/lib/audit-trail";
|
||||||
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
|
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
|
||||||
import { requireApiAccess } from "@/lib/authorization";
|
import { requireApiAccess } from "@/lib/authorization";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
|
|
||||||
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
|
|
||||||
import { generateReceiptNo } from "@/features/receipts/lib/generate-receipt-no";
|
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
type SubmitTx = Prisma.TransactionClient & {
|
|
||||||
lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation;
|
|
||||||
purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry;
|
|
||||||
purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary;
|
|
||||||
};
|
|
||||||
const parseId = (id: string) => {
|
const parseId = (id: string) => {
|
||||||
try {
|
try {
|
||||||
return BigInt(id);
|
return BigInt(id);
|
||||||
@ -30,271 +22,75 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
const parsedId = parseId((await context.params).id);
|
const parsedId = parseId((await context.params).id);
|
||||||
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
|
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
|
||||||
|
|
||||||
try {
|
const purchase = await prisma.purchase.findUnique({
|
||||||
const purchaseId = parsedId;
|
where: { id: parsedId },
|
||||||
const purchase = await prisma.purchase.findUnique({
|
include: {
|
||||||
where: { id: purchaseId },
|
_count: {
|
||||||
include: {
|
select: {
|
||||||
agent: { select: { id: true, name: true } },
|
receipts: true,
|
||||||
profitShareScheme: { select: { id: true, shareAgent: true } },
|
lots: true
|
||||||
receipts: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
receiptNo: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
lines: {
|
|
||||||
include: {
|
|
||||||
grade: true,
|
|
||||||
unit: true,
|
|
||||||
warehouse: true,
|
|
||||||
warehouseLocation: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!purchase) return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
|
||||||
|
|
||||||
if (purchase.status === "SUBMITTED") {
|
|
||||||
return NextResponse.json({ message: "Purchase sudah disubmit" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (purchase.receipts.length > 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: `Purchase sudah memiliki receipt ${purchase.receipts[0]?.receiptNo ?? ""}`.trim() },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!purchase.receivedByEmployeeId) {
|
|
||||||
return NextResponse.json({ message: "Penerima belum dipilih" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!purchase.receivedAt) {
|
|
||||||
return NextResponse.json({ message: "Waktu diterima belum diisi" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const receivedAt = purchase.receivedAt;
|
|
||||||
const receiptDate = new Date(receivedAt.toISOString().slice(0, 10));
|
|
||||||
const receiptNo = await generateReceiptNo(receiptDate);
|
|
||||||
const sourceCode = purchase.agent?.name || purchase.purchaseNo;
|
|
||||||
const enteredQtyLines = purchase.lines.filter((line) => Number(line.qtyAccepted.toNumber()) > 0);
|
|
||||||
if (enteredQtyLines.length === 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Tidak ada baris dengan qty yang masuk > 0 untuk generate lot" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const missingWarehouseLine = enteredQtyLines.find((line) => !line.warehouseId);
|
|
||||||
if (missingWarehouseLine) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Warehouse belum diisi pada salah satu baris pembelian" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const generated = await prisma.$transaction(async (rawTx) => {
|
|
||||||
const tx = rawTx as SubmitTx;
|
|
||||||
const baseLotCode = await generateLotCode(receivedAt, sourceCode);
|
|
||||||
const codeMatch = baseLotCode.match(/-(\d+)$/);
|
|
||||||
if (!codeMatch) {
|
|
||||||
throw new Error("Gagal generate kode lot");
|
|
||||||
}
|
|
||||||
const lotPrefix = baseLotCode.replace(/-\d+$/, "");
|
|
||||||
let lotSuffix = Number(codeMatch[1] ?? "0") || 1;
|
|
||||||
|
|
||||||
const availableLotCount = await tx.inventoryLot.count({ where: { purchaseId } });
|
|
||||||
if (availableLotCount > 0) {
|
|
||||||
throw new Error("Lot untuk purchase ini sudah pernah digenerate");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingReceiptCount = await tx.receipt.count({ where: { purchaseId } });
|
|
||||||
if (existingReceiptCount > 0) {
|
|
||||||
throw new Error("Receipt untuk purchase ini sudah pernah dibuat");
|
|
||||||
}
|
|
||||||
|
|
||||||
const receipt = await tx.receipt.create({
|
|
||||||
data: {
|
|
||||||
receiptNo,
|
|
||||||
purchaseId: purchase.id,
|
|
||||||
receiptDate,
|
|
||||||
status: "FINALIZED",
|
|
||||||
notes: purchase.notes || null,
|
|
||||||
receivedById: BigInt(auth.user.id)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.receiptLine.createMany({
|
|
||||||
data: enteredQtyLines.map((line) => ({
|
|
||||||
receiptId: receipt.id,
|
|
||||||
purchaseLineId: line.id,
|
|
||||||
gradeId: line.gradeId,
|
|
||||||
qtyReceived: line.qtyReceived,
|
|
||||||
qtyAccepted: line.qtyAccepted,
|
|
||||||
qtyRejected: line.qtyRejected,
|
|
||||||
unitId: line.unitId,
|
|
||||||
unitCost: line.unitCost,
|
|
||||||
warehouseId: line.warehouseId!,
|
|
||||||
warehouseLocationId: line.warehouseLocationId,
|
|
||||||
notes: line.notes || null
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
const receiptLines = await tx.receiptLine.findMany({
|
|
||||||
where: { receiptId: receipt.id },
|
|
||||||
orderBy: { id: "asc" }
|
|
||||||
});
|
|
||||||
|
|
||||||
const lots = [];
|
|
||||||
for (const line of receiptLines) {
|
|
||||||
if (!line.warehouseId) {
|
|
||||||
throw new Error("Warehouse belum diisi pada salah satu baris pembelian");
|
|
||||||
}
|
|
||||||
const availableQty = Number(line.qtyAccepted.toNumber());
|
|
||||||
const lotCode = `${lotPrefix}-${String(lotSuffix).padStart(3, "0")}`;
|
|
||||||
lotSuffix += 1;
|
|
||||||
|
|
||||||
const lot = await tx.inventoryLot.create({
|
|
||||||
data: {
|
|
||||||
lotCode,
|
|
||||||
sourceType: "PURCHASE",
|
|
||||||
sourceRefId: purchaseId,
|
|
||||||
purchaseId: purchase.id,
|
|
||||||
purchaseLineId: line.purchaseLineId,
|
|
||||||
receiptId: receipt.id,
|
|
||||||
receiptLineId: line.id,
|
|
||||||
gradeId: line.gradeId,
|
|
||||||
warehouseId: line.warehouseId,
|
|
||||||
warehouseLocationId: line.warehouseLocationId,
|
|
||||||
originalQty: availableQty,
|
|
||||||
availableQty,
|
|
||||||
unitId: line.unitId,
|
|
||||||
unitCost: line.unitCost,
|
|
||||||
receivedAt,
|
|
||||||
status: "ACTIVE",
|
|
||||||
qrCodeValue: lotCode,
|
|
||||||
barcodeValue: lotCode
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const costTotalAllocated = Number(
|
|
||||||
(availableQty * line.unitCost.toNumber()).toFixed(2)
|
|
||||||
);
|
|
||||||
const allocation = await tx.lotPurchaseAllocation.create({
|
|
||||||
data: {
|
|
||||||
lotId: lot.id,
|
|
||||||
purchaseId: purchase.id,
|
|
||||||
purchaseLineId: line.id,
|
|
||||||
sourceType: "PURCHASE",
|
|
||||||
sourceRefId: purchase.id,
|
|
||||||
agentIdSnapshot: purchase.agentId,
|
|
||||||
profitShareSchemeIdSnapshot: purchase.profitShareSchemeId,
|
|
||||||
qtyAllocated: new Prisma.Decimal(availableQty),
|
|
||||||
costTotalAllocated: new Prisma.Decimal(costTotalAllocated),
|
|
||||||
unitCostSnapshot: line.unitCost
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.purchaseRealizationEntry.create({
|
|
||||||
data: {
|
|
||||||
purchaseId: purchase.id,
|
|
||||||
lotId: lot.id,
|
|
||||||
allocationId: allocation.id,
|
|
||||||
eventType: "OPENING_COST",
|
|
||||||
referenceType: "PURCHASE",
|
|
||||||
referenceId: purchase.id,
|
|
||||||
occurredAt: receivedAt,
|
|
||||||
qtyIn: new Prisma.Decimal(availableQty),
|
|
||||||
qtyOut: new Prisma.Decimal(0),
|
|
||||||
qtyShrinkage: new Prisma.Decimal(0),
|
|
||||||
amountCost: new Prisma.Decimal(costTotalAllocated),
|
|
||||||
amountRevenue: new Prisma.Decimal(0),
|
|
||||||
amountExpense: new Prisma.Decimal(0),
|
|
||||||
amountProfit: new Prisma.Decimal(0),
|
|
||||||
agentSharePercentSnapshot: purchase.profitShareScheme?.shareAgent ?? null,
|
|
||||||
agentAmount: new Prisma.Decimal(0),
|
|
||||||
notes: `Opening realization dari purchase line ${line.purchaseLineId.toString()}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
lots.push({
|
|
||||||
id: lot.id.toString(),
|
|
||||||
lot_code: lot.lotCode
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await tx.purchase.update({
|
|
||||||
where: { id: purchaseId },
|
|
||||||
data: { status: "SUBMITTED" }
|
|
||||||
});
|
|
||||||
|
|
||||||
await recalculatePurchaseRealizationSummary(
|
|
||||||
tx,
|
|
||||||
purchase.id,
|
|
||||||
purchase.profitShareScheme?.shareAgent.toNumber() ?? null
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
purchase: updated,
|
|
||||||
receipt: {
|
|
||||||
id: receipt.id.toString(),
|
|
||||||
receipt_no: receipt.receiptNo
|
|
||||||
},
|
|
||||||
lots
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await createAuditTrailSafe({
|
|
||||||
userId: auth.user.id,
|
|
||||||
action: "PURCHASE_SUBMITTED",
|
|
||||||
entityType: "PURCHASE",
|
|
||||||
entityId: generated.purchase.id,
|
|
||||||
method: request.method,
|
|
||||||
pathname: new URL(request.url).pathname,
|
|
||||||
statusCode: 200,
|
|
||||||
summary: `Purchase ${purchase.purchaseNo} disubmit + receipt + lot dibuat`,
|
|
||||||
metadata: buildAuditChangeMetadata(
|
|
||||||
{ status: purchase.status, lot_count: 0, receipt_no: null },
|
|
||||||
{
|
|
||||||
status: generated.purchase.status,
|
|
||||||
lot_count: generated.lots.length,
|
|
||||||
receipt_no: generated.receipt.receipt_no
|
|
||||||
}
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (generated.lots.length === 0) {
|
|
||||||
return NextResponse.json({ message: "Lot untuk purchase ini sudah pernah digenerate" }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
success: true,
|
|
||||||
status: generated.purchase.status,
|
|
||||||
lot_count: generated.lots.length,
|
|
||||||
receipt: generated.receipt
|
|
||||||
},
|
},
|
||||||
{ status: 200 }
|
lines: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!purchase) return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (purchase._count.receipts > 0 || purchase._count.lots > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Purchase sudah diterima dan memiliki receipt/lot." },
|
||||||
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Kode lot duplikat saat submit. Coba lagi." },
|
|
||||||
{ status: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error("PURCHASE_SUBMIT_ERROR", {
|
|
||||||
purchaseId: parsedId?.toString(),
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack
|
|
||||||
});
|
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
|
||||||
}
|
|
||||||
console.error("PURCHASE_SUBMIT_ERROR", {
|
|
||||||
purchaseId: parsedId?.toString(),
|
|
||||||
message: "Unknown error"
|
|
||||||
});
|
|
||||||
return NextResponse.json({ message: "Submit gagal karena kesalahan internal" }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!purchase.receivedByEmployeeId) {
|
||||||
|
return NextResponse.json({ message: "Penerima belum dipilih" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!purchase.receivedAt) {
|
||||||
|
return NextResponse.json({ message: "Waktu diterima belum diisi" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const enteredQtyLines = purchase.lines.filter((line) => Number(line.qtyAccepted.toNumber()) > 0);
|
||||||
|
if (enteredQtyLines.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Tidak ada baris dengan qty yang masuk > 0 untuk diajukan" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchase.status === "SUBMITTED") {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
status: purchase.status,
|
||||||
|
message: "Purchase sudah diajukan dan menunggu penerimaan."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.purchase.update({
|
||||||
|
where: { id: parsedId },
|
||||||
|
data: { status: "SUBMITTED" }
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAuditTrailSafe({
|
||||||
|
userId: auth.user.id,
|
||||||
|
action: "PURCHASE_SUBMITTED",
|
||||||
|
entityType: "PURCHASE",
|
||||||
|
entityId: updated.id,
|
||||||
|
method: request.method,
|
||||||
|
pathname: new URL(request.url).pathname,
|
||||||
|
statusCode: 200,
|
||||||
|
summary: `Purchase ${updated.purchaseNo} diajukan untuk penerimaan`,
|
||||||
|
metadata: buildAuditChangeMetadata(
|
||||||
|
{ status: purchase.status },
|
||||||
|
{ status: updated.status }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
status: updated.status,
|
||||||
|
message: "Purchase diajukan. Buat receipt dan lot dari menu Penerimaan."
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,6 +84,12 @@ export async function GET(request: Request) {
|
|||||||
select: {
|
select: {
|
||||||
subtotal: true
|
subtotal: true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
receipts: true,
|
||||||
|
lots: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: [{ createdAt: "desc" }]
|
orderBy: [{ createdAt: "desc" }]
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
|
||||||
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
|
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
|
||||||
import { createAuditTrailSafe } from "@/lib/audit-trail";
|
import { createAuditTrailSafe } from "@/lib/audit-trail";
|
||||||
import { requireApiAccess } from "@/lib/authorization";
|
import { requireApiAccess } from "@/lib/authorization";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
type RouteContext = { params: Promise<{ id: string }> };
|
type RouteContext = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
type GenerateLotsTx = Prisma.TransactionClient & {
|
||||||
|
lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation;
|
||||||
|
purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry;
|
||||||
|
purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary;
|
||||||
|
};
|
||||||
|
|
||||||
const parseId = (id: string) => {
|
const parseId = (id: string) => {
|
||||||
try {
|
try {
|
||||||
return BigInt(id);
|
return BigInt(id);
|
||||||
@ -26,7 +35,12 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
const existingReceipt = await prisma.receipt.findUnique({
|
const existingReceipt = await prisma.receipt.findUnique({
|
||||||
where: { id: parsedId },
|
where: { id: parsedId },
|
||||||
include: {
|
include: {
|
||||||
purchase: true,
|
purchase: {
|
||||||
|
include: {
|
||||||
|
agent: { select: { id: true, name: true } },
|
||||||
|
profitShareScheme: { select: { id: true, shareAgent: true } }
|
||||||
|
}
|
||||||
|
},
|
||||||
lines: true,
|
lines: true,
|
||||||
lots: true
|
lots: true
|
||||||
}
|
}
|
||||||
@ -43,72 +57,156 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const generated = await prisma.$transaction(async (tx) => {
|
const acceptedLines = existingReceipt.lines.filter((line) => Number(line.qtyAccepted.toNumber()) > 0);
|
||||||
const lots = [];
|
if (acceptedLines.length === 0) {
|
||||||
for (const line of existingReceipt.lines) {
|
return NextResponse.json(
|
||||||
if (Number(line.qtyAccepted) <= 0) {
|
{ message: "Tidak ada baris receipt dengan qty accepted > 0." },
|
||||||
continue;
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const generated = await prisma.$transaction(async (rawTx) => {
|
||||||
|
const tx = rawTx as GenerateLotsTx;
|
||||||
|
const sourceCode = existingReceipt.purchase.agent?.name || existingReceipt.purchase.purchaseNo;
|
||||||
|
const baseLotCode = await generateLotCode(existingReceipt.receiptDate, sourceCode);
|
||||||
|
const codeMatch = baseLotCode.match(/-(\d+)$/);
|
||||||
|
if (!codeMatch) {
|
||||||
|
throw new Error("Gagal generate kode lot");
|
||||||
|
}
|
||||||
|
const lotPrefix = baseLotCode.replace(/-\d+$/, "");
|
||||||
|
let lotSuffix = Number(codeMatch[1] ?? "0") || 1;
|
||||||
|
|
||||||
|
const existingLotCount = await tx.inventoryLot.count({
|
||||||
|
where: { receiptId: existingReceipt.id }
|
||||||
|
});
|
||||||
|
if (existingLotCount > 0) {
|
||||||
|
throw new Error("Lot untuk receipt ini sudah pernah dibuat");
|
||||||
}
|
}
|
||||||
|
|
||||||
const lotCode = await generateLotCode(
|
const lots = [];
|
||||||
existingReceipt.receiptDate,
|
for (const line of acceptedLines) {
|
||||||
existingReceipt.purchaseId.toString()
|
const availableQty = Number(line.qtyAccepted.toNumber());
|
||||||
|
const lotCode = `${lotPrefix}-${String(lotSuffix).padStart(3, "0")}`;
|
||||||
|
lotSuffix += 1;
|
||||||
|
|
||||||
|
const lot = await tx.inventoryLot.create({
|
||||||
|
data: {
|
||||||
|
lotCode,
|
||||||
|
sourceType: "RECEIPT",
|
||||||
|
sourceRefId: line.id,
|
||||||
|
purchaseId: existingReceipt.purchaseId,
|
||||||
|
purchaseLineId: line.purchaseLineId,
|
||||||
|
receiptId: existingReceipt.id,
|
||||||
|
receiptLineId: line.id,
|
||||||
|
gradeId: line.gradeId,
|
||||||
|
warehouseId: line.warehouseId,
|
||||||
|
warehouseLocationId: line.warehouseLocationId,
|
||||||
|
originalQty: line.qtyAccepted,
|
||||||
|
availableQty: line.qtyAccepted,
|
||||||
|
unitId: line.unitId,
|
||||||
|
unitCost: line.unitCost,
|
||||||
|
receivedAt: existingReceipt.receiptDate,
|
||||||
|
status: "ACTIVE",
|
||||||
|
qrCodeValue: lotCode,
|
||||||
|
barcodeValue: lotCode
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const costTotalAllocated = Number((availableQty * line.unitCost.toNumber()).toFixed(2));
|
||||||
|
const allocation = await tx.lotPurchaseAllocation.create({
|
||||||
|
data: {
|
||||||
|
lotId: lot.id,
|
||||||
|
purchaseId: existingReceipt.purchaseId,
|
||||||
|
purchaseLineId: line.purchaseLineId,
|
||||||
|
sourceType: "RECEIPT",
|
||||||
|
sourceRefId: existingReceipt.id,
|
||||||
|
agentIdSnapshot: existingReceipt.purchase.agentId,
|
||||||
|
profitShareSchemeIdSnapshot: existingReceipt.purchase.profitShareSchemeId,
|
||||||
|
qtyAllocated: new Prisma.Decimal(availableQty),
|
||||||
|
costTotalAllocated: new Prisma.Decimal(costTotalAllocated),
|
||||||
|
unitCostSnapshot: line.unitCost
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.purchaseRealizationEntry.create({
|
||||||
|
data: {
|
||||||
|
purchaseId: existingReceipt.purchaseId,
|
||||||
|
lotId: lot.id,
|
||||||
|
allocationId: allocation.id,
|
||||||
|
eventType: "OPENING_COST",
|
||||||
|
referenceType: "RECEIPT",
|
||||||
|
referenceId: existingReceipt.id,
|
||||||
|
occurredAt: existingReceipt.receiptDate,
|
||||||
|
qtyIn: new Prisma.Decimal(availableQty),
|
||||||
|
qtyOut: new Prisma.Decimal(0),
|
||||||
|
qtyShrinkage: new Prisma.Decimal(0),
|
||||||
|
amountCost: new Prisma.Decimal(costTotalAllocated),
|
||||||
|
amountRevenue: new Prisma.Decimal(0),
|
||||||
|
amountExpense: new Prisma.Decimal(0),
|
||||||
|
amountProfit: new Prisma.Decimal(0),
|
||||||
|
agentSharePercentSnapshot: existingReceipt.purchase.profitShareScheme?.shareAgent ?? null,
|
||||||
|
agentAmount: new Prisma.Decimal(0),
|
||||||
|
notes: `Opening realization dari receipt line ${line.id.toString()}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lots.push(lot);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.receipt.update({
|
||||||
|
where: { id: existingReceipt.id },
|
||||||
|
data: { status: "FINALIZED" }
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.purchase.update({
|
||||||
|
where: { id: existingReceipt.purchaseId },
|
||||||
|
data: { status: "SUBMITTED" }
|
||||||
|
});
|
||||||
|
|
||||||
|
await recalculatePurchaseRealizationSummary(
|
||||||
|
tx,
|
||||||
|
existingReceipt.purchaseId,
|
||||||
|
existingReceipt.purchase.profitShareScheme?.shareAgent.toNumber() ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
const lot = await tx.inventoryLot.create({
|
return lots;
|
||||||
data: {
|
|
||||||
lotCode,
|
|
||||||
sourceType: "RECEIPT",
|
|
||||||
sourceRefId: line.id,
|
|
||||||
purchaseId: existingReceipt.purchaseId,
|
|
||||||
purchaseLineId: line.purchaseLineId,
|
|
||||||
receiptId: existingReceipt.id,
|
|
||||||
receiptLineId: line.id,
|
|
||||||
gradeId: line.gradeId,
|
|
||||||
warehouseId: line.warehouseId,
|
|
||||||
warehouseLocationId: line.warehouseLocationId,
|
|
||||||
originalQty: line.qtyAccepted,
|
|
||||||
availableQty: line.qtyAccepted,
|
|
||||||
unitId: line.unitId,
|
|
||||||
unitCost: line.unitCost,
|
|
||||||
receivedAt: existingReceipt.receiptDate,
|
|
||||||
status: "ACTIVE",
|
|
||||||
qrCodeValue: lotCode,
|
|
||||||
barcodeValue: lotCode
|
|
||||||
}
|
|
||||||
});
|
|
||||||
lots.push(lot);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.receipt.update({
|
|
||||||
where: { id: existingReceipt.id },
|
|
||||||
data: { status: "FINALIZED" }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return lots;
|
await createAuditTrailSafe({
|
||||||
});
|
userId: auth.user.id,
|
||||||
|
action: "LOTS_GENERATED",
|
||||||
|
entityType: "RECEIPT",
|
||||||
|
entityId: existingReceipt.id,
|
||||||
|
method: request.method,
|
||||||
|
pathname: new URL(request.url).pathname,
|
||||||
|
statusCode: 200,
|
||||||
|
summary: `${generated.length} lot dibuat dari receipt ${existingReceipt.receiptNo}`,
|
||||||
|
metadata: {
|
||||||
|
receipt_no: existingReceipt.receiptNo,
|
||||||
|
purchase_no: existingReceipt.purchase.purchaseNo,
|
||||||
|
lot_codes: generated.map((lot) => lot.lotCode)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await createAuditTrailSafe({
|
return NextResponse.json({
|
||||||
userId: auth.user.id,
|
receipt_id: existingReceipt.id.toString(),
|
||||||
action: "LOTS_GENERATED",
|
lots: generated.map((lot) => ({
|
||||||
entityType: "RECEIPT",
|
id: lot.id.toString(),
|
||||||
entityId: existingReceipt.id,
|
lot_code: lot.lotCode,
|
||||||
method: request.method,
|
qr_code_value: lot.qrCodeValue
|
||||||
pathname: new URL(request.url).pathname,
|
}))
|
||||||
statusCode: 200,
|
});
|
||||||
summary: `${generated.length} lot dibuat dari receipt ${existingReceipt.receiptNo}`,
|
} catch (error) {
|
||||||
metadata: {
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
receipt_no: existingReceipt.receiptNo,
|
return NextResponse.json(
|
||||||
lot_codes: generated.map((lot) => lot.lotCode)
|
{ message: "Kode lot duplikat saat membuat lot. Coba lagi." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
if (error instanceof Error) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
return NextResponse.json({
|
}
|
||||||
receipt_id: existingReceipt.id.toString(),
|
throw error;
|
||||||
lots: generated.map((lot) => ({
|
}
|
||||||
id: lot.id.toString(),
|
|
||||||
lot_code: lot.lotCode,
|
|
||||||
qr_code_value: lot.qrCodeValue
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,11 +55,42 @@ export async function POST(request: Request) {
|
|||||||
const systemUser = await ensureSystemUser();
|
const systemUser = await ensureSystemUser();
|
||||||
const receiptDate = new Date(`${parsed.data.receipt_date}T00:00:00.000Z`);
|
const receiptDate = new Date(`${parsed.data.receipt_date}T00:00:00.000Z`);
|
||||||
const receiptNo = await generateReceiptNo(receiptDate);
|
const receiptNo = await generateReceiptNo(receiptDate);
|
||||||
|
const purchaseId = BigInt(parsed.data.purchase_id);
|
||||||
|
|
||||||
|
const purchase = await prisma.purchase.findUnique({
|
||||||
|
where: { id: purchaseId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
receipts: true,
|
||||||
|
lots: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!purchase) {
|
||||||
|
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchase.status !== "SUBMITTED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Purchase harus diajukan sebelum dibuatkan receipt." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (purchase._count.receipts > 0 || purchase._count.lots > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "Purchase sudah memiliki receipt/lot." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const receipt = await prisma.receipt.create({
|
const receipt = await prisma.receipt.create({
|
||||||
data: {
|
data: {
|
||||||
receiptNo,
|
receiptNo,
|
||||||
purchaseId: BigInt(parsed.data.purchase_id),
|
purchaseId,
|
||||||
receiptDate,
|
receiptDate,
|
||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
notes: parsed.data.notes || null,
|
notes: parsed.data.notes || null,
|
||||||
|
|||||||
@ -1348,7 +1348,9 @@ export function PurchasesClient({ currencyCode }: PurchasesClientProps) {
|
|||||||
<td>
|
<td>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="button" onClick={() => void openDetail(item.id)} className="ops-btn-secondary">{dict.common.detail}</button>
|
<button type="button" onClick={() => void openDetail(item.id)} className="ops-btn-secondary">{dict.common.detail}</button>
|
||||||
<button type="button" onClick={() => void startEdit(item.id)} className="ops-btn-secondary">{dict.common.edit}</button>
|
{item.receipt_count === 0 && item.lot_count === 0 ? (
|
||||||
|
<button type="button" onClick={() => void startEdit(item.id)} className="ops-btn-secondary">{dict.common.edit}</button>
|
||||||
|
) : null}
|
||||||
{(item.status === "DRAFT" || item.status === "CANCELLED") ? (
|
{(item.status === "DRAFT" || item.status === "CANCELLED") ? (
|
||||||
<button type="button" onClick={() => void handleDeleteDraftPurchase(item.id)} className="ops-btn-danger">
|
<button type="button" onClick={() => void handleDeleteDraftPurchase(item.id)} className="ops-btn-danger">
|
||||||
{dict.common.delete}
|
{dict.common.delete}
|
||||||
|
|||||||
@ -10,6 +10,10 @@ type PurchaseListSerializable = {
|
|||||||
receivedAt: Date | null;
|
receivedAt: Date | null;
|
||||||
agent: { id: bigint; name: string } | null;
|
agent: { id: bigint; name: string } | null;
|
||||||
lines: Array<{ subtotal: DecimalLike }>;
|
lines: Array<{ subtotal: DecimalLike }>;
|
||||||
|
_count?: {
|
||||||
|
receipts: number;
|
||||||
|
lots: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type PurchaseDetailSerializable = {
|
type PurchaseDetailSerializable = {
|
||||||
@ -93,6 +97,8 @@ export function serializePurchaseListItem(purchase: PurchaseListSerializable): P
|
|||||||
: null,
|
: null,
|
||||||
status: purchase.status,
|
status: purchase.status,
|
||||||
line_count: purchase.lines.length,
|
line_count: purchase.lines.length,
|
||||||
|
receipt_count: purchase._count?.receipts ?? 0,
|
||||||
|
lot_count: purchase._count?.lots ?? 0,
|
||||||
grand_total: purchase.lines.reduce((sum, line) => sum + line.subtotal.toNumber(), 0)
|
grand_total: purchase.lines.reduce((sum, line) => sum + line.subtotal.toNumber(), 0)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export type PurchaseListItem = {
|
|||||||
} | null;
|
} | null;
|
||||||
status: string;
|
status: string;
|
||||||
line_count: number;
|
line_count: number;
|
||||||
|
receipt_count: number;
|
||||||
|
lot_count: number;
|
||||||
grand_total: number;
|
grand_total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user