PO logic
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
11
ROADMAP.md
11
ROADMAP.md
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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" }],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user