backfill from projects
This commit is contained in:
@@ -6,6 +6,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Reverse project visibility on quote and sales-order detail pages, purchase-order header project linkage, sales-order-driven project auto-derivation for new work orders and purchase orders, quote-to-sales-order project carry-through during conversion, and migration backfill for existing work orders and purchase orders linked through project sales orders
|
||||||
- Finance module with customer-payment posting against sales orders, finance costing assumptions, sales-order cash/spend ledger rollups, manufacturing cost snapshots, and CapEx tracking for equipment, tooling, and consumables
|
- Finance module with customer-payment posting against sales orders, finance costing assumptions, sales-order cash/spend ledger rollups, manufacturing cost snapshots, and CapEx tracking for equipment, tooling, and consumables
|
||||||
- Inventory-backed shipment picking from shipment detail pages, including sales-order line remaining-quantity visibility, warehouse/location source selection, issued-stock posting, and shipment pick history
|
- Inventory-backed shipment picking from shipment detail pages, including sales-order line remaining-quantity visibility, warehouse/location source selection, issued-stock posting, and shipment pick history
|
||||||
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline
|
- Project cockpit section on project detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and project cost snapshot rollups, plus direct launch paths into prefilled work-order and purchase-order follow-through and a chronological project activity timeline
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Current foundation scope includes:
|
|||||||
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
- branded quote, sales-order, and purchase-order PDFs through the shared backend document pipeline
|
||||||
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
|
||||||
- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments
|
- shipping shipments linked to sales orders with inventory-backed picking, stock issue posting, packing slips, shipping labels, bills of lading, and logistics attachments
|
||||||
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments
|
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, attachments, reverse-linked quote/sales-order visibility, and downstream project-context carry-through into generated work orders and purchase orders
|
||||||
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments
|
||||||
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
|
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
|
||||||
- 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
|
||||||
@@ -116,6 +116,7 @@ Current interactions:
|
|||||||
|
|
||||||
- CRM: each project should link to a customer account and relevant contacts
|
- CRM: each project should link to a customer account and relevant contacts
|
||||||
- Sales: quotes and sales orders can already attach to projects
|
- Sales: quotes and sales orders can already attach to projects
|
||||||
|
- Sales workflow now also exposes the reverse project link on quote and sales-order detail pages, and quote conversion carries linked project context forward into the created sales order
|
||||||
- Shipping: shipments tied to project deliverables are visible from the project record
|
- Shipping: shipments tied to project deliverables are visible from the project record
|
||||||
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets
|
- Dashboard: projects now contribute status, risk, backlog, and overdue widgets
|
||||||
- Detail/List UX: projects now surface milestone progress and linked execution rollups
|
- Detail/List UX: projects now surface milestone progress and linked execution rollups
|
||||||
@@ -123,8 +124,8 @@ Current interactions:
|
|||||||
Next expansion areas:
|
Next expansion areas:
|
||||||
|
|
||||||
- Inventory: projects should reference item/BOM scope and later expose shortages or allocations
|
- Inventory: projects should reference item/BOM scope and later expose shortages or allocations
|
||||||
- Purchasing: project material demand is now visible through linked PO, receipt, vendor, and outstanding-supply rollups, and should later expand into project-side purchasing actions
|
- Purchasing: project material demand is now visible through linked PO, receipt, vendor, and outstanding-supply rollups, and purchase orders now persist header-level project context derived from linked sales demand or explicit project selection
|
||||||
- Manufacturing: work orders should link back to projects without turning projects into the manufacturing module
|
- Manufacturing: work orders already auto-link back to the project when the originating sales order belongs to a project, without turning projects into the manufacturing module
|
||||||
- Planning: project milestones and execution dates should feed workbench scheduling, dependency views, and richer planner drilldowns
|
- Planning: project milestones and execution dates should feed workbench scheduling, dependency views, and richer planner drilldowns
|
||||||
|
|
||||||
## Manufacturing Direction
|
## Manufacturing Direction
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
|
- Packing-slip, shipping-label, and bill-of-lading PDF rendering for shipments
|
||||||
- Logistics attachments directly on shipment records
|
- Logistics attachments directly on shipment records
|
||||||
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
- Projects foundation with customer, quote, sales-order, shipment, owner, due-date, notes, and attachment linkage
|
||||||
|
- Reverse project linkage visibility on quote and sales-order detail pages, plus project-context carry-through into generated work orders and purchase orders with sales-order-driven backfill for existing records
|
||||||
- Project milestones and project-side milestone/work-order rollups
|
- Project milestones and project-side milestone/work-order rollups
|
||||||
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
|
||||||
- Project list/detail/create/edit workflows and dashboard program widgets
|
- Project list/detail/create/edit workflows and dashboard program widgets
|
||||||
@@ -78,6 +79,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- Project records with customer linkage, status, owner, priority, due dates, milestones, and notes
|
- Project records with customer linkage, status, owner, priority, due dates, milestones, and notes
|
||||||
- Project milestone status tracking and project-side milestone/work-order rollups
|
- Project milestone status tracking and project-side milestone/work-order rollups
|
||||||
- Project-to-quote, sales-order, and shipment linkage for delivery context
|
- Project-to-quote, sales-order, and shipment linkage for delivery context
|
||||||
|
- Quote-to-sales-order project carry-through plus reverse-linked project visibility from the sales workflow
|
||||||
- Project attachments through the shared file pipeline
|
- Project attachments through the shared file pipeline
|
||||||
- Project list/detail/create/edit flows and dashboard visibility
|
- Project list/detail/create/edit flows and dashboard visibility
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
|
|||||||
- 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
|
- Prefilled work-order and purchase-order draft generation launched from demand-planning recommendations
|
||||||
|
- Generated work orders and purchase orders now auto-carry linked project context when demand traces back to a project-linked sales order
|
||||||
- Shared shortage and readiness rollups surfaced across dashboard, planning, project, purchasing, and manufacturing views
|
- 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
|
- Preferred-vendor sourcing on inventory items for buy-side planning defaults
|
||||||
- Pegged work-order and purchase-order supply links back to originating sales demand
|
- Pegged work-order and purchase-order supply links back to originating sales demand
|
||||||
|
|||||||
@@ -427,7 +427,18 @@ export function PurchaseDetailPage() {
|
|||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project Link</p>
|
||||||
|
{activeDocument.projectId ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Link to={`/projects/${activeDocument.projectId}`} className="inline-flex items-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text hover:bg-page/70">
|
||||||
|
{activeDocument.projectNumber} / {activeDocument.projectName}
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-muted">This purchase order is linked to the project context used by project cockpit and downstream rollups.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-muted">No linked project is currently attached to this purchase order.</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||||
<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>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ 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 seededProjectId = searchParams.get("projectId");
|
||||||
|
const seededProjectNumber = searchParams.get("projectNumber");
|
||||||
|
const seededProjectName = searchParams.get("projectName");
|
||||||
const planningOrderId = searchParams.get("planningOrderId");
|
const planningOrderId = searchParams.get("planningOrderId");
|
||||||
const selectedPlanningItemId = searchParams.get("itemId");
|
const selectedPlanningItemId = searchParams.get("itemId");
|
||||||
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
|
const [form, setForm] = useState<PurchaseOrderInput>(emptyPurchaseOrderInput);
|
||||||
@@ -57,6 +60,12 @@ 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 (mode === "create" && seededProjectId) {
|
||||||
|
setForm((current) => ({ ...current, projectId: current.projectId || seededProjectId }));
|
||||||
|
}
|
||||||
|
}, [mode, seededProjectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
|
if (!token || mode !== "create" || !planningOrderId || itemOptions.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -103,6 +112,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
setForm((current) => ({
|
setForm((current) => ({
|
||||||
...current,
|
...current,
|
||||||
vendorId: current.vendorId || autoVendorId || "",
|
vendorId: current.vendorId || autoVendorId || "",
|
||||||
|
projectId: current.projectId || seededProjectId || null,
|
||||||
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
|
notes: current.notes || `Demand-planning recommendation from sales order ${planning.documentNumber}.`,
|
||||||
lines: current.lines.length > 0 ? current.lines : recommendedLines,
|
lines: current.lines.length > 0 ? current.lines : recommendedLines,
|
||||||
}));
|
}));
|
||||||
@@ -124,7 +134,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
setStatus("Unable to load demand-planning recommendations.");
|
setStatus("Unable to load demand-planning recommendations.");
|
||||||
});
|
});
|
||||||
}, [itemOptions, mode, planningOrderId, seededVendorId, selectedPlanningItemId, token, vendors]);
|
}, [itemOptions, mode, planningOrderId, seededProjectId, seededVendorId, selectedPlanningItemId, token, vendors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || mode !== "edit" || !orderId) {
|
if (!token || mode !== "edit" || !orderId) {
|
||||||
@@ -135,6 +145,7 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
.then((document) => {
|
.then((document) => {
|
||||||
setForm({
|
setForm({
|
||||||
vendorId: document.vendorId,
|
vendorId: document.vendorId,
|
||||||
|
projectId: document.projectId,
|
||||||
status: document.status,
|
status: document.status,
|
||||||
issueDate: document.issueDate,
|
issueDate: document.issueDate,
|
||||||
taxPercent: document.taxPercent,
|
taxPercent: document.taxPercent,
|
||||||
@@ -341,6 +352,19 @@ export function PurchaseFormPage({ mode }: { mode: "create" | "edit" }) {
|
|||||||
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<input type="date" value={form.issueDate.slice(0, 10)} onChange={(event) => updateField("issueDate", new Date(event.target.value).toISOString())} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3 text-sm">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Linked Project</div>
|
||||||
|
<div className="mt-2 font-semibold text-text">
|
||||||
|
{mode === "edit"
|
||||||
|
? (form.projectId ? "Project context saved on this purchase order." : "No project linked.")
|
||||||
|
: (seededProjectId ? `${seededProjectNumber || "Project"}${seededProjectName ? ` - ${seededProjectName}` : ""}` : "Will auto-link from sales-order demand when possible.")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted">
|
||||||
|
{mode === "edit"
|
||||||
|
? "This header link is used for downstream project cockpit and finance rollups."
|
||||||
|
: "Generated purchasing from a project-linked sales order will carry project context automatically."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
|
||||||
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
<textarea value={form.notes} onChange={(event) => updateField("notes", event.target.value)} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const purchaseStatusPalette: Record<PurchaseOrderStatus, string> = {
|
|||||||
|
|
||||||
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
export const emptyPurchaseOrderInput: PurchaseOrderInput = {
|
||||||
vendorId: "",
|
vendorId: "",
|
||||||
|
projectId: null,
|
||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
issueDate: new Date().toISOString(),
|
issueDate: new Date().toISOString(),
|
||||||
taxPercent: 0,
|
taxPercent: 0,
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
|
notes: `Generated from sales order ${activeDocument.documentNumber} demand planning.`,
|
||||||
});
|
});
|
||||||
|
if (activeDocument.linkedProjectId) {
|
||||||
|
params.set("projectId", activeDocument.linkedProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
return `/manufacturing/work-orders/new?${params.toString()}`;
|
return `/manufacturing/work-orders/new?${params.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -186,6 +189,15 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
if (vendorId) {
|
if (vendorId) {
|
||||||
params.set("vendorId", vendorId);
|
params.set("vendorId", vendorId);
|
||||||
}
|
}
|
||||||
|
if (activeDocument.linkedProjectId) {
|
||||||
|
params.set("projectId", activeDocument.linkedProjectId);
|
||||||
|
}
|
||||||
|
if (activeDocument.linkedProjectNumber) {
|
||||||
|
params.set("projectNumber", activeDocument.linkedProjectNumber);
|
||||||
|
}
|
||||||
|
if (activeDocument.linkedProjectName) {
|
||||||
|
params.set("projectName", activeDocument.linkedProjectName);
|
||||||
|
}
|
||||||
|
|
||||||
return `/purchasing/orders/new?${params.toString()}`;
|
return `/purchasing/orders/new?${params.toString()}`;
|
||||||
}
|
}
|
||||||
@@ -521,7 +533,18 @@ export function SalesDetailPage({ entity }: { entity: SalesDocumentEntity }) {
|
|||||||
</dl>
|
</dl>
|
||||||
</article>
|
</article>
|
||||||
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Project Link</p>
|
||||||
|
{activeDocument.linkedProjectId ? (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<Link to={`/projects/${activeDocument.linkedProjectId}`} className="inline-flex items-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text hover:bg-page/70">
|
||||||
|
{activeDocument.linkedProjectNumber} / {activeDocument.linkedProjectName}
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-muted">This {entity === "quote" ? "quote" : "sales order"} is already linked to a project, and downstream WO/PO launches will carry that project context.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-muted">No linked project is currently attached to this {entity === "quote" ? "quote" : "sales order"}.</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-5 text-xs font-semibold uppercase tracking-[0.24em] text-muted">Notes</p>
|
||||||
<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>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
ALTER TABLE "PurchaseOrder" ADD COLUMN "projectId" TEXT REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX "PurchaseOrder_projectId_issueDate_idx" ON "PurchaseOrder"("projectId", "issueDate");
|
||||||
|
|
||||||
|
UPDATE "WorkOrder"
|
||||||
|
SET "projectId" = (
|
||||||
|
SELECT "Project"."id"
|
||||||
|
FROM "Project"
|
||||||
|
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
|
||||||
|
ORDER BY "Project"."createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE "projectId" IS NULL
|
||||||
|
AND "salesOrderId" IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "Project"
|
||||||
|
WHERE "Project"."salesOrderId" = "WorkOrder"."salesOrderId"
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE "PurchaseOrder"
|
||||||
|
SET "projectId" = (
|
||||||
|
SELECT "Project"."id"
|
||||||
|
FROM "PurchaseOrderLine"
|
||||||
|
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
|
||||||
|
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
|
||||||
|
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
|
||||||
|
ORDER BY "Project"."createdAt" ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE "projectId" IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM "PurchaseOrderLine"
|
||||||
|
INNER JOIN "Project" ON "Project"."salesOrderId" = "PurchaseOrderLine"."salesOrderId"
|
||||||
|
WHERE "PurchaseOrderLine"."purchaseOrderId" = "PurchaseOrder"."id"
|
||||||
|
AND "PurchaseOrderLine"."salesOrderId" IS NOT NULL
|
||||||
|
);
|
||||||
@@ -618,6 +618,7 @@ model Project {
|
|||||||
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
|
shipment Shipment? @relation(fields: [shipmentId], references: [id], onDelete: SetNull)
|
||||||
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||||
workOrders WorkOrder[]
|
workOrders WorkOrder[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
milestones ProjectMilestone[]
|
milestones ProjectMilestone[]
|
||||||
|
|
||||||
@@index([customerId, createdAt])
|
@@index([customerId, createdAt])
|
||||||
@@ -864,6 +865,7 @@ model PurchaseOrder {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
documentNumber String @unique
|
documentNumber String @unique
|
||||||
vendorId String
|
vendorId String
|
||||||
|
projectId String?
|
||||||
status String
|
status String
|
||||||
issueDate DateTime
|
issueDate DateTime
|
||||||
taxPercent Float @default(0)
|
taxPercent Float @default(0)
|
||||||
@@ -872,10 +874,13 @@ model PurchaseOrder {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
lines PurchaseOrderLine[]
|
lines PurchaseOrderLine[]
|
||||||
receipts PurchaseReceipt[]
|
receipts PurchaseReceipt[]
|
||||||
revisions PurchaseOrderRevision[]
|
revisions PurchaseOrderRevision[]
|
||||||
capexEntries CapexEntry[]
|
capexEntries CapexEntry[]
|
||||||
|
|
||||||
|
@@index([projectId, issueDate])
|
||||||
}
|
}
|
||||||
|
|
||||||
model PurchaseOrderLine {
|
model PurchaseOrderLine {
|
||||||
|
|||||||
@@ -868,15 +868,18 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|||||||
return { ok: false as const, reason: "Build item must have at least one station operation before a work order can be created." };
|
return { ok: false as const, reason: "Build item must have at least one station operation before a work order can be created." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let projectSalesOrderId: string | null = null;
|
||||||
if (payload.projectId) {
|
if (payload.projectId) {
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { id: payload.projectId },
|
where: { id: payload.projectId },
|
||||||
select: { id: true },
|
select: { id: true, salesOrderId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return { ok: false as const, reason: "Linked project was not found." };
|
return { ok: false as const, reason: "Linked project was not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
projectSalesOrderId = project.salesOrderId ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.salesOrderId) {
|
if (payload.salesOrderId) {
|
||||||
@@ -888,6 +891,10 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|||||||
if (!salesOrder) {
|
if (!salesOrder) {
|
||||||
return { ok: false as const, reason: "Linked sales order was not found." };
|
return { ok: false as const, reason: "Linked sales order was not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (projectSalesOrderId && projectSalesOrderId !== payload.salesOrderId) {
|
||||||
|
return { ok: false as const, reason: "Linked project does not match the selected sales order." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.salesOrderLineId) {
|
if (payload.salesOrderLineId) {
|
||||||
@@ -928,6 +935,28 @@ async function validateWorkOrderInput(payload: WorkOrderInput) {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deriveProjectIdForWorkOrder(payload: WorkOrderInput) {
|
||||||
|
if (payload.projectId) {
|
||||||
|
return payload.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.salesOrderId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.findFirst({
|
||||||
|
where: {
|
||||||
|
salesOrderId: payload.salesOrderId,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return project?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
|
export async function listManufacturingItemOptions(): Promise<ManufacturingItemOptionDto[]> {
|
||||||
const items = await prisma.inventoryItem.findMany({
|
const items = await prisma.inventoryItem.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -1158,13 +1187,14 @@ export async function createWorkOrder(payload: WorkOrderInput, actorId?: string
|
|||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return { ok: false as const, reason: validated.reason };
|
return { ok: false as const, reason: validated.reason };
|
||||||
}
|
}
|
||||||
|
const derivedProjectId = await deriveProjectIdForWorkOrder(payload);
|
||||||
|
|
||||||
const workOrderNumber = await nextWorkOrderNumber();
|
const workOrderNumber = await nextWorkOrderNumber();
|
||||||
const created = await workOrderModel.create({
|
const created = await workOrderModel.create({
|
||||||
data: {
|
data: {
|
||||||
workOrderNumber,
|
workOrderNumber,
|
||||||
itemId: payload.itemId,
|
itemId: payload.itemId,
|
||||||
projectId: payload.projectId,
|
projectId: derivedProjectId,
|
||||||
salesOrderId: payload.salesOrderId,
|
salesOrderId: payload.salesOrderId,
|
||||||
salesOrderLineId: payload.salesOrderLineId,
|
salesOrderLineId: payload.salesOrderLineId,
|
||||||
warehouseId: payload.warehouseId,
|
warehouseId: payload.warehouseId,
|
||||||
@@ -1223,12 +1253,13 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
|
|||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
return { ok: false as const, reason: validated.reason };
|
return { ok: false as const, reason: validated.reason };
|
||||||
}
|
}
|
||||||
|
const derivedProjectId = await deriveProjectIdForWorkOrder(payload);
|
||||||
|
|
||||||
await workOrderModel.update({
|
await workOrderModel.update({
|
||||||
where: { id: workOrderId },
|
where: { id: workOrderId },
|
||||||
data: {
|
data: {
|
||||||
itemId: payload.itemId,
|
itemId: payload.itemId,
|
||||||
projectId: payload.projectId,
|
projectId: derivedProjectId,
|
||||||
salesOrderId: payload.salesOrderId,
|
salesOrderId: payload.salesOrderId,
|
||||||
salesOrderLineId: payload.salesOrderLineId,
|
salesOrderLineId: payload.salesOrderLineId,
|
||||||
warehouseId: payload.warehouseId,
|
warehouseId: payload.warehouseId,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const purchaseLineSchema = z.object({
|
|||||||
|
|
||||||
const purchaseOrderSchema = z.object({
|
const purchaseOrderSchema = z.object({
|
||||||
vendorId: z.string().trim().min(1),
|
vendorId: z.string().trim().min(1),
|
||||||
|
projectId: z.string().trim().min(1).nullable().optional(),
|
||||||
status: z.enum(purchaseOrderStatuses),
|
status: z.enum(purchaseOrderStatuses),
|
||||||
issueDate: z.string().datetime(),
|
issueDate: z.string().datetime(),
|
||||||
taxPercent: z.number().min(0).max(100),
|
taxPercent: z.number().min(0).max(100),
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ type PurchaseOrderRevisionRecord = {
|
|||||||
type PurchaseOrderRecord = {
|
type PurchaseOrderRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
documentNumber: string;
|
documentNumber: string;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
projectNumber: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
status: string;
|
status: string;
|
||||||
issueDate: Date;
|
issueDate: Date;
|
||||||
taxPercent: number;
|
taxPercent: number;
|
||||||
@@ -145,6 +150,17 @@ type PurchaseOrderRecord = {
|
|||||||
revisions: PurchaseOrderRevisionRecord[];
|
revisions: PurchaseOrderRevisionRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NormalizedPurchaseLine = {
|
||||||
|
itemId: string;
|
||||||
|
salesOrderId: string | null;
|
||||||
|
salesOrderLineId: string | null;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitOfMeasure: PurchaseLineInput["unitOfMeasure"];
|
||||||
|
unitCost: number;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
function roundMoney(value: number) {
|
function roundMoney(value: number) {
|
||||||
return Math.round(value * 100) / 100;
|
return Math.round(value * 100) / 100;
|
||||||
}
|
}
|
||||||
@@ -322,6 +338,63 @@ async function validateLines(lines: PurchaseLineInput[]) {
|
|||||||
return { ok: true as const, lines: normalized };
|
return { ok: true as const, lines: normalized };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolvePurchaseOrderProjectId(projectId: string | null | undefined, lines: NormalizedPurchaseLine[]) {
|
||||||
|
let explicitProjectId = projectId ?? null;
|
||||||
|
|
||||||
|
if (explicitProjectId) {
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id: explicitProjectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
salesOrderId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
return { ok: false as const, reason: "Linked project was not found." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
|
||||||
|
if (linkedSalesOrderIds.length > 0 && project.salesOrderId && linkedSalesOrderIds.some((salesOrderId) => salesOrderId !== project.salesOrderId)) {
|
||||||
|
return { ok: false as const, reason: "Linked project does not match the sales-order demand attached to this purchase order." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true as const, projectId: project.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedSalesOrderIds = [...new Set(lines.flatMap((line) => (line.salesOrderId ? [line.salesOrderId] : [])))];
|
||||||
|
if (linkedSalesOrderIds.length === 0) {
|
||||||
|
return { ok: true as const, projectId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProjects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
salesOrderId: {
|
||||||
|
in: linkedSalesOrderIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
salesOrderId: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectBySalesOrderId = new Map<string, string>();
|
||||||
|
for (const project of matchingProjects) {
|
||||||
|
if (project.salesOrderId && !projectBySalesOrderId.has(project.salesOrderId)) {
|
||||||
|
projectBySalesOrderId.set(project.salesOrderId, project.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivedProjectIds = [...new Set(linkedSalesOrderIds.map((salesOrderId) => projectBySalesOrderId.get(salesOrderId)).filter((value): value is string => Boolean(value)))];
|
||||||
|
if (derivedProjectIds.length > 1) {
|
||||||
|
return { ok: false as const, reason: "Purchase orders can only auto-link to one project. Split the document or set the project intentionally." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true as const, projectId: derivedProjectIds[0] ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
||||||
const receivedByLineId = new Map<string, number>();
|
const receivedByLineId = new Map<string, number>();
|
||||||
|
|
||||||
@@ -370,6 +443,9 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
|
|||||||
documentNumber: record.documentNumber,
|
documentNumber: record.documentNumber,
|
||||||
vendorId: record.vendor.id,
|
vendorId: record.vendor.id,
|
||||||
vendorName: record.vendor.name,
|
vendorName: record.vendor.name,
|
||||||
|
projectId: record.project?.id ?? null,
|
||||||
|
projectNumber: record.project?.projectNumber ?? null,
|
||||||
|
projectName: record.project?.name ?? null,
|
||||||
vendorEmail: record.vendor.email,
|
vendorEmail: record.vendor.email,
|
||||||
paymentTerms: record.vendor.paymentTerms,
|
paymentTerms: record.vendor.paymentTerms,
|
||||||
currencyCode: record.vendor.currencyCode,
|
currencyCode: record.vendor.currencyCode,
|
||||||
@@ -487,6 +563,13 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
|
|||||||
currencyCode: true,
|
currencyCode: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectNumber: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
lines: {
|
lines: {
|
||||||
include: {
|
include: {
|
||||||
item: {
|
item: {
|
||||||
@@ -715,6 +798,9 @@ export async function listPurchaseOrders(filters: { q?: string; status?: Purchas
|
|||||||
documentNumber: detail.documentNumber,
|
documentNumber: detail.documentNumber,
|
||||||
vendorId: detail.vendorId,
|
vendorId: detail.vendorId,
|
||||||
vendorName: detail.vendorName,
|
vendorName: detail.vendorName,
|
||||||
|
projectId: detail.projectId,
|
||||||
|
projectNumber: detail.projectNumber,
|
||||||
|
projectName: detail.projectName,
|
||||||
status: detail.status,
|
status: detail.status,
|
||||||
subtotal: detail.subtotal,
|
subtotal: detail.subtotal,
|
||||||
taxPercent: detail.taxPercent,
|
taxPercent: detail.taxPercent,
|
||||||
@@ -762,6 +848,11 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
|
|||||||
return { ok: false as const, reason: validatedLines.reason };
|
return { ok: false as const, reason: validatedLines.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
|
||||||
|
if (!resolvedProject.ok) {
|
||||||
|
return { ok: false as const, reason: resolvedProject.reason };
|
||||||
|
}
|
||||||
|
|
||||||
const vendor = await prisma.vendor.findUnique({
|
const vendor = await prisma.vendor.findUnique({
|
||||||
where: { id: payload.vendorId },
|
where: { id: payload.vendorId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -777,6 +868,7 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
|
|||||||
data: {
|
data: {
|
||||||
documentNumber,
|
documentNumber,
|
||||||
vendorId: payload.vendorId,
|
vendorId: payload.vendorId,
|
||||||
|
projectId: resolvedProject.projectId,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
issueDate: new Date(payload.issueDate),
|
issueDate: new Date(payload.issueDate),
|
||||||
taxPercent: payload.taxPercent,
|
taxPercent: payload.taxPercent,
|
||||||
@@ -824,6 +916,11 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
|
|||||||
return { ok: false as const, reason: validatedLines.reason };
|
return { ok: false as const, reason: validatedLines.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedProject = await resolvePurchaseOrderProjectId(payload.projectId, validatedLines.lines);
|
||||||
|
if (!resolvedProject.ok) {
|
||||||
|
return { ok: false as const, reason: resolvedProject.reason };
|
||||||
|
}
|
||||||
|
|
||||||
const vendor = await prisma.vendor.findUnique({
|
const vendor = await prisma.vendor.findUnique({
|
||||||
where: { id: payload.vendorId },
|
where: { id: payload.vendorId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -837,6 +934,7 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
|
|||||||
where: { id: documentId },
|
where: { id: documentId },
|
||||||
data: {
|
data: {
|
||||||
vendorId: payload.vendorId,
|
vendorId: payload.vendorId,
|
||||||
|
projectId: resolvedProject.projectId,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
issueDate: new Date(payload.issueDate),
|
issueDate: new Date(payload.issueDate),
|
||||||
taxPercent: payload.taxPercent,
|
taxPercent: payload.taxPercent,
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ type SalesDocumentRecord = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
projects: Array<{
|
||||||
|
id: string;
|
||||||
|
projectNumber: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
revisions: RevisionRecord[];
|
revisions: RevisionRecord[];
|
||||||
lines: SalesLineRecord[];
|
lines: SalesLineRecord[];
|
||||||
};
|
};
|
||||||
@@ -279,6 +285,9 @@ function mapDocument(record: SalesDocumentRecord): SalesDocumentDetailDto {
|
|||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
updatedAt: record.updatedAt.toISOString(),
|
updatedAt: record.updatedAt.toISOString(),
|
||||||
|
linkedProjectId: record.projects[0]?.id ?? null,
|
||||||
|
linkedProjectNumber: record.projects[0]?.projectNumber ?? null,
|
||||||
|
linkedProjectName: record.projects[0]?.name ?? null,
|
||||||
lineCount: lines.length,
|
lineCount: lines.length,
|
||||||
lines,
|
lines,
|
||||||
revisions,
|
revisions,
|
||||||
@@ -383,6 +392,15 @@ function buildInclude() {
|
|||||||
lastName: true,
|
lastName: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projects: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
projectNumber: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" as const }],
|
||||||
|
},
|
||||||
revisions: {
|
revisions: {
|
||||||
include: {
|
include: {
|
||||||
createdBy: {
|
createdBy: {
|
||||||
@@ -799,6 +817,16 @@ export async function convertQuoteToSalesOrder(quoteId: string, userId?: string)
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.project.updateMany({
|
||||||
|
where: {
|
||||||
|
salesQuoteId: quoteId,
|
||||||
|
salesOrderId: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
salesOrderId: created.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return created.id;
|
return created.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export interface PurchaseOrderSummaryDto {
|
|||||||
documentNumber: string;
|
documentNumber: string;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
vendorName: string;
|
vendorName: string;
|
||||||
|
projectId: string | null;
|
||||||
|
projectNumber: string | null;
|
||||||
|
projectName: string | null;
|
||||||
status: PurchaseOrderStatus;
|
status: PurchaseOrderStatus;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
taxPercent: number;
|
taxPercent: number;
|
||||||
@@ -70,6 +73,7 @@ export interface PurchaseOrderDetailDto extends PurchaseOrderSummaryDto {
|
|||||||
|
|
||||||
export interface PurchaseOrderInput {
|
export interface PurchaseOrderInput {
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
|
projectId?: string | null;
|
||||||
status: PurchaseOrderStatus;
|
status: PurchaseOrderStatus;
|
||||||
issueDate: string;
|
issueDate: string;
|
||||||
taxPercent: number;
|
taxPercent: number;
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export interface SalesDocumentDetailDto extends SalesDocumentSummaryDto {
|
|||||||
notes: string;
|
notes: string;
|
||||||
expiresAt: string | null;
|
expiresAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
linkedProjectId: string | null;
|
||||||
|
linkedProjectNumber: string | null;
|
||||||
|
linkedProjectName: string | null;
|
||||||
lines: SalesLineDto[];
|
lines: SalesLineDto[];
|
||||||
revisions: SalesDocumentRevisionDto[];
|
revisions: SalesDocumentRevisionDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user