Initial import of AbelBirdNest Stock

This commit is contained in:
2026-05-16 18:25:51 +07:00
commit 14bb9bf744
472 changed files with 70671 additions and 0 deletions

View File

@ -0,0 +1,138 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeAdjustmentReason } from "@/features/adjustment-reasons/lib/serialize-adjustment-reason";
import { adjustmentReasonInputSchema } from "@/features/adjustment-reasons/schemas/adjustment-reason.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const reason = await prisma.adjustmentReason.findUnique({ where: { id: parsedId } });
if (!reason) return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 });
return NextResponse.json({ data: serializeAdjustmentReason(reason) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = adjustmentReasonInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const existing = await prisma.adjustmentReason.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 });
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "ADJ",
requestedCode: parsed.data.code,
existingCode: existing.code,
countExisting: () =>
prisma.adjustmentReason.count({ where: { code: { startsWith: "ADJ" } } }),
exists: async (code) =>
(await prisma.adjustmentReason.count({ where: { code, id: { not: parsedId } } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const reason = await prisma.adjustmentReason.update({
where: { id: parsedId },
data: {
code: resolvedCode.code,
name: parsed.data.name,
category: parsed.data.category,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "ADJUSTMENT_REASON_UPDATED",
entityType: "ADJUSTMENT_REASON",
entityId: reason.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Adjustment reason ${reason.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
name: existing.name,
category: existing.category,
status: existing.status
},
{
code: reason.code,
name: reason.name,
category: reason.category,
status: reason.status
}
)
});
return NextResponse.json({ data: serializeAdjustmentReason(reason) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode alasan sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.adjustmentReason.findUnique({ where: { id: parsedId } });
await prisma.adjustmentReason.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "ADJUSTMENT_REASON_DELETED",
entityType: "ADJUSTMENT_REASON",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Adjustment reason ${existing?.code ?? parsedId.toString()} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Adjustment reason not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,72 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeAdjustmentReason } from "@/features/adjustment-reasons/lib/serialize-adjustment-reason";
import { adjustmentReasonInputSchema } from "@/features/adjustment-reasons/schemas/adjustment-reason.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const data = await prisma.adjustmentReason.findMany({ orderBy: [{ createdAt: "desc" }] });
return NextResponse.json({ data: data.map(serializeAdjustmentReason) });
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = adjustmentReasonInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "ADJ",
requestedCode: parsed.data.code,
countExisting: () =>
prisma.adjustmentReason.count({ where: { code: { startsWith: "ADJ" } } }),
exists: async (code) =>
(await prisma.adjustmentReason.count({ where: { code } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const reason = await prisma.adjustmentReason.create({
data: {
code: resolvedCode.code,
name: parsed.data.name,
category: parsed.data.category,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "ADJUSTMENT_REASON_CREATED",
entityType: "ADJUSTMENT_REASON",
entityId: reason.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Adjustment reason ${reason.code} dibuat`
});
return NextResponse.json({ data: serializeAdjustmentReason(reason) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode alasan sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { serializeAgent } from "@/features/agents/lib/serialize-agent";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
function parseId(id: string) {
try {
return BigInt(id);
} catch {
return null;
}
}
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const agent = await prisma.agent.findUnique({
where: { id: parsedId },
include: {
profitShareScheme: true,
bankAccounts: { include: { bank: true } },
_count: {
select: {
purchases: true
}
}
}
});
if (!agent) {
return NextResponse.json({ message: "Agent not found" }, { status: 404 });
}
const [mutations, consignmentCount, regularSaleCount] = await Promise.all([
prisma.agentBalanceMutation.findMany({
where: { agentId: parsedId },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }]
}),
prisma.consignmentLine.count({
where: {
status: "CLOSED",
agentCommission: { gt: 0 },
lot: {
purchase: {
agentId: parsedId
}
}
}
}),
prisma.regularSaleLine.findMany({
where: {
agentId: parsedId,
regularSale: {
status: "CLOSED"
}
},
select: {
regularSaleId: true
},
distinct: ["regularSaleId"]
})
]);
return NextResponse.json({
data: {
...serializeAgent(agent),
stats: {
purchase_count: agent._count.purchases,
consignment_close_count: consignmentCount,
regular_sale_close_count: regularSaleCount.length,
history_count: mutations.length
},
balance_history: mutations.map((item) => ({
id: item.id.toString(),
balance_type: item.balanceType as "PROFIT_SHARE" | "CAPITAL",
source: item.source as
| "OPENING_BALANCE"
| "MANUAL_ADJUSTMENT"
| "CONSIGNMENT_COMMISSION"
| "REGULAR_SALE_COMMISSION"
| "JIT_SALE_COMMISSION"
| "OFFICE_BUYOUT_COMMISSION"
| "FUND_REQUEST_PROFIT_SHARE"
| "FUND_REQUEST_CAPITAL",
direction: item.direction as "IN" | "OUT",
amount: item.amount.toNumber(),
balance_after: item.balanceAfter.toNumber(),
occurred_at: item.occurredAt.toISOString(),
effective_date: item.effectiveDate?.toISOString().slice(0, 10) ?? null,
reference_no: item.referenceNo,
description:
item.notes ??
(item.source === "CONSIGNMENT_COMMISSION"
? "Komisi agen dari titip jual"
: item.source === "REGULAR_SALE_COMMISSION"
? "Komisi agen dari penjualan reguler"
: item.source === "JIT_SALE_COMMISSION"
? "Komisi agen dari penjualan just in time"
: item.source === "OFFICE_BUYOUT_COMMISSION"
? "Komisi agen dari pembelian kantor / buyout"
: item.source === "FUND_REQUEST_PROFIT_SHARE"
? "Transfer dana bagi hasil ke agen"
: item.source === "FUND_REQUEST_CAPITAL"
? "Transfer dana modal ke agen"
: item.source === "MANUAL_ADJUSTMENT"
? "Penyesuaian manual saldo agen"
: "Saldo pembuka agen"),
notes: item.referenceType
? `${item.referenceType}${item.referenceId ? ` · ${item.referenceId}` : ""}`
: null
}))
}
});
}

View File

@ -0,0 +1,274 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import {
AGENT_BALANCE_DIRECTIONS,
AGENT_BALANCE_SOURCES,
AGENT_BALANCE_TYPES,
createAgentBalanceMutation
} from "@/features/agents/lib/balance-mutations";
import { serializeAgent } from "@/features/agents/lib/serialize-agent";
import { agentInputSchema } from "@/features/agents/schemas/agent.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const agent = await prisma.agent.findUnique({
where: { id: parsedId },
include: {
profitShareScheme: true,
bankAccounts: { include: { bank: true } }
}
});
if (!agent) return NextResponse.json({ message: "Agent not found" }, { status: 404 });
return NextResponse.json({ data: serializeAgent(agent) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = agentInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const existing = await prisma.agent.findUnique({
where: { id: parsedId },
include: {
profitShareScheme: true,
bankAccounts: { include: { bank: true } }
}
});
if (!existing) return NextResponse.json({ message: "Agent not found" }, { status: 404 });
const profitShareSchemeId = BigInt(parsed.data.profit_share_scheme_id);
const bankIds = parsed.data.bank_accounts.map((account) => BigInt(account.bank_id));
const [scheme, banks] = await Promise.all([
prisma.profitShareScheme.findFirst({
where: { id: profitShareSchemeId, status: "ACTIVE" }
}),
prisma.bank.findMany({
where: { id: { in: bankIds }, status: "ACTIVE" }
})
]);
if (!scheme) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { profit_share_scheme_id: ["Skema bagi hasil harus dipilih dari master aktif"] } },
{ status: 400 }
);
}
if (banks.length !== bankIds.length) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { bank_accounts: ["Semua rekening harus memilih bank aktif dari master"] } },
{ status: 400 }
);
}
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "AGT",
requestedCode: parsed.data.code,
existingCode: existing.code,
countExisting: () => prisma.agent.count({ where: { code: { startsWith: "AGT" } } }),
exists: async (code) => (await prisma.agent.count({ where: { code, id: { not: parsedId } } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const adjustmentDate = new Date();
const nextProfitShareBalance = parsed.data.profit_share_balance;
const nextCapitalBalance = parsed.data.capital_balance;
const previousProfitShareBalance = Number(existing.currentBalance);
const previousCapitalBalance = Number(existing.capitalBalance);
const agent = await prisma.$transaction(async (tx) => {
const updated = await tx.agent.update({
where: { id: parsedId },
data: {
code: resolvedCode.code,
name: parsed.data.name,
identityType: parsed.data.identity_type,
identityNumber: parsed.data.identity_number,
mobilePhone: parsed.data.mobile_phone || null,
email: parsed.data.email || null,
address: parsed.data.address || null,
notes: parsed.data.notes || null,
joinDate: new Date(parsed.data.join_date),
profitShareSchemeId,
currentBalance: nextProfitShareBalance,
capitalBalance: nextCapitalBalance,
bankAccounts: {
deleteMany: {},
create: parsed.data.bank_accounts.map((account) => ({
bankId: BigInt(account.bank_id),
accountNumber: account.account_number
}))
}
},
include: {
profitShareScheme: true,
bankAccounts: { include: { bank: true } }
}
});
const profitShareDelta = Number((nextProfitShareBalance - previousProfitShareBalance).toFixed(2));
if (profitShareDelta !== 0) {
await createAgentBalanceMutation(tx, {
agentId: updated.id,
balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE,
direction: profitShareDelta > 0 ? AGENT_BALANCE_DIRECTIONS.IN : AGENT_BALANCE_DIRECTIONS.OUT,
source: AGENT_BALANCE_SOURCES.MANUAL_ADJUSTMENT,
amount: Math.abs(profitShareDelta),
balanceAfter: nextProfitShareBalance,
effectiveDate: adjustmentDate,
notes: "Penyesuaian manual saldo bagi hasil dari master agent"
});
}
const capitalDelta = Number((nextCapitalBalance - previousCapitalBalance).toFixed(2));
if (capitalDelta !== 0) {
await createAgentBalanceMutation(tx, {
agentId: updated.id,
balanceType: AGENT_BALANCE_TYPES.CAPITAL,
direction: capitalDelta > 0 ? AGENT_BALANCE_DIRECTIONS.IN : AGENT_BALANCE_DIRECTIONS.OUT,
source: AGENT_BALANCE_SOURCES.MANUAL_ADJUSTMENT,
amount: Math.abs(capitalDelta),
balanceAfter: nextCapitalBalance,
effectiveDate: adjustmentDate,
notes: "Penyesuaian manual saldo modal dari master agent"
});
}
return updated;
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "AGENT_UPDATED",
entityType: "AGENT",
entityId: agent.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Agent ${agent.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
name: existing.name,
identity_type: existing.identityType,
identity_number: existing.identityNumber,
mobile_phone: existing.mobilePhone,
email: existing.email,
address: existing.address,
notes: existing.notes,
join_date: existing.joinDate.toISOString().slice(0, 10),
profit_share_scheme_id: existing.profitShareScheme.id.toString(),
profit_share_balance: Number(existing.currentBalance),
capital_balance: Number(existing.capitalBalance),
bank_accounts: existing.bankAccounts.map((account) => ({
bank_id: account.bank.id.toString(),
account_number: account.accountNumber
}))
},
{
code: agent.code,
name: agent.name,
identity_type: agent.identityType,
identity_number: agent.identityNumber,
mobile_phone: agent.mobilePhone,
email: agent.email,
address: agent.address,
notes: agent.notes,
join_date: agent.joinDate.toISOString().slice(0, 10),
profit_share_scheme_id: agent.profitShareScheme.id.toString(),
profit_share_balance: Number(agent.currentBalance),
capital_balance: Number(agent.capitalBalance),
bank_accounts: agent.bankAccounts.map((account) => ({
bank_id: account.bank.id.toString(),
account_number: account.accountNumber
}))
}
)
});
return NextResponse.json({ data: serializeAgent(agent) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Agent not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { identity_number: ["Kode agen atau identitas sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.agent.findUnique({ where: { id: parsedId } });
await prisma.agent.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "AGENT_DELETED",
entityType: "AGENT",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Agent ${existing?.code ?? parsedId.toString()} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Agent not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,169 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import {
AGENT_BALANCE_DIRECTIONS,
AGENT_BALANCE_SOURCES,
AGENT_BALANCE_TYPES,
createAgentBalanceMutation
} from "@/features/agents/lib/balance-mutations";
import { serializeAgent } from "@/features/agents/lib/serialize-agent";
import { agentInputSchema } from "@/features/agents/schemas/agent.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const data = await prisma.agent.findMany({
include: {
profitShareScheme: true,
bankAccounts: {
include: {
bank: true
}
}
},
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({ data: data.map(serializeAgent) });
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = agentInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const profitShareSchemeId = BigInt(parsed.data.profit_share_scheme_id);
const bankIds = parsed.data.bank_accounts.map((account) => BigInt(account.bank_id));
const [scheme, banks] = await Promise.all([
prisma.profitShareScheme.findFirst({
where: { id: profitShareSchemeId, status: "ACTIVE" }
}),
prisma.bank.findMany({
where: { id: { in: bankIds }, status: "ACTIVE" }
})
]);
if (!scheme) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { profit_share_scheme_id: ["Skema bagi hasil harus dipilih dari master aktif"] } },
{ status: 400 }
);
}
if (banks.length !== bankIds.length) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { bank_accounts: ["Semua rekening harus memilih bank aktif dari master"] } },
{ status: 400 }
);
}
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "AGT",
requestedCode: parsed.data.code,
countExisting: () => prisma.agent.count({ where: { code: { startsWith: "AGT" } } }),
exists: async (code) => (await prisma.agent.count({ where: { code } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const joinDate = new Date(parsed.data.join_date);
const agent = await prisma.$transaction(async (tx) => {
const created = await tx.agent.create({
data: {
code: resolvedCode.code,
name: parsed.data.name,
identityType: parsed.data.identity_type,
identityNumber: parsed.data.identity_number,
mobilePhone: parsed.data.mobile_phone || null,
email: parsed.data.email || null,
address: parsed.data.address || null,
notes: parsed.data.notes || null,
joinDate,
profitShareSchemeId,
currentBalance: parsed.data.profit_share_balance,
capitalBalance: parsed.data.capital_balance,
bankAccounts: {
create: parsed.data.bank_accounts.map((account) => ({
bankId: BigInt(account.bank_id),
accountNumber: account.account_number
}))
}
},
include: {
profitShareScheme: true,
bankAccounts: { include: { bank: true } }
}
});
if (parsed.data.profit_share_balance > 0) {
await createAgentBalanceMutation(tx, {
agentId: created.id,
balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE,
direction: AGENT_BALANCE_DIRECTIONS.IN,
source: AGENT_BALANCE_SOURCES.OPENING_BALANCE,
amount: parsed.data.profit_share_balance,
balanceAfter: parsed.data.profit_share_balance,
effectiveDate: joinDate,
notes: "Saldo bagi hasil awal agent"
});
}
if (parsed.data.capital_balance > 0) {
await createAgentBalanceMutation(tx, {
agentId: created.id,
balanceType: AGENT_BALANCE_TYPES.CAPITAL,
direction: AGENT_BALANCE_DIRECTIONS.IN,
source: AGENT_BALANCE_SOURCES.OPENING_BALANCE,
amount: parsed.data.capital_balance,
balanceAfter: parsed.data.capital_balance,
effectiveDate: joinDate,
notes: "Saldo modal awal agent"
});
}
return created;
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "AGENT_CREATED",
entityType: "AGENT",
entityId: agent.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Agent ${agent.code} dibuat`
});
return NextResponse.json({ data: serializeAgent(agent) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { identity_number: ["Kode agen atau identitas sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import * as XLSX from "xlsx";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
function parseDateStart(value: string | null) {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function parseDateEnd(value: string | null) {
if (!value) return null;
const date = new Date(`${value}T23:59:59.999Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function buildWhere(searchParams: URLSearchParams): Prisma.AuditTrailWhereInput {
const action = searchParams.get("action")?.trim();
const userId = searchParams.get("user_id")?.trim();
const dateFrom = parseDateStart(searchParams.get("date_from"));
const dateTo = parseDateEnd(searchParams.get("date_to"));
const search = searchParams.get("search")?.trim();
const where: Prisma.AuditTrailWhereInput = {};
if (action) {
where.action = action;
}
if (userId) {
try {
where.userId = BigInt(userId);
} catch {
where.userId = BigInt(-1);
}
}
if (dateFrom || dateTo) {
where.occurredAt = {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {})
};
}
if (search) {
where.OR = [
{ action: { contains: search, mode: "insensitive" } },
{ entityType: { contains: search, mode: "insensitive" } },
{ entityId: { contains: search, mode: "insensitive" } },
{ pathname: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ user: { name: { contains: search, mode: "insensitive" } } },
{ user: { email: { contains: search, mode: "insensitive" } } },
{ user: { username: { contains: search, mode: "insensitive" } } }
];
}
return where;
}
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const { searchParams } = new URL(request.url);
const where = buildWhere(searchParams);
const items = await prisma.auditTrail.findMany({
where,
include: {
user: {
include: {
role: { select: { code: true } }
}
}
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }]
});
const rows = items.map((item) => ({
"Occurred At": item.occurredAt.toISOString(),
Action: item.action,
"Entity Type": item.entityType,
"Entity ID": item.entityId ?? "",
Method: item.method,
Pathname: item.pathname,
"Status Code": item.statusCode,
Summary: item.summary ?? "",
User: item.user?.name ?? "",
Email: item.user?.email ?? "",
Username: item.user?.username ?? "",
Role: item.user?.role.code ?? "",
Metadata: item.metadata ? JSON.stringify(item.metadata) : ""
}));
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.json_to_sheet(rows);
XLSX.utils.book_append_sheet(workbook, worksheet, "Audit Trail");
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" });
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type":
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename=\"audit-trail-${new Date()
.toISOString()
.slice(0, 10)}.xlsx\"`
}
});
}

View File

@ -0,0 +1,108 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { serializeAuditTrail } from "@/features/audit-trail/lib/serialize-audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
function parseDateStart(value: string | null) {
if (!value) return null;
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function parseDateEnd(value: string | null) {
if (!value) return null;
const date = new Date(`${value}T23:59:59.999Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function buildWhere(searchParams: URLSearchParams): Prisma.AuditTrailWhereInput {
const action = searchParams.get("action")?.trim();
const userId = searchParams.get("user_id")?.trim();
const dateFrom = parseDateStart(searchParams.get("date_from"));
const dateTo = parseDateEnd(searchParams.get("date_to"));
const search = searchParams.get("search")?.trim();
const where: Prisma.AuditTrailWhereInput = {};
if (action) {
where.action = action;
}
if (userId) {
try {
where.userId = BigInt(userId);
} catch {
where.userId = BigInt(-1);
}
}
if (dateFrom || dateTo) {
where.occurredAt = {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {})
};
}
if (search) {
where.OR = [
{ action: { contains: search, mode: "insensitive" } },
{ entityType: { contains: search, mode: "insensitive" } },
{ entityId: { contains: search, mode: "insensitive" } },
{ pathname: { contains: search, mode: "insensitive" } },
{ summary: { contains: search, mode: "insensitive" } },
{ user: { name: { contains: search, mode: "insensitive" } } },
{ user: { email: { contains: search, mode: "insensitive" } } },
{ user: { username: { contains: search, mode: "insensitive" } } }
];
}
return where;
}
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const { searchParams } = new URL(request.url);
const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1);
const perPage = Math.min(100, Math.max(10, Number(searchParams.get("per_page") ?? "25") || 25));
const skip = (page - 1) * perPage;
const where = buildWhere(searchParams);
const [items, total, actionRows] = await Promise.all([
prisma.auditTrail.findMany({
where,
include: {
user: {
include: {
role: { select: { code: true } }
}
}
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
skip,
take: perPage
}),
prisma.auditTrail.count({ where }),
prisma.auditTrail.findMany({
distinct: ["action"],
select: { action: true },
orderBy: { action: "asc" }
})
]);
return NextResponse.json({
data: items.map(serializeAuditTrail),
meta: {
page,
per_page: perPage,
total,
total_pages: Math.max(1, Math.ceil(total / perPage))
},
filters: {
actions: actionRows.map((item) => item.action)
}
});
}

View File

@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { hashPassword, verifyPassword } from "@/lib/auth";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
const changePasswordSchema = z
.object({
current_password: z.string().min(1, "Password saat ini wajib diisi"),
password: z.string().min(8, "Password baru minimal 8 karakter"),
password_confirmation: z.string().min(8, "Konfirmasi password minimal 8 karakter")
})
.refine((value) => value.password === value.password_confirmation, {
message: "Konfirmasi password tidak sama",
path: ["password_confirmation"]
});
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
try {
const body = await request.json();
const parsed = changePasswordSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const user = await prisma.user.findUnique({
where: { id: BigInt(auth.user.id) }
});
if (!user || !user.passwordHash) {
return NextResponse.json({ message: "User tidak ditemukan" }, { status: 404 });
}
if (!verifyPassword(parsed.data.current_password, user.passwordHash)) {
return NextResponse.json(
{
message: "Password saat ini tidak valid"
},
{ status: 422 }
);
}
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash: hashPassword(parsed.data.password)
}
});
await createAuditTrailSafe({
userId: user.id,
action: "PASSWORD_CHANGED",
entityType: "AUTH",
entityId: user.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: "User mengganti password akun sendiri"
});
return NextResponse.json({
message: "Password berhasil diperbarui"
});
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal mengganti password"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { sendPasswordResetEmail } from "@/features/auth/lib/auth-emails";
import { buildPasswordResetUrl, issuePasswordResetToken } from "@/features/auth/lib/password-reset";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
const forgotPasswordSchema = z.object({
email: z.string().trim().email("Email tidak valid")
});
export async function POST(request: Request) {
try {
const parsed = forgotPasswordSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const email = parsed.data.email.toLowerCase();
const user = await prisma.user.findFirst({
where: {
email,
status: "ACTIVE",
emailVerifiedAt: {
not: null
}
}
});
if (user?.email) {
const { plainToken } = await issuePasswordResetToken(user.id);
await sendPasswordResetEmail({
to: user.email,
name: user.name,
resetUrl: buildPasswordResetUrl(plainToken)
});
await createAuditTrailSafe({
userId: user.id,
action: "PASSWORD_RESET_REQUESTED",
entityType: "AUTH",
entityId: user.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: "Permintaan reset password dikirim"
});
}
return NextResponse.json({
message: "Jika email terdaftar, link reset password sudah dikirim."
});
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal mengirim email reset password"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,142 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getDefaultPathForRole } from "@/config/access-control";
import { ensureAuthBootstrap } from "@/features/auth/lib/bootstrap-auth";
import {
getSessionTimeoutSeconds,
isEmailVerificationRequired
} from "@/lib/app-settings";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import {
AUTH_COOKIE_NAME,
createSessionToken,
getSessionTtlSeconds,
getSessionCookieOptions,
verifyPassword
} from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const loginSchema = z.object({
identity: z.string().trim().min(1, "Email atau username wajib diisi"),
password: z.string().min(1, "Password wajib diisi")
});
export async function POST(request: Request) {
try {
await ensureAuthBootstrap();
const body = await request.json();
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const { identity, password } = parsed.data;
const normalizedIdentity = identity.toLowerCase();
const [mustVerifyEmail, sessionTtlSeconds] = await Promise.all([
isEmailVerificationRequired(),
getSessionTimeoutSeconds()
]);
const user = await prisma.user.findFirst({
where: {
status: "ACTIVE",
OR: [{ email: normalizedIdentity }, { username: normalizedIdentity }]
},
include: {
role: true
}
});
if (!user || !user.passwordHash || !verifyPassword(password, user.passwordHash)) {
await createAuditTrailSafe({
action: "LOGIN_FAILED",
entityType: "AUTH",
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 401,
summary: "Login gagal",
metadata: { identity: normalizedIdentity }
});
return NextResponse.json(
{
message: "Email/username atau password tidak valid"
},
{ status: 401 }
);
}
if (mustVerifyEmail && !user.emailVerifiedAt) {
await createAuditTrailSafe({
userId: user.id,
action: "LOGIN_BLOCKED_UNVERIFIED",
entityType: "AUTH",
entityId: user.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 403,
summary: "Login ditolak karena email belum diverifikasi"
});
return NextResponse.json(
{
message: "Email belum diverifikasi. Cek inbox Anda atau kirim ulang verifikasi."
},
{ status: 403 }
);
}
const sessionUser = {
id: user.id.toString(),
name: user.name,
role: user.role.code,
email: user.email,
username: user.username
};
const sessionToken = createSessionToken(sessionUser, sessionTtlSeconds);
const response = NextResponse.json({
message: "Login berhasil",
data: {
user: sessionUser,
redirect_to: getDefaultPathForRole(sessionUser.role),
session_token: sessionToken,
token_type: "Bearer",
expires_in: sessionTtlSeconds || getSessionTtlSeconds()
}
});
response.cookies.set(
AUTH_COOKIE_NAME,
sessionToken,
getSessionCookieOptions(sessionTtlSeconds)
);
await createAuditTrailSafe({
userId: user.id,
action: "LOGIN_SUCCESS",
entityType: "AUTH",
entityId: user.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: "Login berhasil"
});
return response;
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal login"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { AUTH_COOKIE_NAME, getSessionCookieOptions } from "@/lib/auth";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
export async function POST(request: Request) {
const auth = requireApiAccess(request);
const response = NextResponse.json({
message: "Logout berhasil"
});
response.cookies.set(AUTH_COOKIE_NAME, "", {
...getSessionCookieOptions(),
maxAge: 0
});
await createAuditTrailSafe({
userId: auth.ok ? auth.user.id : null,
action: "LOGOUT",
entityType: "AUTH",
entityId: auth.ok ? auth.user.id : null,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: "Logout berhasil"
});
return response;
}

View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth-server";
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json(
{
message: "Unauthorized"
},
{ status: 401 }
);
}
return NextResponse.json({
data: {
user
}
});
}

View File

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { sendEmailVerificationEmail } from "@/features/auth/lib/auth-emails";
import {
buildEmailVerificationUrl,
issueEmailVerificationToken
} from "@/features/auth/lib/email-verification";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
const resendVerificationSchema = z.object({
email: z.string().trim().email("Email tidak valid")
});
export async function POST(request: Request) {
try {
const parsed = resendVerificationSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const email = parsed.data.email.toLowerCase();
const user = await prisma.user.findFirst({
where: {
email,
status: "ACTIVE",
emailVerifiedAt: null
}
});
if (user?.email) {
const { plainToken } = await issueEmailVerificationToken(user.id);
await sendEmailVerificationEmail({
to: user.email,
name: user.name,
verifyUrl: buildEmailVerificationUrl(plainToken)
});
await createAuditTrailSafe({
userId: user.id,
action: "EMAIL_VERIFICATION_RESENT",
entityType: "AUTH",
entityId: user.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: "Kirim ulang email verifikasi"
});
}
return NextResponse.json({
message: "Jika email membutuhkan verifikasi, link verifikasi sudah dikirim."
});
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal mengirim email verifikasi"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
consumePasswordResetToken
} from "@/features/auth/lib/password-reset";
import { hashPassword } from "@/lib/auth";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
const resetPasswordSchema = z
.object({
token: z.string().min(1, "Token wajib diisi"),
password: z.string().min(8, "Password minimal 8 karakter"),
password_confirmation: z.string().min(8, "Konfirmasi password minimal 8 karakter")
})
.refine((value) => value.password === value.password_confirmation, {
message: "Konfirmasi password tidak sama",
path: ["password_confirmation"]
});
export async function POST(request: Request) {
try {
const parsed = resetPasswordSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const resetToken = await consumePasswordResetToken(parsed.data.token);
if (!resetToken) {
return NextResponse.json(
{
message: "Token reset password tidak valid atau sudah kedaluwarsa."
},
{ status: 400 }
);
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: resetToken.userId },
data: {
passwordHash: hashPassword(parsed.data.password)
}
});
await tx.passwordResetToken.update({
where: { id: resetToken.id },
data: {
usedAt: new Date()
}
});
});
await createAuditTrailSafe({
userId: resetToken.userId,
action: "PASSWORD_RESET_COMPLETED",
entityType: "AUTH",
entityId: resetToken.userId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: "Password berhasil direset"
});
return NextResponse.json({
message: "Password berhasil direset. Silakan login kembali."
});
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal reset password"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
consumeEmailVerificationToken
} from "@/features/auth/lib/email-verification";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
const verifyEmailSchema = z.object({
token: z.string().min(1, "Token wajib diisi")
});
export async function POST(request: Request) {
try {
const parsed = verifyEmailSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const verificationToken = await consumeEmailVerificationToken(parsed.data.token);
if (!verificationToken) {
return NextResponse.json(
{
message: "Token verifikasi tidak valid atau sudah kedaluwarsa."
},
{ status: 400 }
);
}
await prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: verificationToken.userId },
data: {
emailVerifiedAt: new Date()
}
});
await tx.emailVerificationToken.update({
where: { id: verificationToken.id },
data: {
usedAt: new Date()
}
});
});
await createAuditTrailSafe({
userId: verificationToken.userId,
action: "EMAIL_VERIFIED",
entityType: "AUTH",
entityId: verificationToken.userId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: "Email berhasil diverifikasi"
});
return NextResponse.json({
message: "Email berhasil diverifikasi. Silakan login."
});
} catch (error) {
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal verifikasi email"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,161 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeBank } from "@/features/banks/lib/serialize-bank";
import { bankInputSchema } from "@/features/banks/schemas/bank.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
type RouteContext = { params: Promise<{ id: string }> };
function parseId(rawId: string) {
try {
return BigInt(rawId);
} catch {
return null;
}
}
async function countBankUsage(bankName: string) {
const customerCount = await prisma.buyer.count({ where: { bankName } });
return customerCount;
}
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const bank = await prisma.bank.findUnique({ where: { id: parsedId } });
if (!bank) return NextResponse.json({ message: "Bank not found" }, { status: 404 });
return NextResponse.json({ data: serializeBank(bank) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = bankInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const existing = await prisma.bank.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Bank not found" }, { status: 404 });
const usageCount = await countBankUsage(existing.name);
if (usageCount > 0 && existing.name !== parsed.data.name) {
return NextResponse.json(
{ message: "Nama bank sedang dipakai di buyer dan tidak bisa diubah." },
{ status: 409 }
);
}
const bank = await prisma.bank.update({
where: { id: parsedId },
data: {
code: parsed.data.code,
name: parsed.data.name,
address: parsed.data.address || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "BANK_UPDATED",
entityType: "BANK",
entityId: bank.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Bank ${bank.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
name: existing.name,
address: existing.address,
status: existing.status
},
{
code: bank.code,
name: bank.name,
address: bank.address,
status: bank.status
}
)
});
return NextResponse.json({ data: serializeBank(bank) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Bank not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{
message: "Validasi gagal",
errors: {
code: ["Kode atau nama bank sudah dipakai"]
}
},
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.bank.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Bank not found" }, { status: 404 });
const usageCount = await countBankUsage(existing.name);
if (usageCount > 0) {
return NextResponse.json(
{ message: "Bank sedang dipakai di buyer dan tidak bisa dihapus." },
{ status: 409 }
);
}
await prisma.bank.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "BANK_DELETED",
entityType: "BANK",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Bank ${existing.code} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Bank not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,71 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeBank } from "@/features/banks/lib/serialize-bank";
import { bankInputSchema } from "@/features/banks/schemas/bank.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const banks = await prisma.bank.findMany({
orderBy: [{ status: "asc" }, { code: "asc" }]
});
return NextResponse.json({
data: banks.map(serializeBank)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = bankInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const bank = await prisma.bank.create({
data: {
code: parsed.data.code,
name: parsed.data.name,
address: parsed.data.address || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "BANK_CREATED",
entityType: "BANK",
entityId: bank.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Bank ${bank.code} dibuat`
});
return NextResponse.json({ data: serializeBank(bank) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{
message: "Validasi gagal",
errors: {
code: ["Kode atau nama bank sudah dipakai"]
}
},
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,265 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer";
import { buyerInputSchema } from "@/features/buyers/schemas/buyer.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
type RouteContext = {
params: Promise<{
id: string;
}>;
};
function parseId(rawId: string) {
try {
return BigInt(rawId);
} catch {
return null;
}
}
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const { id } = await context.params;
const parsedId = parseId(id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const buyer = await prisma.buyer.findUnique({
where: { id: parsedId },
include: {
contactPeople: {
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
}
}
});
if (!buyer) {
return NextResponse.json({ message: "Buyer not found" }, { status: 404 });
}
return NextResponse.json({
data: serializeBuyer(buyer)
});
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const { id } = await context.params;
const parsedId = parseId(id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const json = await request.json();
const parsed = buyerInputSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 400 }
);
}
try {
const existing = await prisma.buyer.findUnique({
where: { id: parsedId },
include: {
contactPeople: {
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
}
}
});
if (!existing) {
return NextResponse.json({ message: "Buyer not found" }, { status: 404 });
}
if (parsed.data.bank_name) {
const bank = await prisma.bank.findFirst({
where: {
name: parsed.data.bank_name,
status: "ACTIVE"
}
});
if (!bank) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: {
bank_name: ["Bank harus dipilih dari master bank aktif untuk buyer"]
}
},
{ status: 400 }
);
}
}
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "CUS",
requestedCode: parsed.data.code,
existingCode: existing.code,
countExisting: () =>
prisma.buyer.count({ where: { code: { startsWith: "CUS" } } }),
exists: async (code) =>
(await prisma.buyer.count({
where: {
code,
id: { not: parsedId }
}
})) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const buyer = await prisma.buyer.update({
where: { id: parsedId },
data: {
code: resolvedCode.code,
name: parsed.data.name,
phone: parsed.data.phone || null,
email: parsed.data.email || null,
bankName: parsed.data.bank_name || null,
bankAccountNumber: parsed.data.bank_account_number || null,
address: parsed.data.address || null,
contactPeople: {
deleteMany: {},
create: parsed.data.contact_people.map((contactPerson) => ({
name: contactPerson.name,
mobilePhone: contactPerson.mobile_phone || null,
email: contactPerson.email || null
}))
},
status: parsed.data.status
},
include: {
contactPeople: {
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
}
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "CUSTOMER_UPDATED",
entityType: "CUSTOMER",
entityId: buyer.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Buyer ${buyer.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
name: existing.name,
phone: existing.phone,
email: existing.email,
bank_name: existing.bankName,
bank_account_number: existing.bankAccountNumber,
address: existing.address,
contact_people: existing.contactPeople.map((contactPerson) => ({
name: contactPerson.name,
mobile_phone: contactPerson.mobilePhone,
email: contactPerson.email
})),
status: existing.status
},
{
code: buyer.code,
name: buyer.name,
phone: buyer.phone,
email: buyer.email,
bank_name: buyer.bankName,
bank_account_number: buyer.bankAccountNumber,
address: buyer.address,
contact_people: buyer.contactPeople.map((contactPerson) => ({
name: contactPerson.name,
mobile_phone: contactPerson.mobilePhone,
email: contactPerson.email
})),
status: buyer.status
}
)
});
return NextResponse.json({
data: serializeBuyer(buyer)
});
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Buyer not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{
message: "Validasi gagal",
errors: {
code: ["Kode pembeli sudah dipakai"]
}
},
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const { id } = await context.params;
const parsedId = parseId(id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
try {
const existing = await prisma.buyer.findUnique({ where: { id: parsedId } });
await prisma.buyer.delete({
where: { id: parsedId }
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "CUSTOMER_DELETED",
entityType: "CUSTOMER",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Buyer ${existing?.code ?? parsedId.toString()} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Buyer not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,139 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer";
import { buyerInputSchema } from "@/features/buyers/schemas/buyer.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const buyers = await prisma.buyer.findMany({
include: {
contactPeople: {
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
}
},
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: buyers.map(serializeBuyer)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const json = await request.json();
const parsed = buyerInputSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 400 }
);
}
try {
if (parsed.data.bank_name) {
const bank = await prisma.bank.findFirst({
where: {
name: parsed.data.bank_name,
status: "ACTIVE"
}
});
if (!bank) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: {
bank_name: ["Bank harus dipilih dari master bank aktif untuk buyer"]
}
},
{ status: 400 }
);
}
}
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "CUS",
requestedCode: parsed.data.code,
countExisting: () =>
prisma.buyer.count({ where: { code: { startsWith: "CUS" } } }),
exists: async (code) =>
(await prisma.buyer.count({ where: { code } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const buyer = await prisma.buyer.create({
data: {
code: resolvedCode.code,
name: parsed.data.name,
phone: parsed.data.phone || null,
email: parsed.data.email || null,
bankName: parsed.data.bank_name || null,
bankAccountNumber: parsed.data.bank_account_number || null,
address: parsed.data.address || null,
contactPeople: {
create: parsed.data.contact_people.map((contactPerson) => ({
name: contactPerson.name,
mobilePhone: contactPerson.mobile_phone || null,
email: contactPerson.email || null
}))
},
status: parsed.data.status
},
include: {
contactPeople: {
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
}
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "CUSTOMER_CREATED",
entityType: "CUSTOMER",
entityId: buyer.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Buyer ${buyer.code} dibuat`
});
return NextResponse.json(
{
data: serializeBuyer(buyer)
},
{ status: 201 }
);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{
message: "Validasi gagal",
errors: {
code: ["Kode pembeli sudah dipakai"]
}
},
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { serializeConsignmentDetail } from "@/features/consignments/lib/serialize-consignment";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
const consignmentDetailInclude = {
sales: true,
buyer: true,
lines: {
include: {
lot: {
include: {
purchase: {
select: {
agent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
}
}
}
} as const;
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseBigInt((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const consignment = await prisma.consignment.findUnique({
where: { id: parsedId },
include: consignmentDetailInclude
});
if (!consignment) {
return NextResponse.json({ message: "Titip jual tidak ditemukan" }, { status: 404 });
}
return NextResponse.json({
data: serializeConsignmentDetail(consignment)
});
}

View File

@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer";
import { serializeConsignmentCandidateLot } from "@/features/consignments/lib/serialize-consignment";
import { serializeSales } from "@/features/sales/lib/serialize-sales";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const [sales, buyers, lots] = await Promise.all([
prisma.sales.findMany({
include: {
bankAccounts: {
include: {
bank: true
}
}
},
orderBy: [{ name: "asc" }]
}),
prisma.buyer.findMany({
include: {
contactPeople: true
},
where: {
status: "ACTIVE"
},
orderBy: [{ name: "asc" }]
}),
prisma.inventoryLot.findMany({
where: {
status: "ACTIVE",
availableQty: {
gt: 0
}
},
include: {
purchaseLine: {
select: {
malUnitPrice: true
}
},
grade: { select: { name: true } },
unit: { select: { code: true } },
warehouse: { select: { name: true } },
warehouseLocation: { select: { name: true } }
},
orderBy: [{ createdAt: "desc" }]
})
]);
return NextResponse.json({
data: {
sales: sales.map(serializeSales),
buyers: buyers.map(serializeBuyer),
lots: lots.map(serializeConsignmentCandidateLot)
}
});
}

View File

@ -0,0 +1,380 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import {
AGENT_BALANCE_DIRECTIONS,
AGENT_BALANCE_SOURCES,
AGENT_BALANCE_TYPES,
createAgentBalanceMutation
} from "@/features/agents/lib/balance-mutations";
import { consignmentCloseLineSchema } from "@/features/consignments/schemas/consignment.schema";
import {
createSalesCommissionMutation,
SALES_COMMISSION_DIRECTIONS,
SALES_COMMISSION_SOURCES
} from "@/features/sales/lib/commission-mutations";
import { buildAllocationShares, getEffectiveLotAllocations, roundAmount } from "@/features/purchase-realization/lib/lot-allocation";
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ lineId: string }> };
type ConsignmentTx = Prisma.TransactionClient & {
purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry;
purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary;
lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation;
};
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
export async function POST(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedLineId = parseBigInt((await context.params).lineId);
if (parsedLineId === null) {
return NextResponse.json({ message: "Invalid line id" }, { status: 400 });
}
const parsed = consignmentCloseLineSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const closeDate = new Date(`${parsed.data.close_date}T00:00:00.000Z`);
if (Number.isNaN(closeDate.getTime())) {
return NextResponse.json({ message: "Tanggal close tidak valid" }, { status: 400 });
}
const existingLine = await prisma.consignmentLine.findUnique({
where: { id: parsedLineId },
include: {
lot: {
include: {
purchase: {
select: {
id: true,
agentId: true,
profitShareSchemeId: true,
agent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
purchaseAllocations: true
}
},
consignment: true
}
});
if (!existingLine) {
return NextResponse.json({ message: "Item titip jual tidak ditemukan" }, { status: 404 });
}
if (existingLine.status === "CLOSED") {
return NextResponse.json({ message: "Item sudah ditutup" }, { status: 409 });
}
const qtyConsigned = existingLine.qtyConsigned.toNumber();
const qtySold = parsed.data.qty_sold;
const qtyReturned = parsed.data.qty_returned;
const salesCommission = parsed.data.sales_commission;
const resolvedQty = qtySold + qtyReturned;
if (resolvedQty > qtyConsigned) {
return NextResponse.json(
{ message: "Berat terjual + kembali tidak boleh melebihi berat titip" },
{ status: 422 }
);
}
const qtyShrinkage = Number((qtyConsigned - qtySold - qtyReturned).toFixed(3));
const malUnitPrice = existingLine.malUnitPriceSnapshot?.toNumber() ?? 0;
const shareAgent =
existingLine.agentSharePercent?.toNumber() ??
existingLine.lot.purchase?.profitShareScheme?.shareAgent.toNumber() ??
0;
const agentCommission = Number(
((((qtySold * (parsed.data.selling_price - malUnitPrice)) - salesCommission) * shareAgent) / 100).toFixed(2)
);
const updated = await prisma.$transaction(async (rawTx) => {
const tx = rawTx as ConsignmentTx;
const nextAvailable = Number((existingLine.lot.availableQty.toNumber() + qtyReturned).toFixed(3));
await tx.inventoryLot.update({
where: { id: existingLine.lotId },
data: {
availableQty: new Prisma.Decimal(nextAvailable),
shrinkageQty: new Prisma.Decimal(
Number((existingLine.lot.shrinkageQty.toNumber() + qtyShrinkage).toFixed(3))
),
status: nextAvailable <= 0 ? "DEPLETED" : "ACTIVE"
}
});
const line = await tx.consignmentLine.update({
where: { id: parsedLineId },
data: {
status: "CLOSED",
closeDate,
sellingPrice: new Prisma.Decimal(parsed.data.selling_price),
qtySold: new Prisma.Decimal(qtySold),
qtyReturned: new Prisma.Decimal(qtyReturned),
qtyShrinkage: new Prisma.Decimal(qtyShrinkage),
salesCommission: new Prisma.Decimal(salesCommission),
agentCommission: new Prisma.Decimal(agentCommission)
}
});
const affectedPurchaseIds = new Set<bigint>();
const allocations = getEffectiveLotAllocations(existingLine.lot);
const allocationBaseQty = allocations.reduce(
(sum, allocation) => sum + allocation.qtyAllocated.toNumber(),
0
);
const soldAndShrinkageQty = Number((qtySold + qtyShrinkage).toFixed(3));
const revenueNet = roundAmount((qtySold * parsed.data.selling_price) - salesCommission);
const shares = buildAllocationShares(allocations, allocationBaseQty, soldAndShrinkageQty);
for (const share of shares) {
affectedPurchaseIds.add(share.allocation.purchaseId);
const soldQtyShare =
soldAndShrinkageQty > 0
? Number((qtySold * (share.affectedAllocationQty / soldAndShrinkageQty)).toFixed(3))
: 0;
const shrinkageQtyShare =
soldAndShrinkageQty > 0
? Number((qtyShrinkage * (share.affectedAllocationQty / soldAndShrinkageQty)).toFixed(3))
: 0;
const revenueShare = roundAmount(
soldAndShrinkageQty > 0 ? revenueNet * (share.affectedAllocationQty / soldAndShrinkageQty) : 0
);
if (soldQtyShare > 0 || revenueShare > 0) {
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: share.allocation.purchaseId,
lotId: existingLine.lotId,
allocationId: share.allocation.id ?? undefined,
eventType: "CONSIGNMENT_REVENUE",
referenceType: "CONSIGNMENT",
referenceId: existingLine.consignmentId,
occurredAt: closeDate,
qtyIn: new Prisma.Decimal(0),
qtyOut: new Prisma.Decimal(soldQtyShare),
qtyShrinkage: new Prisma.Decimal(0),
amountCost: new Prisma.Decimal(0),
amountRevenue: new Prisma.Decimal(revenueShare),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(0),
agentSharePercentSnapshot: null,
agentAmount: new Prisma.Decimal(0),
notes: `Revenue consignment ${existingLine.consignment.consignmentNo}`
}
});
}
if (qtyReturned > 0) {
const returnQtyShare = Number(
(qtyReturned * ((allocationBaseQty > 0 ? share.allocationQty / allocationBaseQty : 0))).toFixed(3)
);
if (returnQtyShare > 0) {
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: share.allocation.purchaseId,
lotId: existingLine.lotId,
allocationId: share.allocation.id ?? undefined,
eventType: "CONSIGNMENT_RETURN",
referenceType: "CONSIGNMENT",
referenceId: existingLine.consignmentId,
occurredAt: closeDate,
qtyIn: new Prisma.Decimal(returnQtyShare),
qtyOut: new Prisma.Decimal(0),
qtyShrinkage: new Prisma.Decimal(0),
amountCost: new Prisma.Decimal(0),
amountRevenue: new Prisma.Decimal(0),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(0),
agentSharePercentSnapshot: null,
agentAmount: new Prisma.Decimal(0),
notes: `Return consignment ${existingLine.consignment.consignmentNo}`
}
});
}
}
if (shrinkageQtyShare > 0) {
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: share.allocation.purchaseId,
lotId: existingLine.lotId,
allocationId: share.allocation.id ?? undefined,
eventType: "CONSIGNMENT_SHRINKAGE",
referenceType: "CONSIGNMENT",
referenceId: existingLine.consignmentId,
occurredAt: closeDate,
qtyIn: new Prisma.Decimal(0),
qtyOut: new Prisma.Decimal(0),
qtyShrinkage: new Prisma.Decimal(shrinkageQtyShare),
amountCost: new Prisma.Decimal(0),
amountRevenue: new Prisma.Decimal(0),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(0),
agentSharePercentSnapshot: null,
agentAmount: new Prisma.Decimal(0),
notes: `Susut consignment ${existingLine.consignment.consignmentNo}`
}
});
}
if (share.allocation.id) {
await tx.lotPurchaseAllocation.update({
where: { id: share.allocation.id },
data: {
qtyAllocated: new Prisma.Decimal(share.remainingQty),
costTotalAllocated: new Prisma.Decimal(
roundAmount(share.remainingQty * share.allocation.unitCostSnapshot.toNumber())
)
}
});
}
}
if (salesCommission > 0) {
const updatedSales = await tx.sales.update({
where: { id: existingLine.consignment.salesId },
data: {
commissionBalance: {
increment: new Prisma.Decimal(salesCommission)
}
},
select: {
commissionBalance: true
}
});
await createSalesCommissionMutation(tx, {
salesId: existingLine.consignment.salesId,
source: SALES_COMMISSION_SOURCES.CONSIGNMENT_COMMISSION,
direction: SALES_COMMISSION_DIRECTIONS.IN,
amount: salesCommission,
balanceAfter: updatedSales.commissionBalance.toNumber(),
effectiveDate: closeDate,
referenceType: "CONSIGNMENT",
referenceId: existingLine.consignmentId.toString(),
referenceNo: existingLine.consignment.consignmentNo,
notes: `Komisi sales dari titip jual ${existingLine.consignment.consignmentNo}`,
metadata: {
line_id: existingLine.id.toString(),
lot_code: existingLine.lot.lotCode
}
});
}
if (existingLine.lot.purchase?.agentId && agentCommission > 0) {
const updatedAgent = await tx.agent.update({
where: { id: existingLine.lot.purchase.agentId },
data: {
currentBalance: {
increment: new Prisma.Decimal(agentCommission)
}
},
select: {
currentBalance: true
}
});
await createAgentBalanceMutation(tx, {
agentId: existingLine.lot.purchase.agentId,
balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE,
direction: AGENT_BALANCE_DIRECTIONS.IN,
source: AGENT_BALANCE_SOURCES.CONSIGNMENT_COMMISSION,
amount: agentCommission,
balanceAfter: updatedAgent.currentBalance.toNumber(),
effectiveDate: closeDate,
referenceType: "CONSIGNMENT",
referenceId: existingLine.consignmentId.toString(),
referenceNo: existingLine.consignment.consignmentNo,
notes: `Komisi agent dari titip jual ${existingLine.consignment.consignmentNo}`
});
}
const openLineCount = await tx.consignmentLine.count({
where: {
consignmentId: existingLine.consignmentId,
status: {
not: "CLOSED"
}
}
});
await tx.consignment.update({
where: { id: existingLine.consignmentId },
data: {
status: openLineCount === 0 ? "CLOSED" : "PARTIAL_CLOSED"
}
});
for (const purchaseId of affectedPurchaseIds) {
const sourcePurchase = await tx.purchase.findUnique({
where: { id: purchaseId },
select: {
profitShareScheme: {
select: {
shareAgent: true
}
}
}
});
await recalculatePurchaseRealizationSummary(
tx,
purchaseId,
sourcePurchase?.profitShareScheme?.shareAgent.toNumber() ?? null
);
}
return line;
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "CONSIGNMENT_LINE_CLOSED",
entityType: "CONSIGNMENT_LINE",
entityId: updated.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Item titip jual ${existingLine.lot.lotCode} ditutup`,
metadata: {
consignment_no: existingLine.consignment.consignmentNo,
lot_code: existingLine.lot.lotCode,
qty_sold: qtySold,
qty_returned: qtyReturned,
qty_shrinkage: qtyShrinkage,
sales_commission: salesCommission,
agent_commission: agentCommission
}
});
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,237 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { generateConsignmentNo } from "@/features/consignments/lib/generate-consignment-no";
import {
serializeConsignmentDetail,
serializeConsignmentListItem
} from "@/features/consignments/lib/serialize-consignment";
import { consignmentCreateSchema } from "@/features/consignments/schemas/consignment.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
const consignmentInclude = {
sales: true,
buyer: true,
lines: true
} as const;
const consignmentDetailInclude = {
sales: true,
buyer: true,
lines: {
include: {
lot: {
include: {
purchase: {
select: {
agent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
}
}
}
} as const;
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const consignments = await prisma.consignment.findMany({
include: consignmentInclude,
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: consignments.map(serializeConsignmentListItem)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = consignmentCreateSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const salesId = parseBigInt(parsed.data.sales_id);
const buyerId = parseBigInt(parsed.data.buyer_id);
const consignmentDate = new Date(`${parsed.data.consignment_date}T00:00:00.000Z`);
if (
salesId === null ||
buyerId === null ||
Number.isNaN(consignmentDate.getTime())
) {
return NextResponse.json({ message: "Invalid reference id or date" }, { status: 400 });
}
const lotIds = parsed.data.lines.map((line) => parseBigInt(line.lot_id));
if (lotIds.some((id) => id === null)) {
return NextResponse.json({ message: "Lot tidak valid" }, { status: 400 });
}
const [sales, buyer, lots] = await Promise.all([
prisma.sales.findUnique({ where: { id: salesId } }),
prisma.buyer.findUnique({ where: { id: buyerId } }),
prisma.inventoryLot.findMany({
where: {
id: {
in: lotIds as bigint[]
}
},
include: {
purchase: {
select: {
agent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
purchaseLine: {
select: {
malUnitPrice: true
}
}
}
})
]);
if (!sales) {
return NextResponse.json({ message: "Sales tidak ditemukan" }, { status: 404 });
}
if (!buyer) {
return NextResponse.json({ message: "Buyer tidak ditemukan" }, { status: 404 });
}
const lotMap = new Map(lots.map((lot) => [lot.id.toString(), lot]));
for (const line of parsed.data.lines) {
const lot = lotMap.get(line.lot_id);
if (!lot) {
return NextResponse.json({ message: `Lot ${line.lot_id} tidak ditemukan` }, { status: 404 });
}
if (lot.status !== "ACTIVE") {
return NextResponse.json(
{ message: `Lot ${lot.lotCode} tidak aktif untuk dititipkan` },
{ status: 422 }
);
}
if (line.qty_consigned > lot.availableQty.toNumber()) {
return NextResponse.json(
{
message: `Qty titip untuk ${lot.lotCode} melebihi stok tersedia ${lot.availableQty.toNumber()}`
},
{ status: 422 }
);
}
}
const consignmentNo = await generateConsignmentNo(consignmentDate);
const created = await prisma.$transaction(async (tx) => {
const consignment = await tx.consignment.create({
data: {
consignmentNo,
consignmentDate,
salesId,
buyerId,
notes: parsed.data.notes ?? null,
createdById: BigInt(auth.user.id),
lines: {
create: parsed.data.lines.map((line) => {
const lot = lotMap.get(line.lot_id)!;
return {
lotId: lot.id,
qtyConsigned: new Prisma.Decimal(line.qty_consigned),
availableQtySnapshot: lot.availableQty,
malUnitPriceSnapshot: lot.purchaseLine?.malUnitPrice ?? null,
agentNameSnapshot: lot.purchase?.agent?.name ?? null,
agentSharePercent: lot.purchase?.profitShareScheme?.shareAgent ?? null,
status: "OPEN",
notes: line.notes ?? null
};
})
}
}
});
for (const line of parsed.data.lines) {
const lot = lotMap.get(line.lot_id)!;
const nextAvailable = Number((lot.availableQty.toNumber() - line.qty_consigned).toFixed(3));
await tx.inventoryLot.update({
where: { id: lot.id },
data: {
availableQty: new Prisma.Decimal(nextAvailable),
status: nextAvailable <= 0 ? "DEPLETED" : "ACTIVE"
}
});
}
return tx.consignment.findUniqueOrThrow({
where: { id: consignment.id },
include: consignmentDetailInclude
});
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "CONSIGNMENT_CREATED",
entityType: "CONSIGNMENT",
entityId: created.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Titip jual ${created.consignmentNo} dibuat`,
metadata: {
consignment_no: created.consignmentNo,
sales_name: created.sales.name,
buyer_name: created.buyer.name,
line_count: created.lines.length
}
});
return NextResponse.json(
{
data: serializeConsignmentDetail(created)
},
{ status: 201 }
);
}

View File

@ -0,0 +1,159 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeCourier } from "@/features/couriers/lib/serialize-courier";
import { courierInputSchema } from "@/features/couriers/schemas/courier.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const item = await prisma.courier.findUnique({ where: { id: parsedId } });
if (!item) return NextResponse.json({ message: "Courier not found" }, { status: 404 });
return NextResponse.json({ data: serializeCourier(item) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = courierInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const existing = await prisma.courier.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Courier not found" }, { status: 404 });
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "CUR",
requestedCode: parsed.data.code,
existingCode: existing.code,
countExisting: () => prisma.courier.count({ where: { code: { startsWith: "CUR" } } }),
exists: async (code) => (await prisma.courier.count({ where: { code, id: { not: parsedId } } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const item = await prisma.courier.update({
where: { id: parsedId },
data: {
code: resolvedCode.code,
name: parsed.data.name,
address: parsed.data.address || null,
phone: parsed.data.phone || null,
website: parsed.data.website || null,
email: parsed.data.email || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "COURIER_UPDATED",
entityType: "COURIER",
entityId: item.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Kurir ${item.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
name: existing.name,
address: existing.address,
phone: existing.phone,
website: existing.website,
email: existing.email,
status: existing.status
},
{
code: item.code,
name: item.name,
address: item.address,
phone: item.phone,
website: item.website,
email: item.email,
status: item.status
}
)
});
return NextResponse.json({ data: serializeCourier(item) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Courier not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode jasa pengiriman sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.courier.findUnique({ where: { id: parsedId } });
await prisma.courier.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "COURIER_DELETED",
entityType: "COURIER",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Kurir ${existing?.code ?? parsedId.toString()} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Courier not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,84 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeCourier } from "@/features/couriers/lib/serialize-courier";
import { courierInputSchema } from "@/features/couriers/schemas/courier.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const items = await prisma.courier.findMany({
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({ data: items.map(serializeCourier) });
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = courierInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "CUR",
requestedCode: parsed.data.code,
countExisting: () => prisma.courier.count({ where: { code: { startsWith: "CUR" } } }),
exists: async (code) => (await prisma.courier.count({ where: { code } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const item = await prisma.courier.create({
data: {
code: resolvedCode.code,
name: parsed.data.name,
address: parsed.data.address || null,
phone: parsed.data.phone || null,
website: parsed.data.website || null,
email: parsed.data.email || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "COURIER_CREATED",
entityType: "COURIER",
entityId: item.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Kurir ${item.code} dibuat`
});
return NextResponse.json({ data: serializeCourier(item) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode jasa pengiriman sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,144 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeCurrency } from "@/features/currencies/lib/serialize-currency";
import { currencyInputSchema } from "@/features/currencies/schemas/currency.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const currency = await prisma.currency.findUnique({ where: { id: parsedId } });
if (!currency) return NextResponse.json({ message: "Currency not found" }, { status: 404 });
return NextResponse.json({ data: serializeCurrency(currency) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = currencyInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const existing = await prisma.currency.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Currency not found" }, { status: 404 });
const currency = await prisma.currency.update({
where: { id: parsedId },
data: {
code: parsed.data.code.toUpperCase(),
name: parsed.data.name,
description: parsed.data.description || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "CURRENCY_UPDATED",
entityType: "CURRENCY",
entityId: currency.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Currency ${currency.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
name: existing.name,
description: existing.description,
status: existing.status
},
{
code: currency.code,
name: currency.name,
description: currency.description,
status: currency.status
}
)
});
return NextResponse.json({ data: serializeCurrency(currency) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Currency not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode mata uang sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.currency.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Currency not found" }, { status: 404 });
const settingsUsingCurrency = await prisma.appSetting.count({
where: { currencyCode: existing.code }
});
if (settingsUsingCurrency > 0) {
return NextResponse.json(
{ message: "Currency sedang dipakai di pengaturan sistem dan tidak bisa dihapus." },
{ status: 409 }
);
}
await prisma.currency.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "CURRENCY_DELETED",
entityType: "CURRENCY",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Currency ${existing.code} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Currency not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,66 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies";
import { serializeCurrency } from "@/features/currencies/lib/serialize-currency";
import { currencyInputSchema } from "@/features/currencies/schemas/currency.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
await ensureDefaultCurrencies();
const data = await prisma.currency.findMany({
orderBy: [{ status: "asc" }, { code: "asc" }]
});
return NextResponse.json({ data: data.map(serializeCurrency) });
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = currencyInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const currency = await prisma.currency.create({
data: {
code: parsed.data.code.toUpperCase(),
name: parsed.data.name,
description: parsed.data.description || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "CURRENCY_CREATED",
entityType: "CURRENCY",
entityId: currency.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Currency ${currency.code} dibuat`
});
return NextResponse.json({ data: serializeCurrency(currency) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode mata uang sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,138 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeEmployee } from "@/features/employees/lib/serialize-employee";
import { employeeInputSchema } from "@/features/employees/schemas/employee.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const employee = await prisma.employee.findUnique({ where: { id: parsedId } });
if (!employee) return NextResponse.json({ message: "Employee not found" }, { status: 404 });
return NextResponse.json({ data: serializeEmployee(employee) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = employeeInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const existing = await prisma.employee.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Employee not found" }, { status: 404 });
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "EMP",
requestedCode: parsed.data.code,
existingCode: existing.code,
countExisting: () => prisma.employee.count({ where: { code: { startsWith: "EMP" } } }),
exists: async (code) => (await prisma.employee.count({ where: { code, id: { not: parsedId } } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const employee = await prisma.employee.update({
where: { id: parsedId },
data: {
code: resolvedCode.code,
name: parsed.data.name,
email: parsed.data.email || null,
mobile: parsed.data.mobile || null,
position: parsed.data.position,
address: parsed.data.address || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "EMPLOYEE_UPDATED",
entityType: "EMPLOYEE",
entityId: employee.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Employee ${employee.code} diubah`
});
return NextResponse.json({ data: serializeEmployee(employee) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Employee not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode karyawan sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.employee.findUnique({ where: { id: parsedId } });
await prisma.employee.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "EMPLOYEE_DELETED",
entityType: "EMPLOYEE",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Employee ${existing?.code ?? parsedId.toString()} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Employee not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,86 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeEmployee } from "@/features/employees/lib/serialize-employee";
import { employeeInputSchema } from "@/features/employees/schemas/employee.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
try {
const items = await prisma.employee.findMany({ orderBy: [{ createdAt: "desc" }] });
return NextResponse.json({ data: items.map(serializeEmployee) });
} catch (error) {
const message =
process.env.NODE_ENV === "development" && error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ message }, { status: 500 });
}
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = employeeInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "EMP",
requestedCode: parsed.data.code,
countExisting: () => prisma.employee.count({ where: { code: { startsWith: "EMP" } } }),
exists: async (code) => (await prisma.employee.count({ where: { code } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const employee = await prisma.employee.create({
data: {
code: resolvedCode.code,
name: parsed.data.name,
email: parsed.data.email || null,
mobile: parsed.data.mobile || null,
position: parsed.data.position,
address: parsed.data.address || null,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "EMPLOYEE_CREATED",
entityType: "EMPLOYEE",
entityId: employee.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Employee ${employee.code} dibuat`
});
return NextResponse.json({ data: serializeEmployee(employee) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode karyawan sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { serializeAgent } from "@/features/agents/lib/serialize-agent";
import { getAppSettings } from "@/lib/app-settings";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const settings = await getAppSettings();
const settingRecord = await prisma.appSetting.findUnique({
where: { singletonKey: "SYSTEM" },
include: {
companyBankAccounts: {
include: { bank: true },
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
}
}
});
const agents = await prisma.agent.findMany({
include: {
profitShareScheme: true,
bankAccounts: {
include: {
bank: true
}
}
},
orderBy: [{ name: "asc" }]
});
return NextResponse.json({
data: {
agents: agents.map(serializeAgent),
company_bank_name: settings.company_bank_name,
company_bank_account_number: settings.company_bank_account_number,
company_bank_accounts: (settingRecord?.companyBankAccounts ?? []).map((account) => ({
id: account.id.toString(),
bank_id: account.bankId.toString(),
bank_code: account.bank.code,
bank_name: account.bank.name,
account_number: account.accountNumber
})),
currency_code: settings.currency_code
}
});
}

View File

@ -0,0 +1,268 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import {
AGENT_BALANCE_DIRECTIONS,
AGENT_BALANCE_SOURCES,
AGENT_BALANCE_TYPES,
createAgentBalanceMutation,
roundBalanceAmount
} from "@/features/agents/lib/balance-mutations";
import { generateFundRequestNo } from "@/features/fund-requests/lib/generate-fund-request-no";
import { serializeFundRequest } from "@/features/fund-requests/lib/serialize-fund-request";
import {
storeFundRequestProof,
validateFundRequestProofFile
} from "@/features/fund-requests/lib/store-transfer-proof";
import { getAppSettings } from "@/lib/app-settings";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
function parseBigInt(value: string | null) {
if (!value) return null;
try {
return BigInt(value);
} catch {
return null;
}
}
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const items = await prisma.fundRequest.findMany({
include: {
agent: true,
agentBankAccount: true,
companyBankAccount: true
},
orderBy: [{ transferredAt: "desc" }, { id: "desc" }]
});
return NextResponse.json({
data: items.map(serializeFundRequest)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
try {
const formData = await request.formData();
const transferType = String(formData.get("transfer_type") ?? "").trim();
const referenceNo = String(formData.get("reference_no") ?? "").trim();
const agentId = parseBigInt(String(formData.get("agent_id") ?? ""));
const agentBankAccountId = parseBigInt(String(formData.get("agent_bank_account_id") ?? ""));
const companyBankAccountId = parseBigInt(String(formData.get("company_bank_account_id") ?? ""));
const amount = Number(formData.get("amount") ?? 0);
const transferredAtRaw = String(formData.get("transferred_at") ?? "").trim();
const proofFile = formData.get("transfer_proof_file");
if (transferType !== "CAPITAL" && transferType !== "PROFIT_SHARE") {
return NextResponse.json({ message: "Tipe transfer tidak valid" }, { status: 422 });
}
if (!referenceNo) {
return NextResponse.json({ message: "No reff wajib diisi" }, { status: 422 });
}
if (agentId === null) {
return NextResponse.json({ message: "Agen wajib dipilih" }, { status: 422 });
}
if (agentBankAccountId === null) {
return NextResponse.json({ message: "Nomor rekening agen wajib dipilih" }, { status: 422 });
}
if (companyBankAccountId === null) {
return NextResponse.json({ message: "Rekening kantor wajib dipilih" }, { status: 422 });
}
if (!Number.isFinite(amount) || amount <= 0) {
return NextResponse.json({ message: "Nominal transfer harus lebih dari 0" }, { status: 422 });
}
if (!transferredAtRaw) {
return NextResponse.json({ message: "Waktu transfer wajib diisi" }, { status: 422 });
}
const transferredAt = new Date(transferredAtRaw);
if (Number.isNaN(transferredAt.getTime())) {
return NextResponse.json({ message: "Waktu transfer tidak valid" }, { status: 422 });
}
const settings = await getAppSettings();
const settingRecord = await prisma.appSetting.findUnique({
where: { singletonKey: "SYSTEM" },
include: {
companyBankAccounts: {
include: { bank: true }
}
}
});
if (!settingRecord || settingRecord.companyBankAccounts.length === 0) {
return NextResponse.json(
{ message: "Lengkapi minimal satu rekening kantor di pengaturan sistem terlebih dahulu." },
{ status: 422 }
);
}
const agent = await prisma.agent.findUnique({
where: { id: agentId },
include: {
bankAccounts: {
include: {
bank: true
}
}
}
});
if (!agent) {
return NextResponse.json({ message: "Agen tidak ditemukan" }, { status: 404 });
}
const selectedBankAccount = agent.bankAccounts.find((item) => item.id === agentBankAccountId);
if (!selectedBankAccount || selectedBankAccount.bank.status !== "ACTIVE") {
return NextResponse.json({ message: "Rekening agen harus dipilih dari rekening aktif yang valid" }, { status: 422 });
}
const selectedCompanyBankAccount = settingRecord.companyBankAccounts.find(
(item) => item.id === companyBankAccountId
);
if (!selectedCompanyBankAccount || selectedCompanyBankAccount.bank.status !== "ACTIVE") {
return NextResponse.json({ message: "Rekening kantor harus dipilih dari rekening aktif yang valid" }, { status: 422 });
}
let proofFileUrl: string | null = null;
if (proofFile instanceof File && proofFile.size > 0) {
const validationError = validateFundRequestProofFile(proofFile);
if (validationError) {
return NextResponse.json({ message: validationError }, { status: 422 });
}
proofFileUrl = await storeFundRequestProof(proofFile);
}
const currentProfitShare = Number(agent.currentBalance);
const currentCapital = Number(agent.capitalBalance);
const nextProfitShare =
transferType === "PROFIT_SHARE" ? roundBalanceAmount(currentProfitShare - amount) : currentProfitShare;
const nextCapital =
transferType === "CAPITAL" ? roundBalanceAmount(currentCapital + amount) : currentCapital;
if (nextProfitShare < 0) {
return NextResponse.json({ message: "Saldo bagi hasil agen tidak mencukupi" }, { status: 422 });
}
const requestNo = await generateFundRequestNo(transferredAt);
const createdId = await prisma.$transaction(async (tx) => {
const saved = await tx.fundRequest.create({
data: {
requestNo,
referenceNo,
transferType,
agentId,
agentBankAccountId,
companyBankAccountId,
agentBankNameSnapshot: selectedBankAccount.bank.name,
agentAccountNumberSnapshot: selectedBankAccount.accountNumber,
companyBankNameSnapshot: selectedCompanyBankAccount.bank.name,
companyAccountNumberSnapshot: selectedCompanyBankAccount.accountNumber,
amount: new Prisma.Decimal(roundBalanceAmount(amount)),
currencyCode: settings.currency_code,
transferredAt,
transferProofFileUrl: proofFileUrl,
status: "SUBMITTED",
createdById: BigInt(auth.user.id)
},
include: {
agent: true,
agentBankAccount: true,
companyBankAccount: true
}
});
if (transferType === "PROFIT_SHARE") {
await tx.agent.update({
where: { id: agentId },
data: {
currentBalance: new Prisma.Decimal(nextProfitShare)
}
});
await createAgentBalanceMutation(tx, {
agentId,
balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE,
direction: AGENT_BALANCE_DIRECTIONS.OUT,
source: AGENT_BALANCE_SOURCES.FUND_REQUEST_PROFIT_SHARE,
amount,
balanceAfter: nextProfitShare,
effectiveDate: transferredAt,
referenceType: "FUND_REQUEST",
referenceId: saved.id.toString(),
referenceNo: saved.requestNo,
notes: `Transfer dana bagi hasil · ${referenceNo}`,
metadata: {
transfer_type: transferType,
reference_no: referenceNo
}
});
} else {
await tx.agent.update({
where: { id: agentId },
data: {
capitalBalance: new Prisma.Decimal(nextCapital)
}
});
await createAgentBalanceMutation(tx, {
agentId,
balanceType: AGENT_BALANCE_TYPES.CAPITAL,
direction: AGENT_BALANCE_DIRECTIONS.IN,
source: AGENT_BALANCE_SOURCES.FUND_REQUEST_CAPITAL,
amount,
balanceAfter: nextCapital,
effectiveDate: transferredAt,
referenceType: "FUND_REQUEST",
referenceId: saved.id.toString(),
referenceNo: saved.requestNo,
notes: `Transfer dana modal · ${referenceNo}`,
metadata: {
transfer_type: transferType,
reference_no: referenceNo
}
});
}
return saved.id;
});
const created = await prisma.fundRequest.findUnique({
where: { id: createdId },
include: {
agent: true,
agentBankAccount: true,
companyBankAccount: true
}
});
if (!created) {
throw new Error("Fund request yang baru dibuat tidak ditemukan.");
}
await createAuditTrailSafe({
userId: auth.user.id,
action: "FUND_REQUEST_CREATED",
entityType: "FUND_REQUEST",
entityId: created.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Fund request ${created.requestNo} dibuat`
});
return NextResponse.json({ data: serializeFundRequest(created) }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ message: error instanceof Error ? error.message : "Gagal menyimpan fund request" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,274 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeGrade } from "@/features/grades/lib/serialize-grade";
import { gradeInputSchema, type GradeInput } from "@/features/grades/schemas/grade.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
function getGradePrefix(isMangkok: boolean) {
return isMangkok ? "MGK" : "GRD";
}
function hasOverlap(items: GradeInput["buy_price_standards"]) {
const normalized = items.map((item) => ({
start: new Date(item.start_date).getTime(),
end: item.end_date ? new Date(item.end_date).getTime() : Number.POSITIVE_INFINITY
}));
for (let i = 0; i < normalized.length; i += 1) {
for (let j = i + 1; j < normalized.length; j += 1) {
const a = normalized[i];
const b = normalized[j];
if (a.start <= b.end && b.start <= a.end) {
return true;
}
}
}
return false;
}
function validatePriceStandards(parsed: GradeInput) {
if (hasOverlap(parsed.buy_price_standards)) {
return { buy_price_standards: ["Periode standar harga beli tidak boleh overlap"] };
}
if (hasOverlap(parsed.sell_price_standards)) {
return { sell_price_standards: ["Periode standar harga jual tidak boleh overlap"] };
}
return null;
}
function buildCodeResolutionParams(parsed: GradeInput, existing: { code: string; isMangkok: boolean }, parsedId: bigint) {
const prefix = getGradePrefix(parsed.is_mangkok);
const existingMatchesPrefix = existing.code.startsWith(prefix);
const requestedCode = parsed.code?.trim();
return {
prefix,
requestedCode: requestedCode || undefined,
existingCode: requestedCode ? existing.code : existingMatchesPrefix ? existing.code : null,
countExisting: () => prisma.grade.count({ where: { code: { startsWith: prefix } } }),
exists: async (code: string) =>
(await prisma.grade.count({ where: { code, id: { not: parsedId } } })) > 0
};
}
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const item = await prisma.grade.findUnique({
where: { id: parsedId },
include: {
buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] },
sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }
}
});
if (!item) return NextResponse.json({ message: "Grade not found" }, { status: 404 });
return NextResponse.json({ data: serializeGrade(item) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = gradeInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const priceValidationError = validatePriceStandards(parsed.data);
if (priceValidationError) {
return NextResponse.json({ message: "Validasi gagal", errors: priceValidationError }, { status: 400 });
}
try {
const existing = await prisma.grade.findUnique({
where: { id: parsedId },
include: {
buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] },
sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }
}
});
if (!existing) return NextResponse.json({ message: "Grade not found" }, { status: 404 });
const resolution = buildCodeResolutionParams(parsed.data, existing, parsedId);
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: resolution.prefix,
requestedCode: resolution.requestedCode,
existingCode: resolution.existingCode,
countExisting: resolution.countExisting,
exists: resolution.exists
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const item = await prisma.grade.update({
where: { id: parsedId },
data: {
code: resolvedCode.code,
isMangkok: parsed.data.is_mangkok,
name: parsed.data.name,
description: parsed.data.description || null,
status: parsed.data.status,
buyPriceStandards: {
deleteMany: {},
create: parsed.data.buy_price_standards.map((standard) => ({
startDate: new Date(standard.start_date),
endDate: standard.end_date ? new Date(standard.end_date) : null,
minPrice: standard.min_price,
maxPrice: standard.max_price,
notes: standard.notes || null
}))
},
sellPriceStandards: {
deleteMany: {},
create: parsed.data.sell_price_standards.map((standard) => ({
startDate: new Date(standard.start_date),
endDate: standard.end_date ? new Date(standard.end_date) : null,
minPrice: standard.min_price,
maxPrice: standard.max_price,
notes: standard.notes || null
}))
}
},
include: {
buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] },
sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "GRADE_UPDATED",
entityType: "GRADE",
entityId: item.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Grade ${item.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
is_mangkok: existing.isMangkok,
name: existing.name,
description: existing.description,
status: existing.status,
buy_price_standards: existing.buyPriceStandards.map((standard) => ({
start_date: standard.startDate.toISOString().slice(0, 10),
end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null,
min_price: Number(standard.minPrice),
max_price: Number(standard.maxPrice),
notes: standard.notes
})),
sell_price_standards: existing.sellPriceStandards.map((standard) => ({
start_date: standard.startDate.toISOString().slice(0, 10),
end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null,
min_price: Number(standard.minPrice),
max_price: Number(standard.maxPrice),
notes: standard.notes
}))
},
{
code: item.code,
is_mangkok: item.isMangkok,
name: item.name,
description: item.description,
status: item.status,
buy_price_standards: item.buyPriceStandards.map((standard) => ({
start_date: standard.startDate.toISOString().slice(0, 10),
end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null,
min_price: Number(standard.minPrice),
max_price: Number(standard.maxPrice),
notes: standard.notes
})),
sell_price_standards: item.sellPriceStandards.map((standard) => ({
start_date: standard.startDate.toISOString().slice(0, 10),
end_date: standard.endDate ? standard.endDate.toISOString().slice(0, 10) : null,
min_price: Number(standard.minPrice),
max_price: Number(standard.maxPrice),
notes: standard.notes
}))
}
)
});
return NextResponse.json({ data: serializeGrade(item) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Grade not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode grade sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.grade.findUnique({ where: { id: parsedId } });
await prisma.grade.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "GRADE_DELETED",
entityType: "GRADE",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Grade ${existing?.code ?? parsedId.toString()} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Grade not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,149 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeGrade } from "@/features/grades/lib/serialize-grade";
import { gradeInputSchema, type GradeInput } from "@/features/grades/schemas/grade.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
function getGradePrefix(isMangkok: boolean) {
return isMangkok ? "MGK" : "GRD";
}
function hasOverlap(items: GradeInput["buy_price_standards"]) {
const normalized = items.map((item) => ({
start: new Date(item.start_date).getTime(),
end: item.end_date ? new Date(item.end_date).getTime() : Number.POSITIVE_INFINITY
}));
for (let i = 0; i < normalized.length; i += 1) {
for (let j = i + 1; j < normalized.length; j += 1) {
const a = normalized[i];
const b = normalized[j];
if (a.start <= b.end && b.start <= a.end) {
return true;
}
}
}
return false;
}
function validatePriceStandards(parsed: GradeInput) {
if (hasOverlap(parsed.buy_price_standards)) {
return { buy_price_standards: ["Periode standar harga beli tidak boleh overlap"] };
}
if (hasOverlap(parsed.sell_price_standards)) {
return { sell_price_standards: ["Periode standar harga jual tidak boleh overlap"] };
}
return null;
}
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const data = await prisma.grade.findMany({
include: {
buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] },
sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }
},
orderBy: [{ isMangkok: "desc" }, { createdAt: "desc" }]
});
return NextResponse.json({ data: data.map(serializeGrade) });
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = gradeInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const priceValidationError = validatePriceStandards(parsed.data);
if (priceValidationError) {
return NextResponse.json({ message: "Validasi gagal", errors: priceValidationError }, { status: 400 });
}
try {
const prefix = getGradePrefix(parsed.data.is_mangkok);
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix,
requestedCode: parsed.data.code,
countExisting: () => prisma.grade.count({ where: { code: { startsWith: prefix } } }),
exists: async (code) => (await prisma.grade.count({ where: { code } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const item = await prisma.grade.create({
data: {
code: resolvedCode.code,
isMangkok: parsed.data.is_mangkok,
name: parsed.data.name,
description: parsed.data.description || null,
status: parsed.data.status,
buyPriceStandards: {
create: parsed.data.buy_price_standards.map((standard) => ({
startDate: new Date(standard.start_date),
endDate: standard.end_date ? new Date(standard.end_date) : null,
minPrice: standard.min_price,
maxPrice: standard.max_price,
notes: standard.notes || null
}))
},
sellPriceStandards: {
create: parsed.data.sell_price_standards.map((standard) => ({
startDate: new Date(standard.start_date),
endDate: standard.end_date ? new Date(standard.end_date) : null,
minPrice: standard.min_price,
maxPrice: standard.max_price,
notes: standard.notes || null
}))
}
},
include: {
buyPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] },
sellPriceStandards: { orderBy: [{ startDate: "asc" }, { id: "asc" }] }
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "GRADE_CREATED",
entityType: "GRADE",
entityId: item.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Grade ${item.code} dibuat`
});
return NextResponse.json({ data: serializeGrade(item) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode grade sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
ok: true,
status: "ok",
service: "abelbirdnest-web",
timestamp: new Date().toISOString()
});
} catch (error) {
return NextResponse.json(
{
ok: false,
status: "degraded",
service: "abelbirdnest-web",
timestamp: new Date().toISOString(),
message: error instanceof Error ? error.message : "Database health check gagal"
},
{ status: 503 }
);
}
}

View File

@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { serializeLotTransformationDetail } from "@/features/lot-transformations/lib/serialize-transformation";
import { getLotTransformationById } from "@/features/lot-transformations/lib/transformation-query";
import { requireApiAccess } from "@/lib/authorization";
type RouteContext = { params: Promise<{ id: string }> };
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const id = parseBigInt((await context.params).id);
if (id === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const item = await getLotTransformationById(id);
if (!item) {
return NextResponse.json({ message: "Transformation not found" }, { status: 404 });
}
return NextResponse.json({
data: serializeLotTransformationDetail(item)
});
}

View File

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { createLotTransformation } from "@/features/lot-transformations/lib/create-lot-transformation";
import { serializeLotTransformationListItem } from "@/features/lot-transformations/lib/serialize-transformation";
import { lotTransformationSchema } from "@/features/lot-transformations/schemas/lot-transformation.schema";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const items = await prisma.lotTransformation.findMany({
include: {
inputs: { select: { qtyUsed: true } },
outputs: { select: { qtyProduced: true } }
},
orderBy: [{ transformationDate: "desc" }, { createdAt: "desc" }]
});
return NextResponse.json({
data: items.map(serializeLotTransformationListItem)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const body = await request.json();
const parsed = lotTransformationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
return createLotTransformation({
payload: parsed.data,
actorUserId: auth.user.id,
requestMethod: request.method,
pathname: new URL(request.url).pathname
});
}

View File

@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function POST(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const lot = await prisma.inventoryLot.findUnique({
where: { id: parsedId },
include: {
grade: true,
warehouse: true,
warehouseLocation: true,
purchase: {
select: {
agent: {
select: {
id: true,
name: true
}
}
}
}
}
});
if (!lot) {
return NextResponse.json({ message: "Lot not found" }, { status: 404 });
}
const sourcePartyName = lot.purchase?.agent?.name ?? "Pembelian bebas";
const gradeName = lot.grade?.name ?? null;
const qrValue = lot.qrCodeValue?.trim() || lot.barcodeValue?.trim() || lot.lotCode;
const barcodeValue = lot.barcodeValue?.trim() || lot.qrCodeValue?.trim() || lot.lotCode;
const pathname = new URL(request.url).pathname;
await createAuditTrailSafe({
userId: auth.user.id,
action: "LOT_LABEL_PRINTED",
entityType: "LOT",
entityId: lot.id,
method: request.method,
pathname,
statusCode: 200,
summary: `Label lot ${lot.lotCode} dicetak`,
metadata: {
lot_code: lot.lotCode,
qr_value: qrValue,
barcode_value: barcodeValue,
source_type: lot.sourceType,
status: lot.status,
supplier_name: lot.purchase?.agent?.name ?? "Pembelian bebas",
grade: gradeName,
warehouse_name: lot.warehouse.name,
location_name: lot.warehouseLocation?.name ?? null,
printed_via: "WEB_LABEL_PRINT"
}
});
return NextResponse.json({
data: {
printable_label: {
lot_id: lot.id.toString(),
lot_code: lot.lotCode,
qr_value: qrValue,
barcode_value: barcodeValue,
supplier: sourcePartyName,
grade: gradeName,
warehouse: lot.warehouse.name,
location: lot.warehouseLocation?.name ?? "-",
received_at: lot.receivedAt.toISOString(),
status: lot.status
}
}
});
}

View File

@ -0,0 +1,158 @@
import { NextResponse } from "next/server";
import { serializeLotDetail } from "@/features/lots/lib/serialize-lot";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const lot = await prisma.inventoryLot.findUnique({
where: { id: parsedId },
include: {
grade: true,
warehouse: true,
warehouseLocation: true,
unit: true,
purchase: {
select: {
id: true,
purchaseNo: true,
purchaseDate: true,
agent: {
select: {
id: true,
name: true
}
},
buyoutSourceAgent: {
select: {
id: true,
name: true
}
}
}
},
purchaseLine: {
select: {
unitPrice: true,
buyoutMalUnitPriceSnapshot: true,
buyoutAgentSharePercent: true,
buyoutProfitAmount: true,
buyoutAgentCommission: true
}
},
receipt: { select: { id: true, receiptNo: true, receiptDate: true } },
parentLot: { select: { id: true, lotCode: true } },
childLots: {
select: {
id: true,
lotCode: true,
status: true,
availableQty: true,
sourceType: true
}
},
transformationOutputs: {
include: {
transformation: {
include: {
inputs: {
include: {
sourceLot: {
select: {
id: true,
lotCode: true,
grade: { select: { name: true } }
}
}
}
},
outputs: {
include: {
resultLot: {
select: {
id: true,
lotCode: true,
availableQty: true,
unitCost: true,
status: true,
grade: { select: { name: true } }
}
}
}
}
}
}
}
},
transformationInputs: {
include: {
transformation: {
include: {
inputs: {
include: {
sourceLot: {
select: {
id: true,
lotCode: true,
grade: { select: { name: true } }
}
}
}
},
outputs: {
include: {
resultLot: {
select: {
id: true,
lotCode: true,
availableQty: true,
unitCost: true,
status: true,
grade: { select: { name: true } }
}
}
}
}
}
}
}
},
consignmentLines: {
include: {
consignment: {
include: {
sales: { select: { name: true } },
buyer: { select: { name: true } }
}
}
},
orderBy: [{ createdAt: "desc" }]
}
}
});
if (!lot) {
return NextResponse.json({ message: "Lot not found" }, { status: 404 });
}
return NextResponse.json({
data: serializeLotDetail(lot)
});
}

View File

@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { serializeLotListItem } from "@/features/lots/lib/serialize-lot";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const lots = await prisma.inventoryLot.findMany({
include: {
purchase: {
select: {
id: true,
agent: {
select: {
id: true,
name: true
}
}
}
},
grade: { select: { name: true } },
unit: { select: { code: true } },
warehouse: { select: { name: true } },
warehouseLocation: { select: { name: true } }
},
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: lots.map(serializeLotListItem)
});
}

View File

@ -0,0 +1,190 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const modulesByRole: Record<string, string[]> = {
OWNER: [
"dashboard",
"lots",
"receipts",
"washing",
"stock_adjustments",
"lot_transformations",
"sales_regular",
"sales_jit",
"consignments",
"purchases",
"fund_requests",
"purchase_analyses",
"purchase_realizations"
],
PURCHASING: [
"dashboard",
"purchases",
"fund_requests",
"purchase_analyses",
"purchase_realizations"
],
WAREHOUSE: [
"dashboard",
"lots",
"receipts",
"washing",
"stock_adjustments"
],
QC: [
"dashboard",
"lots",
"washing",
"lot_transformations",
"stock_adjustments"
],
SALES: [
"dashboard",
"lots",
"sales_regular",
"sales_jit",
"consignments"
],
ADMIN: [
"dashboard",
"lots",
"receipts",
"washing",
"stock_adjustments",
"lot_transformations",
"sales_regular",
"sales_jit",
"consignments",
"purchases",
"fund_requests",
"purchase_analyses",
"purchase_realizations"
],
SYSTEM_ADMIN: [
"dashboard",
"lots",
"receipts",
"washing",
"stock_adjustments",
"lot_transformations",
"sales_regular",
"sales_jit",
"consignments",
"purchases",
"fund_requests",
"purchase_analyses",
"purchase_realizations"
]
};
const [grades, warehouses, summary] = await Promise.all([
prisma.grade.findMany({
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }],
select: { id: true, code: true, name: true }
}),
prisma.warehouse.findMany({
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }],
include: {
locations: {
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }],
select: { id: true, code: true, name: true, locationType: true }
}
}
}),
prisma.$transaction([
prisma.inventoryLot.count({
where: {
status: "ACTIVE",
availableQty: { gt: 0 }
}
}),
prisma.purchase.count({
where: {
purchaseType: "REGULAR",
status: "DRAFT"
}
}),
prisma.purchase.count({
where: {
purchaseType: "REGULAR",
status: "SUBMITTED"
}
}),
prisma.regularSale.count({
where: {
status: "OPEN"
}
}),
prisma.consignmentLine.count({
where: {
status: "OPEN"
}
}),
prisma.lotWashing.count({
where: {
status: "IN_PROGRESS"
}
})
])
]);
const [
activeLotCount,
draftPurchaseCount,
submittedPurchaseCount,
openRegularSaleCount,
openConsignmentLineCount,
inProgressWashingCount
] = summary;
return NextResponse.json({
data: {
user: auth.user,
modules: modulesByRole[auth.user.role] ?? ["dashboard"],
summary: {
active_lot_count: activeLotCount,
draft_purchase_count: draftPurchaseCount,
submitted_purchase_count: submittedPurchaseCount,
open_regular_sale_count: openRegularSaleCount,
open_consignment_line_count: openConsignmentLineCount,
in_progress_washing_count: inProgressWashingCount
},
transformation_types: [
{ code: "MIX", label: "Mixing" },
{ code: "REGRADE", label: "Regrade" }
],
remainder_modes: [
{ code: "KEEP_SOURCE_GRADE", label: "Simpan sisa di lot sumber" },
{ code: "SHRINKAGE", label: "Catat sebagai shrinkage" }
],
processing_loss_modes: [
{ code: "SHRINKAGE", label: "Catat sebagai shrinkage" }
],
grades: grades.map((item) => ({
id: item.id.toString(),
code: item.code,
name: item.name
})),
warehouses: warehouses.map((warehouse) => ({
id: warehouse.id.toString(),
code: warehouse.code,
name: warehouse.name,
locations: warehouse.locations.map((location) => ({
id: location.id.toString(),
code: location.code,
name: location.name,
location_type: location.locationType
}))
}))
}
});
}

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/consignments/[id]/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/consignments/bootstrap/route";

View File

@ -0,0 +1 @@
export { POST } from "@/app/api/v1/consignments/lines/[lineId]/close/route";

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/consignments/route";

View File

@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "@/lib/authorization";
import { getDashboardData } from "@/lib/dashboard";
import type { AppLocale } from "@/lib/i18n";
function normalizeLocale(value: string | null): AppLocale {
return value === "en" ? "en" : "id";
}
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const { searchParams } = new URL(request.url);
const locale = normalizeLocale(searchParams.get("locale"));
const data = await getDashboardData(locale);
return NextResponse.json({ data });
}

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/fund-requests/bootstrap/route";

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/fund-requests/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/lot-transformations/[id]/route";

View File

@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { createLotTransformation } from "@/features/lot-transformations/lib/create-lot-transformation";
import { mobileLotTransformationSchema } from "@/features/mobile/schemas/mobile-lot-transformation.schema";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export { GET } from "@/app/api/v1/lot-transformations/route";
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const body = await request.json();
const parsed = mobileLotTransformationSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const normalizedCodes = parsed.data.inputs.map((input) => input.source_lot_code.trim().toUpperCase());
const sourceLots = await prisma.inventoryLot.findMany({
where: {
lotCode: {
in: normalizedCodes
}
},
select: {
id: true,
lotCode: true
}
});
const lotCodeMap = new Map(sourceLots.map((lot) => [lot.lotCode.toUpperCase(), lot.id.toString()]));
const missingCodes = normalizedCodes.filter((code) => !lotCodeMap.has(code));
if (missingCodes.length > 0) {
return NextResponse.json(
{
message: "Sebagian lot sumber tidak ditemukan",
errors: {
inputs: missingCodes.map((code) => `Lot ${code} tidak ditemukan`)
}
},
{ status: 404 }
);
}
return createLotTransformation({
payload: {
transformation_type: parsed.data.transformation_type,
transformation_date: parsed.data.transformation_date,
remainder_mode: parsed.data.remainder_mode ?? null,
processing_loss_mode: parsed.data.processing_loss_mode ?? null,
notes: parsed.data.notes ?? null,
inputs: parsed.data.inputs.map((input) => ({
source_lot_id: lotCodeMap.get(input.source_lot_code.trim().toUpperCase())!,
qty_used: input.qty_used,
notes: input.notes ?? null
})),
outputs: parsed.data.outputs.map((output) => ({
grade_id: output.grade_id,
warehouse_id: output.warehouse_id,
warehouse_location_id: output.warehouse_location_id ?? null,
qty_produced: output.qty_produced,
notes: output.notes ?? null
}))
},
actorUserId: auth.user.id,
requestMethod: request.method,
pathname: new URL(request.url).pathname
});
}

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/lots/[id]/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/lots/route";

View File

@ -0,0 +1,216 @@
import { NextResponse } from "next/server";
import { serializeLotDetail } from "@/features/lots/lib/serialize-lot";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const { searchParams } = new URL(request.url);
const rawCode = searchParams.get("code")?.trim();
if (!rawCode) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: { code: ["Kode scan wajib diisi"] }
},
{ status: 422 }
);
}
const code = rawCode.toUpperCase();
const lot = await prisma.inventoryLot.findFirst({
where: {
OR: [
{ lotCode: code },
{ qrCodeValue: code },
{ barcodeValue: code }
]
},
include: {
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true,
purchase: {
select: {
id: true,
purchaseNo: true,
purchaseDate: true,
agent: {
select: {
id: true,
name: true
}
},
buyoutSourceAgent: {
select: {
id: true,
name: true
}
}
}
},
purchaseLine: {
select: {
unitPrice: true,
buyoutMalUnitPriceSnapshot: true,
buyoutAgentSharePercent: true,
buyoutProfitAmount: true,
buyoutAgentCommission: true
}
},
receipt: { select: { id: true, receiptNo: true, receiptDate: true } },
parentLot: { select: { id: true, lotCode: true } },
childLots: {
select: {
id: true,
lotCode: true,
status: true,
availableQty: true,
sourceType: true
}
},
transformationOutputs: {
include: {
transformation: {
include: {
inputs: {
include: {
sourceLot: {
select: {
id: true,
lotCode: true,
grade: { select: { name: true } }
}
}
}
},
outputs: {
include: {
resultLot: {
select: {
id: true,
lotCode: true,
availableQty: true,
unitCost: true,
status: true,
grade: { select: { name: true } }
}
}
}
}
}
}
}
},
transformationInputs: {
include: {
transformation: {
include: {
inputs: {
include: {
sourceLot: {
select: {
id: true,
lotCode: true,
grade: { select: { name: true } }
}
}
}
},
outputs: {
include: {
resultLot: {
select: {
id: true,
lotCode: true,
availableQty: true,
unitCost: true,
status: true,
grade: { select: { name: true } }
}
}
}
}
}
}
}
},
consignmentLines: {
include: {
consignment: {
include: {
sales: { select: { name: true } },
buyer: { select: { name: true } }
}
}
},
orderBy: [{ createdAt: "desc" }]
}
}
});
if (!lot) {
return NextResponse.json({ message: "Lot tidak ditemukan" }, { status: 404 });
}
const availableQty = lot.availableQty.toNumber();
const originalQty = lot.originalQty.toNumber();
const damagedQty = lot.damagedQty.toNumber();
const shrinkageQty = lot.shrinkageQty.toNumber();
const reservedQty = lot.reservedQty.toNumber();
const estimatedValue = Number((availableQty * lot.unitCost.toNumber()).toFixed(2));
const movementCount = lot.transformationInputs.length + lot.transformationOutputs.length;
const sourcePartyName = lot.purchase?.agent?.name ?? "Pembelian bebas";
const gradeName = lot.grade?.name ?? null;
return NextResponse.json({
data: {
summary_card: {
lot_code: lot.lotCode,
source_type: lot.sourceType,
status: lot.status,
grade: gradeName,
supplier_name: sourcePartyName,
warehouse_name: lot.warehouse.name,
warehouse_location_name: lot.warehouseLocation?.name ?? null,
available_qty: availableQty,
original_qty: originalQty,
reserved_qty: reservedQty,
damaged_qty: damagedQty,
shrinkage_qty: shrinkageQty,
unit_code: lot.unit.code,
unit_cost: lot.unitCost.toNumber(),
estimated_value: estimatedValue,
purchase_no: lot.purchase?.purchaseNo ?? null,
purchase_date: lot.purchase?.purchaseDate.toISOString() ?? null,
receipt_no: lot.receipt?.receiptNo ?? null,
receipt_date: lot.receipt?.receiptDate.toISOString() ?? null,
received_at: lot.receivedAt.toISOString(),
qr_code_value: lot.qrCodeValue,
barcode_value: lot.barcodeValue,
parent_lot_code: lot.parentLot?.lotCode ?? null,
child_lot_count: lot.childLots.length,
transformation_count: movementCount
},
lot: serializeLotDetail(lot),
procurement: {
supplier_name: sourcePartyName,
purchase_no: lot.purchase?.purchaseNo ?? null,
purchase_date: lot.purchase?.purchaseDate.toISOString() ?? null,
receipt_no: lot.receipt?.receiptNo ?? null,
receipt_date: lot.receipt?.receiptDate.toISOString() ?? null,
received_at: lot.receivedAt.toISOString(),
source_type: lot.sourceType
},
mobile_actions: {
can_mix: auth.user.role === "QC" || auth.user.role === "WAREHOUSE" || auth.user.role === "OWNER" || auth.user.role === "ADMIN" || auth.user.role === "SYSTEM_ADMIN",
can_regrade: auth.user.role === "QC" || auth.user.role === "WAREHOUSE" || auth.user.role === "OWNER" || auth.user.role === "ADMIN" || auth.user.role === "SYSTEM_ADMIN",
can_adjust: auth.user.role === "QC" || auth.user.role === "WAREHOUSE" || auth.user.role === "OWNER" || auth.user.role === "ADMIN" || auth.user.role === "SYSTEM_ADMIN"
}
}
});
}

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/purchase-analyses/[purchaseId]/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/purchase-analyses/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/purchase-realizations/[purchaseId]/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/purchase-realizations/route";

View File

@ -0,0 +1 @@
export { POST } from "@/app/api/v1/purchases/[id]/cancel/route";

View File

@ -0,0 +1 @@
export { GET, PUT } from "@/app/api/v1/purchases/[id]/route";

View File

@ -0,0 +1 @@
export { POST } from "@/app/api/v1/purchases/[id]/submit/route";

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/purchases/route";

View File

@ -0,0 +1 @@
export { POST } from "@/app/api/v1/receipts/[id]/generate-lots/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/receipts/[id]/route";

View File

@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const [purchases, warehouses] = await Promise.all([
prisma.purchase.findMany({
where: {
purchaseType: "REGULAR",
status: "SUBMITTED",
receipts: {
none: {}
}
},
include: {
agent: {
select: {
id: true,
name: true
}
},
lines: {
include: {
grade: {
select: {
id: true,
code: true,
name: true
}
},
unit: {
select: {
id: true,
code: true
}
},
warehouse: {
select: {
id: true,
name: true
}
},
warehouseLocation: {
select: {
id: true,
name: true
}
}
}
}
},
orderBy: [{ purchaseDate: "desc" }, { createdAt: "desc" }]
}),
prisma.warehouse.findMany({
where: { status: "ACTIVE" },
include: {
locations: {
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }],
select: {
id: true,
code: true,
name: true,
locationType: true
}
}
},
orderBy: [{ name: "asc" }]
})
]);
return NextResponse.json({
data: {
purchases: purchases.map((purchase) => ({
id: purchase.id.toString(),
purchase_no: purchase.purchaseNo,
purchase_date: purchase.purchaseDate.toISOString().slice(0, 10),
received_at: purchase.receivedAt?.toISOString() ?? null,
supplier_name: purchase.agent?.name ?? "Pembelian bebas",
notes: purchase.notes,
lines: purchase.lines.map((line) => ({
id: line.id.toString(),
purchase_line_id: line.id.toString(),
grade: line.grade
? {
id: line.grade.id.toString(),
code: line.grade.code,
name: line.grade.name
}
: null,
unit: {
id: line.unit.id.toString(),
code: line.unit.code
},
qty_ordered: line.qtyOrdered.toNumber(),
qty_received: line.qtyReceived.toNumber(),
qty_accepted: line.qtyAccepted.toNumber(),
qty_rejected: line.qtyRejected.toNumber(),
unit_cost: line.unitCost.toNumber(),
warehouse_id: line.warehouse?.id?.toString() ?? null,
warehouse_location_id: line.warehouseLocation?.id?.toString() ?? null
}))
})),
warehouses: warehouses.map((warehouse) => ({
id: warehouse.id.toString(),
code: warehouse.code,
name: warehouse.name,
locations: warehouse.locations.map((location) => ({
id: location.id.toString(),
code: location.code,
name: location.name,
location_type: location.locationType
}))
}))
}
});
}

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/receipts/route";

View File

@ -0,0 +1 @@
export { POST } from "@/app/api/v1/sales-jit/[id]/close/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/sales-jit/[id]/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/sales-jit/bootstrap/route";

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/sales-jit/route";

View File

@ -0,0 +1 @@
export { POST } from "@/app/api/v1/sales-regular/[id]/close/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/sales-regular/[id]/route";

View File

@ -0,0 +1 @@
export { GET } from "@/app/api/v1/sales-regular/bootstrap/route";

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/sales-regular/route";

View File

@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const reasons = await prisma.adjustmentReason.findMany({
where: { status: "ACTIVE" },
orderBy: [{ category: "asc" }, { name: "asc" }]
});
return NextResponse.json({
data: {
adjustment_reasons: reasons.map((reason) => ({
id: reason.id.toString(),
code: reason.code,
name: reason.name,
category: reason.category
}))
}
});
}

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/stock-adjustments/route";

View File

@ -0,0 +1 @@
export { POST } from "@/app/api/v1/washing/[id]/complete/route";

View File

@ -0,0 +1 @@
export { PUT } from "@/app/api/v1/washing/[id]/route";

View File

@ -0,0 +1,72 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const [washingPlaces, grades, warehouses] = await Promise.all([
prisma.washingPlace.findMany({
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }],
select: {
id: true,
code: true,
name: true
}
}),
prisma.grade.findMany({
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }],
select: {
id: true,
code: true,
name: true
}
}),
prisma.warehouse.findMany({
where: { status: "ACTIVE" },
include: {
locations: {
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }],
select: {
id: true,
code: true,
name: true,
locationType: true
}
}
},
orderBy: [{ name: "asc" }]
})
]);
return NextResponse.json({
data: {
washing_places: washingPlaces.map((item) => ({
id: item.id.toString(),
code: item.code,
name: item.name
})),
grades: grades.map((item) => ({
id: item.id.toString(),
code: item.code,
name: item.name
})),
warehouses: warehouses.map((warehouse) => ({
id: warehouse.id.toString(),
code: warehouse.code,
name: warehouse.name,
locations: warehouse.locations.map((location) => ({
id: location.id.toString(),
code: location.code,
name: location.name,
location_type: location.locationType
}))
}))
}
});
}

View File

@ -0,0 +1 @@
export { GET, POST } from "@/app/api/v1/washing/route";

View File

@ -0,0 +1,146 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeProfitShareScheme } from "@/features/profit-share-schemes/lib/serialize-profit-share-scheme";
import { profitShareSchemeInputSchema } from "@/features/profit-share-schemes/schemas/profit-share-scheme.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { requireApiAccess } from "@/lib/authorization";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const scheme = await prisma.profitShareScheme.findUnique({ where: { id: parsedId } });
if (!scheme) return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 });
return NextResponse.json({ data: serializeProfitShareScheme(scheme) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const parsed = profitShareSchemeInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const existing = await prisma.profitShareScheme.findUnique({ where: { id: parsedId } });
if (!existing) return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 });
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "PSS",
requestedCode: parsed.data.code,
existingCode: existing.code,
countExisting: () => prisma.profitShareScheme.count({ where: { code: { startsWith: "PSS" } } }),
exists: async (code) =>
(await prisma.profitShareScheme.count({ where: { code, id: { not: parsedId } } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const scheme = await prisma.profitShareScheme.update({
where: { id: parsedId },
data: {
code: resolvedCode.code,
name: parsed.data.name,
shareAgent: parsed.data.share_agent,
shareCompany: parsed.data.share_company,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "PROFIT_SHARE_SCHEME_UPDATED",
entityType: "PROFIT_SHARE_SCHEME",
entityId: scheme.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Profit share scheme ${scheme.code} diubah`,
metadata: buildAuditChangeMetadata(
{
code: existing.code,
name: existing.name,
share_agent: Number(existing.shareAgent),
share_company: Number(existing.shareCompany),
status: existing.status
},
{
code: scheme.code,
name: scheme.name,
share_agent: Number(scheme.shareAgent),
share_company: Number(scheme.shareCompany),
status: scheme.status
}
)
});
return NextResponse.json({ data: serializeProfitShareScheme(scheme) });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 });
}
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode skema sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.profitShareScheme.findUnique({ where: { id: parsedId } });
await prisma.profitShareScheme.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "PROFIT_SHARE_SCHEME_DELETED",
entityType: "PROFIT_SHARE_SCHEME",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Profit share scheme ${existing?.code ?? parsedId.toString()} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Profit share scheme not found" }, { status: 404 });
}
throw error;
}
}

View File

@ -0,0 +1,76 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { NextResponse } from "next/server";
import { serializeProfitShareScheme } from "@/features/profit-share-schemes/lib/serialize-profit-share-scheme";
import { profitShareSchemeInputSchema } from "@/features/profit-share-schemes/schemas/profit-share-scheme.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { resolveMasterCode } from "@/lib/master-code";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const data = await prisma.profitShareScheme.findMany({ orderBy: [{ createdAt: "desc" }] });
return NextResponse.json({ data: data.map(serializeProfitShareScheme) });
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = profitShareSchemeInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const resolvedCode = await resolveMasterCode({
role: auth.user.role,
prefix: "PSS",
requestedCode: parsed.data.code,
countExisting: () => prisma.profitShareScheme.count({ where: { code: { startsWith: "PSS" } } }),
exists: async (code) => (await prisma.profitShareScheme.count({ where: { code } })) > 0
});
if (!resolvedCode.ok) {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: [resolvedCode.message] } },
{ status: 400 }
);
}
const scheme = await prisma.profitShareScheme.create({
data: {
code: resolvedCode.code,
name: parsed.data.name,
shareAgent: parsed.data.share_agent,
shareCompany: parsed.data.share_company,
status: parsed.data.status
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "PROFIT_SHARE_SCHEME_CREATED",
entityType: "PROFIT_SHARE_SCHEME",
entityId: scheme.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Profit share scheme ${scheme.code} dibuat`
});
return NextResponse.json({ data: serializeProfitShareScheme(scheme) }, { status: 201 });
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
return NextResponse.json(
{ message: "Validasi gagal", errors: { code: ["Kode skema sudah dipakai"] } },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,347 @@
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 }
);
}
}

View File

@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import { serializePurchaseAnalysisListItem } from "@/features/purchase-analysis/lib/serialize-purchase-analysis";
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;
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
try {
const purchases = await prisma.purchase.findMany({
where: {
status: "SUBMITTED",
purchaseType: "REGULAR"
},
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
},
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: purchases.map(serializePurchaseAnalysisListItem)
});
} catch (error) {
console.error("Failed to load purchase analyses:", error);
return NextResponse.json(
{ message: "Gagal memuat analisis pembelian" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { serializePurchaseRealizationDetail } from "@/features/purchase-realization/lib/serialize-purchase-realization";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = {
params: Promise<{
purchaseId: string;
}>;
};
function parseId(rawId: string) {
try {
return BigInt(rawId);
} catch {
return null;
}
}
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 });
}
const purchase = await prisma.purchase.findUnique({
where: { id: parsedId },
select: {
id: true,
purchaseNo: true,
purchaseType: true,
purchaseDate: true,
status: true,
agent: {
select: {
name: true
}
},
buyoutSourceAgent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
name: true,
shareAgent: true
}
},
realizationSummary: true,
realizationEntries: {
select: {
id: true,
eventType: true,
referenceType: true,
referenceId: true,
occurredAt: true,
qtyIn: true,
qtyOut: true,
qtyShrinkage: true,
amountCost: true,
amountRevenue: true,
amountExpense: true,
amountProfit: true,
agentAmount: true,
notes: true,
lot: {
select: {
id: true,
lotCode: true
}
}
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }]
}
}
});
if (!purchase) {
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
}
return NextResponse.json({
data: serializePurchaseRealizationDetail(purchase)
});
}

View File

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { serializePurchaseRealizationListItem } from "@/features/purchase-realization/lib/serialize-purchase-realization";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const purchases = await prisma.purchase.findMany({
where: {
status: "SUBMITTED",
purchaseType: {
in: ["REGULAR", "OFFICE_BUYOUT"]
}
},
select: {
id: true,
purchaseNo: true,
purchaseType: true,
purchaseDate: true,
status: true,
agent: {
select: {
name: true
}
},
buyoutSourceAgent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
name: true,
shareAgent: true
}
},
realizationSummary: true
},
orderBy: [{ purchaseDate: "desc" }, { createdAt: "desc" }]
});
return NextResponse.json({
data: purchases.map(serializePurchaseRealizationListItem)
});
}

View File

@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
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<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function POST(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const purchase = await prisma.purchase.findUnique({ where: { id: parsedId } });
if (!purchase) return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
const updated = await prisma.purchase.update({
where: { id: parsedId },
data: { status: "CANCELLED" }
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "PURCHASE_CANCELLED",
entityType: "PURCHASE",
entityId: updated.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Purchase ${updated.purchaseNo} dibatalkan`,
metadata: buildAuditChangeMetadata(
{ status: purchase.status },
{ status: updated.status }
)
});
return NextResponse.json({ success: true, status: updated.status });
}

View File

@ -0,0 +1,342 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { buildPurchaseCostEntries } from "@/features/purchases/lib/build-purchase-cost-entries";
import { serializePurchaseDetail } from "@/features/purchases/lib/serialize-purchase";
import { isPurchasePayloadValidationError, parsePurchaseRequestPayload } from "@/features/purchases/lib/parse-purchase-request";
import { type PurchaseInput } from "@/features/purchases/schemas/purchase.schema";
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<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
type ParsedLine = PurchaseInput["lines"][number];
function sumLineQty(lines: ParsedLine[]) {
return lines.reduce((sum, line) => sum + Number(line.qty_ordered || 0), 0);
}
function toBigIntOrNull(raw: string | undefined | null) {
if (!raw) return null;
return BigInt(raw);
}
function toDateOrThrow(rawDate: string, label: string): Date {
const parsed = new Date(rawDate);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`${label} tidak valid`);
}
return parsed;
}
function buildLineCreateInput(
line: ParsedLine,
defaultWarehouseId: string,
defaultWarehouseLocationId: string | null,
fallbackGradeId: string
) {
const warehouseId = line.warehouse_id || defaultWarehouseId;
const warehouseLocationId = line.warehouse_location_id || defaultWarehouseLocationId;
return {
gradeId: BigInt(line.grade_id || fallbackGradeId),
qtyOrdered: line.qty_ordered,
purchaseMoisturePercent: null,
qtyReceived: line.qty_received,
qtyAccepted: line.qty_accepted,
qtyRejected: line.qty_rejected,
moistureReceivedPercent: null,
unitId: BigInt(line.unit_id),
unitPrice: line.unit_price,
unitCost: line.unit_cost,
malUnitPrice: line.mal_unit_price ?? null,
warehouseId: toBigIntOrNull(warehouseId),
warehouseLocationId: toBigIntOrNull(warehouseLocationId),
classificationStatus: line.classification_status,
subtotal: line.qty_ordered * line.unit_price,
notes: line.notes || null
};
}
const detailInclude = {
agent: true,
profitShareScheme: true,
courier: true,
receivedByEmployee: true,
analysis: {
include: {
costEntries: true
}
},
lines: {
include: {
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
}
} as const;
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
const purchase = await prisma.purchase.findUnique({
where: { id: parsedId },
include: detailInclude
});
if (!purchase) return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
return NextResponse.json({ data: serializePurchaseDetail(purchase) });
}
export async function PUT(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
let parsed: { payload: PurchaseInput; costProofFiles: Array<{ index: number; file: File }> };
try {
parsed = await parsePurchaseRequestPayload(request);
} catch (error) {
if (isPurchasePayloadValidationError(error)) {
return NextResponse.json(
{ message: error.message, errors: error.errors },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: "Request tidak valid" }, { status: 400 });
}
const { payload, costProofFiles } = parsed;
const finalWeight = sumLineQty(payload.lines);
const costEntries = await buildPurchaseCostEntries(payload, costProofFiles);
const missingGradeLine = payload.lines.some((line) => !line.grade_id?.trim());
try {
const existing = await prisma.purchase.findUnique({
where: { id: parsedId },
include: {
lines: true
}
});
if (!existing) {
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
}
const purchaseDate = toDateOrThrow(`${payload.purchase_date}T00:00:00.000Z`, "Tanggal pembelian");
const receivedAt = toDateOrThrow(payload.received_at, "Waktu diterima");
let defaultGradeId = "";
if (missingGradeLine) {
const defaultGrade = await prisma.grade.findFirst({
select: { id: true },
orderBy: { id: "asc" }
});
defaultGradeId = defaultGrade ? defaultGrade.id.toString() : "";
if (!defaultGradeId) {
return NextResponse.json(
{ message: "Grade tidak tersedia. Tambahkan grade di master data atau isi grade per line." },
{ status: 400 }
);
}
}
const purchase = await prisma.$transaction(async (tx) => {
await tx.purchaseLine.deleteMany({ where: { purchaseId: parsedId } });
return tx.purchase.update({
where: { id: parsedId },
data: {
purchaseDate,
agentId: toBigIntOrNull(payload.agent_id),
profitShareSchemeId: toBigIntOrNull(payload.profit_share_scheme_id),
courierId: toBigIntOrNull(payload.courier_id),
receivedByEmployeeId: toBigIntOrNull(payload.received_by_employee_id),
receivedAt,
moistureBuyPercent: payload.moisture_buy_percent ?? null,
moistureReceivedPercent: payload.moisture_received_percent ?? null,
aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null,
mkSharePercent: payload.mk_share_percent ?? null,
nonMkSharePercent: payload.non_mk_share_percent ?? null,
shippingCost: null,
incomingOperationalCost: null,
afterArrivalOperationalCost: null,
notes: payload.notes || null,
analysis: {
upsert: {
create: {
status: "DRAFT",
weightBuy: payload.berat_beli ?? finalWeight,
weightReceived: payload.berat_masuk ?? finalWeight,
weightFinal: finalWeight,
moistureBuyPercent: payload.moisture_buy_percent ?? null,
moistureReceivedPercent: payload.moisture_received_percent ?? null,
moistureFinalPercent: payload.moisture_final_percent ?? null,
aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null,
averagePrice: payload.average_price ?? null,
modalBeli: payload.modal_beli ?? null,
modalMasuk: payload.modal_masuk ?? null,
modalJual: payload.modal_jual ?? null,
modalBarang: payload.modal_barang ?? null,
totalModalBeli: payload.total_modal_beli ?? null,
totalModalMal: payload.total_modal_mal ?? null,
marketReferencePrice: payload.market_reference_price ?? null,
costEntries: {
create: costEntries
}
},
update: {
weightBuy: payload.berat_beli ?? finalWeight,
weightReceived: payload.berat_masuk ?? finalWeight,
weightFinal: finalWeight,
moistureBuyPercent: payload.moisture_buy_percent ?? null,
moistureReceivedPercent: payload.moisture_received_percent ?? null,
moistureFinalPercent: payload.moisture_final_percent ?? null,
aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null,
averagePrice: payload.average_price ?? null,
modalBeli: payload.modal_beli ?? null,
modalMasuk: payload.modal_masuk ?? null,
modalJual: payload.modal_jual ?? null,
modalBarang: payload.modal_barang ?? null,
totalModalBeli: payload.total_modal_beli ?? null,
totalModalMal: payload.total_modal_mal ?? null,
marketReferencePrice: payload.market_reference_price ?? null,
costEntries: {
deleteMany: {},
create: costEntries
}
}
}
},
lines: {
create: payload.lines.map((line) =>
buildLineCreateInput(
line,
payload.warehouse_id || "",
payload.warehouse_location_id || null,
missingGradeLine
? defaultGradeId
: line.grade_id || defaultGradeId
)
)
}
},
include: detailInclude
});
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "PURCHASE_UPDATED",
entityType: "PURCHASE",
entityId: purchase.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Purchase ${purchase.purchaseNo} diubah`,
metadata: buildAuditChangeMetadata(
{
purchase_date: existing.purchaseDate,
notes: existing.notes,
status: existing.status,
line_count: existing.lines.length,
total_qty: existing.lines.reduce((sum, line) => sum + line.qtyOrdered.toNumber(), 0),
grand_total: existing.lines.reduce((sum, line) => sum + line.subtotal.toNumber(), 0)
},
{
purchase_date: purchase.purchaseDate,
notes: purchase.notes,
status: purchase.status,
line_count: purchase.lines.length,
total_qty: purchase.lines.reduce((sum, line) => sum + line.qtyOrdered.toNumber(), 0),
grand_total: purchase.lines.reduce((sum, line) => sum + line.subtotal.toNumber(), 0)
}
)
});
return NextResponse.json({ data: serializePurchaseDetail(purchase) });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError || error instanceof Prisma.PrismaClientValidationError) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
throw error;
}
}
export async function DELETE(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const existing = await prisma.purchase.findUnique({
where: { id: parsedId },
include: {
_count: {
select: {
receipts: true,
lots: true
}
}
}
});
if (!existing) {
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
}
if (!["DRAFT", "CANCELLED"].includes(existing.status)) {
return NextResponse.json(
{ message: "Hanya draft atau cancelled yang bisa dihapus" },
{ status: 409 }
);
}
if (existing._count.receipts > 0 || existing._count.lots > 0) {
return NextResponse.json(
{ message: "Tidak dapat menghapus karena sudah memiliki relasi transaksi." },
{ status: 409 }
);
}
await prisma.purchase.delete({ where: { id: parsedId } });
await createAuditTrailSafe({
userId: auth.user.id,
action: "PURCHASE_DELETED",
entityType: "PURCHASE",
entityId: parsedId,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Purchase ${existing.purchaseNo} dihapus`
});
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
return NextResponse.json({ message: "Purchase not found" }, { status: 404 });
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return NextResponse.json(
{ message: "Tidak dapat menghapus karena masih terkait transaksi lain." },
{ status: 409 }
);
}
throw error;
}
}

View File

@ -0,0 +1,236 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { buildAuditChangeMetadata } from "@/lib/audit-trail-diff";
import { requireApiAccess } from "@/lib/authorization";
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";
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) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function POST(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) return NextResponse.json({ message: "Invalid id" }, { status: 400 });
try {
const purchaseId = parsedId;
const purchase = await prisma.purchase.findUnique({
where: { id: purchaseId },
include: {
agent: { select: { id: true, name: true } },
profitShareScheme: { select: { id: true, shareAgent: 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.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 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 lots = [];
for (const line of enteredQtyLines) {
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.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.id.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,
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 + lot dibuat`,
metadata: buildAuditChangeMetadata(
{ status: purchase.status, lot_count: 0 },
{ status: generated.purchase.status, lot_count: generated.lots.length }
)
});
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
},
{ status: 200 }
);
} 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 });
}
}

View File

@ -0,0 +1,116 @@
import { NextResponse } from "next/server";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const [agents, lots] = await Promise.all([
prisma.agent.findMany({
where: {
purchases: {
some: {
lots: {
some: {
status: "ACTIVE",
availableQty: {
gt: 0
}
}
}
}
}
},
orderBy: [{ name: "asc" }],
select: {
id: true,
code: true,
name: true
}
}),
prisma.inventoryLot.findMany({
where: {
status: "ACTIVE",
availableQty: {
gt: 0
},
purchase: {
agentId: {
not: null
},
purchaseType: "REGULAR"
}
},
orderBy: [{ receivedAt: "desc" }],
include: {
purchase: {
select: {
agentId: true,
agent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
purchaseLine: {
select: {
malUnitPrice: true
}
},
grade: {
select: {
name: true
}
},
unit: {
select: {
code: true
}
},
warehouse: {
select: {
name: true
}
},
warehouseLocation: {
select: {
name: true
}
}
}
})
]);
return NextResponse.json({
data: {
agents: agents.map((agent) => ({
id: agent.id.toString(),
code: agent.code,
name: agent.name
})),
lots: lots
.filter((lot) => lot.purchase?.agentId && lot.purchase?.agent?.name)
.map((lot) => ({
id: lot.id.toString(),
agent_id: lot.purchase!.agentId!.toString(),
agent_name: lot.purchase!.agent!.name,
lot_code: lot.lotCode,
grade: lot.grade?.name ?? "-",
available_qty: lot.availableQty.toNumber(),
unit_code: lot.unit.code,
mal_unit_price: lot.purchaseLine?.malUnitPrice?.toNumber() ?? 0,
agent_share_percent: lot.purchase?.profitShareScheme?.shareAgent.toNumber() ?? 0,
warehouse: lot.warehouse.name,
location: lot.warehouseLocation?.name ?? null
}))
}
});
}

View File

@ -0,0 +1,480 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import {
AGENT_BALANCE_DIRECTIONS,
AGENT_BALANCE_SOURCES,
AGENT_BALANCE_TYPES,
createAgentBalanceMutation,
roundBalanceAmount
} from "@/features/agents/lib/balance-mutations";
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
import { generatePurchaseNo } from "@/features/purchases/lib/generate-purchase-no";
import { serializeOfficeBuyoutListItem } from "@/features/purchases/lib/serialize-office-buyout";
import { officeBuyoutInputSchema } from "@/features/purchases/schemas/office-buyout.schema";
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
function roundQty(value: number) {
return Number(value.toFixed(3));
}
function roundAmount(value: number) {
return Number(value.toFixed(2));
}
type OfficeBuyoutTx = Prisma.TransactionClient & {
lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation;
purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry;
purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary;
};
const officeBuyoutInclude = {
buyoutSourceAgent: {
select: {
id: true,
name: true
}
},
lines: {
include: {
unit: {
select: {
code: true
}
},
sourceLot: {
select: {
id: true,
lotCode: true,
grade: {
select: {
name: true
}
}
}
}
}
},
lots: {
select: {
lotCode: true,
purchaseLineId: true
}
}
} as const;
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const purchases = await prisma.purchase.findMany({
where: {
purchaseType: "OFFICE_BUYOUT"
},
include: officeBuyoutInclude,
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: purchases.map(serializeOfficeBuyoutListItem)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = officeBuyoutInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{
message: "Validasi gagal",
errors: parsed.error.flatten().fieldErrors
},
{ status: 422 }
);
}
const purchaseDate = new Date(`${parsed.data.purchase_date}T00:00:00.000Z`);
const sourceAgentId = parseBigInt(parsed.data.buyout_source_agent_id);
if (sourceAgentId === null || Number.isNaN(purchaseDate.getTime())) {
return NextResponse.json({ message: "Data referensi atau tanggal tidak valid" }, { status: 400 });
}
const sourceLotIds = parsed.data.lines.map((line) => parseBigInt(line.source_lot_id));
if (sourceLotIds.some((id) => id === null)) {
return NextResponse.json({ message: "Lot sumber tidak valid" }, { status: 400 });
}
const [agent, sourceLots] = await Promise.all([
prisma.agent.findUnique({
where: { id: sourceAgentId },
select: {
id: true,
name: true,
currentBalance: true
}
}),
prisma.inventoryLot.findMany({
where: {
id: {
in: sourceLotIds as bigint[]
}
},
include: {
purchase: {
select: {
id: true,
agentId: true,
profitShareSchemeId: true,
purchaseType: true,
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
purchaseAllocations: true,
purchaseLine: {
select: {
malUnitPrice: true
}
},
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
})
]);
if (!agent) {
return NextResponse.json({ message: "Agen sumber tidak ditemukan" }, { status: 404 });
}
const sourceLotMap = new Map(sourceLots.map((lot) => [lot.id.toString(), lot]));
for (const line of parsed.data.lines) {
const sourceLot = sourceLotMap.get(line.source_lot_id);
if (!sourceLot) {
return NextResponse.json({ message: `Lot ${line.source_lot_id} tidak ditemukan` }, { status: 404 });
}
if (sourceLot.purchase?.purchaseType !== "REGULAR" || sourceLot.purchase?.agentId !== sourceAgentId) {
return NextResponse.json({ message: `Lot ${sourceLot.lotCode} tidak berasal dari agen yang dipilih` }, { status: 422 });
}
if (sourceLot.status !== "ACTIVE") {
return NextResponse.json({ message: `Lot ${sourceLot.lotCode} tidak aktif untuk buyout` }, { status: 422 });
}
if (line.qty_buyout > sourceLot.availableQty.toNumber()) {
return NextResponse.json(
{
message: `Qty buyout untuk ${sourceLot.lotCode} melebihi stok tersedia ${sourceLot.availableQty.toNumber()}`
},
{ status: 422 }
);
}
}
const purchaseNo = await generatePurchaseNo(purchaseDate);
const created = await prisma.$transaction(async (rawTx) => {
const tx = rawTx as OfficeBuyoutTx;
const purchase = await tx.purchase.create({
data: {
purchaseNo,
purchaseType: "OFFICE_BUYOUT",
purchaseDate,
buyoutSourceAgentId: sourceAgentId,
receivedAt: purchaseDate,
status: "SUBMITTED",
notes: parsed.data.notes ?? null,
createdById: BigInt(auth.user.id)
}
});
const baseLotCode = await generateLotCode(purchaseDate, agent.name);
const codeMatch = baseLotCode.match(/-(\d+)$/);
if (!codeMatch) {
throw new Error("Gagal generate kode lot buyout");
}
const lotPrefix = baseLotCode.replace(/-\d+$/, "");
let lotSuffix = Number(codeMatch[1] ?? "0") || 1;
let totalAgentCommission = 0;
const affectedSourcePurchaseIds = new Set<bigint>();
for (const line of parsed.data.lines) {
const sourceLot = sourceLotMap.get(line.source_lot_id)!;
const qtyBuyout = roundQty(line.qty_buyout);
const buyoutUnitPrice = roundAmount(line.buyout_unit_price);
const malUnitPrice = sourceLot.purchaseLine?.malUnitPrice?.toNumber() ?? 0;
const agentSharePercent = sourceLot.purchase?.profitShareScheme?.shareAgent.toNumber() ?? 0;
const profitAmount = Math.max(0, roundAmount((buyoutUnitPrice - malUnitPrice) * qtyBuyout));
const agentCommission = roundAmount(profitAmount * (agentSharePercent / 100));
totalAgentCommission += agentCommission;
if (sourceLot.purchase?.id) {
affectedSourcePurchaseIds.add(sourceLot.purchase.id);
}
const createdLine = await tx.purchaseLine.create({
data: {
purchaseId: purchase.id,
gradeId: sourceLot.gradeId,
sourceLotId: sourceLot.id,
qtyOrdered: new Prisma.Decimal(qtyBuyout),
qtyReceived: new Prisma.Decimal(qtyBuyout),
qtyAccepted: new Prisma.Decimal(qtyBuyout),
qtyRejected: new Prisma.Decimal(0),
unitId: sourceLot.unitId,
unitPrice: new Prisma.Decimal(buyoutUnitPrice),
buyoutMalUnitPriceSnapshot: new Prisma.Decimal(roundAmount(malUnitPrice)),
buyoutAgentSharePercent: new Prisma.Decimal(roundAmount(agentSharePercent)),
buyoutProfitAmount: new Prisma.Decimal(profitAmount),
buyoutAgentCommission: new Prisma.Decimal(agentCommission),
unitCost: new Prisma.Decimal(buyoutUnitPrice),
malUnitPrice: sourceLot.purchaseLine?.malUnitPrice ?? null,
subtotal: new Prisma.Decimal(roundAmount(qtyBuyout * buyoutUnitPrice)),
classificationStatus: "FINAL",
warehouseId: sourceLot.warehouseId,
warehouseLocationId: sourceLot.warehouseLocationId,
notes: line.notes ?? null
}
});
const lotCode = `${lotPrefix}-${String(lotSuffix).padStart(3, "0")}`;
lotSuffix += 1;
const createdLot = await tx.inventoryLot.create({
data: {
lotCode,
parentLotId: sourceLot.id,
sourceType: "OFFICE_BUYOUT",
sourceRefId: purchase.id,
purchaseId: purchase.id,
purchaseLineId: createdLine.id,
gradeId: sourceLot.gradeId,
warehouseId: sourceLot.warehouseId,
warehouseLocationId: sourceLot.warehouseLocationId,
originalQty: new Prisma.Decimal(qtyBuyout),
availableQty: new Prisma.Decimal(qtyBuyout),
unitId: sourceLot.unitId,
unitCost: new Prisma.Decimal(buyoutUnitPrice),
receivedAt: purchaseDate,
status: "ACTIVE",
qrCodeValue: lotCode,
barcodeValue: lotCode,
notes: `Buyout kantor dari lot ${sourceLot.lotCode}`
}
});
const buyoutAmount = roundAmount(qtyBuyout * buyoutUnitPrice);
const buyoutAllocation = await tx.lotPurchaseAllocation.create({
data: {
lotId: createdLot.id,
purchaseId: purchase.id,
purchaseLineId: createdLine.id,
sourceType: "OFFICE_BUYOUT",
sourceRefId: purchase.id,
agentIdSnapshot: null,
profitShareSchemeIdSnapshot: null,
qtyAllocated: new Prisma.Decimal(qtyBuyout),
costTotalAllocated: new Prisma.Decimal(buyoutAmount),
unitCostSnapshot: new Prisma.Decimal(buyoutUnitPrice),
notes: `Allocation buyout dari lot ${sourceLot.lotCode}`
}
});
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: purchase.id,
lotId: createdLot.id,
allocationId: buyoutAllocation.id,
eventType: "OPENING_COST",
referenceType: "PURCHASE",
referenceId: purchase.id,
occurredAt: purchaseDate,
qtyIn: new Prisma.Decimal(qtyBuyout),
qtyOut: new Prisma.Decimal(0),
qtyShrinkage: new Prisma.Decimal(0),
amountCost: new Prisma.Decimal(buyoutAmount),
amountRevenue: new Prisma.Decimal(0),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(0),
agentSharePercentSnapshot: null,
agentAmount: new Prisma.Decimal(0),
notes: `Opening realization office buyout ${purchase.purchaseNo}`
}
});
const sourceAllocations =
sourceLot.purchaseAllocations.length > 0
? sourceLot.purchaseAllocations
: sourceLot.purchase?.id
? [
{
id: null,
lotId: sourceLot.id,
purchaseId: sourceLot.purchase.id,
purchaseLineId: sourceLot.purchaseLineId,
sourceType: "PURCHASE",
sourceRefId: sourceLot.purchase.id,
agentIdSnapshot: sourceLot.purchase.agentId ?? null,
profitShareSchemeIdSnapshot: sourceLot.purchase.profitShareSchemeId ?? null,
qtyAllocated: new Prisma.Decimal(sourceLot.availableQty),
costTotalAllocated: new Prisma.Decimal(
roundAmount(sourceLot.availableQty.toNumber() * sourceLot.unitCost.toNumber())
),
unitCostSnapshot: sourceLot.unitCost,
notes: null,
createdAt: new Date(),
updatedAt: new Date()
}
]
: [];
const sourceAvailableQty = sourceLot.availableQty.toNumber();
for (const allocation of sourceAllocations) {
const allocationQty = allocation.qtyAllocated.toNumber();
if (sourceAvailableQty <= 0 || allocationQty <= 0) continue;
const allocationRatio = allocationQty / sourceAvailableQty;
const transferredQty = roundQty(qtyBuyout * allocationRatio);
const transferredRevenue = roundAmount(buyoutAmount * allocationRatio);
const transferredCost = roundAmount(allocation.unitCostSnapshot.toNumber() * transferredQty);
const transferredProfit = Math.max(0, roundAmount(transferredRevenue - transferredCost));
const transferredAgentAmount = roundAmount(transferredProfit * (agentSharePercent / 100));
const remainingQty = roundQty(Math.max(0, allocationQty - transferredQty));
const remainingCost = roundAmount(
Math.max(0, allocation.costTotalAllocated.toNumber() - transferredCost)
);
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: allocation.purchaseId,
lotId: sourceLot.id,
allocationId: allocation.id ?? undefined,
eventType: "OFFICE_BUYOUT_REVENUE",
referenceType: "PURCHASE",
referenceId: purchase.id,
occurredAt: purchaseDate,
qtyIn: new Prisma.Decimal(0),
qtyOut: new Prisma.Decimal(transferredQty),
qtyShrinkage: new Prisma.Decimal(0),
amountCost: new Prisma.Decimal(0),
amountRevenue: new Prisma.Decimal(transferredRevenue),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(transferredProfit),
agentSharePercentSnapshot:
agentSharePercent > 0 ? new Prisma.Decimal(roundAmount(agentSharePercent)) : null,
agentAmount: new Prisma.Decimal(transferredAgentAmount),
notes: `Revenue office buyout ${purchase.purchaseNo} dari lot ${sourceLot.lotCode}`
}
});
if (allocation.id) {
await tx.lotPurchaseAllocation.update({
where: { id: allocation.id },
data: {
qtyAllocated: new Prisma.Decimal(remainingQty),
costTotalAllocated: new Prisma.Decimal(remainingCost)
}
});
}
}
const nextAvailable = roundQty(sourceLot.availableQty.toNumber() - qtyBuyout);
await tx.inventoryLot.update({
where: { id: sourceLot.id },
data: {
availableQty: new Prisma.Decimal(nextAvailable),
status: nextAvailable <= 0 ? "CLOSED" : "ACTIVE"
}
});
}
if (totalAgentCommission > 0) {
const nextBalance = roundBalanceAmount(agent.currentBalance.toNumber() + totalAgentCommission);
await tx.agent.update({
where: { id: agent.id },
data: {
currentBalance: new Prisma.Decimal(nextBalance)
}
});
await createAgentBalanceMutation(tx, {
agentId: agent.id,
balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE,
direction: AGENT_BALANCE_DIRECTIONS.IN,
source: AGENT_BALANCE_SOURCES.OFFICE_BUYOUT_COMMISSION,
amount: totalAgentCommission,
balanceAfter: nextBalance,
effectiveDate: purchaseDate,
referenceType: "PURCHASE",
referenceId: purchase.id.toString(),
referenceNo: purchase.purchaseNo,
notes: `Komisi buyout kantor ${purchase.purchaseNo}`,
metadata: {
purchase_type: "OFFICE_BUYOUT",
source_agent_name: agent.name
}
});
}
await recalculatePurchaseRealizationSummary(tx, purchase.id, null);
for (const sourcePurchaseId of affectedSourcePurchaseIds) {
const sourcePurchase = await tx.purchase.findUnique({
where: { id: sourcePurchaseId },
select: {
profitShareScheme: {
select: {
shareAgent: true
}
}
}
});
await recalculatePurchaseRealizationSummary(
tx,
sourcePurchaseId,
sourcePurchase?.profitShareScheme?.shareAgent.toNumber() ?? null
);
}
return tx.purchase.findUniqueOrThrow({
where: { id: purchase.id },
include: officeBuyoutInclude
});
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "OFFICE_BUYOUT_CREATED",
entityType: "PURCHASE",
entityId: created.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Buyout kantor ${created.purchaseNo} dibuat`,
metadata: {
purchase_type: "OFFICE_BUYOUT",
purchase_no: created.purchaseNo,
source_agent_name: agent.name,
line_count: created.lines.length
}
});
return NextResponse.json(
{
data: serializeOfficeBuyoutListItem(created)
},
{ status: 201 }
);
}

View File

@ -0,0 +1,240 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { ensureSystemUser } from "@/features/purchases/lib/system-user";
import {
serializePurchaseDetail,
serializePurchaseListItem
} from "@/features/purchases/lib/serialize-purchase";
import { generatePurchaseNo } from "@/features/purchases/lib/generate-purchase-no";
import { buildPurchaseCostEntries } from "@/features/purchases/lib/build-purchase-cost-entries";
import {
isPurchasePayloadValidationError,
parsePurchaseRequestPayload
} from "@/features/purchases/lib/parse-purchase-request";
import { type PurchaseInput } from "@/features/purchases/schemas/purchase.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
function toBigIntOrNull(raw: string | null | undefined): bigint | null {
if (!raw) return null;
return BigInt(raw);
}
function toDateOrThrow(rawDate: string, label: string): Date {
const parsed = new Date(rawDate);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`${label} tidak valid`);
}
return parsed;
}
type PurchaseLinePayload = PurchaseInput["lines"][number];
function sumLineQty(lines: PurchaseLinePayload[]) {
return lines.reduce((sum, line) => sum + Number(line.qty_ordered || 0), 0);
}
function buildLineCreateInput(
line: PurchaseLinePayload,
defaultWarehouseId: string,
defaultWarehouseLocationId: string | null,
fallbackGradeId: string
) {
const gradeId = line.grade_id || fallbackGradeId;
const warehouseId = line.warehouse_id || defaultWarehouseId;
const warehouseLocationId = line.warehouse_location_id || defaultWarehouseLocationId;
return {
gradeId: gradeId ? BigInt(gradeId) : null,
qtyOrdered: line.qty_ordered,
purchaseMoisturePercent: null,
qtyReceived: line.qty_received,
qtyAccepted: line.qty_accepted,
qtyRejected: line.qty_rejected,
moistureReceivedPercent: null,
unitId: BigInt(line.unit_id),
unitPrice: line.unit_price,
unitCost: line.unit_cost,
malUnitPrice: line.mal_unit_price ?? null,
subtotal: line.qty_ordered * line.unit_price,
classificationStatus: line.classification_status,
warehouseId: toBigIntOrNull(warehouseId),
warehouseLocationId: toBigIntOrNull(warehouseLocationId),
notes: line.notes || null
};
}
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const purchaseType = new URL(request.url).searchParams.get("purchase_type")?.trim().toUpperCase() || "REGULAR";
const data = await prisma.purchase.findMany({
where: {
purchaseType
},
include: {
agent: {
select: {
id: true,
name: true
}
},
lines: {
select: {
subtotal: true
}
}
},
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: data.map(serializePurchaseListItem)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
let parsed: { payload: PurchaseInput; costProofFiles: Array<{ index: number; file: File }> };
try {
parsed = await parsePurchaseRequestPayload(request);
} catch (error) {
if (isPurchasePayloadValidationError(error)) {
return NextResponse.json(
{ message: error.message, errors: error.errors },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: "Request tidak valid" }, { status: 400 });
}
const systemUser = await ensureSystemUser();
const { payload, costProofFiles } = parsed;
const finalWeight = sumLineQty(payload.lines);
const purchaseDate = toDateOrThrow(`${payload.purchase_date}T00:00:00.000Z`, "Tanggal pembelian");
const receivedAt = toDateOrThrow(payload.received_at, "Waktu diterima");
const purchaseNo = await generatePurchaseNo(purchaseDate);
const costEntries = await buildPurchaseCostEntries(payload, costProofFiles);
const missingGradeLine = payload.lines.some((line) => !line.grade_id?.trim());
let defaultGradeId = "";
if (missingGradeLine) {
const defaultGrade = await prisma.grade.findFirst({
select: { id: true },
orderBy: { id: "asc" }
});
defaultGradeId = defaultGrade ? defaultGrade.id.toString() : "";
if (!defaultGradeId) {
return NextResponse.json(
{ message: "Grade tidak tersedia. Tambahkan grade di master data atau isi grade per line." },
{ status: 400 }
);
}
}
try {
const purchase = await prisma.purchase.create({
data: {
purchaseNo,
purchaseType: "REGULAR",
purchaseDate,
agentId: toBigIntOrNull(payload.agent_id),
profitShareSchemeId: toBigIntOrNull(payload.profit_share_scheme_id),
courierId: toBigIntOrNull(payload.courier_id),
receivedByEmployeeId: toBigIntOrNull(payload.received_by_employee_id),
receivedAt,
moistureBuyPercent: payload.moisture_buy_percent ?? null,
moistureReceivedPercent: payload.moisture_received_percent ?? null,
aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null,
mkSharePercent: payload.mk_share_percent ?? null,
nonMkSharePercent: payload.non_mk_share_percent ?? null,
shippingCost: null,
incomingOperationalCost: null,
afterArrivalOperationalCost: null,
status: "DRAFT",
notes: payload.notes || null,
createdById: systemUser.id,
lines: {
create: payload.lines.map((line) =>
buildLineCreateInput(
line,
payload.warehouse_id || "",
payload.warehouse_location_id || null,
missingGradeLine
? defaultGradeId
: line.grade_id || defaultGradeId
)
)
},
analysis: {
create: {
status: "DRAFT",
weightBuy: payload.berat_beli ?? finalWeight,
weightReceived: payload.berat_masuk ?? finalWeight,
weightFinal: finalWeight,
moistureBuyPercent: payload.moisture_buy_percent ?? null,
moistureReceivedPercent: payload.moisture_received_percent ?? null,
moistureFinalPercent: payload.moisture_final_percent ?? null,
aboveAverageRatioPercent: payload.above_average_ratio_percent ?? null,
averagePrice: payload.average_price ?? null,
modalBeli: payload.modal_beli ?? null,
modalMasuk: payload.modal_masuk ?? null,
modalJual: payload.modal_jual ?? null,
modalBarang: payload.modal_barang ?? null,
totalModalBeli: payload.total_modal_beli ?? null,
totalModalMal: payload.total_modal_mal ?? null,
marketReferencePrice: payload.market_reference_price ?? null,
costEntries: {
create: costEntries
}
}
}
},
include: {
agent: true,
profitShareScheme: true,
courier: true,
receivedByEmployee: true,
lines: {
include: {
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
},
analysis: {
include: {
costEntries: true
}
}
}
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "PURCHASE_CREATED",
entityType: "PURCHASE",
entityId: purchase.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Purchase ${purchase.purchaseNo} dibuat`,
metadata: {
purchase_no: purchase.purchaseNo,
line_count: purchase.lines.length
}
});
return NextResponse.json({ data: serializePurchaseDetail(purchase) }, { status: 201 });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError || error instanceof Prisma.PrismaClientValidationError) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
throw error;
}
}

View File

@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { generateLotCode } from "@/features/receipts/lib/generate-lot-code";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
export async function POST(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const existingReceipt = await prisma.receipt.findUnique({
where: { id: parsedId },
include: {
purchase: true,
lines: true,
lots: true
}
});
if (!existingReceipt) {
return NextResponse.json({ message: "Receipt not found" }, { status: 404 });
}
if (existingReceipt.lots.length > 0) {
return NextResponse.json(
{ message: "Lots already generated for this receipt" },
{ status: 409 }
);
}
const generated = await prisma.$transaction(async (tx) => {
const lots = [];
for (const line of existingReceipt.lines) {
if (Number(line.qtyAccepted) <= 0) {
continue;
}
const lotCode = await generateLotCode(
existingReceipt.receiptDate,
existingReceipt.purchaseId.toString()
);
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
}
});
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,
lot_codes: generated.map((lot) => lot.lotCode)
}
});
return NextResponse.json({
receipt_id: existingReceipt.id.toString(),
lots: generated.map((lot) => ({
id: lot.id.toString(),
lot_code: lot.lotCode,
qr_code_value: lot.qrCodeValue
}))
});
}

View File

@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { serializeReceiptDetail } from "@/features/receipts/lib/serialize-receipt";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
const parseId = (id: string) => {
try {
return BigInt(id);
} catch {
return null;
}
};
const receiptDetailInclude = {
purchase: true,
lines: {
include: {
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
},
lots: true
} as const;
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseId((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const receipt = await prisma.receipt.findUnique({
where: { id: parsedId },
include: receiptDetailInclude
});
if (!receipt) {
return NextResponse.json({ message: "Receipt not found" }, { status: 404 });
}
return NextResponse.json({ data: serializeReceiptDetail(receipt) });
}

View File

@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { ensureSystemUser } from "@/features/purchases/lib/system-user";
import { generateReceiptNo } from "@/features/receipts/lib/generate-receipt-no";
import {
serializeReceiptDetail,
serializeReceiptListItem
} from "@/features/receipts/lib/serialize-receipt";
import { receiptInputSchema } from "@/features/receipts/schemas/receipt.schema";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { prisma } from "@/lib/prisma";
import { requireApiAccess } from "@/lib/authorization";
const receiptDetailInclude = {
purchase: true,
lines: {
include: {
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
},
lots: true
} as const;
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const data = await prisma.receipt.findMany({
include: {
purchase: true,
lines: true,
lots: true
},
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: data.map(serializeReceiptListItem)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsed = receiptInputSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json(
{ message: "Validasi gagal", errors: parsed.error.flatten().fieldErrors },
{ status: 400 }
);
}
const systemUser = await ensureSystemUser();
const receiptDate = new Date(`${parsed.data.receipt_date}T00:00:00.000Z`);
const receiptNo = await generateReceiptNo(receiptDate);
const receipt = await prisma.receipt.create({
data: {
receiptNo,
purchaseId: BigInt(parsed.data.purchase_id),
receiptDate,
status: "DRAFT",
notes: parsed.data.notes || null,
receivedById: systemUser.id,
lines: {
create: parsed.data.lines.map((line) => ({
purchaseLineId: BigInt(line.purchase_line_id),
gradeId: line.grade_id ? BigInt(line.grade_id) : null,
qtyReceived: line.qty_received,
qtyAccepted: line.qty_accepted,
qtyRejected: line.qty_rejected,
unitId: BigInt(line.unit_id),
unitCost: line.unit_cost,
warehouseId: BigInt(line.warehouse_id),
warehouseLocationId: line.warehouse_location_id
? BigInt(line.warehouse_location_id)
: null,
notes: line.notes || null
}))
}
},
include: receiptDetailInclude
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "RECEIPT_CREATED",
entityType: "RECEIPT",
entityId: receipt.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Receipt ${receipt.receiptNo} dibuat`,
metadata: {
receipt_no: receipt.receiptNo,
purchase_no: receipt.purchase.purchaseNo,
line_count: receipt.lines.length
}
});
return NextResponse.json({ data: serializeReceiptDetail(receipt) }, { status: 201 });
}

View File

@ -0,0 +1,243 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import {
AGENT_BALANCE_DIRECTIONS,
AGENT_BALANCE_SOURCES,
AGENT_BALANCE_TYPES,
createAgentBalanceMutation
} from "@/features/agents/lib/balance-mutations";
import {
isJitSalePayloadValidationError,
parseJitSaleCloseRequest
} from "@/features/sales-jit/lib/parse-jit-sale-request";
import { serializeJitSaleDetail } from "@/features/sales-jit/lib/serialize-jit-sale";
import {
storeRegularSaleReceipt,
validateRegularSaleReceiptFile
} from "@/features/sales-regular/lib/store-shipping-receipt";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
function roundAmount(value: number) {
return Number(value.toFixed(2));
}
function roundQty(value: number) {
return Number(value.toFixed(3));
}
function createAgentBalanceKey(agentId: bigint) {
return agentId.toString();
}
const jitSaleDetailInclude = {
buyer: true,
courier: true,
lines: {
include: {
grade: true,
agent: true,
profitShareScheme: true
}
}
} as const;
export async function POST(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
try {
const parsedId = parseBigInt((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const { payload, shippingReceiptFile } = await parseJitSaleCloseRequest(request);
const closeDate = new Date(`${payload.close_date}T00:00:00.000Z`);
if (Number.isNaN(closeDate.getTime())) {
return NextResponse.json({ message: "Tanggal penyelesaian tidak valid" }, { status: 400 });
}
if (shippingReceiptFile) {
const fileError = validateRegularSaleReceiptFile(shippingReceiptFile);
if (fileError) {
return NextResponse.json({ message: fileError }, { status: 422 });
}
}
const existingSale = await prisma.jitSale.findUnique({
where: { id: parsedId },
include: jitSaleDetailInclude
});
if (!existingSale) {
return NextResponse.json({ message: "Penjualan just in time tidak ditemukan" }, { status: 404 });
}
if (existingSale.status === "CLOSED") {
return NextResponse.json({ message: "Penjualan just in time sudah ditutup" }, { status: 409 });
}
const payloadLineIds = new Set(payload.lines.map((line) => line.line_id));
if (payloadLineIds.size !== existingSale.lines.length || payload.lines.length !== existingSale.lines.length) {
return NextResponse.json(
{ message: "Semua item penjualan wajib ditutup dalam satu proses" },
{ status: 422 }
);
}
const lineMap = new Map(existingSale.lines.map((line) => [line.id.toString(), line]));
const exchangeRate = existingSale.exchangeRate?.toNumber() ?? 1;
let totalNominalBuyer = 0;
let totalNominalCompany = 0;
let totalAgentCommission = 0;
const agentBalanceAdjustments = new Map<string, { agentId: bigint; amount: number }>();
for (const payloadLine of payload.lines) {
const line = lineMap.get(payloadLine.line_id);
if (!line) {
return NextResponse.json(
{ message: `Line ${payloadLine.line_id} tidak terdaftar pada penjualan ini` },
{ status: 422 }
);
}
const qtyActualSold = line.qtyPlanned.toNumber();
const nominalBuyer = qtyActualSold * payloadLine.selling_price_actual;
const nominalCompany = nominalBuyer * exchangeRate;
const actualSellingPriceCompany = payloadLine.selling_price_actual * exchangeRate;
const malUnitPrice = line.malUnitPrice.toNumber();
const shareAgent = line.agentSharePercent?.toNumber() ?? 0;
const commissionBase = (actualSellingPriceCompany - malUnitPrice) * qtyActualSold;
const lineAgentCommission = roundAmount(commissionBase * (shareAgent / 100));
totalNominalBuyer += nominalBuyer;
totalNominalCompany += nominalCompany;
totalAgentCommission += lineAgentCommission;
if (line.agentId && lineAgentCommission > 0) {
const key = createAgentBalanceKey(line.agentId);
const current = agentBalanceAdjustments.get(key);
if (current) {
current.amount = roundAmount(current.amount + lineAgentCommission);
} else {
agentBalanceAdjustments.set(key, {
agentId: line.agentId,
amount: lineAgentCommission
});
}
}
}
const shippingReceiptFileUrl = shippingReceiptFile
? await storeRegularSaleReceipt(shippingReceiptFile)
: existingSale.shippingReceiptFileUrl;
const updated = await prisma.$transaction(async (tx) => {
for (const payloadLine of payload.lines) {
const line = lineMap.get(payloadLine.line_id)!;
await tx.jitSaleLine.update({
where: { id: line.id },
data: {
qtyActualSold: new Prisma.Decimal(roundQty(line.qtyPlanned.toNumber())),
sellingPriceActual: new Prisma.Decimal(roundAmount(payloadLine.selling_price_actual))
}
});
}
await tx.jitSale.update({
where: { id: existingSale.id },
data: {
closeDate,
shippingReceiptFileUrl,
totalNominalBuyer: new Prisma.Decimal(roundAmount(totalNominalBuyer)),
totalNominalCompany: new Prisma.Decimal(roundAmount(totalNominalCompany)),
totalAgentCommission: new Prisma.Decimal(roundAmount(totalAgentCommission)),
status: "CLOSED"
}
});
for (const adjustment of agentBalanceAdjustments.values()) {
const updatedAgent = await tx.agent.update({
where: { id: adjustment.agentId },
data: {
currentBalance: {
increment: new Prisma.Decimal(adjustment.amount)
}
},
select: {
currentBalance: true
}
});
await createAgentBalanceMutation(tx, {
agentId: adjustment.agentId,
balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE,
direction: AGENT_BALANCE_DIRECTIONS.IN,
source: AGENT_BALANCE_SOURCES.JIT_SALE_COMMISSION,
amount: adjustment.amount,
balanceAfter: updatedAgent.currentBalance.toNumber(),
effectiveDate: closeDate,
referenceType: "JIT_SALE",
referenceId: existingSale.id.toString(),
referenceNo: existingSale.saleNo,
notes: `Komisi agent dari penjualan just in time ${existingSale.saleNo}`
});
}
return tx.jitSale.findUniqueOrThrow({
where: { id: existingSale.id },
include: jitSaleDetailInclude
});
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "JIT_SALE_CLOSED",
entityType: "JIT_SALE",
entityId: updated.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Penjualan just in time ${updated.saleNo} ditutup`,
metadata: {
sale_no: updated.saleNo,
close_date: updated.closeDate?.toISOString().slice(0, 10) ?? null,
total_nominal_buyer: updated.totalNominalBuyer.toNumber(),
total_agent_commission: updated.totalAgentCommission.toNumber()
}
});
return NextResponse.json({
data: serializeJitSaleDetail(updated)
});
} catch (error) {
if (isJitSalePayloadValidationError(error)) {
return NextResponse.json(
{
message: error.message,
errors: error.errors
},
{ status: 422 }
);
}
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal menutup penjualan just in time"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { serializeJitSaleDetail } from "@/features/sales-jit/lib/serialize-jit-sale";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
const jitSaleDetailInclude = {
buyer: true,
courier: true,
lines: {
include: {
grade: true,
agent: true,
profitShareScheme: true
}
}
} as const;
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseBigInt((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const sale = await prisma.jitSale.findUnique({
where: { id: parsedId },
include: jitSaleDetailInclude
});
if (!sale) {
return NextResponse.json({ message: "Penjualan just in time tidak ditemukan" }, { status: 404 });
}
return NextResponse.json({
data: serializeJitSaleDetail(sale)
});
}

View File

@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { serializeAgent } from "@/features/agents/lib/serialize-agent";
import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer";
import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies";
import { serializeCurrency } from "@/features/currencies/lib/serialize-currency";
import { serializeCourier } from "@/features/couriers/lib/serialize-courier";
import { serializeGrade } from "@/features/grades/lib/serialize-grade";
import { serializeProfitShareScheme } from "@/features/profit-share-schemes/lib/serialize-profit-share-scheme";
import { requireApiAccess } from "@/lib/authorization";
import { getAppSettings } from "@/lib/app-settings";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
await ensureDefaultCurrencies();
const settings = await getAppSettings();
const [buyers, couriers, currencies, grades, agents, profitShareSchemes] = await Promise.all([
prisma.buyer.findMany({
where: { status: "ACTIVE" },
include: { contactPeople: true },
orderBy: [{ name: "asc" }]
}),
prisma.courier.findMany({
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }]
}),
prisma.currency.findMany({
where: {
OR: [{ status: "ACTIVE" }, { code: settings.currency_code }]
},
orderBy: [{ code: "asc" }]
}),
prisma.grade.findMany({
where: { status: "ACTIVE" },
include: {
buyPriceStandards: {
orderBy: [{ startDate: "desc" }]
},
sellPriceStandards: {
orderBy: [{ startDate: "desc" }]
}
},
orderBy: [{ name: "asc" }]
}),
prisma.agent.findMany({
include: {
profitShareScheme: true,
bankAccounts: {
include: {
bank: true
}
}
},
orderBy: [{ name: "asc" }]
}),
prisma.profitShareScheme.findMany({
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }]
})
]);
return NextResponse.json({
data: {
default_company_currency_code: settings.currency_code,
buyers: buyers.map(serializeBuyer),
couriers: couriers.map(serializeCourier),
currencies: currencies.map(serializeCurrency),
grades: grades.map(serializeGrade),
agents: agents.map(serializeAgent),
profit_share_schemes: profitShareSchemes.map(serializeProfitShareScheme)
}
});
}

View File

@ -0,0 +1,306 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies";
import { generateJitSaleNo } from "@/features/sales-jit/lib/generate-jit-sale-no";
import {
isJitSalePayloadValidationError,
parseJitSaleCreateRequest
} from "@/features/sales-jit/lib/parse-jit-sale-request";
import {
serializeJitSaleDetail,
serializeJitSaleListItem
} from "@/features/sales-jit/lib/serialize-jit-sale";
import {
storeRegularSaleReceipt,
validateRegularSaleReceiptFile
} from "@/features/sales-regular/lib/store-shipping-receipt";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
function roundAmount(value: number) {
return Number(value.toFixed(2));
}
function roundQty(value: number) {
return Number(value.toFixed(3));
}
const jitSaleInclude = {
buyer: true,
lines: true
} as const;
const jitSaleDetailInclude = {
buyer: true,
courier: true,
lines: {
include: {
grade: true,
agent: true,
profitShareScheme: true
}
}
} as const;
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const sales = await prisma.jitSale.findMany({
include: jitSaleInclude,
orderBy: [{ createdAt: "desc" }]
});
return NextResponse.json({
data: sales.map(serializeJitSaleListItem)
});
}
export async function POST(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
try {
await ensureDefaultCurrencies();
const { payload, shippingReceiptFile } = await parseJitSaleCreateRequest(request);
const buyerId = parseBigInt(payload.buyer_id);
const courierId = payload.courier_id ? parseBigInt(payload.courier_id) : null;
const saleDate = new Date(`${payload.sale_date}T00:00:00.000Z`);
const buyerCurrencyCode = payload.buyer_currency_code.trim().toUpperCase();
const companyCurrencyCode = payload.company_currency_code.trim().toUpperCase();
if (
buyerId === null ||
(payload.courier_id && courierId === null) ||
Number.isNaN(saleDate.getTime())
) {
return NextResponse.json({ message: "Invalid reference id or date" }, { status: 400 });
}
const gradeIds = payload.lines.map((line) => parseBigInt(line.grade_id));
const agentIds = payload.lines
.map((line) => (line.agent_id ? parseBigInt(line.agent_id) : null))
.filter((value): value is bigint | null => value !== undefined);
const schemeIds = payload.lines
.map((line) => (line.profit_share_scheme_id ? parseBigInt(line.profit_share_scheme_id) : null))
.filter((value): value is bigint | null => value !== undefined);
if (
gradeIds.some((id) => id === null) ||
agentIds.some((id) => id === null) ||
schemeIds.some((id) => id === null)
) {
return NextResponse.json({ message: "Referensi line tidak valid" }, { status: 400 });
}
if (shippingReceiptFile) {
const fileError = validateRegularSaleReceiptFile(shippingReceiptFile);
if (fileError) {
return NextResponse.json({ message: fileError }, { status: 422 });
}
}
const [buyer, courier, currencies, grades, agents, profitShareSchemes] = await Promise.all([
prisma.buyer.findUnique({ where: { id: buyerId } }),
courierId ? prisma.courier.findUnique({ where: { id: courierId } }) : Promise.resolve(null),
prisma.currency.findMany({
where: {
code: {
in: [buyerCurrencyCode, companyCurrencyCode]
},
status: "ACTIVE"
}
}),
prisma.grade.findMany({
where: {
id: { in: gradeIds as bigint[] },
status: "ACTIVE"
}
}),
prisma.agent.findMany({
where: {
id: {
in: agentIds.filter((id): id is bigint => Boolean(id))
}
}
}),
prisma.profitShareScheme.findMany({
where: {
id: {
in: schemeIds.filter((id): id is bigint => Boolean(id))
},
status: "ACTIVE"
}
})
]);
if (!buyer) {
return NextResponse.json({ message: "Buyer tidak ditemukan" }, { status: 404 });
}
if (payload.courier_id && !courier) {
return NextResponse.json({ message: "Kurir tidak ditemukan" }, { status: 404 });
}
if (currencies.length !== 2 && buyerCurrencyCode !== companyCurrencyCode) {
return NextResponse.json({ message: "Currency tidak valid" }, { status: 422 });
}
if (currencies.length !== 1 && buyerCurrencyCode === companyCurrencyCode) {
return NextResponse.json({ message: "Currency tidak valid" }, { status: 422 });
}
const exchangeRate =
buyerCurrencyCode === companyCurrencyCode ? 1 : Number(payload.exchange_rate ?? 0);
const gradeMap = new Map(grades.map((grade) => [grade.id.toString(), grade]));
const agentMap = new Map(agents.map((agent) => [agent.id.toString(), agent]));
const schemeMap = new Map(profitShareSchemes.map((scheme) => [scheme.id.toString(), scheme]));
let totalNominalBuyer = 0;
let totalNominalCompany = 0;
let totalAgentCommission = 0;
for (const line of payload.lines) {
const grade = gradeMap.get(line.grade_id);
if (!grade) {
return NextResponse.json(
{ message: `Grade ${line.grade_id} tidak ditemukan atau tidak aktif` },
{ status: 404 }
);
}
const agent = line.agent_id ? agentMap.get(line.agent_id) : null;
if (line.agent_id && !agent) {
return NextResponse.json(
{ message: `Agent ${line.agent_id} tidak ditemukan atau tidak aktif` },
{ status: 404 }
);
}
const scheme = line.profit_share_scheme_id
? schemeMap.get(line.profit_share_scheme_id)
: null;
if (line.profit_share_scheme_id && !scheme) {
return NextResponse.json(
{ message: `Skema bagi hasil ${line.profit_share_scheme_id} tidak ditemukan atau tidak aktif` },
{ status: 404 }
);
}
const nominalBuyer = line.qty_planned * line.selling_price_planned;
const nominalCompany = nominalBuyer * exchangeRate;
const plannedSellingPriceCompany = line.selling_price_planned * exchangeRate;
const shareAgent = scheme?.shareAgent.toNumber() ?? 0;
const commissionBase = (plannedSellingPriceCompany - line.mal_unit_price) * line.qty_planned;
totalNominalBuyer += nominalBuyer;
totalNominalCompany += nominalCompany;
totalAgentCommission += commissionBase * (shareAgent / 100);
}
const shippingCostCompany = payload.shipping_cost_buyer * exchangeRate;
const shippingReceiptFileUrl = shippingReceiptFile
? await storeRegularSaleReceipt(shippingReceiptFile)
: null;
const saleNo = await generateJitSaleNo(saleDate);
const created = await prisma.$transaction(async (tx) => {
const sale = await tx.jitSale.create({
data: {
saleNo,
saleDate,
buyerId,
buyerCurrencyCode,
companyCurrencyCode,
exchangeRate:
buyerCurrencyCode === companyCurrencyCode ? null : new Prisma.Decimal(exchangeRate),
courierId,
shippingCostBuyer: new Prisma.Decimal(roundAmount(payload.shipping_cost_buyer)),
shippingCostCompany: new Prisma.Decimal(roundAmount(shippingCostCompany)),
shippingReceiptFileUrl,
totalNominalBuyer: new Prisma.Decimal(roundAmount(totalNominalBuyer)),
totalNominalCompany: new Prisma.Decimal(roundAmount(totalNominalCompany)),
totalAgentCommission: new Prisma.Decimal(roundAmount(totalAgentCommission)),
notes: payload.notes ?? null,
createdById: BigInt(auth.user.id),
lines: {
create: payload.lines.map((line) => {
const agent = line.agent_id ? agentMap.get(line.agent_id) ?? null : null;
const scheme = line.profit_share_scheme_id
? schemeMap.get(line.profit_share_scheme_id) ?? null
: null;
return {
gradeId: parseBigInt(line.grade_id)!,
qtyPlanned: new Prisma.Decimal(roundQty(line.qty_planned)),
malUnitPrice: new Prisma.Decimal(roundAmount(line.mal_unit_price)),
sellingPricePlanned: new Prisma.Decimal(roundAmount(line.selling_price_planned)),
agentId: agent?.id ?? null,
agentNameSnapshot: agent?.name ?? null,
profitShareSchemeId: scheme?.id ?? null,
profitShareSchemeName: scheme?.name ?? null,
agentSharePercent: scheme
? new Prisma.Decimal(roundAmount(scheme.shareAgent.toNumber()))
: null,
notes: line.notes ?? null
};
})
}
}
});
return tx.jitSale.findUniqueOrThrow({
where: { id: sale.id },
include: jitSaleDetailInclude
});
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "JIT_SALE_CREATED",
entityType: "JIT_SALE",
entityId: created.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 201,
summary: `Penjualan JIT ${created.saleNo} dibuat`,
metadata: {
sale_no: created.saleNo,
buyer_id: created.buyerId.toString(),
item_count: created.lines.length,
total_nominal_buyer: created.totalNominalBuyer.toNumber()
}
});
return NextResponse.json(
{
data: serializeJitSaleDetail(created)
},
{ status: 201 }
);
} catch (error) {
if (isJitSalePayloadValidationError(error)) {
return NextResponse.json(
{
message: error.message,
errors: error.errors
},
{ status: 422 }
);
}
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal membuat penjualan just in time"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,459 @@
import { Prisma } from "@prisma/client";
import { NextResponse } from "next/server";
import {
AGENT_BALANCE_DIRECTIONS,
AGENT_BALANCE_SOURCES,
AGENT_BALANCE_TYPES,
createAgentBalanceMutation
} from "@/features/agents/lib/balance-mutations";
import {
isRegularSalePayloadValidationError,
parseRegularSaleCloseRequest
} from "@/features/sales-regular/lib/parse-regular-sale-request";
import { buildAllocationShares, getEffectiveLotAllocations, roundAmount as roundAllocationAmount } from "@/features/purchase-realization/lib/lot-allocation";
import { recalculatePurchaseRealizationSummary } from "@/features/purchase-realization/lib/recalculate-purchase-realization-summary";
import { serializeRegularSaleDetail } from "@/features/sales-regular/lib/serialize-regular-sale";
import {
storeRegularSaleReceipt,
validateRegularSaleReceiptFile
} from "@/features/sales-regular/lib/store-shipping-receipt";
import { createAuditTrailSafe } from "@/lib/audit-trail";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
function roundAmount(value: number) {
return Number(value.toFixed(2));
}
function roundQty(value: number) {
return Number(value.toFixed(3));
}
function createAgentBalanceKey(agentId: bigint) {
return agentId.toString();
}
const regularSaleDetailInclude = {
buyer: true,
courier: true,
lines: {
include: {
lot: {
include: {
purchase: {
select: {
id: true,
agentId: true,
profitShareSchemeId: true,
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
purchaseAllocations: true,
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
}
}
}
} as const;
type RegularSaleTx = Prisma.TransactionClient & {
purchaseRealizationEntry: typeof prisma.purchaseRealizationEntry;
purchaseRealizationSummary: typeof prisma.purchaseRealizationSummary;
lotPurchaseAllocation: typeof prisma.lotPurchaseAllocation;
};
export async function POST(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
try {
const parsedId = parseBigInt((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const { payload, shippingReceiptFile } = await parseRegularSaleCloseRequest(request);
const closeDate = new Date(`${payload.close_date}T00:00:00.000Z`);
if (Number.isNaN(closeDate.getTime())) {
return NextResponse.json({ message: "Tanggal penyelesaian tidak valid" }, { status: 400 });
}
if (shippingReceiptFile) {
const fileError = validateRegularSaleReceiptFile(shippingReceiptFile);
if (fileError) {
return NextResponse.json({ message: fileError }, { status: 422 });
}
}
const existingSale = await prisma.regularSale.findUnique({
where: { id: parsedId },
include: {
buyer: true,
courier: true,
lines: {
include: {
lot: {
include: {
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
}
}
}
}
});
if (!existingSale) {
return NextResponse.json({ message: "Penjualan reguler tidak ditemukan" }, { status: 404 });
}
if (existingSale.status === "CLOSED") {
return NextResponse.json({ message: "Penjualan reguler sudah ditutup" }, { status: 409 });
}
const payloadLineIds = new Set(payload.lines.map((line) => line.line_id));
if (payloadLineIds.size !== existingSale.lines.length || payload.lines.length !== existingSale.lines.length) {
return NextResponse.json(
{ message: "Semua item penjualan wajib ditutup dalam satu proses" },
{ status: 422 }
);
}
const lineMap = new Map(existingSale.lines.map((line) => [line.id.toString(), line]));
let totalNominalBuyer = 0;
let totalNominalCompany = 0;
let totalAgentCommission = 0;
const exchangeRate = existingSale.exchangeRate?.toNumber() ?? 1;
const agentBalanceAdjustments = new Map<string, { agentId: bigint; amount: number }>();
for (const payloadLine of payload.lines) {
const line = lineMap.get(payloadLine.line_id);
if (!line) {
return NextResponse.json(
{ message: `Line ${payloadLine.line_id} tidak terdaftar pada penjualan ini` },
{ status: 422 }
);
}
const qtyPlanned = line.qtyPlanned.toNumber();
const resolvedQty = payloadLine.qty_actual_sold + payloadLine.qty_returned;
if (resolvedQty > qtyPlanned) {
return NextResponse.json(
{
message: `Berat jual aktual + retur untuk ${line.lot.lotCode} tidak boleh melebihi berat jual`
},
{ status: 422 }
);
}
const qtyShrinkage = qtyPlanned - payloadLine.qty_actual_sold - payloadLine.qty_returned;
const nominalBuyer = payloadLine.qty_actual_sold * payloadLine.selling_price_actual;
const nominalCompany = nominalBuyer * exchangeRate;
const actualSellingPriceCompany = payloadLine.selling_price_actual * exchangeRate;
const malUnitPrice = line.malUnitPriceSnapshot?.toNumber() ?? 0;
const shareAgent = line.agentSharePercent?.toNumber() ?? 0;
const commissionBase = (actualSellingPriceCompany - malUnitPrice) * payloadLine.qty_actual_sold;
const lineAgentCommission = roundAmount(commissionBase * (shareAgent / 100));
totalNominalBuyer += nominalBuyer;
totalNominalCompany += nominalCompany;
totalAgentCommission += lineAgentCommission;
if (line.agentId && lineAgentCommission > 0) {
const key = createAgentBalanceKey(line.agentId);
const current = agentBalanceAdjustments.get(key);
if (current) {
current.amount = roundAmount(current.amount + lineAgentCommission);
} else {
agentBalanceAdjustments.set(key, {
agentId: line.agentId,
amount: lineAgentCommission
});
}
}
if (qtyShrinkage < 0) {
return NextResponse.json(
{ message: `Perhitungan susut untuk ${line.lot.lotCode} tidak valid` },
{ status: 422 }
);
}
}
const shippingReceiptFileUrl = shippingReceiptFile
? await storeRegularSaleReceipt(shippingReceiptFile)
: existingSale.shippingReceiptFileUrl;
const updated = await prisma.$transaction(async (rawTx) => {
const tx = rawTx as RegularSaleTx;
const affectedPurchaseIds = new Set<bigint>();
for (const payloadLine of payload.lines) {
const line = lineMap.get(payloadLine.line_id)!;
const qtyShrinkage = roundQty(
line.qtyPlanned.toNumber() - payloadLine.qty_actual_sold - payloadLine.qty_returned
);
const nextAvailable = roundQty(line.lot.availableQty.toNumber() + payloadLine.qty_returned);
const nextShrinkage = roundQty(line.lot.shrinkageQty.toNumber() + qtyShrinkage);
await tx.inventoryLot.update({
where: { id: line.lotId },
data: {
availableQty: new Prisma.Decimal(nextAvailable),
shrinkageQty: new Prisma.Decimal(nextShrinkage),
status: nextAvailable <= 0 ? "DEPLETED" : "ACTIVE"
}
});
await tx.regularSaleLine.update({
where: { id: line.id },
data: {
qtyActualSold: new Prisma.Decimal(roundQty(payloadLine.qty_actual_sold)),
qtyReturned: new Prisma.Decimal(roundQty(payloadLine.qty_returned)),
qtyShrinkage: new Prisma.Decimal(qtyShrinkage),
sellingPriceActual: new Prisma.Decimal(roundAmount(payloadLine.selling_price_actual))
}
});
const allocations = getEffectiveLotAllocations(line.lot);
const allocationBaseQty = allocations.reduce(
(sum, allocation) => sum + allocation.qtyAllocated.toNumber(),
0
);
const soldAndShrinkageQty = roundQty(payloadLine.qty_actual_sold + qtyShrinkage);
const shares = buildAllocationShares(
allocations,
allocationBaseQty,
soldAndShrinkageQty
);
const lineRevenue = roundAmount(payloadLine.qty_actual_sold * payloadLine.selling_price_actual * exchangeRate);
for (const share of shares) {
affectedPurchaseIds.add(share.allocation.purchaseId);
const soldQtyShare =
soldAndShrinkageQty > 0
? roundQty(payloadLine.qty_actual_sold * (share.affectedAllocationQty / soldAndShrinkageQty))
: 0;
const shrinkageQtyShare =
soldAndShrinkageQty > 0
? roundQty(qtyShrinkage * (share.affectedAllocationQty / soldAndShrinkageQty))
: 0;
const revenueShare = roundAllocationAmount(
lineRevenue * (share.affectedAllocationQty / soldAndShrinkageQty || 0)
);
if (soldQtyShare > 0 || revenueShare > 0) {
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: share.allocation.purchaseId,
lotId: line.lotId,
allocationId: share.allocation.id ?? undefined,
eventType: "SALE_REVENUE",
referenceType: "REGULAR_SALE",
referenceId: existingSale.id,
occurredAt: closeDate,
qtyIn: new Prisma.Decimal(0),
qtyOut: new Prisma.Decimal(soldQtyShare),
qtyShrinkage: new Prisma.Decimal(0),
amountCost: new Prisma.Decimal(0),
amountRevenue: new Prisma.Decimal(revenueShare),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(0),
agentSharePercentSnapshot: null,
agentAmount: new Prisma.Decimal(0),
notes: `Revenue regular sale ${existingSale.saleNo}`
}
});
}
if (payloadLine.qty_returned > 0) {
const returnQtyShare = roundQty(
payloadLine.qty_returned * (share.allocationQty / allocationBaseQty || 0)
);
if (returnQtyShare > 0) {
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: share.allocation.purchaseId,
lotId: line.lotId,
allocationId: share.allocation.id ?? undefined,
eventType: "SALE_RETURN",
referenceType: "REGULAR_SALE",
referenceId: existingSale.id,
occurredAt: closeDate,
qtyIn: new Prisma.Decimal(returnQtyShare),
qtyOut: new Prisma.Decimal(0),
qtyShrinkage: new Prisma.Decimal(0),
amountCost: new Prisma.Decimal(0),
amountRevenue: new Prisma.Decimal(0),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(0),
agentSharePercentSnapshot: null,
agentAmount: new Prisma.Decimal(0),
notes: `Return regular sale ${existingSale.saleNo}`
}
});
}
}
if (shrinkageQtyShare > 0) {
await tx.purchaseRealizationEntry.create({
data: {
purchaseId: share.allocation.purchaseId,
lotId: line.lotId,
allocationId: share.allocation.id ?? undefined,
eventType: "SALE_SHRINKAGE",
referenceType: "REGULAR_SALE",
referenceId: existingSale.id,
occurredAt: closeDate,
qtyIn: new Prisma.Decimal(0),
qtyOut: new Prisma.Decimal(0),
qtyShrinkage: new Prisma.Decimal(shrinkageQtyShare),
amountCost: new Prisma.Decimal(0),
amountRevenue: new Prisma.Decimal(0),
amountExpense: new Prisma.Decimal(0),
amountProfit: new Prisma.Decimal(0),
agentSharePercentSnapshot: null,
agentAmount: new Prisma.Decimal(0),
notes: `Susut regular sale ${existingSale.saleNo}`
}
});
}
if (share.allocation.id) {
await tx.lotPurchaseAllocation.update({
where: { id: share.allocation.id },
data: {
qtyAllocated: new Prisma.Decimal(share.remainingQty),
costTotalAllocated: new Prisma.Decimal(
roundAllocationAmount(
share.remainingQty * share.allocation.unitCostSnapshot.toNumber()
)
)
}
});
}
}
}
await tx.regularSale.update({
where: { id: existingSale.id },
data: {
closeDate,
shippingReceiptFileUrl,
totalNominalBuyer: new Prisma.Decimal(roundAmount(totalNominalBuyer)),
totalNominalCompany: new Prisma.Decimal(roundAmount(totalNominalCompany)),
totalAgentCommission: new Prisma.Decimal(roundAmount(totalAgentCommission)),
status: "CLOSED"
}
});
for (const adjustment of agentBalanceAdjustments.values()) {
const updatedAgent = await tx.agent.update({
where: { id: adjustment.agentId },
data: {
currentBalance: {
increment: new Prisma.Decimal(adjustment.amount)
}
},
select: {
currentBalance: true
}
});
await createAgentBalanceMutation(tx, {
agentId: adjustment.agentId,
balanceType: AGENT_BALANCE_TYPES.PROFIT_SHARE,
direction: AGENT_BALANCE_DIRECTIONS.IN,
source: AGENT_BALANCE_SOURCES.REGULAR_SALE_COMMISSION,
amount: adjustment.amount,
balanceAfter: updatedAgent.currentBalance.toNumber(),
effectiveDate: closeDate,
referenceType: "REGULAR_SALE",
referenceId: existingSale.id.toString(),
referenceNo: existingSale.saleNo,
notes: `Komisi agent dari penjualan reguler ${existingSale.saleNo}`
});
}
for (const purchaseId of affectedPurchaseIds) {
const sourcePurchase = await tx.purchase.findUnique({
where: { id: purchaseId },
select: {
profitShareScheme: {
select: {
shareAgent: true
}
}
}
});
await recalculatePurchaseRealizationSummary(
tx,
purchaseId,
sourcePurchase?.profitShareScheme?.shareAgent.toNumber() ?? null
);
}
return tx.regularSale.findUniqueOrThrow({
where: { id: existingSale.id },
include: regularSaleDetailInclude
});
});
await createAuditTrailSafe({
userId: auth.user.id,
action: "REGULAR_SALE_CLOSED",
entityType: "REGULAR_SALE",
entityId: updated.id,
method: request.method,
pathname: new URL(request.url).pathname,
statusCode: 200,
summary: `Penjualan reguler ${updated.saleNo} ditutup`,
metadata: {
sale_no: updated.saleNo,
close_date: updated.closeDate?.toISOString().slice(0, 10) ?? null,
total_nominal_buyer: updated.totalNominalBuyer.toNumber(),
total_agent_commission: updated.totalAgentCommission.toNumber()
}
});
return NextResponse.json({
data: serializeRegularSaleDetail(updated)
});
} catch (error) {
if (isRegularSalePayloadValidationError(error)) {
return NextResponse.json(
{
message: error.message,
errors: error.errors
},
{ status: 422 }
);
}
return NextResponse.json(
{
message: error instanceof Error ? error.message : "Gagal menutup penjualan reguler"
},
{ status: 500 }
);
}
}

View File

@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { serializeRegularSaleDetail } from "@/features/sales-regular/lib/serialize-regular-sale";
import { requireApiAccess } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
type RouteContext = { params: Promise<{ id: string }> };
function parseBigInt(value: string) {
try {
return BigInt(value);
} catch {
return null;
}
}
const regularSaleDetailInclude = {
buyer: true,
courier: true,
lines: {
include: {
lot: {
include: {
grade: true,
unit: true,
warehouse: true,
warehouseLocation: true
}
}
}
}
} as const;
export async function GET(request: Request, context: RouteContext) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
const parsedId = parseBigInt((await context.params).id);
if (parsedId === null) {
return NextResponse.json({ message: "Invalid id" }, { status: 400 });
}
const sale = await prisma.regularSale.findUnique({
where: { id: parsedId },
include: regularSaleDetailInclude
});
if (!sale) {
return NextResponse.json({ message: "Penjualan reguler tidak ditemukan" }, { status: 404 });
}
return NextResponse.json({
data: serializeRegularSaleDetail(sale)
});
}

View File

@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { ensureDefaultCurrencies } from "@/features/currencies/lib/default-currencies";
import { serializeCurrency } from "@/features/currencies/lib/serialize-currency";
import { serializeCourier } from "@/features/couriers/lib/serialize-courier";
import { serializeBuyer } from "@/features/buyers/lib/serialize-buyer";
import { serializeRegularSaleCandidateLot } from "@/features/sales-regular/lib/serialize-regular-sale";
import { requireApiAccess } from "@/lib/authorization";
import { getAppSettings } from "@/lib/app-settings";
import { prisma } from "@/lib/prisma";
export async function GET(request: Request) {
const auth = requireApiAccess(request);
if (!auth.ok) return auth.response;
await ensureDefaultCurrencies();
const settings = await getAppSettings();
const [buyers, couriers, currencies, lots] = await Promise.all([
prisma.buyer.findMany({
where: { status: "ACTIVE" },
include: { contactPeople: true },
orderBy: [{ name: "asc" }]
}),
prisma.courier.findMany({
where: { status: "ACTIVE" },
orderBy: [{ name: "asc" }]
}),
prisma.currency.findMany({
where: {
OR: [{ status: "ACTIVE" }, { code: settings.currency_code }]
},
orderBy: [{ code: "asc" }]
}),
prisma.inventoryLot.findMany({
where: {
status: "ACTIVE",
availableQty: {
gt: 0
}
},
include: {
purchase: {
select: {
agent: {
select: {
name: true
}
},
profitShareScheme: {
select: {
shareAgent: true
}
}
}
},
purchaseLine: {
select: {
malUnitPrice: true
}
},
grade: { select: { name: true } },
unit: { select: { code: true } },
warehouse: { select: { name: true } },
warehouseLocation: { select: { name: true } }
},
orderBy: [{ createdAt: "desc" }]
})
]);
return NextResponse.json({
data: {
default_company_currency_code: settings.currency_code,
buyers: buyers.map(serializeBuyer),
couriers: couriers.map(serializeCourier),
currencies: currencies.map(serializeCurrency),
lots: lots.map(serializeRegularSaleCandidateLot)
}
});
}

Some files were not shown because too many files have changed in this diff Show More