This commit is contained in:
2026-03-15 16:40:25 -05:00
parent 15116807ce
commit 59754c7657
33 changed files with 1620 additions and 49 deletions

View File

@@ -24,6 +24,9 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, and attachments
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility - admin diagnostics with runtime footprint, record counts, and persisted audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow - CRM/shipping audit coverage and startup validation surfaced through the admin diagnostics workflow
@@ -126,8 +129,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. Convert demand-planning recommendations into work orders and purchase orders 1. Better user and session visibility for operational admins
2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects 2. Safer destructive-action confirmations and recovery messaging
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,9 +6,14 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### Added
- Shared shortage and readiness rollups across dashboard, planning, project detail, purchasing detail, and manufacturing detail
- Prefilled work-order draft launch for build recommendations and prefilled purchase-order draft launch for buy recommendations from sales-order demand planning
- Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children - Sales-order demand planning with multi-level BOM explosion across manufactured and assembly children
- Netting of sales-order demand against available stock, active reservations, open work orders, and open purchase orders - Netting of sales-order demand against available stock, active reservations, open work orders, and open purchase orders
- Build and buy recommendations surfaced directly on sales-order detail pages - Build and buy recommendations surfaced directly on sales-order detail pages
- Pegged work-order and purchase-order supply tracking back to sales demand so reopened planning views do not overstate remaining recommendations
- Preferred-vendor sourcing on inventory items, used to preseed buy-side planning workflows
- Demand-planning recommendations now reduce against existing linked WO/PO supply and support safer partial conversion behavior
- Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics - Support-log capture for startup warnings, HTTP failures, and server errors, surfaced through admin diagnostics
- Exportable support bundles that now include backup guidance and recent support logs for support handoff - Exportable support bundles that now include backup guidance and recent support logs for support handoff
- Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups - Deeper startup diagnostics with writable-path checks, database-file validation, startup timing, and pass/warn/fail rollups
@@ -57,7 +62,8 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging - Admin diagnostics now includes structured startup summaries and a dedicated support-log view for faster debugging
- Roadmap and project docs now treat demand planning and supply generation as its own phase ahead of the deferred admin QOL work - Roadmap and project docs now treat demand planning and supply generation as its own phase ahead of the deferred admin QOL work
- Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice - Roadmap and project docs now treat backup verification checklist and restore drill guidance as the next active priority after the backup/support-tooling slice
- Roadmap and project docs now treat recommendation-to-WO/PO generation and cross-module shortage rollups as the next active priorities after the first demand-planning slice - Manufacturing work-order material requirements now include live available/shortage visibility instead of only required-versus-issued math
- Roadmap and project docs now move back to admin session visibility and destructive-action safety after the demand-planning rollout
## 2026-03-15 ## 2026-03-15

View File

@@ -28,6 +28,9 @@ This repository implements the platform foundation milestone:
- manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility - manufacturing work orders with project linkage, station master data, item operation templates, auto-generated work-order operations, attachments, and dashboard visibility
- planning gantt timelines backed by live project and manufacturing schedule data - planning gantt timelines backed by live project and manufacturing schedule data
- sales-order demand planning with multi-level BOM explosion, net stock/open-supply coverage, and build/buy recommendations - sales-order demand planning with multi-level BOM explosion, net stock/open-supply coverage, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
- pegged work-order and purchase-order supply coverage tied back to sales demand, with preferred-vendor sourcing defaults
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity - admin diagnostics with runtime footprint, storage visibility, record counts, and recent audit activity
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM/shipping audit coverage and startup validation surfaced through diagnostics - CRM/shipping audit coverage and startup validation surfaced through diagnostics
@@ -69,5 +72,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- convert demand-planning recommendations into work orders and purchase orders - better user and session visibility for operational admins
- shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects - safer destructive-action confirmations and recovery messaging

View File

@@ -27,6 +27,9 @@ Current foundation scope includes:
- manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments - manufacturing work orders with project linkage, station-based operation templates, material issue posting, completion posting, and work-order attachments
- planning gantt timelines with live project and manufacturing schedule data - planning gantt timelines with live project and manufacturing schedule data
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
- pegged WO/PO supply tracking back to sales demand with preferred-vendor sourcing on inventory items
- shared shortage and readiness rollups across dashboard, planning, projects, purchasing, and manufacturing
- admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility - admin diagnostics with runtime footprint, record counts, and recent audit-trail visibility
- admin user management with account creation, activation, role assignment, and role-permission editing - admin user management with account creation, activation, role assignment, and role-permission editing
- CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page - CRM and shipping audit coverage plus startup validation surfaced through the admin diagnostics page
@@ -54,8 +57,8 @@ Current completed foundation areas:
Near-term priorities: Near-term priorities:
1. Convert demand-planning recommendations into work orders and purchase orders 1. Better user and session visibility for operational admins
2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects 2. Safer destructive-action confirmations and recovery messaging
Revisit / deferred items: Revisit / deferred items:
@@ -353,6 +356,8 @@ The current admin operations slice supports:
- persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions - persisted audit events for core settings, inventory, purchasing, sales, project, and manufacturing write actions
- an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity - an admin diagnostics page for runtime footprint, data/storage path visibility, key record counts, and recent audit activity
- a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations - a sales-order demand-planning view with multi-level BOM netting and build/buy recommendations
- prefilled work-order and purchase-order draft launch paths from sales-order demand-planning recommendations
- shared shortage/readiness rollups across planning, project, purchasing, dashboard, and manufacturing views
- a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration - a dedicated user-management page for account creation, activation, role assignment, password reset-style updates, and role-permission administration
- CRM customer/vendor changes and shipping mutations now flow into the shared audit trail - CRM customer/vendor changes and shipping mutations now flow into the shared audit trail
- startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot - startup validation now checks storage paths, writable storage readiness, database connectivity, client bundle readiness, Chromium availability, and risky production defaults during server boot
@@ -363,8 +368,8 @@ The current admin operations slice supports:
Current follow-up direction: Current follow-up direction:
- convert demand-planning recommendations into work orders and purchase orders - better user and session visibility for operational admins
- shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects - safer destructive-action confirmations and recovery messaging
## UI Notes ## UI Notes

View File

@@ -262,6 +262,11 @@ Foundation slice shipped:
- Multi-level BOM explosion from sales-order lines through manufactured and assembly children - Multi-level BOM explosion from sales-order lines through manufactured and assembly children
- Netting against available stock, active reservations, open work orders, and open purchase orders - Netting against available stock, active reservations, open work orders, and open purchase orders
- Build and buy recommendations surfaced directly from the sales-order workflow - Build and buy recommendations surfaced directly from the sales-order workflow
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
- Pegged work-order and purchase-order supply links back to originating sales demand
- Planning recommendations now reduce against already-linked draft/open supply to avoid duplicate WO/PO generation
- Shared MRP demand engine across sales, inventory, purchasing, manufacturing, projects, and planning - Shared MRP demand engine across sales, inventory, purchasing, manufacturing, projects, and planning
- Planned work-order and purchase-order recommendation generation - Planned work-order and purchase-order recommendation generation
@@ -274,7 +279,7 @@ QOL subfeatures:
- Better shortage and substitute-part guidance during planning review - Better shortage and substitute-part guidance during planning review
- Saved planning views by customer, project, item family, and shortage state - Saved planning views by customer, project, item family, and shortage state
- Planner-focused drilldowns from demand source to buy/build action without re-keying data - Planner-focused drilldowns from demand source to buy/build action without re-keying data
- More explicit pegging between parent demand and generated supply actions - Time-phased supply recommendations with vendor lead times and build timing
### Phase 9: Security, audit, and operations maturity ### Phase 9: Security, audit, and operations maturity
@@ -320,5 +325,5 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. Convert demand-planning recommendations into work orders and purchase orders 1. Better user and session visibility for operational admins
2. Shared shortage and readiness rollups across planning, purchasing, manufacturing, and projects 2. Safer destructive-action confirmations and recovery messaging

View File

@@ -68,6 +68,7 @@ import type {
} from "@mrp/shared/dist/projects/types.js"; } from "@mrp/shared/dist/projects/types.js";
import type { import type {
SalesCustomerOptionDto, SalesCustomerOptionDto,
DemandPlanningRollupDto,
SalesDocumentDetailDto, SalesDocumentDetailDto,
SalesDocumentInput, SalesDocumentInput,
SalesOrderPlanningDto, SalesOrderPlanningDto,
@@ -635,6 +636,9 @@ export const api = {
getSalesOrderPlanning(token: string, orderId: string) { getSalesOrderPlanning(token: string, orderId: string) {
return request<SalesOrderPlanningDto>(`/api/v1/sales/orders/${orderId}/planning`, undefined, token); return request<SalesOrderPlanningDto>(`/api/v1/sales/orders/${orderId}/planning`, undefined, token);
}, },
getDemandPlanningRollup(token: string) {
return request<DemandPlanningRollupDto>("/api/v1/sales/planning-rollup", undefined, token);
},
createSalesOrder(token: string, payload: SalesDocumentInput) { createSalesOrder(token: string, payload: SalesDocumentInput) {
return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token); return request<SalesDocumentDetailDto>("/api/v1/sales/orders", { method: "POST", body: JSON.stringify(payload) }, token);
}, },

View File

@@ -1,4 +1,5 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
@@ -16,6 +17,7 @@ interface DashboardSnapshot {
orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null; orders: Awaited<ReturnType<typeof api.getSalesOrders>> | null;
shipments: Awaited<ReturnType<typeof api.getShipments>> | null; shipments: Awaited<ReturnType<typeof api.getShipments>> | null;
projects: Awaited<ReturnType<typeof api.getProjects>> | null; projects: Awaited<ReturnType<typeof api.getProjects>> | null;
planningRollup: DemandPlanningRollupDto | null;
refreshedAt: string; refreshedAt: string;
} }
@@ -87,8 +89,9 @@ export function DashboardPage() {
canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null), canReadManufacturing ? api.getWorkOrders(authToken) : Promise.resolve(null),
canReadSales ? api.getQuotes(authToken) : Promise.resolve(null), canReadSales ? api.getQuotes(authToken) : Promise.resolve(null),
canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null), canReadSales ? api.getSalesOrders(authToken) : Promise.resolve(null),
canReadShipping ? api.getShipments(authToken) : Promise.resolve(null), canReadShipping ? api.getShipments(authToken) : Promise.resolve(null),
canReadProjects ? api.getProjects(authToken) : Promise.resolve(null), canReadProjects ? api.getProjects(authToken) : Promise.resolve(null),
canReadSales ? api.getDemandPlanningRollup(authToken) : Promise.resolve(null),
]); ]);
if (!isMounted) { if (!isMounted) {
@@ -112,6 +115,7 @@ export function DashboardPage() {
orders: results[7].status === "fulfilled" ? results[7].value : null, orders: results[7].status === "fulfilled" ? results[7].value : null,
shipments: results[8].status === "fulfilled" ? results[8].value : null, shipments: results[8].status === "fulfilled" ? results[8].value : null,
projects: results[9].status === "fulfilled" ? results[9].value : null, projects: results[9].status === "fulfilled" ? results[9].value : null,
planningRollup: results[10].status === "fulfilled" ? results[10].value : null,
refreshedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(),
}); });
setIsLoading(false); setIsLoading(false);
@@ -142,6 +146,7 @@ export function DashboardPage() {
const orders = snapshot?.orders ?? []; const orders = snapshot?.orders ?? [];
const shipments = snapshot?.shipments ?? []; const shipments = snapshot?.shipments ?? [];
const projects = snapshot?.projects ?? []; const projects = snapshot?.projects ?? [];
const planningRollup = snapshot?.planningRollup;
const accessibleModules = [ const accessibleModules = [
snapshot?.customers !== null || snapshot?.vendors !== null, snapshot?.customers !== null || snapshot?.vendors !== null,
@@ -199,6 +204,10 @@ export function DashboardPage() {
return new Date(project.dueDate).getTime() < Date.now(); return new Date(project.dueDate).getTime() < Date.now();
}).length; }).length;
const shortageItemCount = planningRollup?.summary.uncoveredItemCount ?? 0;
const buyRecommendationCount = planningRollup?.summary.purchaseRecommendationCount ?? 0;
const buildRecommendationCount = planningRollup?.summary.buildRecommendationCount ?? 0;
const totalUncoveredQuantity = planningRollup?.summary.totalUncoveredQuantity ?? 0;
const lastActivityAt = [ const lastActivityAt = [
...customers.map((customer) => customer.updatedAt), ...customers.map((customer) => customer.updatedAt),
@@ -279,6 +288,14 @@ export function DashboardPage() {
: "Project metrics are permission-gated.", : "Project metrics are permission-gated.",
tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300", tone: "border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
}, },
{
label: "Material Readiness",
value: planningRollup ? `${shortageItemCount}` : "No access",
detail: planningRollup
? `${buildRecommendationCount} build and ${buyRecommendationCount} buy recommendations`
: "Sales read permission is required to surface shortage rollups.",
tone: "border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
},
]; ];
const modulePanels = [ const modulePanels = [
@@ -404,12 +421,12 @@ export function DashboardPage() {
title: "Planning", title: "Planning",
eyebrow: "Schedule Visibility", eyebrow: "Schedule Visibility",
summary: canReadPlanning summary: canReadPlanning
? "Live gantt planning now pulls directly from active projects and open manufacturing work orders to show due-date pressure in one schedule view." ? "Live gantt planning now pulls directly from active projects and open manufacturing work orders, with shared shortage/readiness rollups alongside schedule pressure."
: "Planning read permission is required to surface the live gantt schedule.", : "Planning read permission is required to surface the live gantt schedule.",
metrics: [ metrics: [
{ label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" }, { label: "At risk projects", value: canReadPlanning ? `${atRiskProjectCount}` : "No access" },
{ label: "Overdue work", value: canReadPlanning ? `${overdueWorkOrderCount}` : "No access" }, { label: "Shortage items", value: canReadPlanning && planningRollup ? `${shortageItemCount}` : "No access" },
{ label: "Schedule links", value: canReadPlanning ? `${activeProjectCount + activeWorkOrderCount}` : "No access" }, { label: "Build / buy", value: canReadPlanning && planningRollup ? `${buildRecommendationCount} / ${buyRecommendationCount}` : "No access" },
], ],
links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [], links: canReadPlanning ? [{ label: "Open gantt", to: "/planning/gantt" }] : [],
}, },
@@ -533,6 +550,28 @@ export function DashboardPage() {
))} ))}
</section> </section>
<section className="grid gap-3 xl:grid-cols-6"> <section className="grid gap-3 xl:grid-cols-6">
<article className="rounded-[28px] 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">Planning Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Shared shortage and readiness</h4>
<div className="mt-4 grid gap-2">
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Shortage items</span>
<span className="font-semibold text-text">{planningRollup ? `${shortageItemCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Build recommendations</span>
<span className="font-semibold text-text">{planningRollup ? `${buildRecommendationCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Buy recommendations</span>
<span className="font-semibold text-text">{planningRollup ? `${buyRecommendationCount}` : "No access"}</span>
</div>
<div className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<span className="text-muted">Uncovered qty</span>
<span className="font-semibold text-text">{planningRollup ? `${totalUncoveredQuantity}` : "No access"}</span>
</div>
</div>
</article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[28px] 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">Inventory Watch</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Watch</p>
<h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4> <h4 className="mt-2 text-lg font-bold text-text">Master data pressure points</h4>

View File

@@ -3,7 +3,7 @@ import { Gantt } from "@svar-ui/react-gantt";
import "@svar-ui/react-gantt/style.css"; import "@svar-ui/react-gantt/style.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared"; import type { DemandPlanningRollupDto, GanttTaskDto, PlanningExceptionDto, PlanningTimelineDto } from "@mrp/shared";
import { useAuth } from "../../auth/AuthProvider"; import { useAuth } from "../../auth/AuthProvider";
import { ApiError, api } from "../../lib/api"; import { ApiError, api } from "../../lib/api";
@@ -24,6 +24,7 @@ export function GanttPage() {
const { token } = useAuth(); const { token } = useAuth();
const { mode } = useTheme(); const { mode } = useTheme();
const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null); const [timeline, setTimeline] = useState<PlanningTimelineDto | null>(null);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const [status, setStatus] = useState("Loading live planning timeline..."); const [status, setStatus] = useState("Loading live planning timeline...");
useEffect(() => { useEffect(() => {
@@ -31,10 +32,10 @@ export function GanttPage() {
return; return;
} }
api Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
.getPlanningTimeline(token) .then(([data, rollup]) => {
.then((data) => {
setTimeline(data); setTimeline(data);
setPlanningRollup(rollup);
setStatus("Planning timeline loaded."); setStatus("Planning timeline loaded.");
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -90,6 +91,16 @@ export function GanttPage() {
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Unscheduled Work</p>
<div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div> <div className="mt-2 text-xl font-extrabold text-text">{summary?.unscheduledWorkOrders ?? 0}</div>
</article> </article>
<article className="rounded-[24px] 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">Shortage Items</p>
<div className="mt-2 text-xl font-extrabold text-text">{planningRollup?.summary.uncoveredItemCount ?? 0}</div>
</article>
<article className="rounded-[24px] 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">Build / Buy</p>
<div className="mt-2 text-xl font-extrabold text-text">
{planningRollup ? `${planningRollup.summary.totalBuildQuantity} / ${planningRollup.summary.totalPurchaseQuantity}` : "0 / 0"}
</div>
</article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div <div
@@ -148,6 +159,16 @@ export function GanttPage() {
</section> </section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="rounded-[28px] 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">Planner Actions</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Planner Actions</p>
<div className="mt-4 space-y-2 rounded-3xl border border-line/70 bg-page/60 p-3 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Uncovered quantity</span>
<span className="font-semibold text-text">{planningRollup?.summary.totalUncoveredQuantity ?? 0}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted">Projects with linked demand</span>
<span className="font-semibold text-text">{planningRollup?.summary.projectCount ?? 0}</span>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
<Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <Link to="/projects" className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Open projects Open projects

View File

@@ -252,6 +252,10 @@ export function InventoryDetailPage() {
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Default price</dt>
<dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd> <dd className="mt-2 text-sm text-text">{item.defaultPrice == null ? "Not set" : `$${item.defaultPrice.toFixed(2)}`}</dd>
</div> </div>
<div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Preferred vendor</dt>
<dd className="mt-2 text-sm text-text">{item.preferredVendorName ?? "Not set"}</dd>
</div>
<div> <div>
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt> <dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Flags</dt>
<dd className="mt-2 text-sm text-text"> <dd className="mt-2 text-sm text-text">

View File

@@ -1,3 +1,4 @@
import type { PurchaseVendorOptionDto } from "@mrp/shared";
import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js"; import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared"; import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -18,8 +19,11 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput); const [form, setForm] = useState<InventoryItemInput>(emptyInventoryItemInput);
const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]); const [componentOptions, setComponentOptions] = useState<InventoryItemOptionDto[]>([]);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]); const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
const [vendorOptions, setVendorOptions] = useState<PurchaseVendorOptionDto[]>([]);
const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]); const [componentSearchTerms, setComponentSearchTerms] = useState<string[]>([]);
const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null); const [activeComponentPicker, setActiveComponentPicker] = useState<number | null>(null);
const [vendorSearchTerm, setVendorSearchTerm] = useState("");
const [vendorPickerOpen, setVendorPickerOpen] = useState(false);
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item..."); const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -72,6 +76,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
unitOfMeasure: item.unitOfMeasure, unitOfMeasure: item.unitOfMeasure,
isSellable: item.isSellable, isSellable: item.isSellable,
isPurchasable: item.isPurchasable, isPurchasable: item.isPurchasable,
preferredVendorId: item.preferredVendorId,
defaultCost: item.defaultCost, defaultCost: item.defaultCost,
defaultPrice: item.defaultPrice, defaultPrice: item.defaultPrice,
notes: item.notes, notes: item.notes,
@@ -93,6 +98,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
}); });
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku)); setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded."); setStatus("Inventory item loaded.");
setVendorSearchTerm(item.preferredVendorName ?? "");
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item."; const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
@@ -106,12 +112,21 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
} }
api.getManufacturingStations(token).then(setStations).catch(() => setStations([])); api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
}, [token]); }, [token]);
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) { function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
} }
function getSelectedVendorName(vendorId: string | null) {
if (!vendorId) {
return "";
}
return vendorOptions.find((vendor) => vendor.id === vendorId)?.name ?? "";
}
function updateBomLine(index: number, nextLine: InventoryBomLineInput) { function updateBomLine(index: number, nextLine: InventoryBomLineInput) {
setForm((current) => ({ setForm((current) => ({
...current, ...current,
@@ -315,6 +330,76 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
</label> </label>
</div> </div>
</div> </div>
<label className="block xl:max-w-xl">
<span className="mb-2 block text-sm font-semibold text-text">Preferred vendor</span>
<div className="relative">
<input
value={vendorSearchTerm}
onChange={(event) => {
setVendorSearchTerm(event.target.value);
updateField("preferredVendorId", null);
setVendorPickerOpen(true);
}}
onFocus={() => setVendorPickerOpen(true)}
onBlur={() => {
window.setTimeout(() => {
setVendorPickerOpen(false);
if (form.preferredVendorId) {
setVendorSearchTerm(getSelectedVendorName(form.preferredVendorId));
}
}, 120);
}}
disabled={!form.isPurchasable}
placeholder={form.isPurchasable ? "Search vendor" : "Enable purchasable to assign sourcing"}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand disabled:cursor-not-allowed disabled:opacity-60"
/>
{vendorPickerOpen && form.isPurchasable ? (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-2xl border border-line/70 bg-surface shadow-panel">
<button
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("preferredVendorId", null);
setVendorSearchTerm("");
setVendorPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition hover:bg-page/70"
>
<div className="font-semibold text-text">No preferred vendor</div>
</button>
{vendorOptions
.filter((vendor) => {
const query = vendorSearchTerm.trim().toLowerCase();
if (!query) {
return true;
}
return vendor.name.toLowerCase().includes(query) || vendor.email.toLowerCase().includes(query);
})
.slice(0, 12)
.map((vendor) => (
<button
key={vendor.id}
type="button"
onMouseDown={(event) => {
event.preventDefault();
updateField("preferredVendorId", vendor.id);
setVendorSearchTerm(vendor.name);
setVendorPickerOpen(false);
}}
className="block w-full border-b border-line/50 px-2 py-2 text-left text-sm transition last:border-b-0 hover:bg-page/70"
>
<div className="font-semibold text-text">{vendor.name}</div>
<div className="mt-1 text-xs text-muted">{vendor.email}</div>
</button>
))}
</div>
) : null}
</div>
<div className="mt-2 text-xs text-muted">
{form.preferredVendorId ? getSelectedVendorName(form.preferredVendorId) : "Demand planning uses this vendor when creating buy recommendations."}
</div>
</label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Description</span> <span className="mb-2 block text-sm font-semibold text-text">Description</span>
<textarea <textarea

View File

@@ -41,6 +41,7 @@ export const emptyInventoryItemInput: InventoryItemInput = {
unitOfMeasure: "EA", unitOfMeasure: "EA",
isSellable: true, isSellable: true,
isPurchasable: true, isPurchasable: true,
preferredVendorId: null,
defaultCost: null, defaultCost: null,
defaultPrice: null, defaultPrice: null,
notes: "", notes: "",

View File

@@ -141,6 +141,7 @@ export function WorkOrderDetailPage() {
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/manufacturing/work-orders" 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 work orders</Link> <Link to="/manufacturing/work-orders" 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 work orders</Link>
{workOrder.projectId ? <Link to={`/projects/${workOrder.projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open project</Link> : null} {workOrder.projectId ? <Link to={`/projects/${workOrder.projectId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open project</Link> : null}
{workOrder.salesOrderId ? <Link to={`/sales/orders/${workOrder.salesOrderId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open sales order</Link> : null}
<Link to={`/inventory/items/${workOrder.itemId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open item</Link> <Link to={`/inventory/items/${workOrder.itemId}`} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">Open item</Link>
{canManage ? <Link to={`/manufacturing/work-orders/${workOrder.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit work order</Link> : null} {canManage ? <Link to={`/manufacturing/work-orders/${workOrder.id}/edit`} className="inline-flex items-center justify-center rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white">Edit work order</Link> : null}
</div> </div>
@@ -170,6 +171,7 @@ export function WorkOrderDetailPage() {
<article className="rounded-[24px] 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">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article> <article className="rounded-[24px] 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">Project</p><div className="mt-2 text-base font-bold text-text">{workOrder.projectNumber || "Unlinked"}</div></article>
<article className="rounded-[24px] 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">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article> <article className="rounded-[24px] 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">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
<article className="rounded-[24px] 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">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article> <article className="rounded-[24px] 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">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
<article className="rounded-[24px] 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">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -179,6 +181,7 @@ export function WorkOrderDetailPage() {
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Item type</dt><dd className="mt-1 text-sm text-text">{workOrder.itemType}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Output location</dt><dd className="mt-1 text-sm text-text">{workOrder.warehouseCode} / {workOrder.locationCode}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project customer</dt><dd className="mt-1 text-sm text-text">{workOrder.projectCustomerName || "Not linked"}</dd></div> <div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Project customer</dt><dd className="mt-1 text-sm text-text">{workOrder.projectCustomerName || "Not linked"}</dd></div>
<div><dt className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Demand source</dt><dd className="mt-1 text-sm text-text">{workOrder.salesOrderNumber ?? "Not linked"}</dd></div>
</dl> </dl>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -299,6 +302,8 @@ export function WorkOrderDetailPage() {
<th className="px-3 py-3">Required</th> <th className="px-3 py-3">Required</th>
<th className="px-3 py-3">Issued</th> <th className="px-3 py-3">Issued</th>
<th className="px-3 py-3">Remaining</th> <th className="px-3 py-3">Remaining</th>
<th className="px-3 py-3">Available</th>
<th className="px-3 py-3">Shortage</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-line/70"> <tbody className="divide-y divide-line/70">
@@ -309,6 +314,8 @@ export function WorkOrderDetailPage() {
<td className="px-3 py-3 text-text">{requirement.requiredQuantity}</td> <td className="px-3 py-3 text-text">{requirement.requiredQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.issuedQuantity}</td> <td className="px-3 py-3 text-text">{requirement.issuedQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td> <td className="px-3 py-3 text-text">{requirement.remainingQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.availableQuantity}</td>
<td className="px-3 py-3 text-text">{requirement.shortageQuantity}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -17,6 +17,13 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
const { workOrderId } = useParams(); const { workOrderId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const seededProjectId = searchParams.get("projectId"); const seededProjectId = searchParams.get("projectId");
const seededItemId = searchParams.get("itemId");
const seededSalesOrderId = searchParams.get("salesOrderId");
const seededSalesOrderLineId = searchParams.get("salesOrderLineId");
const seededQuantity = searchParams.get("quantity");
const seededStatus = searchParams.get("status");
const seededDueDate = searchParams.get("dueDate");
const seededNotes = searchParams.get("notes");
const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput); const [form, setForm] = useState<WorkOrderInput>(emptyWorkOrderInput);
const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]); const [itemOptions, setItemOptions] = useState<ManufacturingItemOptionDto[]>([]);
const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]); const [projectOptions, setProjectOptions] = useState<ManufacturingProjectOptionDto[]>([]);
@@ -33,7 +40,25 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
return; return;
} }
api.getManufacturingItemOptions(token).then(setItemOptions).catch(() => setItemOptions([])); api.getManufacturingItemOptions(token).then((options) => {
setItemOptions(options);
if (mode === "create" && seededItemId) {
const seededItem = options.find((option) => option.id === seededItemId);
if (seededItem) {
setForm((current) => ({
...current,
itemId: seededItem.id,
salesOrderId: seededSalesOrderId || current.salesOrderId,
salesOrderLineId: seededSalesOrderLineId || current.salesOrderLineId,
quantity: seededQuantity ? Number.parseInt(seededQuantity, 10) || current.quantity : current.quantity,
status: seededStatus && workOrderStatusOptions.some((option) => option.value === seededStatus) ? (seededStatus as WorkOrderInput["status"]) : current.status,
dueDate: seededDueDate || current.dueDate,
notes: seededNotes || current.notes,
}));
setItemSearchTerm(`${seededItem.sku} - ${seededItem.name}`);
}
}
}).catch(() => setItemOptions([]));
api.getManufacturingProjectOptions(token).then((options) => { api.getManufacturingProjectOptions(token).then((options) => {
setProjectOptions(options); setProjectOptions(options);
if (mode === "create" && seededProjectId) { if (mode === "create" && seededProjectId) {
@@ -45,7 +70,7 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
} }
}).catch(() => setProjectOptions([])); }).catch(() => setProjectOptions([]));
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([])); api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
}, [mode, seededProjectId, token]); }, [mode, seededDueDate, seededItemId, seededNotes, seededProjectId, seededQuantity, seededSalesOrderId, seededSalesOrderLineId, seededStatus, token]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "edit" || !workOrderId) { if (!token || mode !== "edit" || !workOrderId) {
@@ -57,6 +82,8 @@ export function WorkOrderFormPage({ mode }: { mode: "create" | "edit" }) {
setForm({ setForm({
itemId: workOrder.itemId, itemId: workOrder.itemId,
projectId: workOrder.projectId, projectId: workOrder.projectId,
salesOrderId: workOrder.salesOrderId,
salesOrderLineId: workOrder.salesOrderLineId,
status: workOrder.status, status: workOrder.status,
quantity: workOrder.quantity, quantity: workOrder.quantity,
warehouseId: workOrder.warehouseId, warehouseId: workOrder.warehouseId,

View File

@@ -26,6 +26,8 @@ export const workOrderStatusPalette: Record<WorkOrderStatus, string> = {
export const emptyWorkOrderInput: WorkOrderInput = { export const emptyWorkOrderInput: WorkOrderInput = {
itemId: "", itemId: "",
projectId: null, projectId: null,
salesOrderId: null,
salesOrderLineId: null,
status: "DRAFT", status: "DRAFT",
quantity: 1, quantity: 1,
warehouseId: "", warehouseId: "",

View File

@@ -1,5 +1,6 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js"; import type { ProjectDetailDto } from "@mrp/shared/dist/projects/types.js";
import type { SalesOrderPlanningDto } from "@mrp/shared/dist/sales/types.js";
import type { WorkOrderSummaryDto } from "@mrp/shared"; import type { WorkOrderSummaryDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -15,6 +16,7 @@ export function ProjectDetailPage() {
const { projectId } = useParams(); const { projectId } = useParams();
const [project, setProject] = useState<ProjectDetailDto | null>(null); const [project, setProject] = useState<ProjectDetailDto | null>(null);
const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]); const [workOrders, setWorkOrders] = useState<WorkOrderSummaryDto[]>([]);
const [planning, setPlanning] = useState<SalesOrderPlanningDto | null>(null);
const [status, setStatus] = useState("Loading project..."); const [status, setStatus] = useState("Loading project...");
const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false; const canManage = user?.permissions.includes(permissions.projectsWrite) ?? false;
@@ -28,6 +30,11 @@ export function ProjectDetailPage() {
.then((nextProject) => { .then((nextProject) => {
setProject(nextProject); setProject(nextProject);
setStatus("Project loaded."); setStatus("Project loaded.");
if (nextProject.salesOrderId) {
api.getSalesOrderPlanning(token, nextProject.salesOrderId).then(setPlanning).catch(() => setPlanning(null));
} else {
setPlanning(null);
}
return api.getWorkOrders(token, { projectId: nextProject.id }); return api.getWorkOrders(token, { projectId: nextProject.id });
}) })
.then((nextWorkOrders) => setWorkOrders(nextWorkOrders)) .then((nextWorkOrders) => setWorkOrders(nextWorkOrders))
@@ -97,6 +104,47 @@ export function ProjectDetailPage() {
</div> </div>
</div> </div>
</section> </section>
{planning ? (
<section className="rounded-[28px] 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">Material Readiness</p>
<div className="mt-5 grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Build Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalBuildQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Buy Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalPurchaseQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Uncovered Qty</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.totalUncoveredQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-page/60 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Shortage Items</p>
<div className="mt-2 text-base font-bold text-text">{planning.summary.uncoveredItemCount}</div>
</article>
</div>
<div className="mt-5 space-y-3">
{planning.items
.filter((item) => item.recommendedBuildQuantity > 0 || item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)
.slice(0, 8)
.map((item) => (
<div key={item.itemId} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</div>
<div className="text-sm text-muted">
Build {item.recommendedBuildQuantity} · Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
</div>
</div>
</div>
))}
</div>
</section>
) : null}
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>

View File

@@ -2,6 +2,7 @@ import { permissions } from "@mrp/shared";
import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared"; import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js"; import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js"; import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import type { DemandPlanningRollupDto } from "@mrp/shared/dist/sales/types.js";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -23,6 +24,7 @@ export function PurchaseDetailPage() {
const [status, setStatus] = useState("Loading purchase order..."); const [status, setStatus] = useState("Loading purchase order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [isOpeningPdf, setIsOpeningPdf] = useState(false); const [isOpeningPdf, setIsOpeningPdf] = useState(false);
const [planningRollup, setPlanningRollup] = useState<DemandPlanningRollupDto | null>(null);
const canManage = user?.permissions.includes("purchasing.write") ?? false; const canManage = user?.permissions.includes("purchasing.write") ?? false;
const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false); const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false);
@@ -41,6 +43,7 @@ export function PurchaseDetailPage() {
const message = error instanceof ApiError ? error.message : "Unable to load purchase order."; const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
setStatus(message); setStatus(message);
}); });
api.getDemandPlanningRollup(token).then(setPlanningRollup).catch(() => setPlanningRollup(null));
if (!canReceive) { if (!canReceive) {
return; return;
@@ -90,6 +93,8 @@ export function PurchaseDetailPage() {
const activeDocument = document; const activeDocument = document;
const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0); const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0);
const demandContextItems =
planningRollup?.items.filter((item) => activeDocument.lines.some((line) => line.itemId === item.itemId) && (item.recommendedPurchaseQuantity > 0 || item.uncoveredQuantity > 0)) ?? [];
function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) { function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) {
setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value })); setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value }));
@@ -258,6 +263,30 @@ export function PurchaseDetailPage() {
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p> <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{activeDocument.notes || "No notes recorded for this document."}</p>
</article> </article>
</div> </div>
<section className="rounded-[28px] 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">Demand Context</p>
{demandContextItems.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No active shared shortage or buy-signal records currently point at items on this purchase order.
</div>
) : (
<div className="mt-5 space-y-3">
{demandContextItems.map((item) => (
<div key={item.itemId} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{item.itemSku}</div>
<div className="mt-1 text-xs text-muted">{item.itemName}</div>
</div>
<div className="text-sm text-muted">
Buy {item.recommendedPurchaseQuantity} · Uncovered {item.uncoveredQuantity}
</div>
</div>
</div>
))}
</div>
)}
</section>
<section className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <section className="rounded-[28px] 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">Line Items</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Line Items</p>
{activeDocument.lines.length === 0 ? ( {activeDocument.lines.length === 0 ? (
@@ -266,13 +295,16 @@ export function PurchaseDetailPage() {
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm"> <table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted"> <thead className="bg-page/80 text-left text-muted">
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr> <tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Demand Source</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
</thead> </thead>
<tbody className="divide-y divide-line/70 bg-surface"> <tbody className="divide-y divide-line/70 bg-surface">
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => ( {activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
<tr key={line.id}> <tr key={line.id}>
<td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td> <td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td>
<td className="px-2 py-2 text-muted">{line.description}</td> <td className="px-2 py-2 text-muted">{line.description}</td>
<td className="px-2 py-2 text-muted">
{line.salesOrderId && line.salesOrderNumber ? <Link to={`/sales/orders/${line.salesOrderId}`} className="hover:text-brand">{line.salesOrderNumber}</Link> : "Unlinked"}
</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td> <td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.receivedQuantity}</td> <td className="px-2 py-2 text-muted">{line.receivedQuantity}</td>
<td className="px-2 py-2 text-muted">{line.remainingQuantity}</td> <td className="px-2 py-2 text-muted">{line.remainingQuantity}</td>

View File

@@ -1,4 +1,4 @@
import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto } from "@mrp/shared"; import type { InventoryItemOptionDto, PurchaseLineInput, PurchaseOrderInput, PurchaseVendorOptionDto, SalesOrderPlanningDto, SalesOrderPlanningNodeDto } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
@@ -13,6 +13,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const { orderId } = useParams(); const { orderId } = useParams();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const seededVendorId = searchParams.get("vendorId"); const seededVendorId = searchParams.get("vendorId");
const planningOrderId = searchParams.get("planningOrderId");
const selectedPlanningItemId = searchParams.get("itemId");
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput); const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order..."); const [status, setStatus] = useState(mode === "create" ? "Create a new purchase order." : "Loading purchase order...");
const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]); const [vendors, setVendors] = useState<PurchaseVendorOptionDto[]>([]);
@@ -23,6 +25,14 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null); const [activeLinePicker, setActiveLinePicker] = useState<number | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
function collectRecommendedPurchaseNodes(node: SalesOrderPlanningNodeDto): SalesOrderPlanningNodeDto[] {
const nodes = node.recommendedPurchaseQuantity > 0 ? [node] : [];
for (const child of node.children) {
nodes.push(...collectRecommendedPurchaseNodes(child));
}
return nodes;
}
const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0); const subtotal = form.lines.reduce((sum: number, line: PurchaseLineInput) => sum + line.quantity * line.unitCost, 0);
const taxAmount = subtotal * (form.taxPercent / 100); const taxAmount = subtotal * (form.taxPercent / 100);
const total = subtotal + taxAmount + form.freightAmount; const total = subtotal + taxAmount + form.freightAmount;
@@ -45,6 +55,75 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([])); api.getInventoryItemOptions(token).then((options) => setItemOptions(options.filter((option: InventoryItemOptionDto) => option.isPurchasable))).catch(() => setItemOptions([]));
}, [mode, seededVendorId, token]); }, [mode, seededVendorId, token]);
useEffect(() => {
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
return;
}
api.getSalesOrderPlanning(token, planningOrderId)
.then((planning: SalesOrderPlanningDto) => {
const recommendedNodes = planning.lines.flatMap((line) =>
collectRecommendedPurchaseNodes(line.rootNode).map((node) => ({
salesOrderLineId: line.lineId,
...node,
}))
);
const filteredNodes = selectedPlanningItemId
? recommendedNodes.filter((node) => node.itemId === selectedPlanningItemId)
: recommendedNodes;
const recommendedLines = filteredNodes.map((node, index) => {
const inventoryItem = itemOptions.find((option) => option.id === node.itemId);
return {
itemId: node.itemId,
description: node.itemName,
quantity: node.recommendedPurchaseQuantity,
unitOfMeasure: node.unitOfMeasure,
unitCost: inventoryItem?.defaultCost ?? 0,
salesOrderId: planning.orderId,
salesOrderLineId: node.salesOrderLineId,
position: (index + 1) * 10,
} satisfies PurchaseLineInput;
});
if (recommendedLines.length === 0) {
return;
}
const preferredVendorIds = [
...new Set(
recommendedLines
.map((line) => itemOptions.find((option) => option.id === line.itemId)?.preferredVendorId)
.filter((vendorId): vendorId is string => Boolean(vendorId))
),
];
const autoVendorId = seededVendorId || (preferredVendorIds.length === 1 ? preferredVendorIds[0] : null);
setForm((current) => ({
...current,
vendorId: current.vendorId || autoVendorId || "",
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
lines: current.lines.length > 0 ? current.lines : recommendedLines,
}));
if (autoVendorId) {
const autoVendor = vendors.find((vendor) => vendor.id === autoVendorId);
if (autoVendor) {
setVendorSearchTerm(autoVendor.name);
}
}
setLineSearchTerms((current) =>
current.length > 0 ? current : recommendedLines.map((line) => itemOptions.find((option) => option.id === line.itemId)?.sku ?? "")
);
setStatus(
preferredVendorIds.length > 1 && !seededVendorId
? `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}. Multiple preferred vendors exist, so confirm the vendor before saving.`
: `Loaded ${recommendedLines.length} recommended buy lines from ${planning.documentNumber}.`
);
})
.catch(() => {
setStatus("Unable to load demand-planning recommendations.");
});
}, [itemOptions, mode, planningOrderId, seededVendorId, selectedPlanningItemId, token, vendors]);
useEffect(() => { useEffect(() => {
if (!token || mode !== "edit" || !orderId) { if (!token || mode !== "edit" || !orderId) {
return; return;
@@ -59,12 +138,14 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
taxPercent: document.taxPercent, taxPercent: document.taxPercent,
freightAmount: document.freightAmount, freightAmount: document.freightAmount,
notes: document.notes, notes: document.notes,
lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number }) => ({ lines: document.lines.map((line: { itemId: string; description: string; quantity: number; unitOfMeasure: PurchaseLineInput["unitOfMeasure"]; unitCost: number; position: number; salesOrderId: string | null; salesOrderLineId: string | null }) => ({
itemId: line.itemId, itemId: line.itemId,
description: line.description, description: line.description,
quantity: line.quantity, quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure, unitOfMeasure: line.unitOfMeasure,
unitCost: line.unitCost, unitCost: line.unitCost,
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
position: line.position, position: line.position,
})), })),
}); });
@@ -308,6 +389,8 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
...line, ...line,
itemId: option.id, itemId: option.id,
description: line.description || option.name, description: line.description || option.name,
salesOrderId: line.salesOrderId ?? null,
salesOrderLineId: line.salesOrderLineId ?? null,
}); });
updateLineSearchTerm(index, option.sku); updateLineSearchTerm(index, option.sku);
setActiveLinePicker(null); setActiveLinePicker(null);

View File

@@ -24,6 +24,8 @@ function PlanningNodeCard({ node }: { node: SalesOrderPlanningNodeDto }) {
</div> </div>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>Linked WO {node.linkedWorkOrderSupply}</div>
<div>Linked PO {node.linkedPurchaseSupply}</div>
<div>Stock {node.supplyFromStock}</div> <div>Stock {node.supplyFromStock}</div>
<div>Open WO {node.supplyFromOpenWorkOrders}</div> <div>Open WO {node.supplyFromOpenWorkOrders}</div>
<div>Open PO {node.supplyFromOpenPurchaseOrders}</div> <div>Open PO {node.supplyFromOpenPurchaseOrders}</div>
@@ -61,6 +63,8 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const canManage = user?.permissions.includes(permissions.salesWrite) ?? false; const canManage = user?.permissions.includes(permissions.salesWrite) ?? false;
const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false; const canManageShipping = user?.permissions.includes(permissions.shippingWrite) ?? false;
const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false; const canReadShipping = user?.permissions.includes(permissions.shippingRead) ?? false;
const canManageManufacturing = user?.permissions.includes(permissions.manufacturingWrite) ?? false;
const canManagePurchasing = user?.permissions.includes(permissions.purchasingWrite) ?? false;
useEffect(() => { useEffect(() => {
if (!token || !documentId) { if (!token || !documentId) {
@@ -90,6 +94,31 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
const activeDocument = document; const activeDocument = document;
function buildWorkOrderRecommendationLink(itemId: string, quantity: number) {
const params = new URLSearchParams({
itemId,
salesOrderId: activeDocument.id,
quantity: quantity.toString(),
status: "DRAFT",
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
});
return `/manufacturing/work-orders/new?${params.toString()}`;
}
function buildPurchaseRecommendationLink(itemId?: string, vendorId?: string | null) {
const params = new URLSearchParams();
params.set("planningOrderId", activeDocument.id);
if (itemId) {
params.set("itemId", itemId);
}
if (vendorId) {
params.set("vendorId", vendorId);
}
return `/purchasing/orders/new?${params.toString()}`;
}
async function handleStatusChange(nextStatus: SalesDocumentStatus) { async function handleStatusChange(nextStatus: SalesDocumentStatus) {
if (!token) { if (!token) {
return; return;
@@ -431,12 +460,15 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<tr> <tr>
<th className="px-2 py-2">Item</th> <th className="px-2 py-2">Item</th>
<th className="px-2 py-2">Gross</th> <th className="px-2 py-2">Gross</th>
<th className="px-2 py-2">Linked WO</th>
<th className="px-2 py-2">Linked PO</th>
<th className="px-2 py-2">Available</th> <th className="px-2 py-2">Available</th>
<th className="px-2 py-2">Open WO</th> <th className="px-2 py-2">Open WO</th>
<th className="px-2 py-2">Open PO</th> <th className="px-2 py-2">Open PO</th>
<th className="px-2 py-2">Build</th> <th className="px-2 py-2">Build</th>
<th className="px-2 py-2">Buy</th> <th className="px-2 py-2">Buy</th>
<th className="px-2 py-2">Uncovered</th> <th className="px-2 py-2">Uncovered</th>
<th className="px-2 py-2">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-line/70 bg-surface"> <tbody className="divide-y divide-line/70 bg-surface">
@@ -447,17 +479,46 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
<div className="mt-1 text-xs text-muted">{item.itemName}</div> <div className="mt-1 text-xs text-muted">{item.itemName}</div>
</td> </td>
<td className="px-2 py-2 text-muted">{item.grossDemand}</td> <td className="px-2 py-2 text-muted">{item.grossDemand}</td>
<td className="px-2 py-2 text-muted">{item.linkedWorkOrderSupply}</td>
<td className="px-2 py-2 text-muted">{item.linkedPurchaseSupply}</td>
<td className="px-2 py-2 text-muted">{item.availableQuantity}</td> <td className="px-2 py-2 text-muted">{item.availableQuantity}</td>
<td className="px-2 py-2 text-muted">{item.openWorkOrderSupply}</td> <td className="px-2 py-2 text-muted">{item.openWorkOrderSupply}</td>
<td className="px-2 py-2 text-muted">{item.openPurchaseSupply}</td> <td className="px-2 py-2 text-muted">{item.openPurchaseSupply}</td>
<td className="px-2 py-2 text-muted">{item.recommendedBuildQuantity}</td> <td className="px-2 py-2 text-muted">{item.recommendedBuildQuantity}</td>
<td className="px-2 py-2 text-muted">{item.recommendedPurchaseQuantity}</td> <td className="px-2 py-2 text-muted">{item.recommendedPurchaseQuantity}</td>
<td className="px-2 py-2 text-muted">{item.uncoveredQuantity}</td> <td className="px-2 py-2 text-muted">{item.uncoveredQuantity}</td>
<td className="px-2 py-2">
<div className="flex flex-wrap gap-2">
{canManageManufacturing && item.recommendedBuildQuantity > 0 ? (
<Link
to={buildWorkOrderRecommendationLink(item.itemId, item.recommendedBuildQuantity)}
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
>
Draft WO
</Link>
) : null}
{canManagePurchasing && item.recommendedPurchaseQuantity > 0 ? (
<Link
to={buildPurchaseRecommendationLink(item.itemId)}
className="rounded-2xl border border-line/70 px-2 py-1 text-xs font-semibold text-text"
>
Draft PO
</Link>
) : null}
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{canManagePurchasing && planning.summary.purchaseRecommendationCount > 0 ? (
<div className="mt-4 flex justify-end">
<Link to={buildPurchaseRecommendationLink()} className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
Draft purchase order from recommendations
</Link>
</div>
) : null}
<div className="mt-5 space-y-3"> <div className="mt-5 space-y-3">
{planning.lines.map((line) => ( {planning.lines.map((line) => (
<div key={line.lineId} className="rounded-3xl border border-line/70 bg-page/60 p-3"> <div key={line.lineId} className="rounded-3xl border border-line/70 bg-page/60 p-3">

View File

@@ -0,0 +1,13 @@
ALTER TABLE "InventoryItem" ADD COLUMN "preferredVendorId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "WorkOrder" ADD COLUMN "salesOrderLineId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderId" TEXT;
ALTER TABLE "PurchaseOrderLine" ADD COLUMN "salesOrderLineId" TEXT;
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
CREATE INDEX "WorkOrder_salesOrderId_dueDate_idx" ON "WorkOrder"("salesOrderId", "dueDate");
CREATE INDEX "WorkOrder_salesOrderLineId_dueDate_idx" ON "WorkOrder"("salesOrderLineId", "dueDate");
CREATE INDEX "PurchaseOrderLine_salesOrderId_position_idx" ON "PurchaseOrderLine"("salesOrderId", "position");
CREATE INDEX "PurchaseOrderLine_salesOrderLineId_position_idx" ON "PurchaseOrderLine"("salesOrderLineId", "position");

View File

@@ -122,6 +122,7 @@ model InventoryItem {
unitOfMeasure String unitOfMeasure String
isSellable Boolean @default(true) isSellable Boolean @default(true)
isPurchasable Boolean @default(true) isPurchasable Boolean @default(true)
preferredVendorId String?
defaultCost Float? defaultCost Float?
defaultPrice Float? defaultPrice Float?
notes String notes String
@@ -138,6 +139,9 @@ model InventoryItem {
operations InventoryItemOperation[] operations InventoryItemOperation[]
reservations InventoryReservation[] reservations InventoryReservation[]
transfers InventoryTransfer[] transfers InventoryTransfer[]
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
@@index([preferredVendorId])
} }
model Warehouse { model Warehouse {
@@ -325,6 +329,7 @@ model Vendor {
contactEntries CrmContactEntry[] contactEntries CrmContactEntry[]
contacts CrmContact[] contacts CrmContact[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
preferredSupplyItems InventoryItem[]
} }
model CrmContactEntry { model CrmContactEntry {
@@ -417,6 +422,8 @@ model SalesOrder {
shipments Shipment[] shipments Shipment[]
projects Project[] projects Project[]
revisions SalesOrderRevision[] revisions SalesOrderRevision[]
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
} }
model SalesOrderLine { model SalesOrderLine {
@@ -432,6 +439,8 @@ model SalesOrderLine {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade) order SalesOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
@@index([orderId, position]) @@index([orderId, position])
} }
@@ -519,6 +528,8 @@ model WorkOrder {
workOrderNumber String @unique workOrderNumber String @unique
itemId String itemId String
projectId String? projectId String?
salesOrderId String?
salesOrderLineId String?
warehouseId String warehouseId String
locationId String locationId String
status String status String
@@ -530,6 +541,8 @@ model WorkOrder {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict) warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict) location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
operations WorkOrderOperation[] operations WorkOrderOperation[]
@@ -539,6 +552,8 @@ model WorkOrder {
@@index([itemId, createdAt]) @@index([itemId, createdAt])
@@index([projectId, dueDate]) @@index([projectId, dueDate])
@@index([salesOrderId, dueDate])
@@index([salesOrderLineId, dueDate])
@@index([status, dueDate]) @@index([status, dueDate])
@@index([warehouseId, createdAt]) @@index([warehouseId, createdAt])
} }
@@ -650,6 +665,8 @@ model PurchaseOrderLine {
id String @id @default(cuid()) id String @id @default(cuid())
purchaseOrderId String purchaseOrderId String
itemId String itemId String
salesOrderId String?
salesOrderLineId String?
description String description String
quantity Int quantity Int
unitOfMeasure String unitOfMeasure String
@@ -659,9 +676,13 @@ model PurchaseOrderLine {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade) purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict) item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
salesOrder SalesOrder? @relation(fields: [salesOrderId], references: [id], onDelete: SetNull)
salesOrderLine SalesOrderLine? @relation(fields: [salesOrderLineId], references: [id], onDelete: SetNull)
receiptLines PurchaseReceiptLine[] receiptLines PurchaseReceiptLine[]
@@index([purchaseOrderId, position]) @@index([purchaseOrderId, position])
@@index([salesOrderId, position])
@@index([salesOrderLineId, position])
} }
model PurchaseReceipt { model PurchaseReceipt {

View File

@@ -47,6 +47,7 @@ const inventoryItemSchema = z.object({
unitOfMeasure: z.enum(inventoryUnitsOfMeasure), unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
isSellable: z.boolean(), isSellable: z.boolean(),
isPurchasable: z.boolean(), isPurchasable: z.boolean(),
preferredVendorId: z.string().trim().min(1).nullable(),
defaultCost: z.number().nonnegative().nullable(), defaultCost: z.number().nonnegative().nullable(),
defaultPrice: z.number().nonnegative().nullable(), defaultPrice: z.number().nonnegative().nullable(),
notes: z.string(), notes: z.string(),

View File

@@ -65,6 +65,10 @@ type InventoryDetailRecord = {
unitOfMeasure: string; unitOfMeasure: string;
isSellable: boolean; isSellable: boolean;
isPurchasable: boolean; isPurchasable: boolean;
preferredVendor: {
id: string;
name: string;
} | null;
defaultCost: number | null; defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
notes: string; notes: string;
@@ -392,6 +396,8 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
description: record.description, description: record.description,
defaultCost: record.defaultCost, defaultCost: record.defaultCost,
defaultPrice: record.defaultPrice, defaultPrice: record.defaultPrice,
preferredVendorId: record.preferredVendor?.id ?? null,
preferredVendorName: record.preferredVendor?.name ?? null,
notes: record.notes, notes: record.notes,
createdAt: record.createdAt.toISOString(), createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine), bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
@@ -595,6 +601,30 @@ async function validateOperations(type: InventoryItemType, operations: Inventory
return { ok: true as const, operations: normalized }; return { ok: true as const, operations: normalized };
} }
async function validatePreferredVendor(isPurchasable: boolean, preferredVendorId: string | null) {
if (!preferredVendorId) {
return { ok: true as const };
}
if (!isPurchasable) {
return { ok: false as const, reason: "Only purchasable items may define a preferred vendor." };
}
const vendor = await prisma.vendor.findUnique({
where: { id: preferredVendorId },
select: {
id: true,
status: true,
},
});
if (!vendor || vendor.status === "INACTIVE") {
return { ok: false as const, reason: "Preferred vendor was not found or is inactive." };
}
return { ok: true as const };
}
async function getActiveReservedQuantity(itemId: string, warehouseId: string, locationId: string) { async function getActiveReservedQuantity(itemId: string, warehouseId: string, locationId: string) {
const reservations = await prisma.inventoryReservation.findMany({ const reservations = await prisma.inventoryReservation.findMany({
where: { where: {
@@ -639,7 +669,14 @@ export async function listInventoryItemOptions() {
sku: true, sku: true,
name: true, name: true,
isPurchasable: true, isPurchasable: true,
defaultCost: true,
defaultPrice: true, defaultPrice: true,
preferredVendor: {
select: {
id: true,
name: true,
},
},
}, },
orderBy: [{ sku: "asc" }], orderBy: [{ sku: "asc" }],
}); });
@@ -649,7 +686,10 @@ export async function listInventoryItemOptions() {
sku: item.sku, sku: item.sku,
name: item.name, name: item.name,
isPurchasable: item.isPurchasable, isPurchasable: item.isPurchasable,
defaultCost: item.defaultCost,
defaultPrice: item.defaultPrice, defaultPrice: item.defaultPrice,
preferredVendorId: item.preferredVendor?.id ?? null,
preferredVendorName: item.preferredVendor?.name ?? null,
})); }));
} }
@@ -681,6 +721,12 @@ export async function getInventoryItemById(itemId: string) {
}, },
orderBy: [{ position: "asc" }, { createdAt: "asc" }], orderBy: [{ position: "asc" }, { createdAt: "asc" }],
}, },
preferredVendor: {
select: {
id: true,
name: true,
},
},
inventoryTransactions: { inventoryTransactions: {
include: { include: {
warehouse: { warehouse: {
@@ -1027,6 +1073,10 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
if (!validatedOperations.ok) { if (!validatedOperations.ok) {
return null; return null;
} }
const validatedPreferredVendor = await validatePreferredVendor(payload.isPurchasable, payload.preferredVendorId);
if (!validatedPreferredVendor.ok) {
return null;
}
const item = await prisma.inventoryItem.create({ const item = await prisma.inventoryItem.create({
data: { data: {
@@ -1038,6 +1088,7 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
unitOfMeasure: payload.unitOfMeasure, unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable, isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable, isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost, defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice, defaultPrice: payload.defaultPrice,
notes: payload.notes, notes: payload.notes,
@@ -1091,6 +1142,10 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
if (!validatedOperations.ok) { if (!validatedOperations.ok) {
return null; return null;
} }
const validatedPreferredVendor = await validatePreferredVendor(payload.isPurchasable, payload.preferredVendorId);
if (!validatedPreferredVendor.ok) {
return null;
}
const item = await prisma.inventoryItem.update({ const item = await prisma.inventoryItem.update({
where: { id: itemId }, where: { id: itemId },
@@ -1103,6 +1158,7 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
unitOfMeasure: payload.unitOfMeasure, unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable, isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable, isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost, defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice, defaultPrice: payload.defaultPrice,
notes: payload.notes, notes: payload.notes,

View File

@@ -30,6 +30,8 @@ const stationSchema = z.object({
const workOrderSchema = z.object({ const workOrderSchema = z.object({
itemId: z.string().trim().min(1), itemId: z.string().trim().min(1),
projectId: z.string().trim().min(1).nullable(), projectId: z.string().trim().min(1).nullable(),
salesOrderId: z.string().trim().min(1).nullable(),
salesOrderLineId: z.string().trim().min(1).nullable(),
status: z.enum(workOrderStatuses), status: z.enum(workOrderStatuses),
quantity: z.number().int().positive(), quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1), warehouseId: z.string().trim().min(1),

View File

@@ -75,6 +75,13 @@ type WorkOrderRecord = {
name: string; name: string;
}; };
} | null; } | null;
salesOrder: {
id: string;
documentNumber: string;
} | null;
salesOrderLine: {
id: string;
} | null;
warehouse: { warehouse: {
id: string; id: string;
code: string; code: string;
@@ -191,6 +198,17 @@ function buildInclude() {
}, },
}, },
}, },
salesOrder: {
select: {
id: true,
documentNumber: true,
},
},
salesOrderLine: {
select: {
id: true,
},
},
warehouse: { warehouse: {
select: { select: {
id: true, id: true,
@@ -278,6 +296,9 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
projectId: record.project?.id ?? null, projectId: record.project?.id ?? null,
projectNumber: record.project?.projectNumber ?? null, projectNumber: record.project?.projectNumber ?? null,
projectName: record.project?.name ?? null, projectName: record.project?.name ?? null,
salesOrderId: record.salesOrder?.id ?? null,
salesOrderLineId: record.salesOrderLine?.id ?? null,
salesOrderNumber: record.salesOrder?.documentNumber ?? null,
quantity: record.quantity, quantity: record.quantity,
completedQuantity: record.completedQuantity, completedQuantity: record.completedQuantity,
dueDate: record.dueDate ? record.dueDate.toISOString() : null, dueDate: record.dueDate ? record.dueDate.toISOString() : null,
@@ -293,7 +314,10 @@ function mapSummary(record: WorkOrderRecord): WorkOrderSummaryDto {
}; };
} }
function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto { function mapDetail(
record: WorkOrderRecord,
componentAvailability: Map<string, { onHandQuantity: number; reservedQuantity: number }>
): WorkOrderDetailDto {
const issuedByComponent = new Map<string, number>(); const issuedByComponent = new Map<string, number>();
for (const issue of record.materialIssues) { for (const issue of record.materialIssues) {
@@ -325,6 +349,11 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
materialRequirements: record.item.bomLines.map((line) => { materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity; const requiredQuantity = line.quantity * record.quantity;
const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0; const issuedQuantity = issuedByComponent.get(line.componentItem.id) ?? 0;
const availability = componentAvailability.get(line.componentItem.id) ?? { onHandQuantity: 0, reservedQuantity: 0 };
const onHandQuantity = availability.onHandQuantity;
const reservedQuantity = availability.reservedQuantity;
const availableQuantity = onHandQuantity - reservedQuantity;
const shortageQuantity = Math.max(requiredQuantity - issuedQuantity - Math.max(availableQuantity, 0), 0);
return { return {
componentItemId: line.componentItem.id, componentItemId: line.componentItem.id,
@@ -335,6 +364,10 @@ function mapDetail(record: WorkOrderRecord): WorkOrderDetailDto {
requiredQuantity, requiredQuantity,
issuedQuantity, issuedQuantity,
remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0), remainingQuantity: Math.max(requiredQuantity - issuedQuantity, 0),
onHandQuantity,
reservedQuantity,
availableQuantity,
shortageQuantity,
}; };
}), }),
materialIssues: record.materialIssues.map((issue) => ({ materialIssues: record.materialIssues.map((issue) => ({
@@ -531,6 +564,63 @@ async function getItemLocationOnHand(itemId: string, warehouseId: string, locati
}, 0); }, 0);
} }
async function getComponentAvailability(workOrder: WorkOrderRecord) {
const componentItemIds = [...new Set(workOrder.item.bomLines.map((line) => line.componentItem.id))];
if (componentItemIds.length === 0) {
return new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
}
const [transactions, reservations] = await Promise.all([
prisma.inventoryTransaction.findMany({
where: {
itemId: { in: componentItemIds },
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
},
select: {
itemId: true,
transactionType: true,
quantity: true,
},
}),
prisma.inventoryReservation.findMany({
where: {
itemId: { in: componentItemIds },
warehouseId: workOrder.warehouse.id,
locationId: workOrder.location.id,
status: "ACTIVE",
},
select: {
itemId: true,
quantity: true,
},
}),
]);
const availability = new Map<string, { onHandQuantity: number; reservedQuantity: number }>();
for (const itemId of componentItemIds) {
availability.set(itemId, { onHandQuantity: 0, reservedQuantity: 0 });
}
for (const transaction of transactions) {
const current = availability.get(transaction.itemId);
if (!current) {
continue;
}
current.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
}
for (const reservation of reservations) {
const current = availability.get(reservation.itemId);
if (!current) {
continue;
}
current.reservedQuantity += reservation.quantity;
}
return availability;
}
async function validateWorkOrderInput(payload: WorkOrderInput) { async function validateWorkOrderInput(payload: WorkOrderInput) {
const item = await prisma.inventoryItem.findUnique({ const item = await prisma.inventoryItem.findUnique({
where: { id: payload.itemId }, where: { id: payload.itemId },
@@ -573,6 +663,40 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
} }
} }
if (payload.salesOrderId) {
const salesOrder = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
select: { id: true },
});
if (!salesOrder) {
return { ok: false as const, reason: "Linked sales order was not found." };
}
}
if (payload.salesOrderLineId) {
if (!payload.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line requires a linked sales order." };
}
const salesOrderLine = await prisma.salesOrderLine.findUnique({
where: { id: payload.salesOrderLineId },
select: {
id: true,
orderId: true,
itemId: true,
},
});
if (!salesOrderLine || salesOrderLine.orderId !== payload.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line was not found on the selected sales order." };
}
if (salesOrderLine.itemId !== payload.itemId) {
return { ok: false as const, reason: "Linked sales-order line item does not match the selected build item." };
}
}
const location = await prisma.warehouseLocation.findUnique({ const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId }, where: { id: payload.locationId },
select: { select: {
@@ -727,7 +851,11 @@ export async function getWorkOrderById(workOrderId: string) {
include: buildInclude(), include: buildInclude(),
}); });
return workOrder ? mapDetail(workOrder as WorkOrderRecord) : null; if (!workOrder) {
return null;
}
return mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
} }
export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) { export async function createWorkOrder(payload: WorkOrderInput, actorId?: string | null) {
@@ -742,6 +870,8 @@ export async function createWorkOrder(payload: WorkOrderInput, actorId?: string
workOrderNumber, workOrderNumber,
itemId: payload.itemId, itemId: payload.itemId,
projectId: payload.projectId, projectId: payload.projectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId, warehouseId: payload.warehouseId,
locationId: payload.locationId, locationId: payload.locationId,
status: payload.status, status: payload.status,
@@ -804,6 +934,8 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
data: { data: {
itemId: payload.itemId, itemId: payload.itemId,
projectId: payload.projectId, projectId: payload.projectId,
salesOrderId: payload.salesOrderId,
salesOrderLineId: payload.salesOrderLineId,
warehouseId: payload.warehouseId, warehouseId: payload.warehouseId,
locationId: payload.locationId, locationId: payload.locationId,
status: payload.status, status: payload.status,
@@ -916,7 +1048,7 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." }; return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
} }
const currentDetail = mapDetail(workOrder as WorkOrderRecord); const currentDetail = mapDetail(workOrder as WorkOrderRecord, await getComponentAvailability(workOrder as WorkOrderRecord));
const currentRequirement = currentDetail.materialRequirements.find( const currentRequirement = currentDetail.materialRequirements.find(
(requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId (requirement: WorkOrderDetailDto["materialRequirements"][number]) => requirement.componentItemId === payload.componentItemId
); );

View File

@@ -17,6 +17,8 @@ import {
const purchaseLineSchema = z.object({ const purchaseLineSchema = z.object({
itemId: z.string().trim().min(1), itemId: z.string().trim().min(1),
salesOrderId: z.string().trim().min(1).nullable().optional(),
salesOrderLineId: z.string().trim().min(1).nullable().optional(),
description: z.string(), description: z.string(),
quantity: z.number().int().positive(), quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure), unitOfMeasure: z.enum(inventoryUnitsOfMeasure),

View File

@@ -45,6 +45,8 @@ export interface PurchaseOrderPdfData {
type PurchaseLineRecord = { type PurchaseLineRecord = {
id: string; id: string;
salesOrderId: string | null;
salesOrderLineId: string | null;
description: string; description: string;
quantity: number; quantity: number;
unitOfMeasure: string; unitOfMeasure: string;
@@ -58,6 +60,10 @@ type PurchaseLineRecord = {
sku: string; sku: string;
name: string; name: string;
}; };
salesOrder: {
id: string;
documentNumber: string;
} | null;
}; };
type PurchaseReceiptLineRecord = { type PurchaseReceiptLineRecord = {
@@ -175,6 +181,8 @@ function normalizeLines(lines: PurchaseLineInput[]) {
return lines return lines
.map((line, index) => ({ .map((line, index) => ({
itemId: line.itemId, itemId: line.itemId,
salesOrderId: line.salesOrderId ?? null,
salesOrderLineId: line.salesOrderLineId ?? null,
description: line.description.trim(), description: line.description.trim(),
quantity: Number(line.quantity), quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure, unitOfMeasure: line.unitOfMeasure,
@@ -213,6 +221,63 @@ async function validateLines(lines: PurchaseLineInput[]) {
return { ok: false as const, reason: "Purchase orders can only include purchasable inventory items." }; return { ok: false as const, reason: "Purchase orders can only include purchasable inventory items." };
} }
const salesOrderIds = [...new Set(normalized.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
const salesOrderLineIds = [...new Set(normalized.flatMap((line) => (line.salesOrderLineId ? [line.salesOrderLineId] : [])))];
if (normalized.some((line) => line.salesOrderLineId && !line.salesOrderId)) {
return { ok: false as const, reason: "Linked sales-order lines require a linked sales order." };
}
if (salesOrderIds.length > 0) {
const salesOrders = await prisma.salesOrder.findMany({
where: { id: { in: salesOrderIds } },
select: { id: true },
});
if (salesOrders.length !== salesOrderIds.length) {
return { ok: false as const, reason: "One or more linked sales orders do not exist." };
}
}
if (salesOrderLineIds.length > 0) {
const salesOrderLines = await prisma.salesOrderLine.findMany({
where: { id: { in: salesOrderLineIds } },
select: {
id: true,
orderId: true,
itemId: true,
},
});
const salesOrderLinesById = new Map(
salesOrderLines.map((line) => [
line.id,
{
orderId: line.orderId,
itemId: line.itemId,
},
])
);
if (salesOrderLines.length !== salesOrderLineIds.length) {
return { ok: false as const, reason: "One or more linked sales-order lines do not exist." };
}
for (const line of normalized) {
if (!line.salesOrderLineId) {
continue;
}
const salesOrderLine = salesOrderLinesById.get(line.salesOrderLineId);
if (!salesOrderLine || salesOrderLine.orderId !== line.salesOrderId) {
return { ok: false as const, reason: "Linked sales-order line does not belong to the selected sales order." };
}
if (salesOrderLine.itemId !== line.itemId) {
return { ok: false as const, reason: "Linked sales-order line item does not match the purchase item." };
}
}
}
return { ok: true as const, lines: normalized }; return { ok: true as const, lines: normalized };
} }
@@ -240,6 +305,9 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
lineTotal: line.quantity * line.unitCost, lineTotal: line.quantity * line.unitCost,
receivedQuantity: receivedByLineId.get(line.id) ?? 0, receivedQuantity: receivedByLineId.get(line.id) ?? 0,
remainingQuantity: Math.max(0, line.quantity - (receivedByLineId.get(line.id) ?? 0)), remainingQuantity: Math.max(0, line.quantity - (receivedByLineId.get(line.id) ?? 0)),
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
salesOrderNumber: line.salesOrder?.documentNumber ?? null,
position: line.position, position: line.position,
})); }));
const totals = calculateTotals( const totals = calculateTotals(
@@ -305,6 +373,12 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
name: true, name: true,
}, },
}, },
salesOrder: {
select: {
id: true,
documentNumber: true,
},
},
}, },
orderBy: [{ position: "asc" }, { createdAt: "asc" }], orderBy: [{ position: "asc" }, { createdAt: "asc" }],
}, },

View File

@@ -1,4 +1,11 @@
import type { SalesDocumentStatus, SalesOrderPlanningDto, SalesOrderPlanningItemDto, SalesOrderPlanningNodeDto } from "@mrp/shared/dist/sales/types.js"; import type {
DemandPlanningProjectSummaryDto,
DemandPlanningRollupDto,
SalesDocumentStatus,
SalesOrderPlanningDto,
SalesOrderPlanningItemDto,
SalesOrderPlanningNodeDto,
} from "@mrp/shared/dist/sales/types.js";
import { prisma } from "../../lib/prisma.js"; import { prisma } from "../../lib/prisma.js";
type PlanningItemSnapshot = { type PlanningItemSnapshot = {
@@ -32,6 +39,11 @@ type PlanningSupplySnapshot = {
openPurchaseSupply: number; openPurchaseSupply: number;
}; };
type PlanningLinkedSupplySnapshot = {
linkedWorkOrderSupply: number;
linkedPurchaseSupply: number;
};
export type SalesOrderPlanningSnapshot = { export type SalesOrderPlanningSnapshot = {
orderId: string; orderId: string;
documentNumber: string; documentNumber: string;
@@ -39,6 +51,23 @@ export type SalesOrderPlanningSnapshot = {
lines: PlanningLineSnapshot[]; lines: PlanningLineSnapshot[];
itemsById: Record<string, PlanningItemSnapshot>; itemsById: Record<string, PlanningItemSnapshot>;
supplyByItemId: Record<string, PlanningSupplySnapshot>; supplyByItemId: Record<string, PlanningSupplySnapshot>;
orderLinkedSupplyByItemId: Record<string, PlanningLinkedSupplySnapshot>;
lineLinkedSupplyByLineId: Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
};
type DemandPlanningOrderSnapshot = {
orderId: string;
documentNumber: string;
status: SalesDocumentStatus;
issueDate: string;
projectSummaries: Array<{
projectId: string;
projectNumber: string;
projectName: string;
}>;
lines: PlanningLineSnapshot[];
orderLinkedSupplyByItemId: Record<string, PlanningLinkedSupplySnapshot>;
lineLinkedSupplyByLineId: Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
}; };
type MutableSupplyState = { type MutableSupplyState = {
@@ -47,6 +76,11 @@ type MutableSupplyState = {
remainingOpenPurchaseSupply: number; remainingOpenPurchaseSupply: number;
}; };
type MutableLinkedSupplyState = {
remainingLinkedWorkOrderSupply: number;
remainingLinkedPurchaseSupply: number;
};
function createEmptySupplySnapshot(): PlanningSupplySnapshot { function createEmptySupplySnapshot(): PlanningSupplySnapshot {
return { return {
onHandQuantity: 0, onHandQuantity: 0,
@@ -57,6 +91,13 @@ function createEmptySupplySnapshot(): PlanningSupplySnapshot {
}; };
} }
function createEmptyLinkedSupplySnapshot(): PlanningLinkedSupplySnapshot {
return {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
};
}
function createMutableSupplyState(snapshot: PlanningSupplySnapshot): MutableSupplyState { function createMutableSupplyState(snapshot: PlanningSupplySnapshot): MutableSupplyState {
return { return {
remainingAvailableQuantity: Math.max(snapshot.availableQuantity, 0), remainingAvailableQuantity: Math.max(snapshot.availableQuantity, 0),
@@ -65,6 +106,13 @@ function createMutableSupplyState(snapshot: PlanningSupplySnapshot): MutableSupp
}; };
} }
function createMutableLinkedSupplyState(snapshot: PlanningLinkedSupplySnapshot): MutableLinkedSupplyState {
return {
remainingLinkedWorkOrderSupply: Math.max(snapshot.linkedWorkOrderSupply, 0),
remainingLinkedPurchaseSupply: Math.max(snapshot.linkedPurchaseSupply, 0),
};
}
function isBuildItem(type: string) { function isBuildItem(type: string) {
return type === "ASSEMBLY" || type === "MANUFACTURED"; return type === "ASSEMBLY" || type === "MANUFACTURED";
} }
@@ -75,6 +123,10 @@ function shouldBuyItem(item: PlanningItemSnapshot) {
export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): SalesOrderPlanningDto { export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): SalesOrderPlanningDto {
const mutableSupplyByItemId = new Map<string, MutableSupplyState>(); const mutableSupplyByItemId = new Map<string, MutableSupplyState>();
const mutableOrderLinkedSupplyByItemId = new Map(
Object.entries(snapshot.orderLinkedSupplyByItemId).map(([itemId, supply]) => [itemId, createMutableLinkedSupplyState(supply)])
);
const mutableLineLinkedSupplyByLineId = new Map<string, Map<string, MutableLinkedSupplyState>>();
const aggregatedByItemId = new Map<string, SalesOrderPlanningItemDto>(); const aggregatedByItemId = new Map<string, SalesOrderPlanningItemDto>();
function getMutableSupply(itemId: string) { function getMutableSupply(itemId: string) {
@@ -105,6 +157,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
onHandQuantity: supply.onHandQuantity, onHandQuantity: supply.onHandQuantity,
reservedQuantity: supply.reservedQuantity, reservedQuantity: supply.reservedQuantity,
availableQuantity: supply.availableQuantity, availableQuantity: supply.availableQuantity,
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
openWorkOrderSupply: supply.openWorkOrderSupply, openWorkOrderSupply: supply.openWorkOrderSupply,
openPurchaseSupply: supply.openPurchaseSupply, openPurchaseSupply: supply.openPurchaseSupply,
supplyFromStock: 0, supplyFromStock: 0,
@@ -118,7 +172,43 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
return created; return created;
} }
function planItemDemand(itemId: string, grossDemand: number, level: number, bomQuantityPerParent: number | null, ancestry: Set<string>): SalesOrderPlanningNodeDto { function getMutableOrderLinkedSupply(itemId: string) {
const existing = mutableOrderLinkedSupplyByItemId.get(itemId);
if (existing) {
return existing;
}
const next = createMutableLinkedSupplyState(createEmptyLinkedSupplySnapshot());
mutableOrderLinkedSupplyByItemId.set(itemId, next);
return next;
}
function getMutableLineLinkedSupply(lineId: string, itemId: string) {
const existingLine = mutableLineLinkedSupplyByLineId.get(lineId);
const existingItem = existingLine?.get(itemId);
if (existingItem) {
return existingItem;
}
const next = createMutableLinkedSupplyState(
snapshot.lineLinkedSupplyByLineId[lineId]?.[itemId] ?? createEmptyLinkedSupplySnapshot()
);
if (existingLine) {
existingLine.set(itemId, next);
} else {
mutableLineLinkedSupplyByLineId.set(lineId, new Map([[itemId, next]]));
}
return next;
}
function planItemDemand(
lineId: string,
itemId: string,
grossDemand: number,
level: number,
bomQuantityPerParent: number | null,
ancestry: Set<string>
): SalesOrderPlanningNodeDto {
const item = snapshot.itemsById[itemId]; const item = snapshot.itemsById[itemId];
if (!item) { if (!item) {
return { return {
@@ -131,6 +221,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
grossDemand, grossDemand,
availableBefore: 0, availableBefore: 0,
availableAfter: 0, availableAfter: 0,
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
supplyFromStock: 0, supplyFromStock: 0,
openWorkOrderSupply: 0, openWorkOrderSupply: 0,
openPurchaseSupply: 0, openPurchaseSupply: 0,
@@ -145,12 +237,37 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
} }
const aggregate = getOrCreateAggregate(item); const aggregate = getOrCreateAggregate(item);
const mutableLineLinkedSupply = getMutableLineLinkedSupply(lineId, itemId);
const mutableOrderLinkedSupply = getMutableOrderLinkedSupply(itemId);
const mutableSupply = getMutableSupply(itemId); const mutableSupply = getMutableSupply(itemId);
const availableBefore = mutableSupply.remainingAvailableQuantity; const availableBefore = mutableSupply.remainingAvailableQuantity;
const openWorkOrderSupply = mutableSupply.remainingOpenWorkOrderSupply; const openWorkOrderSupply = mutableSupply.remainingOpenWorkOrderSupply;
const openPurchaseSupply = mutableSupply.remainingOpenPurchaseSupply; const openPurchaseSupply = mutableSupply.remainingOpenPurchaseSupply;
let remainingDemand = grossDemand; let remainingDemand = grossDemand;
let linkedWorkOrderSupply = 0;
let linkedPurchaseSupply = 0;
if (isBuildItem(item.type)) {
linkedWorkOrderSupply = Math.min(mutableLineLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedWorkOrderSupply -= linkedWorkOrderSupply;
remainingDemand -= linkedWorkOrderSupply;
const orderLinkedWorkOrderSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply -= orderLinkedWorkOrderSupply;
linkedWorkOrderSupply += orderLinkedWorkOrderSupply;
remainingDemand -= orderLinkedWorkOrderSupply;
} else if (shouldBuyItem(item)) {
linkedPurchaseSupply = Math.min(mutableLineLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedPurchaseSupply -= linkedPurchaseSupply;
remainingDemand -= linkedPurchaseSupply;
const orderLinkedPurchaseSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedPurchaseSupply -= orderLinkedPurchaseSupply;
linkedPurchaseSupply += orderLinkedPurchaseSupply;
remainingDemand -= orderLinkedPurchaseSupply;
}
const supplyFromStock = Math.min(availableBefore, remainingDemand); const supplyFromStock = Math.min(availableBefore, remainingDemand);
mutableSupply.remainingAvailableQuantity -= supplyFromStock; mutableSupply.remainingAvailableQuantity -= supplyFromStock;
remainingDemand -= supplyFromStock; remainingDemand -= supplyFromStock;
@@ -176,6 +293,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
} }
aggregate.grossDemand += grossDemand; aggregate.grossDemand += grossDemand;
aggregate.linkedWorkOrderSupply += linkedWorkOrderSupply;
aggregate.linkedPurchaseSupply += linkedPurchaseSupply;
aggregate.supplyFromStock += supplyFromStock; aggregate.supplyFromStock += supplyFromStock;
aggregate.supplyFromOpenWorkOrders += supplyFromOpenWorkOrders; aggregate.supplyFromOpenWorkOrders += supplyFromOpenWorkOrders;
aggregate.supplyFromOpenPurchaseOrders += supplyFromOpenPurchaseOrders; aggregate.supplyFromOpenPurchaseOrders += supplyFromOpenPurchaseOrders;
@@ -190,7 +309,7 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
for (const bomLine of item.bomLines) { for (const bomLine of item.bomLines) {
children.push( children.push(
planItemDemand(bomLine.componentItemId, recommendedBuildQuantity * bomLine.quantity, level + 1, bomLine.quantity, nextAncestry) planItemDemand(lineId, bomLine.componentItemId, recommendedBuildQuantity * bomLine.quantity, level + 1, bomLine.quantity, nextAncestry)
); );
} }
} }
@@ -205,6 +324,8 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
grossDemand, grossDemand,
availableBefore, availableBefore,
availableAfter: mutableSupply.remainingAvailableQuantity, availableAfter: mutableSupply.remainingAvailableQuantity,
linkedWorkOrderSupply,
linkedPurchaseSupply,
supplyFromStock, supplyFromStock,
openWorkOrderSupply, openWorkOrderSupply,
openPurchaseSupply, openPurchaseSupply,
@@ -225,7 +346,7 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
itemName: line.itemName, itemName: line.itemName,
quantity: line.quantity, quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure as SalesOrderPlanningDto["lines"][number]["unitOfMeasure"], unitOfMeasure: line.unitOfMeasure as SalesOrderPlanningDto["lines"][number]["unitOfMeasure"],
rootNode: planItemDemand(line.itemId, line.quantity, 0, null, new Set<string>()), rootNode: planItemDemand(line.id, line.itemId, line.quantity, 0, null, new Set<string>()),
})); }));
const items = [...aggregatedByItemId.values()].sort((left, right) => left.itemSku.localeCompare(right.itemSku)); const items = [...aggregatedByItemId.values()].sort((left, right) => left.itemSku.localeCompare(right.itemSku));
@@ -250,6 +371,353 @@ export function buildSalesOrderPlanning(snapshot: SalesOrderPlanningSnapshot): S
}; };
} }
function createAggregateFromNode(node: SalesOrderPlanningNodeDto): SalesOrderPlanningItemDto[] {
const grouped = new Map<string, SalesOrderPlanningItemDto>();
function visit(current: SalesOrderPlanningNodeDto) {
const existing = grouped.get(current.itemId);
if (existing) {
existing.grossDemand += current.grossDemand;
existing.linkedWorkOrderSupply += current.linkedWorkOrderSupply;
existing.linkedPurchaseSupply += current.linkedPurchaseSupply;
existing.supplyFromStock += current.supplyFromStock;
existing.supplyFromOpenWorkOrders += current.supplyFromOpenWorkOrders;
existing.supplyFromOpenPurchaseOrders += current.supplyFromOpenPurchaseOrders;
existing.recommendedBuildQuantity += current.recommendedBuildQuantity;
existing.recommendedPurchaseQuantity += current.recommendedPurchaseQuantity;
existing.uncoveredQuantity += current.uncoveredQuantity;
} else {
grouped.set(current.itemId, {
itemId: current.itemId,
itemSku: current.itemSku,
itemName: current.itemName,
itemType: current.itemType,
unitOfMeasure: current.unitOfMeasure,
grossDemand: current.grossDemand,
onHandQuantity: 0,
reservedQuantity: 0,
availableQuantity: 0,
linkedWorkOrderSupply: current.linkedWorkOrderSupply,
linkedPurchaseSupply: current.linkedPurchaseSupply,
openWorkOrderSupply: current.openWorkOrderSupply,
openPurchaseSupply: current.openPurchaseSupply,
supplyFromStock: current.supplyFromStock,
supplyFromOpenWorkOrders: current.supplyFromOpenWorkOrders,
supplyFromOpenPurchaseOrders: current.supplyFromOpenPurchaseOrders,
recommendedBuildQuantity: current.recommendedBuildQuantity,
recommendedPurchaseQuantity: current.recommendedPurchaseQuantity,
uncoveredQuantity: current.uncoveredQuantity,
});
}
for (const child of current.children) {
visit(child);
}
}
visit(node);
return [...grouped.values()];
}
export function buildDemandPlanningRollup(
orders: DemandPlanningOrderSnapshot[],
itemsById: Record<string, PlanningItemSnapshot>,
supplyByItemId: Record<string, PlanningSupplySnapshot>
): DemandPlanningRollupDto {
const mutableSupplyByItemId = new Map<string, MutableSupplyState>();
const itemRollups = new Map<string, SalesOrderPlanningItemDto>();
const projectRollups = new Map<string, DemandPlanningProjectSummaryDto>();
function getMutableSupply(itemId: string) {
const existing = mutableSupplyByItemId.get(itemId);
if (existing) {
return existing;
}
const next = createMutableSupplyState(supplyByItemId[itemId] ?? createEmptySupplySnapshot());
mutableSupplyByItemId.set(itemId, next);
return next;
}
function planItemDemand(
itemId: string,
grossDemand: number,
level: number,
bomQuantityPerParent: number | null,
ancestry: Set<string>,
mutableOrderLinkedSupplyByItemId: Map<string, MutableLinkedSupplyState>,
mutableLineLinkedSupplyByLineId: Map<string, Map<string, MutableLinkedSupplyState>>,
lineId: string
): SalesOrderPlanningNodeDto {
const item = itemsById[itemId];
if (!item) {
return {
itemId,
itemSku: "UNKNOWN",
itemName: "Missing item",
itemType: "UNKNOWN",
unitOfMeasure: "EA",
level,
grossDemand,
availableBefore: 0,
availableAfter: 0,
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
supplyFromStock: 0,
openWorkOrderSupply: 0,
openPurchaseSupply: 0,
supplyFromOpenWorkOrders: 0,
supplyFromOpenPurchaseOrders: 0,
recommendedBuildQuantity: 0,
recommendedPurchaseQuantity: 0,
uncoveredQuantity: grossDemand,
bomQuantityPerParent,
children: [],
};
}
const lineSupplyMap = mutableLineLinkedSupplyByLineId.get(lineId);
const mutableLineLinkedSupply =
lineSupplyMap?.get(itemId) ?? createMutableLinkedSupplyState(createEmptyLinkedSupplySnapshot());
if (lineSupplyMap) {
if (!lineSupplyMap.has(itemId)) {
lineSupplyMap.set(itemId, mutableLineLinkedSupply);
}
} else {
mutableLineLinkedSupplyByLineId.set(lineId, new Map([[itemId, mutableLineLinkedSupply]]));
}
const mutableOrderLinkedSupply =
mutableOrderLinkedSupplyByItemId.get(itemId) ?? createMutableLinkedSupplyState(createEmptyLinkedSupplySnapshot());
if (!mutableOrderLinkedSupplyByItemId.has(itemId)) {
mutableOrderLinkedSupplyByItemId.set(itemId, mutableOrderLinkedSupply);
}
const mutableSupply = getMutableSupply(itemId);
const availableBefore = mutableSupply.remainingAvailableQuantity;
const openWorkOrderSupply = mutableSupply.remainingOpenWorkOrderSupply;
const openPurchaseSupply = mutableSupply.remainingOpenPurchaseSupply;
let remainingDemand = grossDemand;
let linkedWorkOrderSupply = 0;
let linkedPurchaseSupply = 0;
if (isBuildItem(item.type)) {
linkedWorkOrderSupply = Math.min(mutableLineLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedWorkOrderSupply -= linkedWorkOrderSupply;
remainingDemand -= linkedWorkOrderSupply;
const orderLinkedWorkOrderSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedWorkOrderSupply -= orderLinkedWorkOrderSupply;
linkedWorkOrderSupply += orderLinkedWorkOrderSupply;
remainingDemand -= orderLinkedWorkOrderSupply;
} else if (shouldBuyItem(item)) {
linkedPurchaseSupply = Math.min(mutableLineLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableLineLinkedSupply.remainingLinkedPurchaseSupply -= linkedPurchaseSupply;
remainingDemand -= linkedPurchaseSupply;
const orderLinkedPurchaseSupply = Math.min(mutableOrderLinkedSupply.remainingLinkedPurchaseSupply, remainingDemand);
mutableOrderLinkedSupply.remainingLinkedPurchaseSupply -= orderLinkedPurchaseSupply;
linkedPurchaseSupply += orderLinkedPurchaseSupply;
remainingDemand -= orderLinkedPurchaseSupply;
}
const supplyFromStock = Math.min(availableBefore, remainingDemand);
mutableSupply.remainingAvailableQuantity -= supplyFromStock;
remainingDemand -= supplyFromStock;
let supplyFromOpenWorkOrders = 0;
let supplyFromOpenPurchaseOrders = 0;
let recommendedBuildQuantity = 0;
let recommendedPurchaseQuantity = 0;
let uncoveredQuantity = 0;
if (isBuildItem(item.type)) {
supplyFromOpenWorkOrders = Math.min(mutableSupply.remainingOpenWorkOrderSupply, remainingDemand);
mutableSupply.remainingOpenWorkOrderSupply -= supplyFromOpenWorkOrders;
remainingDemand -= supplyFromOpenWorkOrders;
recommendedBuildQuantity = remainingDemand;
} else if (shouldBuyItem(item)) {
supplyFromOpenPurchaseOrders = Math.min(mutableSupply.remainingOpenPurchaseSupply, remainingDemand);
mutableSupply.remainingOpenPurchaseSupply -= supplyFromOpenPurchaseOrders;
remainingDemand -= supplyFromOpenPurchaseOrders;
recommendedPurchaseQuantity = remainingDemand;
} else {
uncoveredQuantity = remainingDemand;
}
const children: SalesOrderPlanningNodeDto[] = [];
if (recommendedBuildQuantity > 0 && item.bomLines.length > 0 && !ancestry.has(item.id)) {
const nextAncestry = new Set(ancestry);
nextAncestry.add(item.id);
for (const bomLine of item.bomLines) {
children.push(
planItemDemand(
bomLine.componentItemId,
recommendedBuildQuantity * bomLine.quantity,
level + 1,
bomLine.quantity,
nextAncestry,
mutableOrderLinkedSupplyByItemId,
mutableLineLinkedSupplyByLineId,
lineId
)
);
}
}
return {
itemId: item.id,
itemSku: item.sku,
itemName: item.name,
itemType: item.type,
unitOfMeasure: item.unitOfMeasure as SalesOrderPlanningNodeDto["unitOfMeasure"],
level,
grossDemand,
availableBefore,
availableAfter: mutableSupply.remainingAvailableQuantity,
linkedWorkOrderSupply,
linkedPurchaseSupply,
supplyFromStock,
openWorkOrderSupply,
openPurchaseSupply,
supplyFromOpenWorkOrders,
supplyFromOpenPurchaseOrders,
recommendedBuildQuantity,
recommendedPurchaseQuantity,
uncoveredQuantity,
bomQuantityPerParent,
children,
};
}
for (const order of orders.slice().sort((left, right) => new Date(left.issueDate).getTime() - new Date(right.issueDate).getTime())) {
const orderItems = new Map<string, SalesOrderPlanningItemDto>();
const mutableOrderLinkedSupplyByItemId = new Map(
Object.entries(order.orderLinkedSupplyByItemId).map(([itemId, supply]) => [itemId, createMutableLinkedSupplyState(supply)])
);
const mutableLineLinkedSupplyByLineId = new Map(
Object.entries(order.lineLinkedSupplyByLineId).map(([lineId, items]) => [
lineId,
new Map(Object.entries(items).map(([itemId, supply]) => [itemId, createMutableLinkedSupplyState(supply)])),
])
);
for (const line of order.lines) {
const rootNode = planItemDemand(
line.itemId,
line.quantity,
0,
null,
new Set<string>(),
mutableOrderLinkedSupplyByItemId,
mutableLineLinkedSupplyByLineId,
line.id
);
for (const aggregate of createAggregateFromNode(rootNode)) {
const existingItem = itemRollups.get(aggregate.itemId);
const supply = supplyByItemId[aggregate.itemId] ?? createEmptySupplySnapshot();
if (existingItem) {
existingItem.grossDemand += aggregate.grossDemand;
existingItem.linkedWorkOrderSupply += aggregate.linkedWorkOrderSupply;
existingItem.linkedPurchaseSupply += aggregate.linkedPurchaseSupply;
existingItem.supplyFromStock += aggregate.supplyFromStock;
existingItem.supplyFromOpenWorkOrders += aggregate.supplyFromOpenWorkOrders;
existingItem.supplyFromOpenPurchaseOrders += aggregate.supplyFromOpenPurchaseOrders;
existingItem.recommendedBuildQuantity += aggregate.recommendedBuildQuantity;
existingItem.recommendedPurchaseQuantity += aggregate.recommendedPurchaseQuantity;
existingItem.uncoveredQuantity += aggregate.uncoveredQuantity;
} else {
itemRollups.set(aggregate.itemId, {
...aggregate,
onHandQuantity: supply.onHandQuantity,
reservedQuantity: supply.reservedQuantity,
availableQuantity: supply.availableQuantity,
openWorkOrderSupply: supply.openWorkOrderSupply,
openPurchaseSupply: supply.openPurchaseSupply,
});
}
const existingOrderItem = orderItems.get(aggregate.itemId);
if (existingOrderItem) {
existingOrderItem.grossDemand += aggregate.grossDemand;
existingOrderItem.linkedWorkOrderSupply += aggregate.linkedWorkOrderSupply;
existingOrderItem.linkedPurchaseSupply += aggregate.linkedPurchaseSupply;
existingOrderItem.supplyFromStock += aggregate.supplyFromStock;
existingOrderItem.supplyFromOpenWorkOrders += aggregate.supplyFromOpenWorkOrders;
existingOrderItem.supplyFromOpenPurchaseOrders += aggregate.supplyFromOpenPurchaseOrders;
existingOrderItem.recommendedBuildQuantity += aggregate.recommendedBuildQuantity;
existingOrderItem.recommendedPurchaseQuantity += aggregate.recommendedPurchaseQuantity;
existingOrderItem.uncoveredQuantity += aggregate.uncoveredQuantity;
} else {
orderItems.set(aggregate.itemId, { ...aggregate });
}
}
}
for (const project of order.projectSummaries) {
const existingProject = projectRollups.get(project.projectId);
const metrics = [...orderItems.values()];
const nextProject: DemandPlanningProjectSummaryDto = existingProject ?? {
projectId: project.projectId,
projectNumber: project.projectNumber,
projectName: project.projectName,
salesOrderId: order.orderId,
salesOrderNumber: order.documentNumber,
totalBuildQuantity: 0,
totalPurchaseQuantity: 0,
totalUncoveredQuantity: 0,
buildRecommendationCount: 0,
purchaseRecommendationCount: 0,
uncoveredItemCount: 0,
};
nextProject.totalBuildQuantity += metrics.reduce((sum, item) => sum + item.recommendedBuildQuantity, 0);
nextProject.totalPurchaseQuantity += metrics.reduce((sum, item) => sum + item.recommendedPurchaseQuantity, 0);
nextProject.totalUncoveredQuantity += metrics.reduce((sum, item) => sum + item.uncoveredQuantity, 0);
nextProject.buildRecommendationCount += metrics.filter((item) => item.recommendedBuildQuantity > 0).length;
nextProject.purchaseRecommendationCount += metrics.filter((item) => item.recommendedPurchaseQuantity > 0).length;
nextProject.uncoveredItemCount += metrics.filter((item) => item.uncoveredQuantity > 0).length;
projectRollups.set(project.projectId, nextProject);
}
}
const items = [...itemRollups.values()].sort((left, right) => {
if (right.uncoveredQuantity !== left.uncoveredQuantity) {
return right.uncoveredQuantity - left.uncoveredQuantity;
}
if (right.recommendedPurchaseQuantity !== left.recommendedPurchaseQuantity) {
return right.recommendedPurchaseQuantity - left.recommendedPurchaseQuantity;
}
if (right.recommendedBuildQuantity !== left.recommendedBuildQuantity) {
return right.recommendedBuildQuantity - left.recommendedBuildQuantity;
}
return left.itemSku.localeCompare(right.itemSku);
});
const projects = [...projectRollups.values()].sort((left, right) => {
if (right.totalUncoveredQuantity !== left.totalUncoveredQuantity) {
return right.totalUncoveredQuantity - left.totalUncoveredQuantity;
}
return left.projectNumber.localeCompare(right.projectNumber);
});
return {
generatedAt: new Date().toISOString(),
summary: {
orderCount: orders.length,
projectCount: projects.length,
itemCount: items.length,
buildRecommendationCount: items.filter((item) => item.recommendedBuildQuantity > 0).length,
purchaseRecommendationCount: items.filter((item) => item.recommendedPurchaseQuantity > 0).length,
uncoveredItemCount: items.filter((item) => item.uncoveredQuantity > 0).length,
totalBuildQuantity: items.reduce((sum, item) => sum + item.recommendedBuildQuantity, 0),
totalPurchaseQuantity: items.reduce((sum, item) => sum + item.recommendedPurchaseQuantity, 0),
totalUncoveredQuantity: items.reduce((sum, item) => sum + item.uncoveredQuantity, 0),
},
items,
projects,
};
}
async function collectPlanningItems(rootItemIds: string[]) { async function collectPlanningItems(rootItemIds: string[]) {
const itemsById = new Map<string, PlanningItemSnapshot>(); const itemsById = new Map<string, PlanningItemSnapshot>();
let pendingItemIds = [...new Set(rootItemIds.filter((itemId) => itemId.trim().length > 0))]; let pendingItemIds = [...new Set(rootItemIds.filter((itemId) => itemId.trim().length > 0))];
@@ -366,6 +834,8 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
itemId: true, itemId: true,
quantity: true, quantity: true,
completedQuantity: true, completedQuantity: true,
salesOrderId: true,
salesOrderLineId: true,
}, },
}), }),
prisma.purchaseOrderLine.findMany({ prisma.purchaseOrderLine.findMany({
@@ -382,6 +852,8 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
select: { select: {
itemId: true, itemId: true,
quantity: true, quantity: true,
salesOrderId: true,
salesOrderLineId: true,
receiptLines: { receiptLines: {
select: { select: {
quantity: true, quantity: true,
@@ -392,6 +864,12 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
]); ]);
const supplyByItemId = Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptySupplySnapshot()])) as Record<string, PlanningSupplySnapshot>; const supplyByItemId = Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptySupplySnapshot()])) as Record<string, PlanningSupplySnapshot>;
const orderLinkedSupplyByItemId = Object.fromEntries(
itemIds.map((itemId) => [itemId, createEmptyLinkedSupplySnapshot()])
) as Record<string, PlanningLinkedSupplySnapshot>;
const lineLinkedSupplyByLineId = Object.fromEntries(
order.lines.map((line) => [line.id, {} as Record<string, PlanningLinkedSupplySnapshot>])
) as Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
for (const transaction of transactions) { for (const transaction of transactions) {
const supply = supplyByItemId[transaction.itemId]; const supply = supplyByItemId[transaction.itemId];
@@ -414,6 +892,27 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
} }
for (const workOrder of workOrders) { for (const workOrder of workOrders) {
if (workOrder.salesOrderId === order.id) {
const remainingQuantity = Math.max(workOrder.quantity - workOrder.completedQuantity, 0);
if (workOrder.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByLineId[workOrder.salesOrderLineId] ?? {};
const current =
lineSupplyByItemId[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
lineSupplyByItemId[workOrder.itemId] = current;
lineLinkedSupplyByLineId[workOrder.salesOrderLineId] = lineSupplyByItemId;
} else {
const current = orderLinkedSupplyByItemId[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
orderLinkedSupplyByItemId[workOrder.itemId] = current;
}
continue;
}
if (workOrder.salesOrderId) {
continue;
}
const supply = supplyByItemId[workOrder.itemId]; const supply = supplyByItemId[workOrder.itemId];
if (!supply) { if (!supply) {
continue; continue;
@@ -423,15 +922,36 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
} }
for (const line of purchaseOrderLines) { for (const line of purchaseOrderLines) {
const remainingQuantity = Math.max(
line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0),
0
);
if (line.salesOrderId === order.id) {
if (line.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByLineId[line.salesOrderLineId] ?? {};
const current = lineSupplyByItemId[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
lineSupplyByItemId[line.itemId] = current;
lineLinkedSupplyByLineId[line.salesOrderLineId] = lineSupplyByItemId;
} else {
const current = orderLinkedSupplyByItemId[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
orderLinkedSupplyByItemId[line.itemId] = current;
}
continue;
}
if (line.salesOrderId) {
continue;
}
const supply = supplyByItemId[line.itemId]; const supply = supplyByItemId[line.itemId];
if (!supply) { if (!supply) {
continue; continue;
} }
supply.openPurchaseSupply += Math.max( supply.openPurchaseSupply += remainingQuantity;
line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0),
0
);
} }
for (const itemId of itemIds) { for (const itemId of itemIds) {
@@ -457,5 +977,229 @@ export async function getSalesOrderPlanningById(orderId: string): Promise<SalesO
})), })),
itemsById: Object.fromEntries(itemsById.entries()), itemsById: Object.fromEntries(itemsById.entries()),
supplyByItemId, supplyByItemId,
orderLinkedSupplyByItemId,
lineLinkedSupplyByLineId,
}); });
} }
export async function getDemandPlanningRollup(): Promise<DemandPlanningRollupDto> {
const orders = await prisma.salesOrder.findMany({
where: {
status: {
in: ["ISSUED", "APPROVED"],
},
},
select: {
id: true,
documentNumber: true,
status: true,
issueDate: true,
lines: {
select: {
id: true,
quantity: true,
unitOfMeasure: true,
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
projects: {
select: {
id: true,
projectNumber: true,
name: true,
},
},
},
orderBy: [{ issueDate: "asc" }, { createdAt: "asc" }],
});
const itemsById = await collectPlanningItems(orders.flatMap((order) => order.lines.map((line) => line.item.id)));
const itemIds = [...itemsById.keys()];
const orderIds = new Set(orders.map((order) => order.id));
const [transactions, reservations, workOrders, purchaseOrderLines] = await Promise.all([
prisma.inventoryTransaction.findMany({
where: { itemId: { in: itemIds } },
select: { itemId: true, transactionType: true, quantity: true },
}),
prisma.inventoryReservation.findMany({
where: { itemId: { in: itemIds }, status: "ACTIVE" },
select: { itemId: true, quantity: true },
}),
prisma.workOrder.findMany({
where: {
itemId: { in: itemIds },
status: {
notIn: ["CANCELLED", "COMPLETE"],
},
},
select: {
itemId: true,
quantity: true,
completedQuantity: true,
salesOrderId: true,
salesOrderLineId: true,
},
}),
prisma.purchaseOrderLine.findMany({
where: {
itemId: { in: itemIds },
purchaseOrder: {
status: {
not: "CLOSED",
},
},
},
select: {
itemId: true,
quantity: true,
salesOrderId: true,
salesOrderLineId: true,
receiptLines: {
select: {
quantity: true,
},
},
},
}),
]);
const supplyByItemId = Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptySupplySnapshot()])) as Record<string, PlanningSupplySnapshot>;
const orderLinkedSupplyByOrderId = Object.fromEntries(
orders.map((order) => [
order.id,
Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptyLinkedSupplySnapshot()])),
])
) as Record<string, Record<string, PlanningLinkedSupplySnapshot>>;
const lineLinkedSupplyByOrderId = Object.fromEntries(
orders.map((order) => [
order.id,
Object.fromEntries(order.lines.map((line) => [line.id, {} as Record<string, PlanningLinkedSupplySnapshot>])),
])
) as Record<string, Record<string, Record<string, PlanningLinkedSupplySnapshot>>>;
for (const transaction of transactions) {
const supply = supplyByItemId[transaction.itemId];
if (!supply) {
continue;
}
supply.onHandQuantity += transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity;
}
for (const reservation of reservations) {
const supply = supplyByItemId[reservation.itemId];
if (!supply) {
continue;
}
supply.reservedQuantity += reservation.quantity;
}
for (const workOrder of workOrders) {
const remainingQuantity = Math.max(workOrder.quantity - workOrder.completedQuantity, 0);
if (workOrder.salesOrderId && orderIds.has(workOrder.salesOrderId)) {
if (workOrder.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByOrderId[workOrder.salesOrderId]?.[workOrder.salesOrderLineId] ?? {};
const current =
lineSupplyByItemId[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
lineSupplyByItemId[workOrder.itemId] = current;
if (!lineLinkedSupplyByOrderId[workOrder.salesOrderId]) {
lineLinkedSupplyByOrderId[workOrder.salesOrderId] = {};
}
lineLinkedSupplyByOrderId[workOrder.salesOrderId]![workOrder.salesOrderLineId] = lineSupplyByItemId;
} else {
const current =
orderLinkedSupplyByOrderId[workOrder.salesOrderId]?.[workOrder.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedWorkOrderSupply += remainingQuantity;
if (!orderLinkedSupplyByOrderId[workOrder.salesOrderId]) {
orderLinkedSupplyByOrderId[workOrder.salesOrderId] = {};
}
orderLinkedSupplyByOrderId[workOrder.salesOrderId]![workOrder.itemId] = current;
}
continue;
}
if (workOrder.salesOrderId) {
continue;
}
const supply = supplyByItemId[workOrder.itemId];
if (!supply) {
continue;
}
supply.openWorkOrderSupply += remainingQuantity;
}
for (const line of purchaseOrderLines) {
const remainingQuantity = Math.max(line.quantity - line.receiptLines.reduce((sum, receiptLine) => sum + receiptLine.quantity, 0), 0);
if (line.salesOrderId && orderIds.has(line.salesOrderId)) {
if (line.salesOrderLineId) {
const lineSupplyByItemId = lineLinkedSupplyByOrderId[line.salesOrderId]?.[line.salesOrderLineId] ?? {};
const current = lineSupplyByItemId[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
lineSupplyByItemId[line.itemId] = current;
if (!lineLinkedSupplyByOrderId[line.salesOrderId]) {
lineLinkedSupplyByOrderId[line.salesOrderId] = {};
}
lineLinkedSupplyByOrderId[line.salesOrderId]![line.salesOrderLineId] = lineSupplyByItemId;
} else {
const current =
orderLinkedSupplyByOrderId[line.salesOrderId]?.[line.itemId] ?? createEmptyLinkedSupplySnapshot();
current.linkedPurchaseSupply += remainingQuantity;
if (!orderLinkedSupplyByOrderId[line.salesOrderId]) {
orderLinkedSupplyByOrderId[line.salesOrderId] = {};
}
orderLinkedSupplyByOrderId[line.salesOrderId]![line.itemId] = current;
}
continue;
}
if (line.salesOrderId) {
continue;
}
const supply = supplyByItemId[line.itemId];
if (!supply) {
continue;
}
supply.openPurchaseSupply += remainingQuantity;
}
for (const itemId of itemIds) {
const supply = supplyByItemId[itemId];
if (!supply) {
continue;
}
supply.availableQuantity = supply.onHandQuantity - supply.reservedQuantity;
}
return buildDemandPlanningRollup(
orders.map((order) => ({
orderId: order.id,
documentNumber: order.documentNumber,
status: order.status as SalesDocumentStatus,
issueDate: order.issueDate.toISOString(),
projectSummaries: order.projects.map((project) => ({
projectId: project.id,
projectNumber: project.projectNumber,
projectName: project.name,
})),
lines: order.lines.map((line) => ({
id: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
})),
orderLinkedSupplyByItemId:
orderLinkedSupplyByOrderId[order.id] ?? Object.fromEntries(itemIds.map((itemId) => [itemId, createEmptyLinkedSupplySnapshot()])),
lineLinkedSupplyByLineId:
lineLinkedSupplyByOrderId[order.id] ?? Object.fromEntries(order.lines.map((line) => [line.id, {} as Record<string, PlanningLinkedSupplySnapshot>])),
})),
Object.fromEntries(itemsById.entries()),
supplyByItemId
);
}

View File

@@ -18,7 +18,7 @@ import {
updateSalesDocumentStatus, updateSalesDocumentStatus,
updateSalesDocument, updateSalesDocument,
} from "./service.js"; } from "./service.js";
import { getSalesOrderPlanningById } from "./planning.js"; import { getDemandPlanningRollup, getSalesOrderPlanningById } from "./planning.js";
const salesLineSchema = z.object({ const salesLineSchema = z.object({
itemId: z.string().trim().min(1), itemId: z.string().trim().min(1),
@@ -231,6 +231,10 @@ salesRouter.get("/orders/:orderId/planning", requirePermissions([permissions.sal
return ok(response, planning); return ok(response, planning);
}); });
salesRouter.get("/planning-rollup", requirePermissions([permissions.salesRead]), async (_request, response) => {
return ok(response, await getDemandPlanningRollup());
});
salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => { salesRouter.post("/orders", requirePermissions([permissions.salesWrite]), async (request, response) => {
const parsed = orderSchema.safeParse(request.body); const parsed = orderSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {

View File

@@ -103,35 +103,64 @@ describe("sales order planning", () => {
openPurchaseSupply: 2, openPurchaseSupply: 2,
}, },
}, },
orderLinkedSupplyByItemId: {
"assembly-1": {
linkedWorkOrderSupply: 1,
linkedPurchaseSupply: 0,
},
"mfg-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 0,
},
"buy-2": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 3,
},
},
lineLinkedSupplyByLineId: {
"line-1": {
"buy-1": {
linkedWorkOrderSupply: 0,
linkedPurchaseSupply: 2,
},
},
},
}; };
const planning = buildSalesOrderPlanning(snapshot); const planning = buildSalesOrderPlanning(snapshot);
expect(planning.summary.totalBuildQuantity).toBe(6); expect(planning.summary.totalBuildQuantity).toBe(3);
expect(planning.summary.totalPurchaseQuantity).toBe(10); expect(planning.summary.totalPurchaseQuantity).toBe(0);
const assembly = planning.items.find((item) => item.itemId === "assembly-1"); const assembly = planning.items.find((item) => item.itemId === "assembly-1");
const manufacturedChild = planning.items.find((item) => item.itemId === "mfg-1"); const manufacturedChild = planning.items.find((item) => item.itemId === "mfg-1");
const purchasedChild = planning.items.find((item) => item.itemId === "buy-1"); const purchasedChild = planning.items.find((item) => item.itemId === "buy-1");
const rawMaterial = planning.items.find((item) => item.itemId === "buy-2"); const rawMaterial = planning.items.find((item) => item.itemId === "buy-2");
expect(assembly?.recommendedBuildQuantity).toBe(3); expect(assembly?.linkedWorkOrderSupply).toBe(1);
expect(assembly?.recommendedBuildQuantity).toBe(2);
expect(assembly?.supplyFromStock).toBe(1); expect(assembly?.supplyFromStock).toBe(1);
expect(assembly?.supplyFromOpenWorkOrders).toBe(1); expect(assembly?.supplyFromOpenWorkOrders).toBe(1);
expect(manufacturedChild?.grossDemand).toBe(6); expect(manufacturedChild?.grossDemand).toBe(4);
expect(manufacturedChild?.recommendedBuildQuantity).toBe(3); expect(manufacturedChild?.recommendedBuildQuantity).toBe(1);
expect(manufacturedChild?.supplyFromStock).toBe(2); expect(manufacturedChild?.supplyFromStock).toBe(2);
expect(manufacturedChild?.supplyFromOpenWorkOrders).toBe(1); expect(manufacturedChild?.supplyFromOpenWorkOrders).toBe(1);
expect(purchasedChild?.grossDemand).toBe(9); expect(purchasedChild?.grossDemand).toBe(6);
expect(purchasedChild?.recommendedPurchaseQuantity).toBe(1); expect(purchasedChild?.linkedPurchaseSupply).toBe(2);
expect(purchasedChild?.recommendedPurchaseQuantity).toBe(0);
expect(purchasedChild?.supplyFromStock).toBe(3); expect(purchasedChild?.supplyFromStock).toBe(3);
expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(5); expect(purchasedChild?.supplyFromOpenPurchaseOrders).toBe(1);
expect(rawMaterial?.grossDemand).toBe(12); expect(rawMaterial?.grossDemand).toBe(4);
expect(rawMaterial?.recommendedPurchaseQuantity).toBe(9); expect(rawMaterial?.linkedPurchaseSupply).toBe(3);
expect(rawMaterial?.recommendedPurchaseQuantity).toBe(0);
expect(rawMaterial?.supplyFromStock).toBe(1); expect(rawMaterial?.supplyFromStock).toBe(1);
expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(2); expect(rawMaterial?.supplyFromOpenPurchaseOrders).toBe(0);
}); });
}); });

View File

@@ -56,7 +56,10 @@ export interface InventoryItemOptionDto {
sku: string; sku: string;
name: string; name: string;
isPurchasable: boolean; isPurchasable: boolean;
defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
preferredVendorId: string | null;
preferredVendorName: string | null;
} }
export interface WarehouseLocationOptionDto { export interface WarehouseLocationOptionDto {
@@ -210,6 +213,8 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
description: string; description: string;
defaultCost: number | null; defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
preferredVendorId: string | null;
preferredVendorName: string | null;
notes: string; notes: string;
createdAt: string; createdAt: string;
bomLines: InventoryBomLineDto[]; bomLines: InventoryBomLineDto[];
@@ -234,6 +239,7 @@ export interface InventoryItemInput {
isPurchasable: boolean; isPurchasable: boolean;
defaultCost: number | null; defaultCost: number | null;
defaultPrice: number | null; defaultPrice: number | null;
preferredVendorId: string | null;
notes: string; notes: string;
bomLines: InventoryBomLineInput[]; bomLines: InventoryBomLineInput[];
operations: InventoryItemOperationInput[]; operations: InventoryItemOperationInput[];

View File

@@ -58,6 +58,9 @@ export interface WorkOrderSummaryDto {
locationId: string; locationId: string;
locationCode: string; locationCode: string;
locationName: string; locationName: string;
salesOrderId: string | null;
salesOrderLineId: string | null;
salesOrderNumber: string | null;
operationCount: number; operationCount: number;
totalPlannedMinutes: number; totalPlannedMinutes: number;
updatedAt: string; updatedAt: string;
@@ -87,6 +90,10 @@ export interface WorkOrderMaterialRequirementDto {
requiredQuantity: number; requiredQuantity: number;
issuedQuantity: number; issuedQuantity: number;
remainingQuantity: number; remainingQuantity: number;
onHandQuantity: number;
reservedQuantity: number;
availableQuantity: number;
shortageQuantity: number;
} }
export interface WorkOrderMaterialIssueDto { export interface WorkOrderMaterialIssueDto {
@@ -130,6 +137,8 @@ export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
export interface WorkOrderInput { export interface WorkOrderInput {
itemId: string; itemId: string;
projectId: string | null; projectId: string | null;
salesOrderId: string | null;
salesOrderLineId: string | null;
status: WorkOrderStatus; status: WorkOrderStatus;
quantity: number; quantity: number;
warehouseId: string; warehouseId: string;

View File

@@ -24,6 +24,9 @@ export interface PurchaseLineDto {
lineTotal: number; lineTotal: number;
receivedQuantity: number; receivedQuantity: number;
remainingQuantity: number; remainingQuantity: number;
salesOrderId: string | null;
salesOrderLineId: string | null;
salesOrderNumber: string | null;
position: number; position: number;
} }
@@ -33,6 +36,8 @@ export interface PurchaseLineInput {
quantity: number; quantity: number;
unitOfMeasure: InventoryUnitOfMeasure; unitOfMeasure: InventoryUnitOfMeasure;
unitCost: number; unitCost: number;
salesOrderId?: string | null;
salesOrderLineId?: string | null;
position: number; position: number;
} }

View File

@@ -74,6 +74,8 @@ export interface SalesOrderPlanningNodeDto {
grossDemand: number; grossDemand: number;
availableBefore: number; availableBefore: number;
availableAfter: number; availableAfter: number;
linkedWorkOrderSupply: number;
linkedPurchaseSupply: number;
supplyFromStock: number; supplyFromStock: number;
openWorkOrderSupply: number; openWorkOrderSupply: number;
openPurchaseSupply: number; openPurchaseSupply: number;
@@ -106,6 +108,8 @@ export interface SalesOrderPlanningItemDto {
onHandQuantity: number; onHandQuantity: number;
reservedQuantity: number; reservedQuantity: number;
availableQuantity: number; availableQuantity: number;
linkedWorkOrderSupply: number;
linkedPurchaseSupply: number;
openWorkOrderSupply: number; openWorkOrderSupply: number;
openPurchaseSupply: number; openPurchaseSupply: number;
supplyFromStock: number; supplyFromStock: number;
@@ -137,6 +141,39 @@ export interface SalesOrderPlanningDto {
items: SalesOrderPlanningItemDto[]; items: SalesOrderPlanningItemDto[];
} }
export interface DemandPlanningProjectSummaryDto {
projectId: string;
projectNumber: string;
projectName: string;
salesOrderId: string;
salesOrderNumber: string;
totalBuildQuantity: number;
totalPurchaseQuantity: number;
totalUncoveredQuantity: number;
buildRecommendationCount: number;
purchaseRecommendationCount: number;
uncoveredItemCount: number;
}
export interface DemandPlanningRollupSummaryDto {
orderCount: number;
projectCount: number;
itemCount: number;
buildRecommendationCount: number;
purchaseRecommendationCount: number;
uncoveredItemCount: number;
totalBuildQuantity: number;
totalPurchaseQuantity: number;
totalUncoveredQuantity: number;
}
export interface DemandPlanningRollupDto {
generatedAt: string;
summary: DemandPlanningRollupSummaryDto;
items: SalesOrderPlanningItemDto[];
projects: DemandPlanningProjectSummaryDto[];
}
export interface SalesDocumentInput { export interface SalesDocumentInput {
customerId: string; customerId: string;
status: SalesDocumentStatus; status: SalesDocumentStatus;