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:
918
src/services/warehouse.service.ts
Normal file
918
src/services/warehouse.service.ts
Normal 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 } };
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user