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