pick orders

This commit is contained in:
2026-03-18 07:27:33 -05:00
parent e00639bb8b
commit 02e14319ac
10 changed files with 763 additions and 40 deletions

View File

@@ -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);
});

View File

@@ -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);