Initial import of AbelBirdNest Stock
This commit is contained in:
138
src/app/api/v1/adjustment-reasons/[id]/route.ts
Normal file
138
src/app/api/v1/adjustment-reasons/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
src/app/api/v1/adjustment-reasons/route.ts
Normal file
72
src/app/api/v1/adjustment-reasons/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
121
src/app/api/v1/agents/[id]/detail/route.ts
Normal file
121
src/app/api/v1/agents/[id]/detail/route.ts
Normal 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
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
274
src/app/api/v1/agents/[id]/route.ts
Normal file
274
src/app/api/v1/agents/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
169
src/app/api/v1/agents/route.ts
Normal file
169
src/app/api/v1/agents/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/app/api/v1/audit-trail/export/route.ts
Normal file
114
src/app/api/v1/audit-trail/export/route.ts
Normal 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\"`
|
||||
}
|
||||
});
|
||||
}
|
||||
108
src/app/api/v1/audit-trail/route.ts
Normal file
108
src/app/api/v1/audit-trail/route.ts
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
84
src/app/api/v1/auth/change-password/route.ts
Normal file
84
src/app/api/v1/auth/change-password/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/app/api/v1/auth/forgot-password/route.ts
Normal file
68
src/app/api/v1/auth/forgot-password/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
142
src/app/api/v1/auth/login/route.ts
Normal file
142
src/app/api/v1/auth/login/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
src/app/api/v1/auth/logout/route.ts
Normal file
30
src/app/api/v1/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
22
src/app/api/v1/auth/me/route.ts
Normal file
22
src/app/api/v1/auth/me/route.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
69
src/app/api/v1/auth/resend-verification/route.ts
Normal file
69
src/app/api/v1/auth/resend-verification/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
83
src/app/api/v1/auth/reset-password/route.ts
Normal file
83
src/app/api/v1/auth/reset-password/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/v1/auth/verify-email/route.ts
Normal file
75
src/app/api/v1/auth/verify-email/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
161
src/app/api/v1/banks/[id]/route.ts
Normal file
161
src/app/api/v1/banks/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/app/api/v1/banks/route.ts
Normal file
71
src/app/api/v1/banks/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
265
src/app/api/v1/buyers/[id]/route.ts
Normal file
265
src/app/api/v1/buyers/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
139
src/app/api/v1/buyers/route.ts
Normal file
139
src/app/api/v1/buyers/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/app/api/v1/consignments/[id]/route.ts
Normal file
69
src/app/api/v1/consignments/[id]/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
62
src/app/api/v1/consignments/bootstrap/route.ts
Normal file
62
src/app/api/v1/consignments/bootstrap/route.ts
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
380
src/app/api/v1/consignments/lines/[lineId]/close/route.ts
Normal file
380
src/app/api/v1/consignments/lines/[lineId]/close/route.ts
Normal 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 });
|
||||
}
|
||||
237
src/app/api/v1/consignments/route.ts
Normal file
237
src/app/api/v1/consignments/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
159
src/app/api/v1/couriers/[id]/route.ts
Normal file
159
src/app/api/v1/couriers/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
84
src/app/api/v1/couriers/route.ts
Normal file
84
src/app/api/v1/couriers/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
144
src/app/api/v1/currencies/[id]/route.ts
Normal file
144
src/app/api/v1/currencies/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
66
src/app/api/v1/currencies/route.ts
Normal file
66
src/app/api/v1/currencies/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
138
src/app/api/v1/employees/[id]/route.ts
Normal file
138
src/app/api/v1/employees/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
86
src/app/api/v1/employees/route.ts
Normal file
86
src/app/api/v1/employees/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
49
src/app/api/v1/fund-requests/bootstrap/route.ts
Normal file
49
src/app/api/v1/fund-requests/bootstrap/route.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
268
src/app/api/v1/fund-requests/route.ts
Normal file
268
src/app/api/v1/fund-requests/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
274
src/app/api/v1/grades/[id]/route.ts
Normal file
274
src/app/api/v1/grades/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
149
src/app/api/v1/grades/route.ts
Normal file
149
src/app/api/v1/grades/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
27
src/app/api/v1/health/route.ts
Normal file
27
src/app/api/v1/health/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/lot-transformations/[id]/route.ts
Normal file
34
src/app/api/v1/lot-transformations/[id]/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
48
src/app/api/v1/lot-transformations/route.ts
Normal file
48
src/app/api/v1/lot-transformations/route.ts
Normal 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
|
||||
});
|
||||
}
|
||||
94
src/app/api/v1/lots/[id]/print-label/route.ts
Normal file
94
src/app/api/v1/lots/[id]/print-label/route.ts
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
158
src/app/api/v1/lots/[id]/route.ts
Normal file
158
src/app/api/v1/lots/[id]/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
34
src/app/api/v1/lots/route.ts
Normal file
34
src/app/api/v1/lots/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
190
src/app/api/v1/mobile/bootstrap/route.ts
Normal file
190
src/app/api/v1/mobile/bootstrap/route.ts
Normal 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
|
||||
}))
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
1
src/app/api/v1/mobile/consignments/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/consignments/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/consignments/[id]/route";
|
||||
1
src/app/api/v1/mobile/consignments/bootstrap/route.ts
Normal file
1
src/app/api/v1/mobile/consignments/bootstrap/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/consignments/bootstrap/route";
|
||||
@ -0,0 +1 @@
|
||||
export { POST } from "@/app/api/v1/consignments/lines/[lineId]/close/route";
|
||||
1
src/app/api/v1/mobile/consignments/route.ts
Normal file
1
src/app/api/v1/mobile/consignments/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/consignments/route";
|
||||
20
src/app/api/v1/mobile/dashboard/route.ts
Normal file
20
src/app/api/v1/mobile/dashboard/route.ts
Normal 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 });
|
||||
}
|
||||
1
src/app/api/v1/mobile/fund-requests/bootstrap/route.ts
Normal file
1
src/app/api/v1/mobile/fund-requests/bootstrap/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/fund-requests/bootstrap/route";
|
||||
1
src/app/api/v1/mobile/fund-requests/route.ts
Normal file
1
src/app/api/v1/mobile/fund-requests/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/fund-requests/route";
|
||||
1
src/app/api/v1/mobile/lot-transformations/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/lot-transformations/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/lot-transformations/[id]/route";
|
||||
76
src/app/api/v1/mobile/lot-transformations/route.ts
Normal file
76
src/app/api/v1/mobile/lot-transformations/route.ts
Normal 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
|
||||
});
|
||||
}
|
||||
1
src/app/api/v1/mobile/lots/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/lots/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/lots/[id]/route";
|
||||
1
src/app/api/v1/mobile/lots/route.ts
Normal file
1
src/app/api/v1/mobile/lots/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/lots/route";
|
||||
216
src/app/api/v1/mobile/lots/scan/route.ts
Normal file
216
src/app/api/v1/mobile/lots/scan/route.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/purchase-analyses/[purchaseId]/route";
|
||||
1
src/app/api/v1/mobile/purchase-analyses/route.ts
Normal file
1
src/app/api/v1/mobile/purchase-analyses/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/purchase-analyses/route";
|
||||
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/purchase-realizations/[purchaseId]/route";
|
||||
1
src/app/api/v1/mobile/purchase-realizations/route.ts
Normal file
1
src/app/api/v1/mobile/purchase-realizations/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/purchase-realizations/route";
|
||||
1
src/app/api/v1/mobile/purchases/[id]/cancel/route.ts
Normal file
1
src/app/api/v1/mobile/purchases/[id]/cancel/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { POST } from "@/app/api/v1/purchases/[id]/cancel/route";
|
||||
1
src/app/api/v1/mobile/purchases/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/purchases/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, PUT } from "@/app/api/v1/purchases/[id]/route";
|
||||
1
src/app/api/v1/mobile/purchases/[id]/submit/route.ts
Normal file
1
src/app/api/v1/mobile/purchases/[id]/submit/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { POST } from "@/app/api/v1/purchases/[id]/submit/route";
|
||||
1
src/app/api/v1/mobile/purchases/route.ts
Normal file
1
src/app/api/v1/mobile/purchases/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/purchases/route";
|
||||
@ -0,0 +1 @@
|
||||
export { POST } from "@/app/api/v1/receipts/[id]/generate-lots/route";
|
||||
1
src/app/api/v1/mobile/receipts/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/receipts/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/receipts/[id]/route";
|
||||
121
src/app/api/v1/mobile/receipts/bootstrap/route.ts
Normal file
121
src/app/api/v1/mobile/receipts/bootstrap/route.ts
Normal 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
|
||||
}))
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
1
src/app/api/v1/mobile/receipts/route.ts
Normal file
1
src/app/api/v1/mobile/receipts/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/receipts/route";
|
||||
1
src/app/api/v1/mobile/sales-jit/[id]/close/route.ts
Normal file
1
src/app/api/v1/mobile/sales-jit/[id]/close/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { POST } from "@/app/api/v1/sales-jit/[id]/close/route";
|
||||
1
src/app/api/v1/mobile/sales-jit/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/sales-jit/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/sales-jit/[id]/route";
|
||||
1
src/app/api/v1/mobile/sales-jit/bootstrap/route.ts
Normal file
1
src/app/api/v1/mobile/sales-jit/bootstrap/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/sales-jit/bootstrap/route";
|
||||
1
src/app/api/v1/mobile/sales-jit/route.ts
Normal file
1
src/app/api/v1/mobile/sales-jit/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/sales-jit/route";
|
||||
1
src/app/api/v1/mobile/sales-regular/[id]/close/route.ts
Normal file
1
src/app/api/v1/mobile/sales-regular/[id]/close/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { POST } from "@/app/api/v1/sales-regular/[id]/close/route";
|
||||
1
src/app/api/v1/mobile/sales-regular/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/sales-regular/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/sales-regular/[id]/route";
|
||||
1
src/app/api/v1/mobile/sales-regular/bootstrap/route.ts
Normal file
1
src/app/api/v1/mobile/sales-regular/bootstrap/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET } from "@/app/api/v1/sales-regular/bootstrap/route";
|
||||
1
src/app/api/v1/mobile/sales-regular/route.ts
Normal file
1
src/app/api/v1/mobile/sales-regular/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/sales-regular/route";
|
||||
25
src/app/api/v1/mobile/stock-adjustments/bootstrap/route.ts
Normal file
25
src/app/api/v1/mobile/stock-adjustments/bootstrap/route.ts
Normal 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
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
1
src/app/api/v1/mobile/stock-adjustments/route.ts
Normal file
1
src/app/api/v1/mobile/stock-adjustments/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/stock-adjustments/route";
|
||||
1
src/app/api/v1/mobile/washing/[id]/complete/route.ts
Normal file
1
src/app/api/v1/mobile/washing/[id]/complete/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { POST } from "@/app/api/v1/washing/[id]/complete/route";
|
||||
1
src/app/api/v1/mobile/washing/[id]/route.ts
Normal file
1
src/app/api/v1/mobile/washing/[id]/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { PUT } from "@/app/api/v1/washing/[id]/route";
|
||||
72
src/app/api/v1/mobile/washing/bootstrap/route.ts
Normal file
72
src/app/api/v1/mobile/washing/bootstrap/route.ts
Normal 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
|
||||
}))
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
1
src/app/api/v1/mobile/washing/route.ts
Normal file
1
src/app/api/v1/mobile/washing/route.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/app/api/v1/washing/route";
|
||||
146
src/app/api/v1/profit-share-schemes/[id]/route.ts
Normal file
146
src/app/api/v1/profit-share-schemes/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
src/app/api/v1/profit-share-schemes/route.ts
Normal file
76
src/app/api/v1/profit-share-schemes/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
347
src/app/api/v1/purchase-analyses/[purchaseId]/route.ts
Normal file
347
src/app/api/v1/purchase-analyses/[purchaseId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
src/app/api/v1/purchase-analyses/route.ts
Normal file
98
src/app/api/v1/purchase-analyses/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
90
src/app/api/v1/purchase-realizations/[purchaseId]/route.ts
Normal file
90
src/app/api/v1/purchase-realizations/[purchaseId]/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
48
src/app/api/v1/purchase-realizations/route.ts
Normal file
48
src/app/api/v1/purchase-realizations/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
44
src/app/api/v1/purchases/[id]/cancel/route.ts
Normal file
44
src/app/api/v1/purchases/[id]/cancel/route.ts
Normal 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 });
|
||||
}
|
||||
342
src/app/api/v1/purchases/[id]/route.ts
Normal file
342
src/app/api/v1/purchases/[id]/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
236
src/app/api/v1/purchases/[id]/submit/route.ts
Normal file
236
src/app/api/v1/purchases/[id]/submit/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
116
src/app/api/v1/purchases/office-buyout/bootstrap/route.ts
Normal file
116
src/app/api/v1/purchases/office-buyout/bootstrap/route.ts
Normal 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
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
480
src/app/api/v1/purchases/office-buyout/route.ts
Normal file
480
src/app/api/v1/purchases/office-buyout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
240
src/app/api/v1/purchases/route.ts
Normal file
240
src/app/api/v1/purchases/route.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
114
src/app/api/v1/receipts/[id]/generate-lots/route.ts
Normal file
114
src/app/api/v1/receipts/[id]/generate-lots/route.ts
Normal 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
|
||||
}))
|
||||
});
|
||||
}
|
||||
48
src/app/api/v1/receipts/[id]/route.ts
Normal file
48
src/app/api/v1/receipts/[id]/route.ts
Normal 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) });
|
||||
}
|
||||
104
src/app/api/v1/receipts/route.ts
Normal file
104
src/app/api/v1/receipts/route.ts
Normal 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 });
|
||||
}
|
||||
243
src/app/api/v1/sales-jit/[id]/close/route.ts
Normal file
243
src/app/api/v1/sales-jit/[id]/close/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
50
src/app/api/v1/sales-jit/[id]/route.ts
Normal file
50
src/app/api/v1/sales-jit/[id]/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
77
src/app/api/v1/sales-jit/bootstrap/route.ts
Normal file
77
src/app/api/v1/sales-jit/bootstrap/route.ts
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
306
src/app/api/v1/sales-jit/route.ts
Normal file
306
src/app/api/v1/sales-jit/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
459
src/app/api/v1/sales-regular/[id]/close/route.ts
Normal file
459
src/app/api/v1/sales-regular/[id]/close/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
55
src/app/api/v1/sales-regular/[id]/route.ts
Normal file
55
src/app/api/v1/sales-regular/[id]/route.ts
Normal 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)
|
||||
});
|
||||
}
|
||||
80
src/app/api/v1/sales-regular/bootstrap/route.ts
Normal file
80
src/app/api/v1/sales-regular/bootstrap/route.ts
Normal 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
Reference in New Issue
Block a user