fixes
This commit is contained in:
@@ -10,6 +10,7 @@ Inven is a single-container inventory management system for Unraid-style deploym
|
|||||||
- Sales orders can be created and shipped
|
- Sales orders can be created and shipped
|
||||||
- Purchase orders can be created and received
|
- Purchase orders can be created and received
|
||||||
- Sales orders and purchase orders are created from relational inventory line selections
|
- Sales orders and purchase orders are created from relational inventory line selections
|
||||||
|
- Shipping and receiving are posted from relational order-line quantity controls
|
||||||
- Sales orders support partial shipments
|
- Sales orders support partial shipments
|
||||||
- Purchase orders support partial receipts
|
- Purchase orders support partial receipts
|
||||||
- Invoices are generated from shipped sales orders
|
- Invoices are generated from shipped sales orders
|
||||||
@@ -75,6 +76,7 @@ Suggested Unraid mapping:
|
|||||||
- Add parts and assemblies in the Parts module.
|
- Add parts and assemblies in the Parts module.
|
||||||
- Define assembly components in the Assemblies module.
|
- Define assembly components in the Assemblies module.
|
||||||
- Create SOs and POs by selecting actual inventory items from the item master instead of typing free-form SKUs.
|
- Create SOs and POs by selecting actual inventory items from the item master instead of typing free-form SKUs.
|
||||||
|
- Fulfill SOs and POs by entering quantities against existing order lines instead of typing SKU text.
|
||||||
- Use purchase orders to restock and receive inventory.
|
- Use purchase orders to restock and receive inventory.
|
||||||
- Use build transactions to convert component stock into assembly stock.
|
- Use build transactions to convert component stock into assembly stock.
|
||||||
- Use sales orders and ship them fully or partially to reduce stock and generate invoices plus journal entries.
|
- Use sales orders and ship them fully or partially to reduce stock and generate invoices plus journal entries.
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { addKitComponent, buildAssembly } from "@/lib/actions";
|
import { addKitComponent, buildAssembly } from "@/lib/actions";
|
||||||
import { getAssembliesWithComponents, getParts } from "@/lib/repository";
|
import { getAssembliesWithComponents, getParts } from "@/lib/repository";
|
||||||
|
|
||||||
export default function AssembliesPage() {
|
export default async function AssembliesPage({
|
||||||
|
searchParams
|
||||||
|
}: {
|
||||||
|
searchParams?: Promise<{ error?: string; success?: string }>;
|
||||||
|
}) {
|
||||||
|
const params = (await searchParams) ?? {};
|
||||||
const parts = getParts();
|
const parts = getParts();
|
||||||
const assemblies = parts.filter((part) => part.kind === "assembly");
|
const assemblies = parts.filter((part) => part.kind === "assembly");
|
||||||
const components = parts.filter((part) => part.kind === "part");
|
const components = parts.filter((part) => part.kind === "part");
|
||||||
@@ -9,6 +14,8 @@ export default function AssembliesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
|
{params.error ? <section className="panel"><p className="muted" style={{ color: "var(--danger)" }}>{params.error}</p></section> : null}
|
||||||
|
{params.success ? <section className="panel"><p className="muted" style={{ color: "var(--success)" }}>{params.success}</p></section> : null}
|
||||||
<section className="two-up">
|
<section className="two-up">
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Bill of Materials</h2>
|
<h2 className="section-title">Bill of Materials</h2>
|
||||||
@@ -16,26 +23,26 @@ export default function AssembliesPage() {
|
|||||||
<form action={addKitComponent} className="form-grid">
|
<form action={addKitComponent} className="form-grid">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="assemblySku">Assembly SKU</label>
|
<label htmlFor="assemblySku">Assembly SKU</label>
|
||||||
<select className="select" id="assemblySku" name="assemblySku">{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
|
<select className="select" id="assemblySku" name="assemblySku" disabled={assemblies.length === 0}>{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="componentSku">Component SKU</label>
|
<label htmlFor="componentSku">Component SKU</label>
|
||||||
<select className="select" id="componentSku" name="componentSku">{components.map((component) => <option key={component.id} value={component.sku}>{component.sku} - {component.name}</option>)}</select>
|
<select className="select" id="componentSku" name="componentSku" disabled={components.length === 0}>{components.map((component) => <option key={component.id} value={component.sku}>{component.sku} - {component.name}</option>)}</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row"><label htmlFor="component-qty">Quantity Per Assembly</label><input className="input" id="component-qty" name="quantity" type="number" min="0.01" step="0.01" defaultValue="1" /></div>
|
<div className="form-row"><label htmlFor="component-qty">Quantity Per Assembly</label><input className="input" id="component-qty" name="quantity" type="number" min="0.01" step="0.01" defaultValue="1" /></div>
|
||||||
<button className="button" type="submit">Save Component</button>
|
<button className="button" type="submit" disabled={assemblies.length === 0 || components.length === 0}>Save Component</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Build Assembly</h2>
|
<h2 className="section-title">Build Assembly</h2>
|
||||||
<p className="section-copy">Consume component stock and create finished kit inventory in one transaction flow.</p>
|
<p className="section-copy">Consume component stock and create finished kit inventory in one transaction flow. This only works after the assembly has a BOM and enough component stock exists.</p>
|
||||||
<form action={buildAssembly} className="form-grid">
|
<form action={buildAssembly} className="form-grid">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="build-assembly">Assembly SKU</label>
|
<label htmlFor="build-assembly">Assembly SKU</label>
|
||||||
<select className="select" id="build-assembly" name="assemblySku">{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
|
<select className="select" id="build-assembly" name="assemblySku" disabled={assemblies.length === 0}>{assemblies.map((assembly) => <option key={assembly.id} value={assembly.sku}>{assembly.sku} - {assembly.name}</option>)}</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row"><label htmlFor="build-qty">Build Quantity</label><input className="input" id="build-qty" name="quantity" type="number" min="1" step="1" defaultValue="1" /></div>
|
<div className="form-row"><label htmlFor="build-qty">Build Quantity</label><input className="input" id="build-qty" name="quantity" type="number" min="1" step="1" defaultValue="1" /></div>
|
||||||
<button className="button secondary" type="submit">Build Now</button>
|
<button className="button secondary" type="submit" disabled={assemblies.length === 0}>Build Now</button>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { receivePurchaseOrder } from "@/lib/actions";
|
import { PurchaseOrderFulfillmentForm } from "@/components/purchase-order-fulfillment-form";
|
||||||
import { PurchaseOrderForm } from "@/components/purchase-order-form";
|
import { PurchaseOrderForm } from "@/components/purchase-order-form";
|
||||||
import { formatCurrency, formatDate } from "@/lib/format";
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
import { getLowStockParts, getOrderItemOptions, getPurchaseOrders, getVendors } from "@/lib/repository";
|
import { getLowStockParts, getOrderItemOptions, getPurchaseOrderLineDetails, getPurchaseOrders, getVendors } from "@/lib/repository";
|
||||||
|
|
||||||
export default function PurchaseOrdersPage() {
|
export default function PurchaseOrdersPage() {
|
||||||
const vendors = getVendors();
|
const vendors = getVendors();
|
||||||
const items = getOrderItemOptions();
|
const items = getOrderItemOptions();
|
||||||
const orders = getPurchaseOrders();
|
const orders = getPurchaseOrders();
|
||||||
|
const orderLines = getPurchaseOrderLineDetails();
|
||||||
const lowStockParts = getLowStockParts();
|
const lowStockParts = getLowStockParts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,7 +20,7 @@ export default function PurchaseOrdersPage() {
|
|||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Receiving Flow</h2>
|
<h2 className="section-title">Receiving Flow</h2>
|
||||||
<p className="section-copy">Leave line quantities blank to receive the remaining balance, or enter `SKU,quantity` rows for a partial receipt.</p>
|
<p className="section-copy">Receive relational order lines directly by entering quantities against the remaining balance on each line.</p>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead><tr><th>Order</th><th>Vendor</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
|
<thead><tr><th>Order</th><th>Vendor</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
|
||||||
@@ -39,11 +40,10 @@ export default function PurchaseOrdersPage() {
|
|||||||
{order.status === "received" ? (
|
{order.status === "received" ? (
|
||||||
<span className="muted">Received</span>
|
<span className="muted">Received</span>
|
||||||
) : (
|
) : (
|
||||||
<form action={receivePurchaseOrder} className="form-grid">
|
<PurchaseOrderFulfillmentForm
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
orderId={order.id}
|
||||||
<textarea className="textarea" name="lines" placeholder={"PART-001,4\nPART-002,10"} />
|
lines={orderLines.filter((line) => line.purchaseOrderId === order.id && line.remainingQuantity > 0)}
|
||||||
<button className="button secondary" type="submit">Receive</button>
|
/>
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { shipSalesOrder } from "@/lib/actions";
|
import { SalesOrderFulfillmentForm } from "@/components/sales-order-fulfillment-form";
|
||||||
import { SalesOrderForm } from "@/components/sales-order-form";
|
import { SalesOrderForm } from "@/components/sales-order-form";
|
||||||
import { formatCurrency, formatDate } from "@/lib/format";
|
import { formatCurrency, formatDate } from "@/lib/format";
|
||||||
import { getCustomers, getOrderItemOptions, getSalesOrders } from "@/lib/repository";
|
import { getCustomers, getOrderItemOptions, getSalesOrderLineDetails, getSalesOrders } from "@/lib/repository";
|
||||||
|
|
||||||
export default function SalesOrdersPage() {
|
export default function SalesOrdersPage() {
|
||||||
const customers = getCustomers();
|
const customers = getCustomers();
|
||||||
const items = getOrderItemOptions();
|
const items = getOrderItemOptions();
|
||||||
const orders = getSalesOrders();
|
const orders = getSalesOrders();
|
||||||
|
const orderLines = getSalesOrderLineDetails();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
@@ -18,7 +19,7 @@ export default function SalesOrdersPage() {
|
|||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
<h2 className="section-title">Shipping Flow</h2>
|
<h2 className="section-title">Shipping Flow</h2>
|
||||||
<p className="section-copy">Leave line quantities blank to ship the remaining balance, or enter `SKU,quantity` rows for a partial shipment.</p>
|
<p className="section-copy">Ship relational order lines directly by choosing quantities from the remaining balance on each line.</p>
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
|
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th><th>Qty Progress</th><th>Created</th><th>Action</th></tr></thead>
|
||||||
@@ -38,11 +39,10 @@ export default function SalesOrdersPage() {
|
|||||||
{order.status === "shipped" ? (
|
{order.status === "shipped" ? (
|
||||||
<span className="muted">Shipped</span>
|
<span className="muted">Shipped</span>
|
||||||
) : (
|
) : (
|
||||||
<form action={shipSalesOrder} className="form-grid">
|
<SalesOrderFulfillmentForm
|
||||||
<input type="hidden" name="orderId" value={order.id} />
|
orderId={order.id}
|
||||||
<textarea className="textarea" name="lines" placeholder={"PART-001,1\nKIT-100,2"} />
|
lines={orderLines.filter((line) => line.salesOrderId === order.id && line.remainingQuantity > 0)}
|
||||||
<button className="button secondary" type="submit">Ship</button>
|
/>
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
74
components/purchase-order-fulfillment-form.tsx
Normal file
74
components/purchase-order-fulfillment-form.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { receivePurchaseOrder } from "@/lib/actions";
|
||||||
|
import type { PurchaseOrderLineDetailRow } from "@/lib/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
orderId: number;
|
||||||
|
lines: PurchaseOrderLineDetailRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PurchaseOrderFulfillmentForm({ orderId, lines }: Props) {
|
||||||
|
const [quantities, setQuantities] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const payload = useMemo(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(
|
||||||
|
lines
|
||||||
|
.map((line) => ({
|
||||||
|
lineId: line.lineId,
|
||||||
|
quantity: Number(quantities[line.lineId] || 0)
|
||||||
|
}))
|
||||||
|
.filter((line) => line.quantity > 0)
|
||||||
|
),
|
||||||
|
[lines, quantities]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={receivePurchaseOrder} className="form-grid">
|
||||||
|
<input type="hidden" name="orderId" value={orderId} />
|
||||||
|
<input type="hidden" name="fulfillmentLines" value={payload} />
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Remaining</th>
|
||||||
|
<th>Receive Now</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<tr key={line.lineId}>
|
||||||
|
<td>{line.sku}</td>
|
||||||
|
<td>{line.partName}</td>
|
||||||
|
<td>{line.remainingQuantity} {line.unitOfMeasure}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={line.remainingQuantity}
|
||||||
|
step="0.01"
|
||||||
|
value={quantities[line.lineId] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuantities((current) => ({
|
||||||
|
...current,
|
||||||
|
[line.lineId]: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button className="button secondary" type="submit">
|
||||||
|
Receive Selected Quantities
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
components/sales-order-fulfillment-form.tsx
Normal file
76
components/sales-order-fulfillment-form.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { shipSalesOrder } from "@/lib/actions";
|
||||||
|
import type { SalesOrderLineDetailRow } from "@/lib/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
orderId: number;
|
||||||
|
lines: SalesOrderLineDetailRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SalesOrderFulfillmentForm({ orderId, lines }: Props) {
|
||||||
|
const [quantities, setQuantities] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const payload = useMemo(
|
||||||
|
() =>
|
||||||
|
JSON.stringify(
|
||||||
|
lines
|
||||||
|
.map((line) => ({
|
||||||
|
lineId: line.lineId,
|
||||||
|
quantity: Number(quantities[line.lineId] || 0)
|
||||||
|
}))
|
||||||
|
.filter((line) => line.quantity > 0)
|
||||||
|
),
|
||||||
|
[lines, quantities]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={shipSalesOrder} className="form-grid">
|
||||||
|
<input type="hidden" name="orderId" value={orderId} />
|
||||||
|
<input type="hidden" name="fulfillmentLines" value={payload} />
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>SKU</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Remaining</th>
|
||||||
|
<th>On Hand</th>
|
||||||
|
<th>Ship Now</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((line) => (
|
||||||
|
<tr key={line.lineId}>
|
||||||
|
<td>{line.sku}</td>
|
||||||
|
<td>{line.partName}</td>
|
||||||
|
<td>{line.remainingQuantity} {line.unitOfMeasure}</td>
|
||||||
|
<td>{line.quantityOnHand} {line.unitOfMeasure}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={Math.min(line.remainingQuantity, line.quantityOnHand)}
|
||||||
|
step="0.01"
|
||||||
|
value={quantities[line.lineId] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setQuantities((current) => ({
|
||||||
|
...current,
|
||||||
|
[line.lineId]: event.target.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button className="button secondary" type="submit">
|
||||||
|
Ship Selected Quantities
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ type ParsedFulfillmentLine = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RelationalFulfillmentLine = {
|
||||||
|
lineId: number;
|
||||||
|
quantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
function db() {
|
function db() {
|
||||||
return getDb();
|
return getDb();
|
||||||
}
|
}
|
||||||
@@ -121,6 +126,32 @@ function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRelationalFulfillmentLines(raw: string): RelationalFulfillmentLine[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
|
||||||
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||||
|
throw new Error("No fulfillment lines selected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.map((line) => {
|
||||||
|
const lineId = Number(line.lineId);
|
||||||
|
const quantity = Number(line.quantity);
|
||||||
|
|
||||||
|
if (!Number.isInteger(lineId) || lineId <= 0) {
|
||||||
|
throw new Error("Invalid line selection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||||
|
throw new Error("Invalid fulfillment quantity.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lineId, quantity };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid relational fulfillment payload.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getPartIdBySku(sku: string) {
|
function getPartIdBySku(sku: string) {
|
||||||
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
|
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -274,8 +305,12 @@ export async function buildAssembly(formData: FormData) {
|
|||||||
const assemblySku = getText(formData, "assemblySku");
|
const assemblySku = getText(formData, "assemblySku");
|
||||||
const buildQuantity = getNumber(formData, "quantity");
|
const buildQuantity = getNumber(formData, "quantity");
|
||||||
|
|
||||||
|
if (!assemblySku) {
|
||||||
|
redirect("/assemblies?error=Select an assembly before building.");
|
||||||
|
}
|
||||||
|
|
||||||
if (buildQuantity <= 0) {
|
if (buildQuantity <= 0) {
|
||||||
throw new Error("Build quantity must be greater than zero.");
|
redirect("/assemblies?error=Build quantity must be greater than zero.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const assemblyId = getPartIdBySku(assemblySku);
|
const assemblyId = getPartIdBySku(assemblySku);
|
||||||
@@ -304,13 +339,13 @@ export async function buildAssembly(formData: FormData) {
|
|||||||
}>;
|
}>;
|
||||||
|
|
||||||
if (components.length === 0) {
|
if (components.length === 0) {
|
||||||
throw new Error("Assembly has no bill of materials defined.");
|
redirect("/assemblies?error=Assembly has no bill of materials defined.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const component of components) {
|
for (const component of components) {
|
||||||
const needed = component.componentQuantity * buildQuantity;
|
const needed = component.componentQuantity * buildQuantity;
|
||||||
if (component.quantityOnHand < needed) {
|
if (component.quantityOnHand < needed) {
|
||||||
throw new Error(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`);
|
redirect(`/assemblies?error=${encodeURIComponent(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +389,7 @@ export async function buildAssembly(formData: FormData) {
|
|||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/parts");
|
revalidatePath("/parts");
|
||||||
revalidatePath("/assemblies");
|
revalidatePath("/assemblies");
|
||||||
|
redirect(`/assemblies?success=${encodeURIComponent(`Built ${buildQuantity} of ${assemblySku}.`)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recordAdjustment(formData: FormData) {
|
export async function recordAdjustment(formData: FormData) {
|
||||||
@@ -548,9 +584,28 @@ export async function shipSalesOrder(formData: FormData) {
|
|||||||
throw new Error("Sales order has no lines.");
|
throw new Error("Sales order has no lines.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
|
const relationalPayload = getText(formData, "fulfillmentLines");
|
||||||
const fulfilledLines = requestedLines.length
|
const fulfilledLines = relationalPayload
|
||||||
? requestedLines.map((request) => {
|
? parseRelationalFulfillmentLines(relationalPayload).map((request) => {
|
||||||
|
const matchingLine = orderLines.find((line) => line.lineId === request.lineId);
|
||||||
|
|
||||||
|
if (!matchingLine) {
|
||||||
|
throw new Error("Selected sales order line is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = matchingLine.quantity - matchingLine.shippedQuantity;
|
||||||
|
if (request.quantity > remaining) {
|
||||||
|
throw new Error(`Cannot ship ${request.quantity} of ${matchingLine.sku}; only ${remaining} remain.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingLine.quantityOnHand < request.quantity) {
|
||||||
|
throw new Error(`Insufficient stock for ${matchingLine.sku}. Need ${request.quantity}, have ${matchingLine.quantityOnHand}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...matchingLine, shipQuantity: request.quantity };
|
||||||
|
})
|
||||||
|
: parseFulfillmentLines(getText(formData, "lines")).length
|
||||||
|
? parseFulfillmentLines(getText(formData, "lines")).map((request) => {
|
||||||
const matchingLine = orderLines.find((line) => line.sku === request.sku);
|
const matchingLine = orderLines.find((line) => line.sku === request.sku);
|
||||||
|
|
||||||
if (!matchingLine) {
|
if (!matchingLine) {
|
||||||
@@ -718,9 +773,24 @@ export async function receivePurchaseOrder(formData: FormData) {
|
|||||||
throw new Error("Purchase order has no lines.");
|
throw new Error("Purchase order has no lines.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
|
const relationalPayload = getText(formData, "fulfillmentLines");
|
||||||
const fulfilledLines = requestedLines.length
|
const fulfilledLines = relationalPayload
|
||||||
? requestedLines.map((request) => {
|
? parseRelationalFulfillmentLines(relationalPayload).map((request) => {
|
||||||
|
const matchingLine = lines.find((line) => line.lineId === request.lineId);
|
||||||
|
|
||||||
|
if (!matchingLine) {
|
||||||
|
throw new Error("Selected purchase order line is invalid.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = matchingLine.quantity - matchingLine.receivedQuantity;
|
||||||
|
if (request.quantity > remaining) {
|
||||||
|
throw new Error(`Cannot receive ${request.quantity} of ${matchingLine.sku}; only ${remaining} remain.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...matchingLine, receiveQuantity: request.quantity };
|
||||||
|
})
|
||||||
|
: parseFulfillmentLines(getText(formData, "lines")).length
|
||||||
|
? parseFulfillmentLines(getText(formData, "lines")).map((request) => {
|
||||||
const matchingLine = lines.find((line) => line.sku === request.sku);
|
const matchingLine = lines.find((line) => line.sku === request.sku);
|
||||||
|
|
||||||
if (!matchingLine) {
|
if (!matchingLine) {
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import type {
|
|||||||
OrderItemOption,
|
OrderItemOption,
|
||||||
PartRow,
|
PartRow,
|
||||||
PurchaseOrderListRow,
|
PurchaseOrderListRow,
|
||||||
|
PurchaseOrderLineDetailRow,
|
||||||
SalesOrderListRow,
|
SalesOrderListRow,
|
||||||
|
SalesOrderLineDetailRow,
|
||||||
VendorBillRow
|
VendorBillRow
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
@@ -190,6 +192,54 @@ export function getPurchaseOrders(): PurchaseOrderListRow[] {
|
|||||||
.all() as PurchaseOrderListRow[];
|
.all() as PurchaseOrderListRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSalesOrderLineDetails(): SalesOrderLineDetailRow[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
sol.id AS lineId,
|
||||||
|
sol.sales_order_id AS salesOrderId,
|
||||||
|
sol.part_id AS partId,
|
||||||
|
p.sku,
|
||||||
|
p.name AS partName,
|
||||||
|
sol.quantity,
|
||||||
|
sol.shipped_quantity AS fulfilledQuantity,
|
||||||
|
sol.quantity - sol.shipped_quantity AS remainingQuantity,
|
||||||
|
sol.unit_price AS unitPrice,
|
||||||
|
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand,
|
||||||
|
p.unit_of_measure AS unitOfMeasure
|
||||||
|
FROM sales_order_lines sol
|
||||||
|
INNER JOIN parts p ON p.id = sol.part_id
|
||||||
|
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
|
||||||
|
ORDER BY sol.sales_order_id DESC, sol.id ASC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all() as SalesOrderLineDetailRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPurchaseOrderLineDetails(): PurchaseOrderLineDetailRow[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
pol.id AS lineId,
|
||||||
|
pol.purchase_order_id AS purchaseOrderId,
|
||||||
|
pol.part_id AS partId,
|
||||||
|
p.sku,
|
||||||
|
p.name AS partName,
|
||||||
|
pol.quantity,
|
||||||
|
pol.received_quantity AS fulfilledQuantity,
|
||||||
|
pol.quantity - pol.received_quantity AS remainingQuantity,
|
||||||
|
pol.unit_cost AS unitCost,
|
||||||
|
p.unit_of_measure AS unitOfMeasure
|
||||||
|
FROM purchase_order_lines pol
|
||||||
|
INNER JOIN parts p ON p.id = pol.part_id
|
||||||
|
ORDER BY pol.purchase_order_id DESC, pol.id ASC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all() as PurchaseOrderLineDetailRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export function getJournalEntries(): JournalEntryRow[] {
|
export function getJournalEntries(): JournalEntryRow[] {
|
||||||
const entries = db()
|
const entries = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
27
lib/types.ts
27
lib/types.ts
@@ -141,3 +141,30 @@ export type VendorBillRow = {
|
|||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
balanceDue: number;
|
balanceDue: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SalesOrderLineDetailRow = {
|
||||||
|
lineId: number;
|
||||||
|
salesOrderId: number;
|
||||||
|
partId: number;
|
||||||
|
sku: string;
|
||||||
|
partName: string;
|
||||||
|
quantity: number;
|
||||||
|
fulfilledQuantity: number;
|
||||||
|
remainingQuantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
quantityOnHand: number;
|
||||||
|
unitOfMeasure: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PurchaseOrderLineDetailRow = {
|
||||||
|
lineId: number;
|
||||||
|
purchaseOrderId: number;
|
||||||
|
partId: number;
|
||||||
|
sku: string;
|
||||||
|
partName: string;
|
||||||
|
quantity: number;
|
||||||
|
fulfilledQuantity: number;
|
||||||
|
remainingQuantity: number;
|
||||||
|
unitCost: number;
|
||||||
|
unitOfMeasure: string;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user