The machine charts plotted the workbook's monthly_income.fixed_expenses, which
is NOT the app's running cost. So the charts and the /running Overview
disagreed about the same machine in the same month — CT01 by R 40 640, DT01 by
R 121 115. The workbook's figure also carries depreciation/ownership and an
operator allocation, which are capital recovery and overhead rather than
running cost.
Every cost value now comes from the running-cost register: fixed = insurance
+ financing, running = purchases. Profit is income - fixed - running, and it
is ONE formula on every surface, so /charts, /income and the dashboard quote
the same figure for the same machine.
R 8 456 243 fleet income, Jan-Oct
- R 4 705 292 fixed (insurance + financing, from the register)
- R 306 387 running (purchases, from the register)
= R 3 444 564 profit (was R 2 510 908 on the workbook's fixed)
The legacy repairs sheet comes out completely. It was the old workbook's
"Plant Monthly Running Cost" — read by no page, maintained by nobody — and a
repair is bought on a purchase order, so its money is already in Purchases.
The collection is still seeded, so the history is not lost, but it is no
longer in any total, any chart or any profit.
Also:
- the profit badge wears the CHART's own green, and profit is green and
loss red on every surface. It was hard-coded to the teal brand colour,
so the label and the line it labelled were two different colours.
- September and October now state a profit instead of a dash: the dash
existed because the workbook had not posted those months' fixed costs,
and the register has the premium and the instalment for every month.
- the containers and the water trailers show profit equal to their income,
because the running-cost model carries no cost for them.
Overheads roll-up — an audit found three things out of order, and the roll-up
total itself reconciles exactly (R 580 485,37, unexplained R 0,00):
- the dashboard's Overhead mix legend named only five of the six sections,
so the figures it listed added up to R 565 489 against a headline of
R 580 485, and the donut drew a slice nobody could name.
- the /overheads note claimed machine instalments were excluded, while the
Bastion Finance block on the same page carried R 115 575,94 of them and
R 11 204,76 of insurance for the same machines. The note now says so,
with the figures read off the rows at render time.
- two Subscriptions rows had VAT that did not balance — the sheet had the
TOTAL in the VAT cell (115%), and annual excl/VAT against a monthly
total. Both are corrected on the client's instruction; the roll-up total
does not move, only the VAT breakdown does, and the seed's VAT gate now
passes instead of exiting non-zero.
Verified: 161 tests pass (4 new ones pin the register join), the production
build is clean, the reconciliation script passes, and the charts, the profit
label and the overhead legend were checked on screen.
123 lines
4.7 KiB
TypeScript
123 lines
4.7 KiB
TypeScript
import "server-only";
|
|
import { serverAuth } from "./pb";
|
|
import { requireUser } from "./auth";
|
|
import type { Asset, CostLine, FinancingTerm, InsuranceRate, Mechanic, OilItem, OilMovement, Operator, MonthlyIncome, 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);
|
|
}
|
|
|
|
// ⚠️ There is deliberately NO reader for `monthly_running_costs`.
|
|
//
|
|
// That collection holds the old workbook's legacy "Plant Monthly Running Cost" sheet — a
|
|
// repairs figure from before this app existed. Client ruling, 2026-11-05: repairs are not
|
|
// tracked on their own; a repair is bought on a purchase order and lives in the Purchases
|
|
// register. The seed still populates the collection, but no page reads it and it is in no
|
|
// profit calculation. Do not re-wire it — and do not delete the seed step without the
|
|
// client's say-so, because that would destroy the last copy of the legacy figure.
|
|
|
|
// Per-machine, per-month cost lines by kind — `insurance`, `instalment`,
|
|
// `tracking`. These are the fixed per-machine charges the workbook splits out by term.
|
|
// Keep them apart from the purchase/insurance/finance figures the Overview reads, 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" });
|
|
}
|