936 lines
29 KiB
TypeScript
936 lines
29 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { authenticateUser, createSession, destroySession } from "@/lib/auth";
|
|
import { getDb } from "@/lib/db";
|
|
|
|
type ParsedLine = {
|
|
sku: string;
|
|
quantity: number;
|
|
amount: number;
|
|
};
|
|
|
|
type RelationalOrderLine = {
|
|
partId: number;
|
|
quantity: number;
|
|
amount: number;
|
|
};
|
|
|
|
type ParsedFulfillmentLine = {
|
|
sku: string;
|
|
quantity: number;
|
|
};
|
|
|
|
function db() {
|
|
return getDb();
|
|
}
|
|
|
|
function getText(formData: FormData, key: string) {
|
|
return String(formData.get(key) ?? "").trim();
|
|
}
|
|
|
|
function getNumber(formData: FormData, key: string) {
|
|
const value = Number(getText(formData, key));
|
|
return Number.isFinite(value) ? value : 0;
|
|
}
|
|
|
|
function parseLines(raw: string): ParsedLine[] {
|
|
return raw
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [sku, quantity, amount] = line.split(",").map((piece) => piece.trim());
|
|
if (!sku || !quantity || !amount) {
|
|
throw new Error(`Invalid line format: "${line}". Use SKU,quantity,amount.`);
|
|
}
|
|
|
|
const parsedQuantity = Number(quantity);
|
|
const parsedAmount = Number(amount);
|
|
|
|
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0 || !Number.isFinite(parsedAmount) || parsedAmount < 0) {
|
|
throw new Error(`Invalid line values: "${line}".`);
|
|
}
|
|
|
|
return {
|
|
sku,
|
|
quantity: parsedQuantity,
|
|
amount: parsedAmount
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseRelationalOrderLines(raw: string): RelationalOrderLine[] {
|
|
try {
|
|
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
|
|
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
throw new Error("Order must contain at least one line.");
|
|
}
|
|
|
|
const lines = parsed.map((line) => {
|
|
const partId = Number(line.partId);
|
|
const quantity = Number(line.quantity);
|
|
const amount = Number(line.amount);
|
|
|
|
if (!Number.isInteger(partId) || partId <= 0) {
|
|
throw new Error("Invalid item selection.");
|
|
}
|
|
|
|
if (!Number.isFinite(quantity) || quantity <= 0) {
|
|
throw new Error("Invalid line quantity.");
|
|
}
|
|
|
|
if (!Number.isFinite(amount) || amount < 0) {
|
|
throw new Error("Invalid line amount.");
|
|
}
|
|
|
|
return { partId, quantity, amount };
|
|
});
|
|
|
|
const uniquePartIds = new Set(lines.map((line) => line.partId));
|
|
if (uniquePartIds.size !== lines.length) {
|
|
throw new Error("Each inventory item can only appear once per order.");
|
|
}
|
|
|
|
return lines;
|
|
} catch {
|
|
throw new Error("Invalid relational order payload.");
|
|
}
|
|
}
|
|
|
|
function parseFulfillmentLines(raw: string): ParsedFulfillmentLine[] {
|
|
return raw
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [sku, quantity] = line.split(",").map((piece) => piece.trim());
|
|
|
|
if (!sku || !quantity) {
|
|
throw new Error(`Invalid fulfillment line format: "${line}". Use SKU,quantity.`);
|
|
}
|
|
|
|
const parsedQuantity = Number(quantity);
|
|
|
|
if (!Number.isFinite(parsedQuantity) || parsedQuantity <= 0) {
|
|
throw new Error(`Invalid fulfillment quantity: "${line}".`);
|
|
}
|
|
|
|
return { sku, quantity: parsedQuantity };
|
|
});
|
|
}
|
|
|
|
function getPartIdBySku(sku: string) {
|
|
const row = db().prepare(`SELECT id FROM parts WHERE sku = ?`).get(sku) as { id: number } | undefined;
|
|
if (!row) {
|
|
throw new Error(`Part with SKU "${sku}" does not exist.`);
|
|
}
|
|
return row.id;
|
|
}
|
|
|
|
function getExistingPart(partId: number) {
|
|
const row = db()
|
|
.prepare(`SELECT id FROM parts WHERE id = ?`)
|
|
.get(partId) as { id: number } | undefined;
|
|
|
|
if (!row) {
|
|
throw new Error(`Selected inventory item ${partId} does not exist.`);
|
|
}
|
|
|
|
return row.id;
|
|
}
|
|
|
|
function getOrderNumber(prefix: string, table: "sales_orders" | "purchase_orders") {
|
|
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
|
|
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
|
|
}
|
|
|
|
function getDocumentNumber(prefix: string, table: "customer_invoices" | "vendor_bills") {
|
|
const row = db().prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
|
|
return `${prefix}-${String((row.count ?? 0) + 1).padStart(5, "0")}`;
|
|
}
|
|
|
|
function createJournalEntry(
|
|
entryType: string,
|
|
referenceType: string,
|
|
referenceId: number | null,
|
|
description: string,
|
|
lines: Array<{ accountCode: string; accountName: string; debit: number; credit: number }>
|
|
) {
|
|
const tx = db().transaction(() => {
|
|
const result = db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO journal_entries (entry_type, reference_type, reference_id, description)
|
|
VALUES (?, ?, ?, ?)
|
|
`
|
|
)
|
|
.run(entryType, referenceType, referenceId, description);
|
|
|
|
const journalEntryId = Number(result.lastInsertRowid);
|
|
const insertLine = db().prepare(
|
|
`
|
|
INSERT INTO journal_lines (journal_entry_id, account_code, account_name, debit, credit)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`
|
|
);
|
|
|
|
for (const line of lines) {
|
|
insertLine.run(journalEntryId, line.accountCode, line.accountName, line.debit, line.credit);
|
|
}
|
|
});
|
|
|
|
tx();
|
|
}
|
|
|
|
function resolveAccount(code: string) {
|
|
const row = db().prepare(`SELECT code, name FROM accounts WHERE code = ?`).get(code) as { code: string; name: string } | undefined;
|
|
|
|
if (!row) {
|
|
throw new Error(`Account "${code}" does not exist.`);
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
function parseJournalLines(raw: string) {
|
|
return raw
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [accountCode, debit, credit] = line.split(",").map((piece) => piece.trim());
|
|
|
|
if (!accountCode || debit === undefined || credit === undefined) {
|
|
throw new Error(`Invalid journal line format: "${line}". Use account code,debit,credit.`);
|
|
}
|
|
|
|
const parsedDebit = Number(debit);
|
|
const parsedCredit = Number(credit);
|
|
|
|
if (!Number.isFinite(parsedDebit) || !Number.isFinite(parsedCredit) || parsedDebit < 0 || parsedCredit < 0) {
|
|
throw new Error(`Invalid journal amounts: "${line}".`);
|
|
}
|
|
|
|
if ((parsedDebit === 0 && parsedCredit === 0) || (parsedDebit > 0 && parsedCredit > 0)) {
|
|
throw new Error(`Journal lines must have either a debit or a credit: "${line}".`);
|
|
}
|
|
|
|
const account = resolveAccount(accountCode);
|
|
|
|
return {
|
|
accountCode: account.code,
|
|
accountName: account.name,
|
|
debit: parsedDebit,
|
|
credit: parsedCredit
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function createPart(formData: FormData) {
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO parts (sku, name, description, kind, unit_cost, sale_price, reorder_point, unit_of_measure)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
)
|
|
.run(
|
|
getText(formData, "sku"),
|
|
getText(formData, "name"),
|
|
getText(formData, "description"),
|
|
getText(formData, "kind"),
|
|
getNumber(formData, "unitCost"),
|
|
getNumber(formData, "salePrice"),
|
|
getNumber(formData, "reorderPoint"),
|
|
getText(formData, "unitOfMeasure") || "ea"
|
|
);
|
|
|
|
revalidatePath("/");
|
|
revalidatePath("/parts");
|
|
revalidatePath("/assemblies");
|
|
}
|
|
|
|
export async function addKitComponent(formData: FormData) {
|
|
const assemblyId = getPartIdBySku(getText(formData, "assemblySku"));
|
|
const componentId = getPartIdBySku(getText(formData, "componentSku"));
|
|
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO kit_components (assembly_part_id, component_part_id, quantity)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT (assembly_part_id, component_part_id)
|
|
DO UPDATE SET quantity = excluded.quantity
|
|
`
|
|
)
|
|
.run(assemblyId, componentId, getNumber(formData, "quantity"));
|
|
|
|
revalidatePath("/assemblies");
|
|
}
|
|
|
|
export async function buildAssembly(formData: FormData) {
|
|
const assemblySku = getText(formData, "assemblySku");
|
|
const buildQuantity = getNumber(formData, "quantity");
|
|
|
|
if (buildQuantity <= 0) {
|
|
throw new Error("Build quantity must be greater than zero.");
|
|
}
|
|
|
|
const assemblyId = getPartIdBySku(assemblySku);
|
|
|
|
const components = db()
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
kc.component_part_id AS componentId,
|
|
kc.quantity AS componentQuantity,
|
|
p.sku,
|
|
p.unit_cost AS unitCost,
|
|
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
|
|
FROM kit_components kc
|
|
INNER JOIN parts p ON p.id = kc.component_part_id
|
|
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
|
|
WHERE kc.assembly_part_id = ?
|
|
`
|
|
)
|
|
.all(assemblyId) as Array<{
|
|
componentId: number;
|
|
componentQuantity: number;
|
|
sku: string;
|
|
unitCost: number;
|
|
quantityOnHand: number;
|
|
}>;
|
|
|
|
if (components.length === 0) {
|
|
throw new Error("Assembly has no bill of materials defined.");
|
|
}
|
|
|
|
for (const component of components) {
|
|
const needed = component.componentQuantity * buildQuantity;
|
|
if (component.quantityOnHand < needed) {
|
|
throw new Error(`Not enough stock for component ${component.sku}. Need ${needed}, have ${component.quantityOnHand}.`);
|
|
}
|
|
}
|
|
|
|
const buildCost = components.reduce((sum, component) => sum + component.unitCost * component.componentQuantity, 0);
|
|
|
|
const tx = db().transaction(() => {
|
|
const insertInventory = db().prepare(
|
|
`
|
|
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
);
|
|
const updateAssemblyCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
|
|
|
|
for (const component of components) {
|
|
insertInventory.run(
|
|
component.componentId,
|
|
component.componentQuantity * buildQuantity * -1,
|
|
component.unitCost,
|
|
"assembly_consume",
|
|
"assembly_build",
|
|
assemblyId,
|
|
`Consumed for ${assemblySku}`
|
|
);
|
|
}
|
|
|
|
insertInventory.run(
|
|
assemblyId,
|
|
buildQuantity,
|
|
buildCost,
|
|
"assembly_build",
|
|
"assembly_build",
|
|
assemblyId,
|
|
`Built ${buildQuantity} of ${assemblySku}`
|
|
);
|
|
|
|
updateAssemblyCost.run(buildCost, assemblyId);
|
|
});
|
|
|
|
tx();
|
|
revalidatePath("/");
|
|
revalidatePath("/parts");
|
|
revalidatePath("/assemblies");
|
|
}
|
|
|
|
export async function recordAdjustment(formData: FormData) {
|
|
const partId = getPartIdBySku(getText(formData, "sku"));
|
|
const quantityDelta = getNumber(formData, "quantityDelta");
|
|
const unitCost = getNumber(formData, "unitCost");
|
|
const notes = getText(formData, "notes");
|
|
const inventoryImpact = Math.abs(quantityDelta * unitCost);
|
|
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, notes)
|
|
VALUES (?, ?, ?, 'adjustment', 'manual_adjustment', ?)
|
|
`
|
|
)
|
|
.run(partId, quantityDelta, unitCost, notes);
|
|
|
|
if (inventoryImpact > 0) {
|
|
createJournalEntry("adjustment", "manual_adjustment", partId, notes || "Inventory adjustment posted", [
|
|
{
|
|
accountCode: quantityDelta >= 0 ? "1200" : "6100",
|
|
accountName: quantityDelta >= 0 ? "Inventory" : "Inventory Adjustments",
|
|
debit: quantityDelta >= 0 ? inventoryImpact : 0,
|
|
credit: quantityDelta >= 0 ? 0 : inventoryImpact
|
|
},
|
|
{
|
|
accountCode: quantityDelta >= 0 ? "3000" : "1200",
|
|
accountName: quantityDelta >= 0 ? "Owner Equity" : "Inventory",
|
|
debit: quantityDelta >= 0 ? 0 : inventoryImpact,
|
|
credit: quantityDelta >= 0 ? inventoryImpact : 0
|
|
}
|
|
]);
|
|
}
|
|
|
|
revalidatePath("/");
|
|
revalidatePath("/parts");
|
|
revalidatePath("/accounting");
|
|
}
|
|
|
|
export async function createCustomer(formData: FormData) {
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO customers (code, name, email, phone, billing_address, shipping_address)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`
|
|
)
|
|
.run(
|
|
getText(formData, "code"),
|
|
getText(formData, "name"),
|
|
getText(formData, "email"),
|
|
getText(formData, "phone"),
|
|
getText(formData, "billingAddress"),
|
|
getText(formData, "shippingAddress")
|
|
);
|
|
|
|
revalidatePath("/customers");
|
|
revalidatePath("/");
|
|
}
|
|
|
|
export async function createAccount(formData: FormData) {
|
|
const category = getText(formData, "category");
|
|
|
|
if (!["asset", "liability", "equity", "revenue", "expense"].includes(category)) {
|
|
throw new Error("Invalid account category.");
|
|
}
|
|
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO accounts (code, name, category, is_system)
|
|
VALUES (?, ?, ?, 0)
|
|
`
|
|
)
|
|
.run(getText(formData, "code"), getText(formData, "name"), category);
|
|
|
|
revalidatePath("/accounting");
|
|
}
|
|
|
|
export async function createVendor(formData: FormData) {
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO vendors (code, name, email, phone, address)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`
|
|
)
|
|
.run(
|
|
getText(formData, "code"),
|
|
getText(formData, "name"),
|
|
getText(formData, "email"),
|
|
getText(formData, "phone"),
|
|
getText(formData, "address")
|
|
);
|
|
|
|
revalidatePath("/vendors");
|
|
revalidatePath("/");
|
|
}
|
|
|
|
export async function createSalesOrder(formData: FormData) {
|
|
const customerId = Number(getText(formData, "customerId"));
|
|
const relationalLinesPayload = getText(formData, "lineItems");
|
|
const lines = relationalLinesPayload
|
|
? parseRelationalOrderLines(relationalLinesPayload)
|
|
: parseLines(getText(formData, "lines")).map((line) => ({
|
|
partId: getPartIdBySku(line.sku),
|
|
quantity: line.quantity,
|
|
amount: line.amount
|
|
}));
|
|
const customerRow = db().prepare(`SELECT id FROM customers WHERE id = ?`).get(customerId) as { id: number } | undefined;
|
|
|
|
if (!customerRow) {
|
|
throw new Error("Selected customer does not exist.");
|
|
}
|
|
|
|
const tx = db().transaction(() => {
|
|
const result = db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO sales_orders (order_number, customer_id, status, notes)
|
|
VALUES (?, ?, 'open', ?)
|
|
`
|
|
)
|
|
.run(getOrderNumber("SO", "sales_orders"), customerRow.id, getText(formData, "notes"));
|
|
|
|
const orderId = Number(result.lastInsertRowid);
|
|
const insertLine = db().prepare(
|
|
`
|
|
INSERT INTO sales_order_lines (sales_order_id, part_id, quantity, unit_price)
|
|
VALUES (?, ?, ?, ?)
|
|
`
|
|
);
|
|
|
|
for (const line of lines) {
|
|
insertLine.run(orderId, getExistingPart(line.partId), line.quantity, line.amount);
|
|
}
|
|
});
|
|
|
|
tx();
|
|
revalidatePath("/");
|
|
revalidatePath("/sales-orders");
|
|
}
|
|
|
|
export async function shipSalesOrder(formData: FormData) {
|
|
const orderId = Number(getText(formData, "orderId"));
|
|
const order = db()
|
|
.prepare(`SELECT customer_id AS customerId, status FROM sales_orders WHERE id = ?`)
|
|
.get(orderId) as { customerId: number; status: string } | undefined;
|
|
|
|
if (!order) {
|
|
throw new Error("Sales order not found.");
|
|
}
|
|
|
|
if (order.status === "shipped") {
|
|
throw new Error("Sales order has already been shipped.");
|
|
}
|
|
|
|
const orderLines = db()
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
sol.id AS lineId,
|
|
so.order_number AS orderNumber,
|
|
p.id AS partId,
|
|
p.sku,
|
|
p.unit_cost AS unitCost,
|
|
sol.quantity,
|
|
sol.shipped_quantity AS shippedQuantity,
|
|
sol.unit_price AS unitPrice,
|
|
COALESCE(ib.quantity_on_hand, 0) AS quantityOnHand
|
|
FROM sales_order_lines sol
|
|
INNER JOIN sales_orders so ON so.id = sol.sales_order_id
|
|
INNER JOIN parts p ON p.id = sol.part_id
|
|
LEFT JOIN inventory_balances ib ON ib.part_id = p.id
|
|
WHERE sol.sales_order_id = ?
|
|
`
|
|
)
|
|
.all(orderId) as Array<{
|
|
lineId: number;
|
|
orderNumber: string;
|
|
partId: number;
|
|
sku: string;
|
|
unitCost: number;
|
|
quantity: number;
|
|
shippedQuantity: number;
|
|
unitPrice: number;
|
|
quantityOnHand: number;
|
|
}>;
|
|
|
|
if (orderLines.length === 0) {
|
|
throw new Error("Sales order has no lines.");
|
|
}
|
|
|
|
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
|
|
const fulfilledLines = requestedLines.length
|
|
? requestedLines.map((request) => {
|
|
const matchingLine = orderLines.find((line) => line.sku === request.sku);
|
|
|
|
if (!matchingLine) {
|
|
throw new Error(`SKU ${request.sku} is not on this sales order.`);
|
|
}
|
|
|
|
const remaining = matchingLine.quantity - matchingLine.shippedQuantity;
|
|
if (request.quantity > remaining) {
|
|
throw new Error(`Cannot ship ${request.quantity} of ${request.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 };
|
|
})
|
|
: orderLines
|
|
.map((line) => {
|
|
const remaining = line.quantity - line.shippedQuantity;
|
|
return remaining > 0 ? { ...line, shipQuantity: remaining } : null;
|
|
})
|
|
.filter((line): line is NonNullable<typeof line> => line !== null);
|
|
|
|
if (fulfilledLines.length === 0) {
|
|
throw new Error("No shippable quantities were provided.");
|
|
}
|
|
|
|
const revenue = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitPrice, 0);
|
|
const cogs = fulfilledLines.reduce((sum, line) => sum + line.shipQuantity * line.unitCost, 0);
|
|
const orderNumber = orderLines[0].orderNumber;
|
|
|
|
const tx = db().transaction(() => {
|
|
const insertInventory = db().prepare(
|
|
`
|
|
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
|
|
VALUES (?, ?, ?, 'sales_shipment', 'sales_order', ?, ?)
|
|
`
|
|
);
|
|
const updateLine = db().prepare(`UPDATE sales_order_lines SET shipped_quantity = shipped_quantity + ? WHERE id = ?`);
|
|
|
|
for (const line of fulfilledLines) {
|
|
insertInventory.run(line.partId, line.shipQuantity * -1, line.unitCost, orderId, `Shipment for ${orderNumber}`);
|
|
updateLine.run(line.shipQuantity, line.lineId);
|
|
}
|
|
|
|
const remainingCount = db()
|
|
.prepare(`SELECT COUNT(*) AS count FROM sales_order_lines WHERE sales_order_id = ? AND shipped_quantity < quantity`)
|
|
.get(orderId) as { count: number };
|
|
const nextStatus = remainingCount.count > 0 ? "partial" : "shipped";
|
|
|
|
db().prepare(`UPDATE sales_orders SET status = ?, shipped_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO customer_invoices (invoice_number, sales_order_id, customer_id, status, due_date, total_amount, paid_amount)
|
|
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
|
|
`
|
|
)
|
|
.run(getDocumentNumber("INV", "customer_invoices"), orderId, order.customerId, revenue);
|
|
});
|
|
|
|
tx();
|
|
|
|
createJournalEntry("shipment", "sales_order", orderId, `Shipment posted for ${orderNumber}`, [
|
|
{ accountCode: "1100", accountName: "Accounts Receivable", debit: revenue, credit: 0 },
|
|
{ accountCode: "4000", accountName: "Sales Revenue", debit: 0, credit: revenue },
|
|
{ accountCode: "5000", accountName: "Cost of Goods Sold", debit: cogs, credit: 0 },
|
|
{ accountCode: "1200", accountName: "Inventory", debit: 0, credit: cogs }
|
|
]);
|
|
|
|
revalidatePath("/");
|
|
revalidatePath("/parts");
|
|
revalidatePath("/sales-orders");
|
|
revalidatePath("/accounting");
|
|
revalidatePath("/invoices");
|
|
}
|
|
|
|
export async function createPurchaseOrder(formData: FormData) {
|
|
const vendorId = Number(getText(formData, "vendorId"));
|
|
const relationalLinesPayload = getText(formData, "lineItems");
|
|
const lines = relationalLinesPayload
|
|
? parseRelationalOrderLines(relationalLinesPayload)
|
|
: parseLines(getText(formData, "lines")).map((line) => ({
|
|
partId: getPartIdBySku(line.sku),
|
|
quantity: line.quantity,
|
|
amount: line.amount
|
|
}));
|
|
const vendorRow = db().prepare(`SELECT id FROM vendors WHERE id = ?`).get(vendorId) as { id: number } | undefined;
|
|
|
|
if (!vendorRow) {
|
|
throw new Error("Selected vendor does not exist.");
|
|
}
|
|
|
|
const tx = db().transaction(() => {
|
|
const result = db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO purchase_orders (order_number, vendor_id, status, notes)
|
|
VALUES (?, ?, 'ordered', ?)
|
|
`
|
|
)
|
|
.run(getOrderNumber("PO", "purchase_orders"), vendorRow.id, getText(formData, "notes"));
|
|
|
|
const orderId = Number(result.lastInsertRowid);
|
|
const insertLine = db().prepare(
|
|
`
|
|
INSERT INTO purchase_order_lines (purchase_order_id, part_id, quantity, unit_cost)
|
|
VALUES (?, ?, ?, ?)
|
|
`
|
|
);
|
|
|
|
for (const line of lines) {
|
|
insertLine.run(orderId, getExistingPart(line.partId), line.quantity, line.amount);
|
|
}
|
|
});
|
|
|
|
tx();
|
|
revalidatePath("/");
|
|
revalidatePath("/purchase-orders");
|
|
}
|
|
|
|
export async function receivePurchaseOrder(formData: FormData) {
|
|
const orderId = Number(getText(formData, "orderId"));
|
|
const order = db()
|
|
.prepare(`SELECT vendor_id AS vendorId, status FROM purchase_orders WHERE id = ?`)
|
|
.get(orderId) as { vendorId: number; status: string } | undefined;
|
|
|
|
if (!order) {
|
|
throw new Error("Purchase order not found.");
|
|
}
|
|
|
|
if (order.status === "received") {
|
|
throw new Error("Purchase order has already been received.");
|
|
}
|
|
|
|
const lines = db()
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
pol.id AS lineId,
|
|
po.order_number AS orderNumber,
|
|
pol.part_id AS partId,
|
|
pol.quantity,
|
|
pol.received_quantity AS receivedQuantity,
|
|
p.sku,
|
|
pol.unit_cost AS unitCost
|
|
FROM purchase_order_lines pol
|
|
INNER JOIN purchase_orders po ON po.id = pol.purchase_order_id
|
|
INNER JOIN parts p ON p.id = pol.part_id
|
|
WHERE pol.purchase_order_id = ?
|
|
`
|
|
)
|
|
.all(orderId) as Array<{
|
|
lineId: number;
|
|
orderNumber: string;
|
|
partId: number;
|
|
quantity: number;
|
|
receivedQuantity: number;
|
|
sku: string;
|
|
unitCost: number;
|
|
}>;
|
|
|
|
if (lines.length === 0) {
|
|
throw new Error("Purchase order has no lines.");
|
|
}
|
|
|
|
const requestedLines = parseFulfillmentLines(getText(formData, "lines"));
|
|
const fulfilledLines = requestedLines.length
|
|
? requestedLines.map((request) => {
|
|
const matchingLine = lines.find((line) => line.sku === request.sku);
|
|
|
|
if (!matchingLine) {
|
|
throw new Error(`SKU ${request.sku} is not on this purchase order.`);
|
|
}
|
|
|
|
const remaining = matchingLine.quantity - matchingLine.receivedQuantity;
|
|
if (request.quantity > remaining) {
|
|
throw new Error(`Cannot receive ${request.quantity} of ${request.sku}; only ${remaining} remain.`);
|
|
}
|
|
|
|
return { ...matchingLine, receiveQuantity: request.quantity };
|
|
})
|
|
: lines
|
|
.map((line) => {
|
|
const remaining = line.quantity - line.receivedQuantity;
|
|
return remaining > 0 ? { ...line, receiveQuantity: remaining } : null;
|
|
})
|
|
.filter((line): line is NonNullable<typeof line> => line !== null);
|
|
|
|
if (fulfilledLines.length === 0) {
|
|
throw new Error("No receivable quantities were provided.");
|
|
}
|
|
|
|
const receiptValue = fulfilledLines.reduce((sum, line) => sum + line.receiveQuantity * line.unitCost, 0);
|
|
const orderNumber = lines[0].orderNumber;
|
|
|
|
const tx = db().transaction(() => {
|
|
const insertInventory = db().prepare(
|
|
`
|
|
INSERT INTO inventory_transactions (part_id, quantity_delta, unit_cost, transaction_type, reference_type, reference_id, notes)
|
|
VALUES (?, ?, ?, 'purchase_receipt', 'purchase_order', ?, ?)
|
|
`
|
|
);
|
|
|
|
const updatePartCost = db().prepare(`UPDATE parts SET unit_cost = ? WHERE id = ?`);
|
|
const updateLine = db().prepare(`UPDATE purchase_order_lines SET received_quantity = received_quantity + ? WHERE id = ?`);
|
|
|
|
for (const line of fulfilledLines) {
|
|
insertInventory.run(line.partId, line.receiveQuantity, line.unitCost, orderId, `Receipt for ${orderNumber}`);
|
|
updatePartCost.run(line.unitCost, line.partId);
|
|
updateLine.run(line.receiveQuantity, line.lineId);
|
|
}
|
|
|
|
const remainingCount = db()
|
|
.prepare(`SELECT COUNT(*) AS count FROM purchase_order_lines WHERE purchase_order_id = ? AND received_quantity < quantity`)
|
|
.get(orderId) as { count: number };
|
|
const nextStatus = remainingCount.count > 0 ? "partial" : "received";
|
|
|
|
db().prepare(`UPDATE purchase_orders SET status = ?, received_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, orderId);
|
|
db()
|
|
.prepare(
|
|
`
|
|
INSERT INTO vendor_bills (bill_number, purchase_order_id, vendor_id, status, due_date, total_amount, paid_amount)
|
|
VALUES (?, ?, ?, 'open', DATE('now', '+30 day'), ?, 0)
|
|
`
|
|
)
|
|
.run(getDocumentNumber("BILL", "vendor_bills"), orderId, order.vendorId, receiptValue);
|
|
});
|
|
|
|
tx();
|
|
|
|
createJournalEntry("receipt", "purchase_order", orderId, `Receipt posted for ${orderNumber}`, [
|
|
{ accountCode: "1200", accountName: "Inventory", debit: receiptValue, credit: 0 },
|
|
{ accountCode: "2000", accountName: "Accounts Payable", debit: 0, credit: receiptValue }
|
|
]);
|
|
|
|
revalidatePath("/");
|
|
revalidatePath("/parts");
|
|
revalidatePath("/purchase-orders");
|
|
revalidatePath("/accounting");
|
|
revalidatePath("/vendor-bills");
|
|
}
|
|
|
|
export async function createManualJournalEntry(formData: FormData) {
|
|
const description = getText(formData, "description");
|
|
const lines = parseJournalLines(getText(formData, "lines"));
|
|
const debitTotal = lines.reduce((sum, line) => sum + line.debit, 0);
|
|
const creditTotal = lines.reduce((sum, line) => sum + line.credit, 0);
|
|
|
|
if (Math.abs(debitTotal - creditTotal) > 0.005) {
|
|
throw new Error("Manual journal entry is not balanced.");
|
|
}
|
|
|
|
createJournalEntry("manual", "manual_journal", null, description || "Manual journal entry", lines);
|
|
|
|
revalidatePath("/accounting");
|
|
}
|
|
|
|
export async function loginAction(formData: FormData) {
|
|
const email = getText(formData, "email");
|
|
const password = getText(formData, "password");
|
|
const user = authenticateUser(db(), email, password);
|
|
|
|
if (!user) {
|
|
throw new Error("Invalid email or password.");
|
|
}
|
|
|
|
await createSession(user);
|
|
redirect("/");
|
|
}
|
|
|
|
export async function logoutAction() {
|
|
await destroySession();
|
|
redirect("/login");
|
|
}
|
|
|
|
export async function receiveCustomerPayment(formData: FormData) {
|
|
const invoiceId = Number(getText(formData, "invoiceId"));
|
|
const amount = getNumber(formData, "amount");
|
|
const notes = getText(formData, "notes");
|
|
|
|
if (amount <= 0) {
|
|
throw new Error("Payment amount must be greater than zero.");
|
|
}
|
|
|
|
const invoice = db()
|
|
.prepare(
|
|
`
|
|
SELECT id, invoice_number AS invoiceNumber, total_amount AS totalAmount, paid_amount AS paidAmount
|
|
FROM customer_invoices
|
|
WHERE id = ?
|
|
`
|
|
)
|
|
.get(invoiceId) as { id: number; invoiceNumber: string; totalAmount: number; paidAmount: number } | undefined;
|
|
|
|
if (!invoice) {
|
|
throw new Error("Invoice not found.");
|
|
}
|
|
|
|
const balanceDue = invoice.totalAmount - invoice.paidAmount;
|
|
|
|
if (amount > balanceDue) {
|
|
throw new Error("Payment cannot exceed invoice balance.");
|
|
}
|
|
|
|
const newPaidAmount = invoice.paidAmount + amount;
|
|
const newStatus = Math.abs(newPaidAmount - invoice.totalAmount) <= 0.005 ? "paid" : "partial";
|
|
|
|
const tx = db().transaction(() => {
|
|
db()
|
|
.prepare(`INSERT INTO customer_payments (invoice_id, amount, notes) VALUES (?, ?, ?)`)
|
|
.run(invoiceId, amount, notes);
|
|
db()
|
|
.prepare(`UPDATE customer_invoices SET paid_amount = ?, status = ? WHERE id = ?`)
|
|
.run(newPaidAmount, newStatus, invoiceId);
|
|
});
|
|
|
|
tx();
|
|
|
|
createJournalEntry("customer_payment", "customer_invoice", invoiceId, `Payment received for ${invoice.invoiceNumber}`, [
|
|
{ accountCode: "1000", accountName: "Cash", debit: amount, credit: 0 },
|
|
{ accountCode: "1100", accountName: "Accounts Receivable", debit: 0, credit: amount }
|
|
]);
|
|
|
|
revalidatePath("/");
|
|
revalidatePath("/accounting");
|
|
revalidatePath("/invoices");
|
|
}
|
|
|
|
export async function payVendorBill(formData: FormData) {
|
|
const vendorBillId = Number(getText(formData, "vendorBillId"));
|
|
const amount = getNumber(formData, "amount");
|
|
const notes = getText(formData, "notes");
|
|
|
|
if (amount <= 0) {
|
|
throw new Error("Payment amount must be greater than zero.");
|
|
}
|
|
|
|
const bill = db()
|
|
.prepare(
|
|
`
|
|
SELECT id, bill_number AS billNumber, total_amount AS totalAmount, paid_amount AS paidAmount
|
|
FROM vendor_bills
|
|
WHERE id = ?
|
|
`
|
|
)
|
|
.get(vendorBillId) as { id: number; billNumber: string; totalAmount: number; paidAmount: number } | undefined;
|
|
|
|
if (!bill) {
|
|
throw new Error("Vendor bill not found.");
|
|
}
|
|
|
|
const balanceDue = bill.totalAmount - bill.paidAmount;
|
|
|
|
if (amount > balanceDue) {
|
|
throw new Error("Payment cannot exceed vendor bill balance.");
|
|
}
|
|
|
|
const newPaidAmount = bill.paidAmount + amount;
|
|
const newStatus = Math.abs(newPaidAmount - bill.totalAmount) <= 0.005 ? "paid" : "partial";
|
|
|
|
const tx = db().transaction(() => {
|
|
db()
|
|
.prepare(`INSERT INTO vendor_payments (vendor_bill_id, amount, notes) VALUES (?, ?, ?)`)
|
|
.run(vendorBillId, amount, notes);
|
|
db()
|
|
.prepare(`UPDATE vendor_bills SET paid_amount = ?, status = ? WHERE id = ?`)
|
|
.run(newPaidAmount, newStatus, vendorBillId);
|
|
});
|
|
|
|
tx();
|
|
|
|
createJournalEntry("vendor_payment", "vendor_bill", vendorBillId, `Vendor payment posted for ${bill.billNumber}`, [
|
|
{ accountCode: "2000", accountName: "Accounts Payable", debit: amount, credit: 0 },
|
|
{ accountCode: "1000", accountName: "Cash", debit: 0, credit: amount }
|
|
]);
|
|
|
|
revalidatePath("/");
|
|
revalidatePath("/accounting");
|
|
revalidatePath("/vendor-bills");
|
|
}
|