Design review

This commit is contained in:
2026-07-11 18:23:25 -04:00
parent 9457838bea
commit 0419c58de1
+40 -15
View File
@@ -41,6 +41,10 @@ and no API/JSON layer to maintain. TypeScript stays on the server where the logi
Five tables plus one view. Money stored as **INTEGER cents** to avoid float rounding.
Dates stored as ISO-8601 TEXT (`YYYY-MM-DD` or full datetime).
**Discounts:** entered as a line item with a negative `unit_price` (e.g. a `labor` line "10% discount" at `-$5.00`). No special column needed.
**Soft deletes:** orders are never hard-deleted. Setting `deleted_at` hides them from all normal queries; they can be recovered if needed.
```sql
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -79,7 +83,7 @@ CREATE TABLE service_orders (
'WALK IN SERVICE')),
service_rep TEXT,
technician TEXT,
status TEXT DEFAULT 'OPEN', -- OPEN / IN PROGRESS / DONE / PAID
status TEXT DEFAULT 'OPEN' CHECK (status IN ('OPEN','IN PROGRESS','DONE','PAID')),
date_in TEXT DEFAULT (date('now')),
date_out TEXT,
due_date TEXT,
@@ -92,7 +96,8 @@ CREATE TABLE service_orders (
service_call INTEGER DEFAULT 0,
disposal INTEGER DEFAULT 0,
amount_paid INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
created_at TEXT DEFAULT (datetime('now')),
deleted_at TEXT -- soft delete; NULL = active
);
CREATE TABLE line_items (
@@ -113,22 +118,34 @@ CREATE TABLE work_log (
logged_at TEXT DEFAULT (datetime('now'))
);
-- ROUND() keeps cents integer despite REAL quantity (e.g. 1.5 hrs × 175_00 ¢).
CREATE VIEW order_totals AS
SELECT
so.id AS service_order_id,
COALESCE(SUM(CASE WHEN li.kind='part' THEN li.quantity*li.unit_price END),0) AS total_parts,
COALESCE(SUM(CASE WHEN li.kind='labor' THEN li.quantity*li.unit_price END),0) AS total_labor,
COALESCE(SUM(CASE WHEN li.kind='sublet' THEN li.quantity*li.unit_price END),0) AS total_sublet,
COALESCE(SUM(li.quantity*li.unit_price),0)
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total
COALESCE(SUM(CASE WHEN li.kind='part' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_parts,
COALESCE(SUM(CASE WHEN li.kind='labor' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_labor,
COALESCE(SUM(CASE WHEN li.kind='sublet' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_sublet,
COALESCE(SUM(ROUND(li.quantity*li.unit_price)),0)
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total,
COALESCE(SUM(ROUND(li.quantity*li.unit_price)),0)
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal
- so.amount_paid AS balance_due
FROM service_orders so
LEFT JOIN line_items li ON li.service_order_id = so.id
WHERE so.deleted_at IS NULL
GROUP BY so.id;
CREATE INDEX idx_line_items_order ON line_items(service_order_id);
CREATE INDEX idx_work_log_order ON work_log(service_order_id);
CREATE INDEX idx_clocks_customer ON clocks(customer_id);
CREATE INDEX idx_orders_customer ON service_orders(customer_id);
```
**Order numbers:** `order_number` starts at 8247 (next after the sample) and
increments. Simplest approach: `MAX(order_number)+1` at insert time inside a
transaction. `better-sqlite3` is synchronous so there's no race on a single-user app.
increments. Simplest approach: `COALESCE(MAX(order_number), 8246) + 1` at insert
time inside a transaction — the `COALESCE` handles an empty table correctly.
`better-sqlite3` is synchronous so there's no race on a single-user app. Gaps from
soft-deleted orders are fine; numbers are never recycled.
## 5. Application Structure
@@ -136,7 +153,7 @@ transaction. `better-sqlite3` is synchronous so there's no race on a single-user
clock-tracker/
├─ src/
│ ├─ server.ts # Express app, route wiring
│ ├─ db.ts # better-sqlite3 connection + prepared statements
│ ├─ db.ts # better-sqlite3 connection + prepared statements; runs PRAGMA foreign_keys=ON
│ ├─ schema.sql # the DDL above (run on first boot if db missing)
│ ├─ money.ts # cents <-> "$1.75" helpers
│ ├─ routes/
@@ -167,9 +184,10 @@ clock-tracker/
| POST | `/orders` | redirect | Create order + customer/clock |
| GET | `/orders/:id` | full page | Order detail (work + parts) |
| POST | `/orders/:id/line-items` | HTML fragment | Add a labor/part line (HTMX) |
| DELETE | `/orders/:id/line-items/:lineId` | empty/200 | Remove a line (HTMX) |
| DELETE | `/orders/:id/line-items/:lineId` | HTML fragment | Remove a line + OOB totals update (HTMX) |
| POST | `/orders/:id/work-log` | HTML fragment | Add a work-log note (HTMX) |
| POST | `/orders/:id` | HTML fragment | Update status/dates/totals |
| DELETE | `/orders/:id` | redirect | Soft-delete order (sets deleted_at) |
| GET | `/orders/:id/invoice` | full page | Printable invoice |
| GET | `/customers` | full page | Customer list/search |
@@ -213,13 +231,20 @@ The app itself never sees or stores credentials.
## 10. Backups
The database is one file (`data/app.db`). A nightly `cron` job copies it somewhere
safe:
safe using `better-sqlite3`'s built-in backup API (avoids needing the `sqlite3` CLI):
```
sqlite3 data/app.db ".backup '/backups/app-$(date +%F).db'"
```ts
// scripts/backup.ts
import Database from 'better-sqlite3';
import { execSync } from 'child_process';
const date = execSync('date +%F').toString().trim();
const db = new Database('data/app.db');
await db.backup(`/backups/app-${date}.db`);
db.close();
```
Keep ~30 days. That's the entire disaster-recovery plan, and it's enough.
Run with `tsx scripts/backup.ts` from cron. Keep ~30 days. That's the entire
disaster-recovery plan, and it's enough.
## 11. Future (explicitly deferred)