mega-plant/lib/server-data.ts

124 lines
4.4 KiB
TypeScript
Raw Permalink Normal View History

import "server-only";
import { serverAuth } from "./pb";
import { requireUser } from "./auth";
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
import type { Asset, CostLine, FinancingTerm, InsuranceRate, Mechanic, OilItem, OilMovement, Operator, MonthlyIncome, MonthlyRunningCost, Overhead, Invoice, InvoiceLine, Site } from "./data";
export * from "./data";
async function dataPB() {
await requireUser();
return serverAuth();
}
export async function getAssets(): Promise<Asset[]> {
const pb = await dataPB();
return pb.collection("assets").getFullList<Asset>({ sort: "category,name" });
}
export async function getAssetsByCategory(category: string): Promise<Asset[]> {
const all = await getAssets();
return all.filter((a) => a.category === category);
}
export async function getMonthlyIncome(year = 2026): Promise<MonthlyIncome[]> {
const pb = await dataPB();
return pb.collection("monthly_income").getFullList<MonthlyIncome>({
filter: `year = ${year}`,
sort: "month",
});
}
// Part 1.2: YTD aggregations operate on actuals only by default.
// `is_forecast` is a boolean field on monthly_income; rows where it's true are excluded.
export async function getActualMonthlyIncome(year = 2026): Promise<MonthlyIncome[]> {
const all = await getMonthlyIncome(year);
return all.filter((r) => !(r as any).is_forecast);
}
export async function getRunningCosts(year = 2026): Promise<MonthlyRunningCost[]> {
const pb = await dataPB();
return pb.collection("monthly_running_costs").getFullList<MonthlyRunningCost>({
filter: `year = ${year}`,
sort: "month",
});
}
Add Running Costs sub-pages: insurance, financing, purchases, oil, operators, mechanics Splits the Running Costs group into six purpose-built pages, each with its own collection and its own source data, plus a coordinate parser for purchase orders. Insurance (R503 330/yr, 20 vehicles) A premium can move every month or three, so it is stored as a RATE PERIOD in insurance_rates and each month's cost is resolved against the rate in force that month. cost_lines insurance rows are derived from those periods. An absent rate is null, never 0 — nobody has entered the date, which is not the same as being insured for free. Financing (20 agreements, R428 585/mo) Instalment from the sheet; start date, paid-off date and the settled toggle are human-entered and preserved across re-seeds. Months-left and outstanding are null until a date is entered, never R0 on a live debt. The two paid-off agreements (WB02, DT01) show as settled and are excluded from the run-rate. Purchases + Oil Stock Uploads PO PDFs the way /income/invoices does. The parser reads columns by x-position, because plain text extraction reorders them, and refuses any PO whose lines do not sum to the sub-total. A cost code either names a machine ("SS-02" -> SS02) or a stock store ("MPLANT, ENGOIL") — never both, never guessed. Oil POs move litres into oil_stock_movements instead of onto a machine; stock is the sum of movements so the first usage is a subtraction from history. Operators (19) and Mechanics (2) Both are people with an employee number, separately reconcilable by the seed so someone added in the app survives. Mechanics get a badge QR; a mechanic with no employee number cannot be printed, because the code is what the QR encodes. Cross-cutting - The seed no longer deletes operators, mechanics or oil items: people are added in the app, and those rows exist in no file. - Re-pointing allocations that PocketBase clears because `assets` is rebuilt with new ids each seed (operators, purchase lines). - One QR encoder for screen and PDF, so a printed code cannot drift from the one on screen. pdf-lib writes real vector PDFs; pdfjs-dist only reads them. - Every figure is null-safe: unknown is never rendered as zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-08 23:06:27 +00:00
// Per-machine, per-month cost lines by kind — `insurance`, `instalment`,
// `tracking`. This is a DIFFERENT collection from `monthly_running_costs`: the
// running-cost rows are repairs & parts, while these are the fixed per-machine
// charges the workbook splits out by term. Keep them apart or the /running
// Overview total silently swallows an insurance figure it never showed.
export async function getCostLines(kind?: string, year = 2026): Promise<CostLine[]> {
const pb = await dataPB();
const filters = [`year = ${year}`];
if (kind) filters.push(`kind = "${kind}"`);
return pb.collection("cost_lines").getFullList<CostLine>({
filter: filters.join(" && "),
sort: "month",
});
}
/**
* Insurance rate periods. The source of truth for a plant vehicle's premium —
* `cost_lines` insurance rows are DERIVED from these, not the other way round.
*/
export async function getInsuranceRates(year = 2026): Promise<InsuranceRate[]> {
const pb = await dataPB();
return pb.collection("insurance_rates").getFullList<InsuranceRate>({
filter: `year = ${year}`,
sort: "month_from",
});
}
export async function getFinancingTerms(): Promise<FinancingTerm[]> {
const pb = await dataPB();
return pb.collection("financing_terms").getFullList<FinancingTerm>({ sort: "installment" });
}
export async function getOperators(): Promise<Operator[]> {
const pb = await dataPB();
return pb.collection("operators").getFullList<Operator>({ sort: "name" });
}
export async function getMechanics(): Promise<Mechanic[]> {
const pb = await dataPB();
return pb.collection("mechanics").getFullList<Mechanic>({ sort: "name" });
}
export async function getOilItems(): Promise<OilItem[]> {
const pb = await dataPB();
return pb.collection("oil_items").getFullList<OilItem>({ sort: "code" });
}
export async function getOilMovements(): Promise<OilMovement[]> {
const pb = await dataPB();
return pb.collection("oil_stock_movements").getFullList<OilMovement>({ sort: "-created" });
}
export async function getOverheads(): Promise<Overhead[]> {
const pb = await dataPB();
return pb.collection("overheads").getFullList<Overhead>({ sort: "section,description" });
}
export async function getOverheadsBySection(section: string): Promise<Overhead[]> {
const all = await getOverheads();
return all.filter((o) => o.section === section);
}
export async function getInvoices(): Promise<Invoice[]> {
const pb = await dataPB();
return pb.collection("invoices").getFullList<Invoice>({ sort: "-invoice_date,-invoice_no" });
}
export async function getInvoiceLines(invoiceId?: string): Promise<InvoiceLine[]> {
const pb = await dataPB();
return pb.collection("invoice_lines").getFullList<InvoiceLine>({
filter: invoiceId ? `invoice = "${invoiceId}"` : "",
sort: "line_no",
});
}
export async function getSites(): Promise<Site[]> {
const pb = await dataPB();
return pb.collection("sites").getFullList<Site>({ sort: "name" });
}