pick orders
This commit is contained in:
@@ -5,7 +5,7 @@ import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, updateShipment, updateShipmentStatus } from "./service.js";
|
||||
import { createShipment, getShipmentById, listShipmentOrderOptions, listShipments, postShipmentPick, updateShipment, updateShipmentStatus } from "./service.js";
|
||||
|
||||
const shipmentSchema = z.object({
|
||||
salesOrderId: z.string().trim().min(1),
|
||||
@@ -28,6 +28,14 @@ const shipmentStatusUpdateSchema = z.object({
|
||||
status: z.enum(shipmentStatuses),
|
||||
});
|
||||
|
||||
const shipmentPickSchema = z.object({
|
||||
salesOrderLineId: z.string().trim().min(1),
|
||||
warehouseId: z.string().trim().min(1),
|
||||
locationId: z.string().trim().min(1),
|
||||
quantity: z.number().positive(),
|
||||
notes: z.string(),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
@@ -112,3 +120,22 @@ shippingRouter.patch("/shipments/:shipmentId/status", requirePermissions([permis
|
||||
|
||||
return ok(response, result.shipment);
|
||||
});
|
||||
|
||||
shippingRouter.post("/shipments/:shipmentId/picks", requirePermissions([permissions.shippingWrite]), async (request, response) => {
|
||||
const shipmentId = getRouteParam(request.params.shipmentId);
|
||||
if (!shipmentId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Shipment id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = shipmentPickSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Shipment pick payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await postShipmentPick(shipmentId, parsed.data, request.authUser?.id);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.shipment, 201);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ShipmentDetailDto,
|
||||
ShipmentInput,
|
||||
ShipmentPickInput,
|
||||
ShipmentOrderOptionDto,
|
||||
ShipmentStatus,
|
||||
ShipmentSummaryDto,
|
||||
@@ -61,10 +62,83 @@ type ShipmentRecord = {
|
||||
customer: {
|
||||
name: string;
|
||||
};
|
||||
lines: Array<{
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitOfMeasure: string;
|
||||
item: {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
picks: Array<{
|
||||
id: string;
|
||||
salesOrderLineId: string;
|
||||
quantity: number;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
item: {
|
||||
id: string;
|
||||
sku: string;
|
||||
name: string;
|
||||
};
|
||||
warehouse: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
location: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
createdBy: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
|
||||
function mapShipmentSummary(record: {
|
||||
id: string;
|
||||
shipmentNumber: string;
|
||||
status: string;
|
||||
shipDate: Date | null;
|
||||
carrier: string;
|
||||
trackingNumber: string;
|
||||
packageCount: number;
|
||||
updatedAt: Date;
|
||||
salesOrder: {
|
||||
id: string;
|
||||
documentNumber: string;
|
||||
customer: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}): ShipmentSummaryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
shipmentNumber: record.shipmentNumber,
|
||||
salesOrderId: record.salesOrder.id,
|
||||
salesOrderNumber: record.salesOrder.documentNumber,
|
||||
customerName: record.salesOrder.customer.name,
|
||||
status: record.status as ShipmentStatus,
|
||||
carrier: record.carrier,
|
||||
trackingNumber: record.trackingNumber,
|
||||
packageCount: record.packageCount,
|
||||
shipDate: record.shipDate ? record.shipDate.toISOString() : null,
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapShipmentDetail(record: ShipmentRecord): ShipmentDetailDto {
|
||||
const pickedByLineId = new Map<string, number>();
|
||||
for (const pick of record.picks) {
|
||||
pickedByLineId.set(pick.salesOrderLineId, (pickedByLineId.get(pick.salesOrderLineId) ?? 0) + pick.quantity);
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
shipmentNumber: record.shipmentNumber,
|
||||
@@ -80,9 +154,58 @@ function mapShipment(record: ShipmentRecord): ShipmentDetailDto {
|
||||
notes: record.notes,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
lines: record.salesOrder.lines.map((line) => {
|
||||
const pickedQuantity = pickedByLineId.get(line.id) ?? 0;
|
||||
return {
|
||||
salesOrderLineId: line.id,
|
||||
itemId: line.item.id,
|
||||
itemSku: line.item.sku,
|
||||
itemName: line.item.name,
|
||||
description: line.description,
|
||||
orderedQuantity: line.quantity,
|
||||
pickedQuantity,
|
||||
remainingQuantity: Math.max(line.quantity - pickedQuantity, 0),
|
||||
unitOfMeasure: line.unitOfMeasure,
|
||||
};
|
||||
}),
|
||||
picks: record.picks.map((pick) => ({
|
||||
id: pick.id,
|
||||
salesOrderLineId: pick.salesOrderLineId,
|
||||
itemId: pick.item.id,
|
||||
itemSku: pick.item.sku,
|
||||
itemName: pick.item.name,
|
||||
quantity: pick.quantity,
|
||||
warehouseId: pick.warehouse.id,
|
||||
warehouseCode: pick.warehouse.code,
|
||||
warehouseName: pick.warehouse.name,
|
||||
locationId: pick.location.id,
|
||||
locationCode: pick.location.code,
|
||||
locationName: pick.location.name,
|
||||
notes: pick.notes,
|
||||
createdAt: pick.createdAt.toISOString(),
|
||||
createdByName: pick.createdBy ? `${pick.createdBy.firstName} ${pick.createdBy.lastName}`.trim() : "System",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
|
||||
const transactions = await prisma.inventoryTransaction.findMany({
|
||||
where: {
|
||||
itemId,
|
||||
warehouseId,
|
||||
locationId,
|
||||
},
|
||||
select: {
|
||||
transactionType: true,
|
||||
quantity: true,
|
||||
},
|
||||
});
|
||||
|
||||
return transactions.reduce((total, transaction) => {
|
||||
return total + (transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function nextShipmentNumber() {
|
||||
const next = (await prisma.shipment.count()) + 1;
|
||||
return `SHP-${String(next).padStart(5, "0")}`;
|
||||
@@ -147,7 +270,7 @@ export async function listShipments(filters: { q?: string; status?: ShipmentStat
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return shipments.map((shipment) => mapShipment(shipment));
|
||||
return shipments.map((shipment) => mapShipmentSummary(shipment));
|
||||
}
|
||||
|
||||
export async function getShipmentById(shipmentId: string) {
|
||||
@@ -161,12 +284,56 @@ export async function getShipmentById(shipmentId: string) {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
lines: {
|
||||
include: {
|
||||
item: {
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
picks: {
|
||||
include: {
|
||||
item: {
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
warehouse: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
location: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return shipment ? mapShipment(shipment) : null;
|
||||
return shipment ? mapShipmentDetail(shipment) : null;
|
||||
}
|
||||
|
||||
export async function createShipment(payload: ShipmentInput, actorId?: string | null) {
|
||||
@@ -300,6 +467,126 @@ export async function updateShipmentStatus(shipmentId: string, status: ShipmentS
|
||||
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
|
||||
}
|
||||
|
||||
export async function postShipmentPick(shipmentId: string, payload: ShipmentPickInput, actorId?: string | null) {
|
||||
const shipment = await prisma.shipment.findUnique({
|
||||
where: { id: shipmentId },
|
||||
include: {
|
||||
salesOrder: {
|
||||
include: {
|
||||
lines: {
|
||||
include: {
|
||||
item: {
|
||||
select: {
|
||||
id: true,
|
||||
sku: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
picks: {
|
||||
select: {
|
||||
salesOrderLineId: true,
|
||||
quantity: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!shipment) {
|
||||
return { ok: false as const, reason: "Shipment was not found." };
|
||||
}
|
||||
|
||||
const line = shipment.salesOrder.lines.find((entry) => entry.id === payload.salesOrderLineId);
|
||||
if (!line) {
|
||||
return { ok: false as const, reason: "Shipment pick must target a line on the linked sales order." };
|
||||
}
|
||||
|
||||
const location = await prisma.warehouseLocation.findUnique({
|
||||
where: { id: payload.locationId },
|
||||
select: {
|
||||
id: true,
|
||||
warehouseId: true,
|
||||
},
|
||||
});
|
||||
if (!location || location.warehouseId !== payload.warehouseId) {
|
||||
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
|
||||
}
|
||||
|
||||
const pickedQuantity = shipment.picks
|
||||
.filter((pick) => pick.salesOrderLineId === payload.salesOrderLineId)
|
||||
.reduce((sum, pick) => sum + pick.quantity, 0);
|
||||
const remainingQuantity = Math.max(line.quantity - pickedQuantity, 0);
|
||||
if (payload.quantity > remainingQuantity) {
|
||||
return { ok: false as const, reason: "Pick quantity exceeds the remaining unpicked sales-order quantity for this shipment line." };
|
||||
}
|
||||
|
||||
const onHand = await getItemLocationOnHand(line.item.id, payload.warehouseId, payload.locationId);
|
||||
if (onHand < payload.quantity) {
|
||||
return { ok: false as const, reason: "Shipment pick would drive the selected stock location below zero on-hand." };
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.shipmentPick.create({
|
||||
data: {
|
||||
shipmentId,
|
||||
salesOrderLineId: payload.salesOrderLineId,
|
||||
itemId: line.item.id,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
quantity: payload.quantity,
|
||||
notes: payload.notes,
|
||||
createdById: actorId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.inventoryTransaction.create({
|
||||
data: {
|
||||
itemId: line.item.id,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
transactionType: "ISSUE",
|
||||
quantity: payload.quantity,
|
||||
reference: `${shipment.shipmentNumber} shipment pick`,
|
||||
notes: payload.notes || `Shipment pick for ${shipment.shipmentNumber}`,
|
||||
createdById: actorId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
if (shipment.status === "DRAFT") {
|
||||
await tx.shipment.update({
|
||||
where: { id: shipmentId },
|
||||
data: {
|
||||
status: "PICKING",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const detail = await getShipmentById(shipmentId);
|
||||
if (detail) {
|
||||
await logAuditEvent({
|
||||
actorId,
|
||||
entityType: "shipment",
|
||||
entityId: shipmentId,
|
||||
action: "pick.posted",
|
||||
summary: `Posted shipment pick for ${detail.shipmentNumber}.`,
|
||||
metadata: {
|
||||
shipmentNumber: detail.shipmentNumber,
|
||||
salesOrderLineId: payload.salesOrderLineId,
|
||||
itemId: line.item.id,
|
||||
warehouseId: payload.warehouseId,
|
||||
locationId: payload.locationId,
|
||||
quantity: payload.quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return detail ? { ok: true as const, shipment: detail } : { ok: false as const, reason: "Unable to load updated shipment." };
|
||||
}
|
||||
|
||||
export async function getShipmentPackingSlipData(shipmentId: string): Promise<ShipmentPackingSlipData | null> {
|
||||
const shipment = await getShipmentDocumentData(shipmentId);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user