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. 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). 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 ```sql
CREATE TABLE customers ( CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -79,7 +83,7 @@ CREATE TABLE service_orders (
'WALK IN SERVICE')), 'WALK IN SERVICE')),
service_rep TEXT, service_rep TEXT,
technician 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_in TEXT DEFAULT (date('now')),
date_out TEXT, date_out TEXT,
due_date TEXT, due_date TEXT,
@@ -92,7 +96,8 @@ CREATE TABLE service_orders (
service_call INTEGER DEFAULT 0, service_call INTEGER DEFAULT 0,
disposal INTEGER DEFAULT 0, disposal INTEGER DEFAULT 0,
amount_paid 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 ( CREATE TABLE line_items (
@@ -113,22 +118,34 @@ CREATE TABLE work_log (
logged_at TEXT DEFAULT (datetime('now')) 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 CREATE VIEW order_totals AS
SELECT SELECT
so.id AS service_order_id, 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='part' THEN ROUND(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='labor' THEN ROUND(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(CASE WHEN li.kind='sublet' THEN ROUND(li.quantity*li.unit_price) END),0) AS total_sublet,
COALESCE(SUM(li.quantity*li.unit_price),0) COALESCE(SUM(ROUND(li.quantity*li.unit_price)),0)
+ so.tax + so.shipping + so.pickup_delivery + so.service_call + so.disposal AS grand_total + 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 FROM service_orders so
LEFT JOIN line_items li ON li.service_order_id = so.id LEFT JOIN line_items li ON li.service_order_id = so.id
WHERE so.deleted_at IS NULL
GROUP BY so.id; 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 **Order numbers:** `order_number` starts at 8247 (next after the sample) and
increments. Simplest approach: `MAX(order_number)+1` at insert time inside a increments. Simplest approach: `COALESCE(MAX(order_number), 8246) + 1` at insert
transaction. `better-sqlite3` is synchronous so there's no race on a single-user app. 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 ## 5. Application Structure
@@ -136,7 +153,7 @@ transaction. `better-sqlite3` is synchronous so there's no race on a single-user
clock-tracker/ clock-tracker/
├─ src/ ├─ src/
│ ├─ server.ts # Express app, route wiring │ ├─ 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) │ ├─ schema.sql # the DDL above (run on first boot if db missing)
│ ├─ money.ts # cents <-> "$1.75" helpers │ ├─ money.ts # cents <-> "$1.75" helpers
│ ├─ routes/ │ ├─ routes/
@@ -167,9 +184,10 @@ clock-tracker/
| POST | `/orders` | redirect | Create order + customer/clock | | POST | `/orders` | redirect | Create order + customer/clock |
| GET | `/orders/:id` | full page | Order detail (work + parts) | | GET | `/orders/:id` | full page | Order detail (work + parts) |
| POST | `/orders/:id/line-items` | HTML fragment | Add a labor/part line (HTMX) | | 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/work-log` | HTML fragment | Add a work-log note (HTMX) |
| POST | `/orders/:id` | HTML fragment | Update status/dates/totals | | 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 | `/orders/:id/invoice` | full page | Printable invoice |
| GET | `/customers` | full page | Customer list/search | | GET | `/customers` | full page | Customer list/search |
@@ -213,13 +231,20 @@ The app itself never sees or stores credentials.
## 10. Backups ## 10. Backups
The database is one file (`data/app.db`). A nightly `cron` job copies it somewhere 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):
``` ```ts
sqlite3 data/app.db ".backup '/backups/app-$(date +%F).db'" // 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) ## 11. Future (explicitly deferred)