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

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

View File

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

View File

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

View File

@@ -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<ShipmentDetailDto>(`/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: {

View File

@@ -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<ShipmentDetailDto | null>(null);
const [relatedShipments, setRelatedShipments] = useState<ShipmentSummaryDto[]>([]);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [pickForm, setPickForm] = useState<ShipmentPickInput>({
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<WarehouseLocationOptionDto[]>([]),
]);
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) => {
loadShipmentDetail(token, shipmentId).catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load shipment.";
setStatus(message);
});
}, [shipmentId, token]);
}, [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 <div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
@@ -150,8 +223,11 @@ export function ShipmentDetailPage() {
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment</p>
<h3 className="mt-2 text-xl font-bold text-text">{shipment.shipmentNumber}</h3>
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} · {shipment.customerName}</p>
<div className="mt-3"><ShipmentStatusBadge status={shipment.status} /></div>
<p className="mt-1 text-sm text-text">{shipment.salesOrderNumber} / {shipment.customerName}</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<ShipmentStatusBadge status={shipment.status} />
<span className="text-xs text-muted">{status}</span>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Link to="/shipping/shipments" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Back to shipments</Link>
@@ -171,12 +247,13 @@ export function ShipmentDetailPage() {
</div>
</div>
</div>
{canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Quick Actions</p>
<p className="mt-2 text-sm text-muted">Update shipment status without opening the editor.</p>
<p className="mt-2 text-sm text-muted">Use inventory-backed picking before marking the shipment packed or shipped.</p>
</div>
<div className="flex flex-wrap gap-2">
{shipmentStatusOptions.map((option) => (
@@ -188,26 +265,249 @@ export function ShipmentDetailPage() {
</div>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Carrier</p><div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Service</p><div className="mt-2 text-base font-bold text-text">{shipment.serviceLevel || "Not set"}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tracking</p><div className="mt-2 text-base font-bold text-text">{shipment.trackingNumber || "Not set"}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Packages</p><div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Carrier</p>
<div className="mt-2 text-base font-bold text-text">{shipment.carrier || "Not set"}</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ordered Units</p>
<div className="mt-2 text-base font-bold text-text">{totalOrderedQuantity}</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Picked Units</p>
<div className="mt-2 text-base font-bold text-text">{totalPickedQuantity}</div>
</article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Packages</p>
<div className="mt-2 text-base font-bold text-text">{shipment.packageCount}</div>
</article>
</section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.3fr)_minmax(340px,0.9fr)]">
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment Lines</p>
<p className="mt-2 text-sm text-muted">Track ordered, picked, and remaining quantity before shipment closeout.</p>
</div>
</div>
<div className="mt-5 overflow-x-auto">
<table className="min-w-full divide-y divide-line/60 text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-[0.16em] text-muted">
<th className="pb-3 pr-3 font-semibold">Item</th>
<th className="pb-3 pr-3 font-semibold">Description</th>
<th className="pb-3 pr-3 font-semibold">Ordered</th>
<th className="pb-3 pr-3 font-semibold">Picked</th>
<th className="pb-3 font-semibold">Remaining</th>
</tr>
</thead>
<tbody className="divide-y divide-line/50">
{shipment.lines.map((line) => (
<tr key={line.salesOrderLineId}>
<td className="py-3 pr-3 align-top">
<div className="font-semibold text-text">{line.itemSku}</div>
<div className="text-xs text-muted">{line.itemName}</div>
</td>
<td className="py-3 pr-3 align-top text-text">{line.description}</td>
<td className="py-3 pr-3 align-top text-text">{line.orderedQuantity} {line.unitOfMeasure}</td>
<td className="py-3 pr-3 align-top text-text">{line.pickedQuantity} {line.unitOfMeasure}</td>
<td className="py-3 align-top">
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${line.remainingQuantity > 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}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</article>
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timing</p>
<dl className="mt-5 grid gap-3">
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt>
<dd className="mt-1 text-sm text-text">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</dt>
<dd className="mt-1 text-sm text-text">{formatDateTime(shipment.createdAt)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Updated</dt>
<dd className="mt-1 text-sm text-text">{formatDateTime(shipment.updatedAt)}</dd>
</div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tracking</dt>
<dd className="mt-1 text-sm text-text">{shipment.trackingNumber || "Not set"}</dd>
</div>
</dl>
</article>
</div>
{canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Pick And Issue From Stock</p>
<p className="mt-2 max-w-2xl text-sm text-muted">
Posting a pick immediately creates an inventory issue transaction against the selected warehouse location and advances draft shipments into picking.
</p>
</div>
<div className="rounded-[16px] border border-line/70 bg-page/60 px-3 py-2 text-xs text-muted">
Select the sales-order line, source location, and quantity you are physically picking.
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Shipment line</span>
<select
value={pickForm.salesOrderLineId}
onChange={(event) => {
const nextLine = shipment.lines.find((line) => line.salesOrderLineId === event.target.value) ?? null;
setPickForm((current) => ({
...current,
salesOrderLineId: event.target.value,
quantity: Math.min(nextLine?.remainingQuantity ?? 1, 1),
}));
}}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
{shipment.lines.map((line) => (
<option key={line.salesOrderLineId} value={line.salesOrderLineId}>
{line.itemSku} / remaining {line.remainingQuantity} {line.unitOfMeasure}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Warehouse</span>
<select
value={pickForm.warehouseId}
onChange={(event) => {
const nextWarehouseId = event.target.value;
const nextLocation = locationOptions.find((location) => location.warehouseId === nextWarehouseId) ?? null;
setPickForm((current) => ({
...current,
warehouseId: nextWarehouseId,
locationId: nextLocation?.locationId ?? "",
}));
}}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
{warehouseOptions.map((warehouse) => (
<option key={warehouse.id} value={warehouse.id}>
{warehouse.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Location</span>
<select
value={pickForm.locationId}
onChange={(event) => setPickForm((current) => ({ ...current, locationId: event.target.value }))}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
>
{availableLocations.map((location) => (
<option key={location.locationId} value={location.locationId}>
{location.warehouseCode} / {location.locationCode} / {location.locationName}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Quantity</span>
<input
type="number"
min={0.0001}
step="any"
value={pickForm.quantity}
onChange={(event) => setPickForm((current) => ({ ...current, quantity: Number(event.target.value) }))}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
/>
</label>
<label className="flex flex-col gap-2 text-sm text-text">
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Notes</span>
<input
type="text"
value={pickForm.notes}
onChange={(event) => setPickForm((current) => ({ ...current, notes: event.target.value }))}
className="rounded-2xl border border-line/70 bg-page px-3 py-2 text-sm text-text outline-none"
placeholder="Picker, carton, or handling notes"
/>
</label>
</div>
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm text-muted">
{selectedLine
? `Remaining on selected line: ${selectedLine.remainingQuantity} ${selectedLine.unitOfMeasure}.`
: "Select a shipment line to issue inventory."}
</div>
<button
type="button"
onClick={() => void handlePostPick()}
disabled={
isPostingPick ||
!selectedLine ||
selectedLine.remainingQuantity <= 0 ||
!pickForm.warehouseId ||
!pickForm.locationId ||
pickForm.quantity <= 0
}
className="inline-flex items-center justify-center rounded-2xl bg-brand px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isPostingPick ? "Issuing stock..." : "Post shipment pick"}
</button>
</div>
</section>
) : null}
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Pick History</p>
<p className="mt-2 text-sm text-muted">Every pick here already issued stock from a specific inventory location.</p>
</div>
</div>
{shipment.picks.length === 0 ? (
<div className="mt-6 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No shipment picks have been posted yet.
</div>
) : (
<div className="mt-5 space-y-3">
{shipment.picks.map((pick) => (
<div key={pick.id} className="rounded-[18px] border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="font-semibold text-text">{pick.itemSku} / {pick.itemName}</div>
<div className="mt-1 text-xs text-muted">
{pick.quantity} issued from {pick.warehouseCode} / {pick.locationCode}
</div>
</div>
<div className="text-right text-xs text-muted">
<div>{pick.createdByName}</div>
<div className="mt-1">{formatDateTime(pick.createdAt)}</div>
</div>
</div>
<div className="mt-2 text-sm text-text">{pick.notes || "No pick notes."}</div>
</div>
))}
</div>
)}
</article>
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Shipment Notes</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{shipment.notes || "No notes recorded for this shipment."}</p>
</article>
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Timing</p>
<dl className="mt-5 grid gap-3">
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Ship Date</dt><dd className="mt-1 text-sm text-text">{shipment.shipDate ? new Date(shipment.shipDate).toLocaleDateString() : "Not set"}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Created</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.createdAt).toLocaleString()}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Updated</dt><dd className="mt-1 text-sm text-text">{new Date(shipment.updatedAt).toLocaleString()}</dd></div>
</dl>
</article>
</div>
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3">
<div>
@@ -227,7 +527,7 @@ export function ShipmentDetailPage() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{related.shipmentNumber}</div>
<div className="mt-1 text-xs text-muted">{related.carrier || "Carrier not set"} · {related.trackingNumber || "No tracking"}</div>
<div className="mt-1 text-xs text-muted">{related.carrier || "Carrier not set"} / {related.trackingNumber || "No tracking"}</div>
</div>
<ShipmentStatusBadge status={related.status} />
</div>
@@ -236,6 +536,7 @@ export function ShipmentDetailPage() {
</div>
)}
</section>
<FileAttachmentsPanel
ownerType="SHIPMENT"
ownerId={shipment.id}
@@ -244,6 +545,7 @@ export function ShipmentDetailPage() {
description="Store carrier paperwork, signed delivery records, bills of lading, and related logistics support files on the shipment record."
emptyMessage="No logistics attachments have been uploaded for this shipment yet."
/>
<ConfirmActionDialog
open={pendingConfirmation != null}
title={pendingConfirmation?.title ?? "Confirm shipment action"}
@@ -271,4 +573,3 @@ export function ShipmentDetailPage() {
</section>
);
}

View File

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

View File

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

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,7 +154,56 @@ 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() {
@@ -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);

View File

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