feat(warehouse): add warehouse service with FIFO and business logic

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-05-29 14:11:22 +02:00
parent f413bae348
commit 637499a8ec

View File

@@ -0,0 +1,918 @@
import prisma from "../config/database";
import type { PrismaClient } from "@prisma/client";
type TxClient = Omit<
PrismaClient,
"$connect" | "$disconnect" | "$on" | "$transaction" | "$extends"
>;
// ---------------------------------------------------------------------------
// Number sequence helpers for warehouse documents
// ---------------------------------------------------------------------------
const WAREHOUSE_NUMBER_PREFIXES: Record<string, string> = {
warehouse_receipt: "PRI",
warehouse_issue: "VYD",
warehouse_inventory: "INV",
};
/**
* Atomically get and increment the next sequence number for a warehouse doc type.
* Uses SELECT ... FOR UPDATE to prevent race conditions.
*/
async function getNextSequence(
type: string,
year: number,
tx: TxClient,
): Promise<number> {
const existing = await tx.$queryRaw<
Array<{ id: number; last_number: number }>
>`
SELECT id, last_number FROM number_sequences
WHERE \`type\` = ${type} AND \`year\` = ${year}
FOR UPDATE
`;
if (existing.length === 0) {
await tx.$executeRaw`
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
VALUES (${type}, ${year}, 1)
`;
return 1;
}
const next = existing[0].last_number + 1;
await tx.$executeRaw`
UPDATE number_sequences
SET \`last_number\` = ${next}
WHERE id = ${existing[0].id}
`;
return next;
}
/**
* Generate a formatted warehouse document number like "PRI-2026-001".
*/
async function generateWarehouseNumber(
type: string,
tx: TxClient,
): Promise<string> {
const year = new Date().getFullYear();
const prefix = WAREHOUSE_NUMBER_PREFIXES[type] || "WH";
const seq = await getNextSequence(type, year, tx);
return `${prefix}-${year}-${String(seq).padStart(3, "0")}`;
}
// ---------------------------------------------------------------------------
// Available Quantity
// ---------------------------------------------------------------------------
/**
* Calculates total stock (sum of unconsumed batch quantities) minus active
* reservation quantities for a given item.
*/
export async function getItemAvailableQty(itemId: number): Promise<number> {
const [batchResult, reservationResult] = await Promise.all([
prisma.sklad_batches.aggregate({
_sum: { quantity: true },
where: { item_id: itemId, is_consumed: false },
}),
prisma.sklad_reservations.aggregate({
_sum: { remaining_qty: true },
where: { item_id: itemId, status: "ACTIVE" },
}),
]);
const totalStock = Number(batchResult._sum.quantity ?? 0);
const reservedQty = Number(reservationResult._sum.remaining_qty ?? 0);
return totalStock - reservedQty;
}
// ---------------------------------------------------------------------------
// FIFO Batch Selection
// ---------------------------------------------------------------------------
/**
* Selects oldest unconsumed batches for an item using FIFO.
* If one batch doesn't have enough, it spans multiple batches.
* Throws if insufficient stock.
*/
export async function selectFifoBatches(
itemId: number,
requestedQty: number,
tx?: TxClient,
): Promise<Array<{ batchId: number; qty: number }>> {
const client = tx ?? prisma;
const batches = await client.sklad_batches.findMany({
where: { item_id: itemId, is_consumed: false, quantity: { gt: 0 } },
orderBy: { received_at: "asc" },
select: { id: true, quantity: true },
});
const result: Array<{ batchId: number; qty: number }> = [];
let remaining = requestedQty;
for (const batch of batches) {
if (remaining <= 0) break;
const batchQty = Number(batch.quantity);
const take = Math.min(batchQty, remaining);
result.push({ batchId: batch.id, qty: take });
remaining -= take;
}
if (remaining > 0) {
throw new Error("Nedostatečné množství na skladě");
}
return result;
}
// ---------------------------------------------------------------------------
// Item Total Stock
// ---------------------------------------------------------------------------
/**
* Sum of unconsumed batch quantities for an item.
*/
export async function getItemTotalStock(itemId: number): Promise<number> {
const result = await prisma.sklad_batches.aggregate({
_sum: { quantity: true },
where: { item_id: itemId, is_consumed: false },
});
return Number(result._sum.quantity ?? 0);
}
// ---------------------------------------------------------------------------
// Item Stock Value
// ---------------------------------------------------------------------------
/**
* Sum of (quantity * unit_price) for unconsumed batches.
*/
export async function getItemStockValue(itemId: number): Promise<number> {
const batches = await prisma.sklad_batches.findMany({
where: { item_id: itemId, is_consumed: false },
select: { quantity: true, unit_price: true },
});
return batches.reduce((sum, b) => {
return sum + Number(b.quantity) * Number(b.unit_price);
}, 0);
}
// ---------------------------------------------------------------------------
// Below Minimum Items
// ---------------------------------------------------------------------------
/**
* Returns items where current stock is below their min_quantity threshold.
*/
export async function getBelowMinimumItems() {
const items = await prisma.sklad_items.findMany({
where: {
is_active: true,
min_quantity: { not: null },
},
include: {
category: { select: { id: true, name: true } },
},
});
const results = [];
for (const item of items) {
const totalStock = await getItemTotalStock(item.id);
const minQty = Number(item.min_quantity ?? 0);
if (totalStock < minQty) {
results.push({
id: item.id,
item_number: item.item_number,
name: item.name,
unit: item.unit,
min_quantity: minQty,
current_stock: totalStock,
category: item.category,
});
}
}
return results;
}
// ---------------------------------------------------------------------------
// Receipt Confirm
// ---------------------------------------------------------------------------
/**
* Confirms a warehouse receipt:
* 1. Validate status is DRAFT
* 2. Generate receipt_number via number_sequences
* 3. For each receipt line: create sklad_batches row, upsert sklad_item_locations
* 4. Update receipt status to CONFIRMED
*/
export async function confirmReceipt(
receiptId: number,
authUserId: number,
): Promise<
| { data: { id: number; receipt_number: string } }
| { error: string; status: number }
> {
return prisma.$transaction(async (tx) => {
const receipt = await tx.sklad_receipts.findUnique({
where: { id: receiptId },
include: { lines: true },
});
if (!receipt) {
return { error: "Příjem nebyl nalezen", status: 404 };
}
if (receipt.status !== "DRAFT") {
return { error: "Příjem není ve stavu DRAFT", status: 400 };
}
const receiptNumber = await generateWarehouseNumber(
"warehouse_receipt",
tx,
);
for (const line of receipt.lines) {
const lineQty = Number(line.quantity);
const linePrice = Number(line.unit_price);
// Create batch for this receipt line
const batch = await tx.sklad_batches.create({
data: {
item_id: line.item_id,
receipt_line_id: line.id,
quantity: line.quantity,
original_qty: line.quantity,
unit_price: line.unit_price,
received_at: new Date(),
is_consumed: false,
},
});
// Upsert item location if location_id is specified
if (line.location_id != null) {
await tx.sklad_item_locations.upsert({
where: {
item_id_location_id: {
item_id: line.item_id,
location_id: line.location_id,
},
},
create: {
item_id: line.item_id,
location_id: line.location_id,
quantity: line.quantity,
},
update: {
quantity: {
increment: line.quantity,
},
},
});
}
}
// Update receipt status and number
await tx.sklad_receipts.update({
where: { id: receiptId },
data: {
status: "CONFIRMED",
receipt_number: receiptNumber,
received_by: authUserId,
modified_at: new Date(),
},
});
return { data: { id: receiptId, receipt_number: receiptNumber } };
});
}
// ---------------------------------------------------------------------------
// Receipt Cancel
// ---------------------------------------------------------------------------
/**
* Cancel a receipt:
* - If DRAFT: just set CANCELLED.
* - If CONFIRMED (storno): validate no batch consumed, reverse item_locations,
* delete batches, set CANCELLED.
*/
export async function cancelReceipt(
receiptId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
const receipt = await prisma.sklad_receipts.findUnique({
where: { id: receiptId },
include: { lines: { include: { batch: true } } },
});
if (!receipt) {
return { error: "Příjem nebyl nalezen", status: 404 };
}
if (receipt.status === "CANCELLED") {
return { error: "Příjem je již zrušen", status: 400 };
}
if (receipt.status === "DRAFT") {
await prisma.sklad_receipts.update({
where: { id: receiptId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: receiptId } };
}
// CONFIRMED -> CANCELLED (storno)
return prisma.$transaction(async (tx) => {
// Validate no batch has been consumed
for (const line of receipt.lines) {
if (line.batch) {
const batchData = await tx.sklad_batches.findUnique({
where: { id: line.batch.id },
});
if (batchData && batchData.is_consumed) {
return {
error: "Příjem nelze zrušit - některé šarže byly již spotřebovány",
status: 400,
};
}
// Also check if quantity has been partially consumed (original_qty != quantity)
if (
batchData &&
Number(batchData.quantity) < Number(batchData.original_qty)
) {
return {
error:
"Příjem nelze zrušit - některé šarže byly částečně spotřebovány",
status: 400,
};
}
}
}
// Reverse item_locations and delete batches
for (const line of receipt.lines) {
if (line.location_id != null) {
// Decrement location quantity (or delete if it would go to zero/below)
const loc = await tx.sklad_item_locations.findUnique({
where: {
item_id_location_id: {
item_id: line.item_id,
location_id: line.location_id,
},
},
});
if (loc) {
const newQty = Number(loc.quantity) - Number(line.quantity);
if (newQty <= 0) {
await tx.sklad_item_locations.delete({
where: { id: loc.id },
});
} else {
await tx.sklad_item_locations.update({
where: { id: loc.id },
data: { quantity: newQty },
});
}
}
}
// Delete the batch (it's a 1:1 with receipt_line via @unique)
if (line.batch) {
await tx.sklad_batches.delete({
where: { id: line.batch.id },
});
}
}
await tx.sklad_receipts.update({
where: { id: receiptId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: receiptId } };
});
}
// ---------------------------------------------------------------------------
// Issue Confirm
// ---------------------------------------------------------------------------
/**
* Confirms a warehouse issue:
* 1. Validate status is DRAFT
* 2. Validate each line's batch has enough quantity
* 3. Generate issue_number via number_sequences
* 4. For each line: decrement batch quantity (set is_consumed if 0),
* decrement item_locations, fulfill reservation if linked
* 5. Update status to CONFIRMED
*/
export async function confirmIssue(
issueId: number,
authUserId: number,
): Promise<
| { data: { id: number; issue_number: string } }
| { error: string; status: number }
> {
return prisma.$transaction(async (tx) => {
const issue = await tx.sklad_issues.findUnique({
where: { id: issueId },
include: { lines: true },
});
if (!issue) {
return { error: "Výdej nebyl nalezen", status: 404 };
}
if (issue.status !== "DRAFT") {
return { error: "Výdej není ve stavu DRAFT", status: 400 };
}
// Validate each line's batch has enough quantity
for (const line of issue.lines) {
const batch = await tx.sklad_batches.findUnique({
where: { id: line.batch_id },
});
if (!batch) {
return {
error: `Šarže ID ${line.batch_id} nebyla nalezena`,
status: 404,
};
}
if (batch.is_consumed) {
return {
error: `Šarže ID ${line.batch_id} je již spotřebována`,
status: 400,
};
}
if (Number(batch.quantity) < Number(line.quantity)) {
return {
error: `Šarže ID ${line.batch_id} nemá dostatečné množství`,
status: 400,
};
}
}
const issueNumber = await generateWarehouseNumber("warehouse_issue", tx);
// Process each issue line
for (const line of issue.lines) {
const lineQty = Number(line.quantity);
// Decrement batch quantity
const batch = await tx.sklad_batches.findUnique({
where: { id: line.batch_id },
});
if (!batch) continue; // already validated above
const newBatchQty = Number(batch.quantity) - lineQty;
await tx.sklad_batches.update({
where: { id: line.batch_id },
data: {
quantity: newBatchQty,
is_consumed: newBatchQty <= 0,
},
});
// Decrement item_locations if location_id is specified
if (line.location_id != null) {
const loc = await tx.sklad_item_locations.findUnique({
where: {
item_id_location_id: {
item_id: line.item_id,
location_id: line.location_id,
},
},
});
if (loc) {
const newLocQty = Number(loc.quantity) - lineQty;
if (newLocQty <= 0) {
await tx.sklad_item_locations.delete({
where: { id: loc.id },
});
} else {
await tx.sklad_item_locations.update({
where: { id: loc.id },
data: { quantity: newLocQty },
});
}
}
}
// Fulfill reservation if linked
if (line.reservation_id != null) {
const reservation = await tx.sklad_reservations.findUnique({
where: { id: line.reservation_id },
});
if (reservation) {
const newRemaining = Number(reservation.remaining_qty) - lineQty;
await tx.sklad_reservations.update({
where: { id: line.reservation_id },
data: {
remaining_qty: Math.max(0, newRemaining),
status: newRemaining <= 0 ? "FULFILLED" : reservation.status,
modified_at: new Date(),
},
});
}
}
}
// Update issue status and number
await tx.sklad_issues.update({
where: { id: issueId },
data: {
status: "CONFIRMED",
issue_number: issueNumber,
issued_by: authUserId,
modified_at: new Date(),
},
});
return { data: { id: issueId, issue_number: issueNumber } };
});
}
// ---------------------------------------------------------------------------
// Issue Cancel
// ---------------------------------------------------------------------------
/**
* Cancel a confirmed issue: restore batch quantities, restore item_locations,
* restore reservations.
*/
export async function cancelIssue(
issueId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
const issue = await prisma.sklad_issues.findUnique({
where: { id: issueId },
include: { lines: true },
});
if (!issue) {
return { error: "Výdej nebyl nalezen", status: 404 };
}
if (issue.status === "CANCELLED") {
return { error: "Výdej je již zrušen", status: 400 };
}
if (issue.status === "DRAFT") {
await prisma.sklad_issues.update({
where: { id: issueId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: issueId } };
}
// CONFIRMED -> CANCELLED: restore everything
return prisma.$transaction(async (tx) => {
for (const line of issue.lines) {
const lineQty = Number(line.quantity);
// Restore batch quantity
const batch = await tx.sklad_batches.findUnique({
where: { id: line.batch_id },
});
if (batch) {
const restoredQty = Number(batch.quantity) + lineQty;
await tx.sklad_batches.update({
where: { id: line.batch_id },
data: {
quantity: restoredQty,
is_consumed: false,
},
});
}
// Restore item_locations
if (line.location_id != null) {
await tx.sklad_item_locations.upsert({
where: {
item_id_location_id: {
item_id: line.item_id,
location_id: line.location_id,
},
},
create: {
item_id: line.item_id,
location_id: line.location_id,
quantity: lineQty,
},
update: {
quantity: {
increment: lineQty,
},
},
});
}
// Restore reservation
if (line.reservation_id != null) {
const reservation = await tx.sklad_reservations.findUnique({
where: { id: line.reservation_id },
});
if (reservation) {
const restoredRemaining = Number(reservation.remaining_qty) + lineQty;
// Restore to ACTIVE if it was FULFILLED
const newStatus =
reservation.status === "FULFILLED" ? "ACTIVE" : reservation.status;
await tx.sklad_reservations.update({
where: { id: line.reservation_id },
data: {
remaining_qty: restoredRemaining,
status: newStatus,
modified_at: new Date(),
},
});
}
}
}
await tx.sklad_issues.update({
where: { id: issueId },
data: { status: "CANCELLED", modified_at: new Date() },
});
return { data: { id: issueId } };
});
}
// ---------------------------------------------------------------------------
// Reservation Create
// ---------------------------------------------------------------------------
/**
* Create a reservation:
* Validate item exists and is_active.
* Calculate available_qty (total stock - active reservations).
* Validate available_qty >= quantity.
* Create reservation with quantity = remaining_qty.
*/
export async function createReservation(data: {
item_id: number;
project_id: number;
quantity: number;
reserved_by?: number;
notes?: string | null;
}): Promise<
{ data: Record<string, unknown> } | { error: string; status: number }
> {
const item = await prisma.sklad_items.findUnique({
where: { id: data.item_id },
});
if (!item) {
return { error: "Položka nebyla nalezena", status: 404 };
}
if (!item.is_active) {
return { error: "Položka není aktivní", status: 400 };
}
const availableQty = await getItemAvailableQty(data.item_id);
if (availableQty < data.quantity) {
return {
error: `Nedostatečné dostupné množství (dostupné: ${availableQty}, požadované: ${data.quantity})`,
status: 400,
};
}
const reservation = await prisma.sklad_reservations.create({
data: {
item_id: data.item_id,
project_id: data.project_id,
quantity: data.quantity,
remaining_qty: data.quantity,
reserved_by: data.reserved_by ?? null,
notes: data.notes ?? null,
status: "ACTIVE",
},
});
return { data: reservation };
}
// ---------------------------------------------------------------------------
// Reservation Cancel
// ---------------------------------------------------------------------------
/**
* Set reservation status to CANCELLED, remaining_qty to 0.
*/
export async function cancelReservation(
reservationId: number,
): Promise<{ data: { id: number } } | { error: string; status: number }> {
const reservation = await prisma.sklad_reservations.findUnique({
where: { id: reservationId },
});
if (!reservation) {
return { error: "Rezervace nebyla nalezena", status: 404 };
}
if (reservation.status === "CANCELLED") {
return { error: "Rezervace je již zrušena", status: 400 };
}
if (reservation.status === "FULFILLED") {
return { error: "Rezervace je již vyplněna", status: 400 };
}
await prisma.sklad_reservations.update({
where: { id: reservationId },
data: {
status: "CANCELLED",
remaining_qty: 0,
modified_at: new Date(),
},
});
return { data: { id: reservationId } };
}
// ---------------------------------------------------------------------------
// Inventory Confirm
// ---------------------------------------------------------------------------
/**
* Confirms an inventory session:
* 1. Validate status is DRAFT
* 2. For each line where difference != 0:
* - If diff > 0: create corrective receipt (auto-confirmed), create batch,
* update item_locations
* - If diff < 0: consume from oldest batches (FIFO), update item_locations
* 3. Generate session_number via number_sequences
* 4. Update status to CONFIRMED
*/
export async function confirmInventorySession(
sessionId: number,
): Promise<
| { data: { id: number; session_number: string } }
| { error: string; status: number }
> {
return prisma.$transaction(async (tx) => {
const session = await tx.sklad_inventory_sessions.findUnique({
where: { id: sessionId },
include: { lines: true },
});
if (!session) {
return { error: "Inventarizace nebyla nalezena", status: 404 };
}
if (session.status !== "DRAFT") {
return { error: "Inventarizace není ve stavu DRAFT", status: 400 };
}
for (const line of session.lines) {
const diff = Number(line.difference);
if (diff === 0) continue;
if (diff > 0) {
// Positive difference: stock surplus -> create corrective receipt
const receiptNumber = await generateWarehouseNumber(
"warehouse_receipt",
tx,
);
const correctiveReceipt = await tx.sklad_receipts.create({
data: {
receipt_number: receiptNumber,
notes: `Korekční příjem z inventarizace #${sessionId}`,
status: "CONFIRMED",
},
});
const receiptLine = await tx.sklad_receipt_lines.create({
data: {
receipt_id: correctiveReceipt.id,
item_id: line.item_id,
quantity: diff,
unit_price: 0,
location_id: line.location_id,
notes: `Korekce inventarizace (přebytek)`,
},
});
// Create batch for the corrective receipt
await tx.sklad_batches.create({
data: {
item_id: line.item_id,
receipt_line_id: receiptLine.id,
quantity: diff,
original_qty: diff,
unit_price: 0,
received_at: new Date(),
is_consumed: false,
},
});
// Upsert item_locations
if (line.location_id != null) {
await tx.sklad_item_locations.upsert({
where: {
item_id_location_id: {
item_id: line.item_id,
location_id: line.location_id,
},
},
create: {
item_id: line.item_id,
location_id: line.location_id,
quantity: diff,
},
update: {
quantity: {
increment: diff,
},
},
});
}
} else {
// Negative difference: stock deficit -> consume from oldest batches (FIFO)
const absDiff = Math.abs(diff);
try {
const fifoBatches = await selectFifoBatches(
line.item_id,
absDiff,
tx,
);
for (const fb of fifoBatches) {
const batch = await tx.sklad_batches.findUnique({
where: { id: fb.batchId },
});
if (!batch) continue;
const newBatchQty = Number(batch.quantity) - fb.qty;
await tx.sklad_batches.update({
where: { id: fb.batchId },
data: {
quantity: newBatchQty,
is_consumed: newBatchQty <= 0,
},
});
}
// Decrement item_locations
if (line.location_id != null) {
const loc = await tx.sklad_item_locations.findUnique({
where: {
item_id_location_id: {
item_id: line.item_id,
location_id: line.location_id,
},
},
});
if (loc) {
const newLocQty = Number(loc.quantity) - absDiff;
if (newLocQty <= 0) {
await tx.sklad_item_locations.delete({
where: { id: loc.id },
});
} else {
await tx.sklad_item_locations.update({
where: { id: loc.id },
data: { quantity: newLocQty },
});
}
}
}
} catch (err) {
const message =
err instanceof Error
? err.message
: "Nedostatečné množství na skladě";
return {
error: `Inventarizace položky ID ${line.item_id}: ${message}`,
status: 400,
};
}
}
}
const sessionNumber = await generateWarehouseNumber(
"warehouse_inventory",
tx,
);
await tx.sklad_inventory_sessions.update({
where: { id: sessionId },
data: {
status: "CONFIRMED",
session_number: sessionNumber,
modified_at: new Date(),
},
});
return { data: { id: sessionId, session_number: sessionNumber } };
});
}