348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
|
|
import {
|
|
purchaseAnalysisInputSchema,
|
|
type PurchaseAnalysisInput
|
|
} from "@/features/purchase-analysis/schemas/purchase-analysis.schema";
|
|
import { serializePurchaseAnalysisDetail } from "@/features/purchase-analysis/lib/serialize-purchase-analysis";
|
|
import { createAuditTrailSafe } from "@/lib/audit-trail";
|
|
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
|
|
import { requireApiAccess } from "@/lib/authorization";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
type RouteContext = {
|
|
params: Promise<{
|
|
purchaseId: string;
|
|
}>;
|
|
};
|
|
|
|
const analysisInclude = {
|
|
agent: {
|
|
select: {
|
|
id: true,
|
|
name: true
|
|
}
|
|
},
|
|
profitShareScheme: {
|
|
select: {
|
|
shareAgent: true
|
|
}
|
|
},
|
|
lines: {
|
|
select: {
|
|
subtotal: true,
|
|
qtyOrdered: true,
|
|
qtyReceived: true,
|
|
qtyAccepted: true,
|
|
unitPrice: true,
|
|
malUnitPrice: true,
|
|
moistureReceivedPercent: true,
|
|
purchaseMoisturePercent: true,
|
|
marketReferencePrice: true,
|
|
unit: {
|
|
select: {
|
|
code: true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
lots: {
|
|
select: {
|
|
originalQty: true,
|
|
finalMoisturePercent: true,
|
|
aboveAverageRatioPercent: true,
|
|
unit: {
|
|
select: {
|
|
code: true
|
|
}
|
|
}
|
|
}
|
|
},
|
|
analysis: {
|
|
include: {
|
|
costEntries: true
|
|
}
|
|
}
|
|
} as const;
|
|
|
|
function parseId(rawId: string) {
|
|
try {
|
|
return BigInt(rawId);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function toDecimal(value: number | null | undefined) {
|
|
if (value === undefined || value === null) return null;
|
|
return value;
|
|
}
|
|
|
|
async function upsertAnalysis(purchaseId: bigint, payload: PurchaseAnalysisInput) {
|
|
return prisma.purchaseAnalysis.upsert({
|
|
where: { purchaseId },
|
|
update: {
|
|
status: payload.status,
|
|
weightBuy: toDecimal(payload.weight_buy),
|
|
weightReceived: toDecimal(payload.weight_received),
|
|
weightFinal: toDecimal(payload.weight_final),
|
|
moistureBuyPercent: toDecimal(payload.moisture_buy_percent),
|
|
moistureReceivedPercent: toDecimal(payload.moisture_received_percent),
|
|
moistureFinalPercent: toDecimal(payload.moisture_final_percent),
|
|
aboveAverageRatioPercent: toDecimal(payload.above_average_ratio_percent),
|
|
averagePrice: toDecimal(payload.average_price),
|
|
modalBeli: toDecimal(payload.modal_beli),
|
|
modalMasuk: toDecimal(payload.modal_masuk),
|
|
modalJual: toDecimal(payload.modal_jual),
|
|
modalBarang: toDecimal(payload.modal_barang),
|
|
totalModalBeli: toDecimal(payload.total_modal_beli),
|
|
totalModalMal: toDecimal(payload.total_modal_mal),
|
|
marketReferencePrice: toDecimal(payload.market_reference_price),
|
|
marketValuationTotal: toDecimal(payload.market_valuation_total),
|
|
agentProfitShareTotal: payload.agent_profit_share_total,
|
|
notes: payload.notes || null,
|
|
costEntries: {
|
|
deleteMany: {},
|
|
create: payload.cost_entries.map((entry) => ({
|
|
costType: entry.cost_type,
|
|
description: entry.description || null,
|
|
amount: entry.amount
|
|
}))
|
|
}
|
|
},
|
|
create: {
|
|
purchaseId,
|
|
status: payload.status,
|
|
weightBuy: toDecimal(payload.weight_buy),
|
|
weightReceived: toDecimal(payload.weight_received),
|
|
weightFinal: toDecimal(payload.weight_final),
|
|
moistureBuyPercent: toDecimal(payload.moisture_buy_percent),
|
|
moistureReceivedPercent: toDecimal(payload.moisture_received_percent),
|
|
moistureFinalPercent: toDecimal(payload.moisture_final_percent),
|
|
aboveAverageRatioPercent: toDecimal(payload.above_average_ratio_percent),
|
|
averagePrice: toDecimal(payload.average_price),
|
|
modalBeli: toDecimal(payload.modal_beli),
|
|
modalMasuk: toDecimal(payload.modal_masuk),
|
|
modalJual: toDecimal(payload.modal_jual),
|
|
modalBarang: toDecimal(payload.modal_barang),
|
|
totalModalBeli: toDecimal(payload.total_modal_beli),
|
|
totalModalMal: toDecimal(payload.total_modal_mal),
|
|
marketReferencePrice: toDecimal(payload.market_reference_price),
|
|
marketValuationTotal: toDecimal(payload.market_valuation_total),
|
|
agentProfitShareTotal: payload.agent_profit_share_total,
|
|
notes: payload.notes || null,
|
|
costEntries: {
|
|
create: payload.cost_entries.map((entry) => ({
|
|
costType: entry.cost_type,
|
|
description: entry.description || null,
|
|
amount: entry.amount
|
|
}))
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function GET(request: Request, context: RouteContext) {
|
|
const auth = requireApiAccess(request);
|
|
if (!auth.ok) return auth.response;
|
|
|
|
const parsedId = parseId((await context.params).purchaseId);
|
|
if (parsedId === null) {
|
|
return NextResponse.json({ message: "Invalid purchase id" }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
const purchase = await prisma.purchase.findUnique({
|
|
where: { id: parsedId },
|
|
select: {
|
|
id: true,
|
|
purchaseNo: true,
|
|
purchaseDate: true,
|
|
status: true,
|
|
moistureBuyPercent: true,
|
|
moistureReceivedPercent: true,
|
|
aboveAverageRatioPercent: true,
|
|
mkSharePercent: true,
|
|
nonMkSharePercent: true,
|
|
shippingCost: true,
|
|
incomingOperationalCost: true,
|
|
afterArrivalOperationalCost: true,
|
|
agent: analysisInclude.agent,
|
|
profitShareScheme: analysisInclude.profitShareScheme,
|
|
lines: analysisInclude.lines,
|
|
lots: analysisInclude.lots,
|
|
analysis: analysisInclude.analysis
|
|
}
|
|
});
|
|
|
|
if (!purchase) {
|
|
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
|
}
|
|
if (purchase.status !== "SUBMITTED") {
|
|
return NextResponse.json(
|
|
{ message: "Purchase analysis hanya tersedia untuk purchase final" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({
|
|
data: serializePurchaseAnalysisDetail(purchase)
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to load purchase analysis detail for ${parsedId.toString()}:`, error);
|
|
return NextResponse.json(
|
|
{ message: "Gagal memuat detail analisis pembelian" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function PUT(request: Request, context: RouteContext) {
|
|
const auth = requireApiAccess(request);
|
|
if (!auth.ok) return auth.response;
|
|
|
|
const parsedId = parseId((await context.params).purchaseId);
|
|
if (parsedId === null) {
|
|
return NextResponse.json({ message: "Invalid purchase id" }, { status: 400 });
|
|
}
|
|
|
|
let requestPayload: unknown;
|
|
try {
|
|
requestPayload = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ message: "Invalid request body" }, { status: 400 });
|
|
}
|
|
|
|
const parsed = purchaseAnalysisInputSchema.safeParse(requestPayload);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{
|
|
message: "Validasi gagal",
|
|
errors: parsed.error.flatten().fieldErrors
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
const purchase = await prisma.purchase.findUnique({
|
|
where: { id: parsedId },
|
|
select: { id: true }
|
|
});
|
|
if (!purchase) {
|
|
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
|
}
|
|
|
|
const existingAnalysis = await prisma.purchaseAnalysis.findUnique({
|
|
where: { purchaseId: parsedId },
|
|
include: { costEntries: true }
|
|
});
|
|
|
|
await upsertAnalysis(parsedId, parsed.data);
|
|
|
|
const refreshed = await prisma.purchase.findUnique({
|
|
where: { id: parsedId },
|
|
select: {
|
|
id: true,
|
|
purchaseNo: true,
|
|
purchaseDate: true,
|
|
status: true,
|
|
moistureBuyPercent: true,
|
|
moistureReceivedPercent: true,
|
|
aboveAverageRatioPercent: true,
|
|
mkSharePercent: true,
|
|
nonMkSharePercent: true,
|
|
shippingCost: true,
|
|
incomingOperationalCost: true,
|
|
afterArrivalOperationalCost: true,
|
|
agent: analysisInclude.agent,
|
|
profitShareScheme: analysisInclude.profitShareScheme,
|
|
lines: analysisInclude.lines,
|
|
lots: analysisInclude.lots,
|
|
analysis: analysisInclude.analysis
|
|
}
|
|
});
|
|
|
|
if (!refreshed) {
|
|
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
|
|
}
|
|
if (refreshed.status !== "SUBMITTED") {
|
|
return NextResponse.json(
|
|
{ message: "Purchase analysis hanya tersedia untuk purchase final" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
await createAuditTrailSafe({
|
|
userId: auth.user.id,
|
|
action: "PURCHASE_ANALYSIS_SAVED",
|
|
entityType: "PURCHASE_ANALYSIS",
|
|
entityId: parsedId,
|
|
method: request.method,
|
|
pathname: new URL(request.url).pathname,
|
|
statusCode: 200,
|
|
summary: `Purchase analysis untuk purchase ${parsedId.toString()} disimpan`,
|
|
metadata: buildAuditChangeMetadata(
|
|
{
|
|
status: existingAnalysis?.status ?? null,
|
|
weight_buy: existingAnalysis?.weightBuy?.toNumber() ?? null,
|
|
weight_received: existingAnalysis?.weightReceived?.toNumber() ?? null,
|
|
weight_final: existingAnalysis?.weightFinal?.toNumber() ?? null,
|
|
moisture_buy_percent: existingAnalysis?.moistureBuyPercent?.toNumber() ?? null,
|
|
moisture_received_percent: existingAnalysis?.moistureReceivedPercent?.toNumber() ?? null,
|
|
moisture_final_percent: existingAnalysis?.moistureFinalPercent?.toNumber() ?? null,
|
|
above_average_ratio_percent: existingAnalysis?.aboveAverageRatioPercent?.toNumber() ?? null,
|
|
average_price: existingAnalysis?.averagePrice?.toNumber() ?? null,
|
|
modal_beli: existingAnalysis?.modalBeli?.toNumber() ?? null,
|
|
modal_masuk: existingAnalysis?.modalMasuk?.toNumber() ?? null,
|
|
modal_jual: existingAnalysis?.modalJual?.toNumber() ?? null,
|
|
modal_barang: existingAnalysis?.modalBarang?.toNumber() ?? null,
|
|
total_modal_beli: existingAnalysis?.totalModalBeli?.toNumber() ?? null,
|
|
total_modal_mal: existingAnalysis?.totalModalMal?.toNumber() ?? null,
|
|
market_reference_price: existingAnalysis?.marketReferencePrice?.toNumber() ?? null,
|
|
market_valuation_total: existingAnalysis?.marketValuationTotal?.toNumber() ?? null,
|
|
agent_profit_share_total: existingAnalysis?.agentProfitShareTotal?.toNumber() ?? null,
|
|
notes: existingAnalysis?.notes ?? null,
|
|
cost_entries: (existingAnalysis?.costEntries ?? []).map((entry) => ({
|
|
cost_type: entry.costType,
|
|
description: entry.description,
|
|
amount: entry.amount.toNumber()
|
|
}))
|
|
},
|
|
{
|
|
status: parsed.data.status,
|
|
weight_buy: parsed.data.weight_buy,
|
|
weight_received: parsed.data.weight_received,
|
|
weight_final: parsed.data.weight_final,
|
|
moisture_buy_percent: parsed.data.moisture_buy_percent,
|
|
moisture_received_percent: parsed.data.moisture_received_percent,
|
|
moisture_final_percent: parsed.data.moisture_final_percent,
|
|
above_average_ratio_percent: parsed.data.above_average_ratio_percent,
|
|
average_price: parsed.data.average_price,
|
|
modal_beli: parsed.data.modal_beli,
|
|
modal_masuk: parsed.data.modal_masuk,
|
|
modal_jual: parsed.data.modal_jual,
|
|
modal_barang: parsed.data.modal_barang,
|
|
total_modal_beli: parsed.data.total_modal_beli,
|
|
total_modal_mal: parsed.data.total_modal_mal,
|
|
market_reference_price: parsed.data.market_reference_price,
|
|
market_valuation_total: parsed.data.market_valuation_total,
|
|
agent_profit_share_total: parsed.data.agent_profit_share_total,
|
|
notes: parsed.data.notes || null,
|
|
cost_entries: parsed.data.cost_entries
|
|
}
|
|
)
|
|
});
|
|
|
|
return NextResponse.json({
|
|
data: serializePurchaseAnalysisDetail(refreshed)
|
|
});
|
|
} catch (error) {
|
|
console.error(`Failed to save purchase analysis for ${parsedId.toString()}:`, error);
|
|
return NextResponse.json(
|
|
{ message: "Gagal menyimpan analisis pembelian" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|