Design review
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user