diff --git a/CHANGELOG.md b/CHANGELOG.md index bb18398..0f98438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh ### Added +- Inventory-backed shipment picking from shipment detail pages, including sales-order line remaining-quantity visibility, warehouse/location source selection, issued-stock posting, and shipment pick history - Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline - Planning workbench replacing the old one-note planning screen with mode switching, dense exception rail, heatmap load view, agenda view, and focus drawer - Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions diff --git a/README.md b/README.md index 881e0be..839b7cc 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Current foundation scope includes: - purchase receiving with warehouse/location posting and receipt history against purchase orders - branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline - purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files -- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments +- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments - projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments - manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments - planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling @@ -296,6 +296,9 @@ The current shipping foundation supports: - shipment list, detail, create, and edit flows - searchable sales-order lookup instead of a static order dropdown - shipment records linked directly to sales orders +- shipment-line ordered, picked, and remaining quantity visibility +- warehouse/location-backed shipment picking with immediate stock issue posting +- shipment pick history tied to the inventory movement that fulfilled the shipment - carrier, service level, tracking number, package count, notes, and ship date fields - shipment quick status actions from the shipment detail page - related-shipment visibility from the sales-order detail page diff --git a/SHIPPED.md b/SHIPPED.md index d6846ff..3deb6cc 100644 --- a/SHIPPED.md +++ b/SHIPPED.md @@ -30,6 +30,7 @@ This file tracks roadmap phases, slices, and major foundations that have already - Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking - Branded sales quote, sales order, and purchase-order PDF templates through the shared Puppeteer pipeline - Shipping shipment records linked to sales orders +- Inventory-backed shipment picking with stock issue posting from warehouse locations and shipment-side pick history - Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments - Logistics attachments directly on shipment records - Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index fd9bc23..f7c2fae 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -105,6 +105,7 @@ import type { ShipmentDetailDto, ShipmentInput, ShipmentOrderOptionDto, + ShipmentPickInput, ShipmentStatus, ShipmentSummaryDto, } from "@mrp/shared/dist/shipping/types.js"; @@ -849,6 +850,9 @@ export const api = { token ); }, + postShipmentPick(token: string, shipmentId: string, payload: ShipmentPickInput) { + return request(`/api/v1/shipping/shipments/${shipmentId}/picks`, { method: "POST", body: JSON.stringify(payload) }, token); + }, async getShipmentPackingSlipPdf(token: string, shipmentId: string) { const response = await fetch(`/api/v1/documents/shipping/shipments/${shipmentId}/packing-slip.pdf`, { headers: { diff --git a/client/src/modules/shipping/ShipmentDetailPage.tsx b/client/src/modules/shipping/ShipmentDetailPage.tsx index 93fbb03..95dd475 100644 --- a/client/src/modules/shipping/ShipmentDetailPage.tsx +++ b/client/src/modules/shipping/ShipmentDetailPage.tsx @@ -1,22 +1,56 @@ import { permissions } from "@mrp/shared"; -import type { ShipmentDetailDto, ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js"; +import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js"; +import type { ShipmentDetailDto, ShipmentPickInput, ShipmentStatus, ShipmentSummaryDto } from "@mrp/shared/dist/shipping/types.js"; import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; -import { api, ApiError } from "../../lib/api"; import { ConfirmActionDialog } from "../../components/ConfirmActionDialog"; import { FileAttachmentsPanel } from "../../components/FileAttachmentsPanel"; +import { api, ApiError } from "../../lib/api"; import { shipmentStatusOptions } from "./config"; import { ShipmentStatusBadge } from "./ShipmentStatusBadge"; +function buildInitialPickForm( + shipment: ShipmentDetailDto | null, + locationOptions: WarehouseLocationOptionDto[], + current?: ShipmentPickInput +): ShipmentPickInput { + const remainingLine = shipment?.lines.find((line) => line.remainingQuantity > 0) ?? shipment?.lines[0] ?? null; + const fallbackLocation = + locationOptions.find((location) => location.warehouseId === current?.warehouseId) ?? locationOptions[0] ?? null; + + return { + salesOrderLineId: current?.salesOrderLineId && shipment?.lines.some((line) => line.salesOrderLineId === current.salesOrderLineId) + ? current.salesOrderLineId + : remainingLine?.salesOrderLineId ?? "", + warehouseId: current?.warehouseId || fallbackLocation?.warehouseId || "", + locationId: current?.locationId || fallbackLocation?.locationId || "", + quantity: current?.quantity ?? Math.min(remainingLine?.remainingQuantity ?? 1, 1), + notes: current?.notes ?? "", + }; +} + +function formatDateTime(value: string) { + return new Date(value).toLocaleString(); +} + export function ShipmentDetailPage() { const { token, user } = useAuth(); const { shipmentId } = useParams(); const [shipment, setShipment] = useState(null); const [relatedShipments, setRelatedShipments] = useState([]); + const [locationOptions, setLocationOptions] = useState([]); + const [pickForm, setPickForm] = useState({ + salesOrderLineId: "", + warehouseId: "", + locationId: "", + quantity: 1, + notes: "", + }); const [status, setStatus] = useState("Loading shipment..."); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); + const [isPostingPick, setIsPostingPick] = useState(false); const [activeDocumentAction, setActiveDocumentAction] = useState<"packing-slip" | "label" | "bol" | null>(null); const [pendingConfirmation, setPendingConfirmation] = useState< | { @@ -34,23 +68,38 @@ export function ShipmentDetailPage() { const canManage = user?.permissions.includes(permissions.shippingWrite) ?? false; + async function loadShipmentDetail(activeToken: string, activeShipmentId: string) { + const [nextShipment, nextLocationOptions] = await Promise.all([ + api.getShipment(activeToken, activeShipmentId), + canManage ? api.getWarehouseLocationOptions(activeToken) : Promise.resolve([]), + ]); + const shipments = await api.getShipments(activeToken, { salesOrderId: nextShipment.salesOrderId }); + + setShipment(nextShipment); + setLocationOptions(nextLocationOptions); + setRelatedShipments(shipments.filter((candidate) => candidate.id !== activeShipmentId)); + setPickForm((current) => buildInitialPickForm(nextShipment, nextLocationOptions, current)); + setStatus("Shipment loaded."); + } + useEffect(() => { if (!token || !shipmentId) { return; } - api.getShipment(token, shipmentId) - .then((nextShipment) => { - setShipment(nextShipment); - setStatus("Shipment loaded."); - return api.getShipments(token, { salesOrderId: nextShipment.salesOrderId }); - }) - .then((shipments) => setRelatedShipments(shipments.filter((candidate) => candidate.id !== shipmentId))) - .catch((error: unknown) => { - const message = error instanceof ApiError ? error.message : "Unable to load shipment."; - setStatus(message); - }); - }, [shipmentId, token]); + loadShipmentDetail(token, shipmentId).catch((error: unknown) => { + const message = error instanceof ApiError ? error.message : "Unable to load shipment."; + setStatus(message); + }); + }, [shipmentId, token, canManage]); + + const selectedLine = shipment?.lines.find((line) => line.salesOrderLineId === pickForm.salesOrderLineId) ?? null; + const availableLocations = locationOptions.filter((location) => !pickForm.warehouseId || location.warehouseId === pickForm.warehouseId); + const warehouseOptions = Array.from( + new Map(locationOptions.map((location) => [location.warehouseId, { id: location.warehouseId, label: `${location.warehouseCode} · ${location.warehouseName}` }])).values() + ); + const totalOrderedQuantity = shipment?.lines.reduce((sum, line) => sum + line.orderedQuantity, 0) ?? 0; + const totalPickedQuantity = shipment?.lines.reduce((sum, line) => sum + line.pickedQuantity, 0) ?? 0; async function applyStatusChange(nextStatus: ShipmentStatus) { if (!token || !shipment) { @@ -62,7 +111,8 @@ export function ShipmentDetailPage() { try { const nextShipment = await api.updateShipmentStatus(token, shipment.id, nextStatus); setShipment(nextShipment); - setStatus("Shipment status updated. Verify carrier paperwork and sales-order expectations if the shipment moved into a terminal state."); + setPickForm((current) => buildInitialPickForm(nextShipment, locationOptions, current)); + setStatus("Shipment status updated. Verify carrier paperwork, inventory issue progress, and sales-order expectations."); } catch (error: unknown) { const message = error instanceof ApiError ? error.message : "Unable to update shipment status."; setStatus(message); @@ -82,11 +132,11 @@ export function ShipmentDetailPage() { description: `Update shipment ${shipment.shipmentNumber} from ${shipment.status} to ${nextStatus}.`, impact: nextStatus === "DELIVERED" - ? "This marks delivery complete and can affect customer communication and project/shipping readiness views." + ? "This marks delivery complete and can affect customer communication, project delivery status, and shipment closeout review." : nextStatus === "SHIPPED" - ? "This marks the shipment as outbound and can trigger customer-facing tracking and downstream delivery expectations." + ? "This marks the shipment as outbound and should only happen after stock has been picked and packed from real inventory locations." : "This changes the logistics state used by related shipping and sales workflows.", - recovery: "If the status is wrong, return the shipment to the correct state and confirm the linked sales order still reflects reality.", + recovery: "If the status is wrong, return the shipment to the correct state and confirm pick quantities still match the physical shipment.", confirmLabel: `Set ${label}`, confirmationLabel: nextStatus === "DELIVERED" ? "Type shipment number to confirm:" : undefined, confirmationValue: nextStatus === "DELIVERED" ? shipment.shipmentNumber : undefined, @@ -139,6 +189,29 @@ export function ShipmentDetailPage() { } } + async function handlePostPick() { + if (!token || !shipment || !selectedLine) { + return; + } + + setIsPostingPick(true); + setStatus("Posting shipment pick and issuing stock..."); + try { + const nextShipment = await api.postShipmentPick(token, shipment.id, { + ...pickForm, + quantity: Number(pickForm.quantity), + }); + setShipment(nextShipment); + setPickForm(buildInitialPickForm(nextShipment, locationOptions)); + setStatus("Shipment pick posted. Inventory was issued from the selected stock location."); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to post shipment pick."; + setStatus(message); + } finally { + setIsPostingPick(false); + } + } + if (!shipment) { return
{status}
; } @@ -150,8 +223,11 @@ export function ShipmentDetailPage() {

Shipment

{shipment.shipmentNumber}

-

{shipment.salesOrderNumber} · {shipment.customerName}

-
+

{shipment.salesOrderNumber} / {shipment.customerName}

+
+ + {status} +
Back to shipments @@ -171,12 +247,13 @@ export function ShipmentDetailPage() {
+ {canManage ? (

Quick Actions

-

Update shipment status without opening the editor.

+

Use inventory-backed picking before marking the shipment packed or shipped.

{shipmentStatusOptions.map((option) => ( @@ -188,26 +265,249 @@ export function ShipmentDetailPage() {
) : null} +
-

Carrier

{shipment.carrier || "Not set"}
-

Service

{shipment.serviceLevel || "Not set"}
-

Tracking

{shipment.trackingNumber || "Not set"}
-

Packages

{shipment.packageCount}
+
+

Carrier

+
{shipment.carrier || "Not set"}
+
+
+

Ordered Units

+
{totalOrderedQuantity}
+
+
+

Picked Units

+
{totalPickedQuantity}
+
+
+

Packages

+
{shipment.packageCount}
+
+ +
+
+
+
+

Shipment Lines

+

Track ordered, picked, and remaining quantity before shipment closeout.

+
+
+
+ + + + + + + + + + + + {shipment.lines.map((line) => ( + + + + + + + + ))} + +
ItemDescriptionOrderedPickedRemaining
+
{line.itemSku}
+
{line.itemName}
+
{line.description}{line.orderedQuantity} {line.unitOfMeasure}{line.pickedQuantity} {line.unitOfMeasure} + 0 ? "bg-amber-500/15 text-amber-700 dark:text-amber-300" : "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"}`}> + {line.remainingQuantity} {line.unitOfMeasure} + +
+
+
+ +
+

Timing

+
+
+
Ship Date
+
{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}
+
+
+
Created
+
{formatDateTime(shipment.createdAt)}
+
+
+
Updated
+
{formatDateTime(shipment.updatedAt)}
+
+
+
Tracking
+
{shipment.trackingNumber || "Not set"}
+
+
+
+
+ + {canManage ? ( +
+
+
+

Pick And Issue From Stock

+

+ Posting a pick immediately creates an inventory issue transaction against the selected warehouse location and advances draft shipments into picking. +

+
+
+ Select the sales-order line, source location, and quantity you are physically picking. +
+
+
+ + + + + +
+
+
+ {selectedLine + ? `Remaining on selected line: ${selectedLine.remainingQuantity} ${selectedLine.unitOfMeasure}.` + : "Select a shipment line to issue inventory."} +
+ +
+
+ ) : null} +
+
+
+
+

Pick History

+

Every pick here already issued stock from a specific inventory location.

+
+
+ {shipment.picks.length === 0 ? ( +
+ No shipment picks have been posted yet. +
+ ) : ( +
+ {shipment.picks.map((pick) => ( +
+
+
+
{pick.itemSku} / {pick.itemName}
+
+ {pick.quantity} issued from {pick.warehouseCode} / {pick.locationCode} +
+
+
+
{pick.createdByName}
+
{formatDateTime(pick.createdAt)}
+
+
+
{pick.notes || "No pick notes."}
+
+ ))} +
+ )} +
+

Shipment Notes

{shipment.notes || "No notes recorded for this shipment."}

-
-

Timing

-
-
Ship Date
{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}
-
Created
{new Date(shipment.createdAt).toLocaleString()}
-
Updated
{new Date(shipment.updatedAt).toLocaleString()}
-
-
+
@@ -227,7 +527,7 @@ export function ShipmentDetailPage() {
{related.shipmentNumber}
-
{related.carrier || "Carrier not set"} · {related.trackingNumber || "No tracking"}
+
{related.carrier || "Carrier not set"} / {related.trackingNumber || "No tracking"}
@@ -236,6 +536,7 @@ export function ShipmentDetailPage() {
)}
+ + ); } - diff --git a/server/prisma/migrations/20260318007000_shipment_picks_inventory_issue/migration.sql b/server/prisma/migrations/20260318007000_shipment_picks_inventory_issue/migration.sql new file mode 100644 index 0000000..ef7a775 --- /dev/null +++ b/server/prisma/migrations/20260318007000_shipment_picks_inventory_issue/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "ShipmentPick" ( + "id" TEXT NOT NULL PRIMARY KEY, + "shipmentId" TEXT NOT NULL, + "salesOrderLineId" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "warehouseId" TEXT NOT NULL, + "locationId" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "notes" TEXT NOT NULL, + "createdById" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "ShipmentPick_shipmentId_fkey" FOREIGN KEY ("shipmentId") REFERENCES "Shipment" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ShipmentPick_salesOrderLineId_fkey" FOREIGN KEY ("salesOrderLineId") REFERENCES "SalesOrderLine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ShipmentPick_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ShipmentPick_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ShipmentPick_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "ShipmentPick_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "ShipmentPick_shipmentId_createdAt_idx" ON "ShipmentPick"("shipmentId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ShipmentPick_salesOrderLineId_createdAt_idx" ON "ShipmentPick"("salesOrderLineId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ShipmentPick_warehouseId_locationId_createdAt_idx" ON "ShipmentPick"("warehouseId", "locationId", "createdAt"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 0682332..3c088b0 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -28,6 +28,7 @@ model User { workOrderCompletions WorkOrderCompletion[] workOrderOperationLaborEntries WorkOrderOperationLaborEntry[] assignedWorkOrderOperations WorkOrderOperation[] + shipmentPicks ShipmentPick[] approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy") approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy") salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy") @@ -164,6 +165,7 @@ model InventoryItem { purchaseOrderLines PurchaseOrderLine[] workOrders WorkOrder[] workOrderMaterialIssues WorkOrderMaterialIssue[] + shipmentPicks ShipmentPick[] operations InventoryItemOperation[] reservations InventoryReservation[] transfers InventoryTransfer[] @@ -224,6 +226,7 @@ model Warehouse { purchaseReceipts PurchaseReceipt[] workOrders WorkOrder[] workOrderMaterialIssues WorkOrderMaterialIssue[] + shipmentPicks ShipmentPick[] reservations InventoryReservation[] transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse") transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse") @@ -295,6 +298,7 @@ model WarehouseLocation { purchaseReceipts PurchaseReceipt[] workOrders WorkOrder[] workOrderMaterialIssues WorkOrderMaterialIssue[] + shipmentPicks ShipmentPick[] reservations InventoryReservation[] transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation") transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation") @@ -509,6 +513,7 @@ model SalesOrderLine { item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) workOrders WorkOrder[] purchaseOrderLines PurchaseOrderLine[] + shipmentPicks ShipmentPick[] @@index([orderId, position]) } @@ -560,10 +565,35 @@ model Shipment { updatedAt DateTime @updatedAt salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Restrict) projects Project[] + picks ShipmentPick[] @@index([salesOrderId, createdAt]) } +model ShipmentPick { + id String @id @default(cuid()) + shipmentId String + salesOrderLineId String + itemId String + warehouseId String + locationId String + quantity Int + notes String + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + shipment Shipment @relation(fields: [shipmentId], references: [id], onDelete: Cascade) + salesOrderLine SalesOrderLine @relation(fields: [salesOrderLineId], references: [id], onDelete: Restrict) + item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) + warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict) + location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict) + createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull) + + @@index([shipmentId, createdAt]) + @@index([salesOrderLineId, createdAt]) + @@index([warehouseId, locationId, createdAt]) +} + model Project { id String @id @default(cuid()) projectNumber String @unique diff --git a/server/src/modules/shipping/router.ts b/server/src/modules/shipping/router.ts index b829379..9b1f411 100644 --- a/server/src/modules/shipping/router.ts +++ b/server/src/modules/shipping/router.ts @@ -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); +}); diff --git a/server/src/modules/shipping/service.ts b/server/src/modules/shipping/service.ts index 4d8e6e7..c8e0555 100644 --- a/server/src/modules/shipping/service.ts +++ b/server/src/modules/shipping/service.ts @@ -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(); + 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 { const shipment = await getShipmentDocumentData(shipmentId); diff --git a/shared/src/shipping/types.ts b/shared/src/shipping/types.ts index 52ad0c6..46897b6 100644 --- a/shared/src/shipping/types.ts +++ b/shared/src/shipping/types.ts @@ -24,10 +24,42 @@ export interface ShipmentSummaryDto { updatedAt: string; } +export interface ShipmentPickDto { + id: string; + salesOrderLineId: string; + itemId: string; + itemSku: string; + itemName: string; + quantity: number; + warehouseId: string; + warehouseCode: string; + warehouseName: string; + locationId: string; + locationCode: string; + locationName: string; + notes: string; + createdAt: string; + createdByName: string; +} + +export interface ShipmentLineDto { + salesOrderLineId: string; + itemId: string; + itemSku: string; + itemName: string; + description: string; + orderedQuantity: number; + pickedQuantity: number; + remainingQuantity: number; + unitOfMeasure: string; +} + export interface ShipmentDetailDto extends ShipmentSummaryDto { serviceLevel: string; notes: string; createdAt: string; + lines: ShipmentLineDto[]; + picks: ShipmentPickDto[]; } export interface ShipmentInput { @@ -40,3 +72,11 @@ export interface ShipmentInput { packageCount: number; notes: string; } + +export interface ShipmentPickInput { + salesOrderLineId: string; + warehouseId: string; + locationId: string; + quantity: number; + notes: string; +}