1462 lines
48 KiB
HTML
1462 lines
48 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>ERP Backend — Module & Data Flow Diagram</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--bg: #f8fafc;
|
||
--surface: #ffffff;
|
||
--text: #1e293b;
|
||
--muted: #64748b;
|
||
--border: #e2e8f0;
|
||
--accent: #2563eb;
|
||
--accent-soft: #eff6ff;
|
||
--code-bg: #f1f5f9;
|
||
--sidebar-w: 280px;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
font-size: 16px;
|
||
line-height: 1.6;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
}
|
||
|
||
.layout {
|
||
display: flex;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
nav.toc {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: var(--sidebar-w);
|
||
height: 100vh;
|
||
overflow-y: auto;
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
padding: 1.25rem 1rem;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
nav.toc h2 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.75rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--muted);
|
||
}
|
||
|
||
nav.toc a {
|
||
display: block;
|
||
padding: 0.35rem 0.5rem;
|
||
color: var(--text);
|
||
text-decoration: none;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
nav.toc a:hover {
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
main {
|
||
margin-left: var(--sidebar-w);
|
||
flex: 1;
|
||
max-width: 960px;
|
||
padding: 2.5rem 2rem 4rem;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 2rem;
|
||
margin: 0 0 0.5rem;
|
||
line-height: 1.25;
|
||
}
|
||
|
||
.meta {
|
||
color: var(--muted);
|
||
margin-bottom: 2rem;
|
||
padding-bottom: 1.5rem;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.meta p { margin: 0.35rem 0; }
|
||
.meta a { color: var(--accent); }
|
||
|
||
h2 {
|
||
font-size: 1.5rem;
|
||
margin: 2.5rem 0 1rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid var(--border);
|
||
scroll-margin-top: 1rem;
|
||
}
|
||
|
||
h2:first-of-type {
|
||
border-top: none;
|
||
padding-top: 0;
|
||
margin-top: 0;
|
||
}
|
||
|
||
h3 {
|
||
font-size: 1.125rem;
|
||
margin: 1.5rem 0 0.75rem;
|
||
color: #334155;
|
||
}
|
||
|
||
p { margin: 0.75rem 0; }
|
||
|
||
ul { margin: 0.75rem 0; padding-left: 1.5rem; }
|
||
li { margin: 0.35rem 0; }
|
||
|
||
strong { font-weight: 600; }
|
||
|
||
code {
|
||
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, monospace;
|
||
font-size: 0.875em;
|
||
background: var(--code-bg);
|
||
padding: 0.15em 0.4em;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 1rem 0 1.5rem;
|
||
font-size: 0.9rem;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
th, td {
|
||
padding: 0.65rem 0.85rem;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
th {
|
||
background: #f1f5f9;
|
||
font-weight: 600;
|
||
}
|
||
|
||
tr:last-child td { border-bottom: none; }
|
||
|
||
.diagram {
|
||
position: relative;
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
margin: 1rem 0 1.5rem;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.diagram-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
padding: 0.5rem 0.75rem;
|
||
background: #f8fafc;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.diagram-toolbar .spacer { flex: 1; }
|
||
|
||
.diagram-toolbar button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 2rem;
|
||
height: 2rem;
|
||
padding: 0 0.5rem;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-size: 0.875rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
|
||
.diagram-toolbar button:hover {
|
||
background: var(--accent-soft);
|
||
border-color: #93c5fd;
|
||
color: var(--accent);
|
||
}
|
||
|
||
.diagram-toolbar button.active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
|
||
.diagram-zoom-label {
|
||
min-width: 3.5rem;
|
||
text-align: center;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
user-select: none;
|
||
}
|
||
|
||
.diagram-viewport {
|
||
position: relative;
|
||
overflow: hidden;
|
||
height: min(70vh, 560px);
|
||
min-height: 280px;
|
||
background: #fafbfc;
|
||
touch-action: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.diagram-viewport.can-pan { cursor: grab; }
|
||
.diagram-viewport.is-panning { cursor: grabbing; }
|
||
|
||
.diagram-stage {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.diagram-canvas {
|
||
display: inline-block;
|
||
transform-origin: 0 0;
|
||
will-change: transform;
|
||
}
|
||
|
||
.diagram-canvas .mermaid,
|
||
.diagram-canvas svg {
|
||
display: block;
|
||
margin: 0;
|
||
max-width: none !important;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.diagram-fullscreen {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 10000;
|
||
background: rgba(15, 23, 42, 0.92);
|
||
flex-direction: column;
|
||
}
|
||
|
||
.diagram-fullscreen.is-open {
|
||
display: flex;
|
||
}
|
||
|
||
.diagram-fullscreen-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.75rem 1rem;
|
||
background: #1e293b;
|
||
color: #f8fafc;
|
||
border-bottom: 1px solid #334155;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.diagram-fullscreen-title {
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 280px;
|
||
}
|
||
|
||
.diagram-fullscreen-header .diagram-toolbar {
|
||
flex: 1;
|
||
background: transparent;
|
||
border: none;
|
||
padding: 0;
|
||
}
|
||
|
||
.diagram-fullscreen-header .diagram-toolbar button {
|
||
background: #334155;
|
||
border-color: #475569;
|
||
color: #f8fafc;
|
||
}
|
||
|
||
.diagram-fullscreen-header .diagram-toolbar button:hover {
|
||
background: #475569;
|
||
border-color: #64748b;
|
||
color: #fff;
|
||
}
|
||
|
||
.diagram-fullscreen-header .diagram-toolbar button.active {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
.diagram-fullscreen-header .diagram-zoom-label {
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.diagram-fullscreen-close {
|
||
min-width: 2.5rem !important;
|
||
font-size: 1.25rem !important;
|
||
line-height: 1;
|
||
}
|
||
|
||
.diagram-fullscreen-viewport {
|
||
flex: 1;
|
||
position: relative;
|
||
overflow: hidden;
|
||
background: #0f172a;
|
||
touch-action: none;
|
||
user-select: none;
|
||
}
|
||
|
||
.diagram-fullscreen-viewport.can-pan { cursor: grab; }
|
||
.diagram-fullscreen-viewport.is-panning { cursor: grabbing; }
|
||
|
||
.diagram-fullscreen-stage {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.diagram-fullscreen-canvas {
|
||
display: inline-block;
|
||
transform-origin: 0 0;
|
||
will-change: transform;
|
||
background: var(--surface);
|
||
border-radius: 8px;
|
||
padding: 1.5rem;
|
||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
.diagram-fullscreen-canvas svg {
|
||
pointer-events: none;
|
||
}
|
||
|
||
.diagram-fullscreen-hint {
|
||
position: absolute;
|
||
bottom: 1rem;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
font-size: 0.8rem;
|
||
color: #94a3b8;
|
||
pointer-events: none;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.note {
|
||
background: var(--accent-soft);
|
||
border-left: 4px solid var(--accent);
|
||
padding: 0.85rem 1rem;
|
||
margin: 1rem 0;
|
||
border-radius: 0 6px 6px 0;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
hr {
|
||
border: none;
|
||
border-top: 1px solid var(--border);
|
||
margin: 2rem 0;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
nav.toc { display: none; }
|
||
main { margin-left: 0; padding: 1.5rem 1rem 3rem; }
|
||
}
|
||
|
||
@media print {
|
||
nav.toc { display: none; }
|
||
main { margin-left: 0; max-width: 100%; }
|
||
.diagram { break-inside: avoid; }
|
||
.diagram-toolbar { display: none; }
|
||
.diagram-viewport { height: auto; min-height: 0; overflow: visible; }
|
||
.diagram-stage { position: static; overflow: visible; }
|
||
.diagram-canvas { transform: none !important; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="layout">
|
||
<nav class="toc">
|
||
<h2>Contents</h2>
|
||
<a href="#s1">1. System architecture</a>
|
||
<a href="#s2">2. API module map</a>
|
||
<a href="#s3">3. Request data flow</a>
|
||
<a href="#s4">4. Auth & session</a>
|
||
<a href="#s5">5. Master dependencies</a>
|
||
<a href="#s6">6. Procurement → Asset</a>
|
||
<a href="#s7">7. Asset lifecycle</a>
|
||
<a href="#s8">8. AMC + renewal</a>
|
||
<a href="#s9">9. Insurance + renewal</a>
|
||
<a href="#s10">10. Service visits</a>
|
||
<a href="#s11">11. Depreciation</a>
|
||
<a href="#s12">12. Alerts</a>
|
||
<a href="#s13">13. Vendors</a>
|
||
<a href="#s14">14. Users & roles</a>
|
||
<a href="#s15">15. Settings</a>
|
||
<a href="#s16">16. Cross-cutting</a>
|
||
<a href="#s17">17. Entity relationships</a>
|
||
<a href="#s18">18. Asset API reference</a>
|
||
<a href="#s19">19. FE screen mapping</a>
|
||
<a href="#s20">20. Renewal summary</a>
|
||
</nav>
|
||
|
||
<main>
|
||
<h1>ERP Backend — Module & Data Flow Diagram</h1>
|
||
|
||
<div class="meta">
|
||
<p><strong>Stack:</strong> Express · PostgreSQL · Prisma · JWT + RBAC</p>
|
||
<p><strong>Base API:</strong> <code>/api/v1</code></p>
|
||
<p><strong>Phase 1 modules:</strong> Auth, Users, Roles, Masters (14), Vendors, Purchase Orders, GRN, Assets (+ AMC / Insurance / Service / Alerts / Depreciation), Settings</p>
|
||
<p>Reference: <a href="../BACKEND_TASKS.md">BACKEND_TASKS.md</a> · <a href="../BACKEND_SETUP.md">BACKEND_SETUP.md</a></p>
|
||
</div>
|
||
|
||
<h2 id="s1">1. High-level system architecture</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TB
|
||
subgraph Client["Frontend / API Client"]
|
||
FE[React App / Postman]
|
||
end
|
||
|
||
subgraph Gateway["Express App (src/app.js)"]
|
||
HELMET[Helmet + CORS + HPP]
|
||
RATE[Rate Limiter /api]
|
||
REQID[Request ID]
|
||
ROUTES["/api/v1 Routes"]
|
||
SWAGGER[Swagger /api-docs]
|
||
ERR[Error Middleware]
|
||
end
|
||
|
||
subgraph Middleware["Per-route chain"]
|
||
AUTH[authenticate JWT]
|
||
RBAC[authorize module + action]
|
||
VAL[validate Joi schema]
|
||
CTRL[Controller]
|
||
end
|
||
|
||
subgraph Services["Business Layer"]
|
||
SVC["*.service.js"]
|
||
REPO["*.repository.js PO/GRN/Assets"]
|
||
end
|
||
|
||
subgraph Data["PostgreSQL"]
|
||
PRISMA[(Prisma ORM)]
|
||
VIEWS[(SQL Views v_asset_*)]
|
||
AUDIT[(audit_logs)]
|
||
end
|
||
|
||
FE --> HELMET --> RATE --> REQID --> ROUTES
|
||
ROUTES --> AUTH --> RBAC --> VAL --> CTRL --> SVC
|
||
SVC --> PRISMA
|
||
SVC --> REPO --> PRISMA
|
||
SVC --> AUDIT
|
||
SVC --> VIEWS
|
||
CTRL --> ERR
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s2">2. Every API module map</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart LR
|
||
subgraph System
|
||
HEALTH[/health /healthz]
|
||
DOCS[/api-docs]
|
||
end
|
||
|
||
subgraph Security
|
||
AUTH_MOD["/auth login|refresh|logout|me"]
|
||
USERS["/users CRUD + export"]
|
||
ROLES["/roles CRUD + permissions matrix"]
|
||
end
|
||
|
||
subgraph Masters["/masters/* (14 sub-masters)"]
|
||
M1[uom]
|
||
M2[item-categories / subcategories / items]
|
||
M3[brands / gst-rates]
|
||
M4[payment-terms / delivery-terms]
|
||
M5[item-categories / subcategories]
|
||
M6[departments / designations]
|
||
M7[locations / plants / warehouses]
|
||
M8[document-series]
|
||
end
|
||
|
||
subgraph Procurement
|
||
VEND["/vendors + addresses|contacts|bank|items"]
|
||
PO["/purchase-orders workflow + PDF"]
|
||
GRN["/grn receipt + cancel + PDF"]
|
||
end
|
||
|
||
subgraph Assets["/assets"]
|
||
A_CORE[CRUD + transfer]
|
||
A_AMC[AMC contracts + renew]
|
||
A_SVC[service visits + status]
|
||
A_INS[insurance + renew]
|
||
A_DEPR[depreciation preview]
|
||
A_ALERT[expiry + service alerts]
|
||
end
|
||
|
||
subgraph Config
|
||
SET["/settings company + email SMTP"]
|
||
end
|
||
|
||
System --> Security --> Masters --> Procurement --> Assets --> Config
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s3">3. Standard request data flow (all protected routes)</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
sequenceDiagram
|
||
participant FE as Frontend
|
||
participant API as Express Route
|
||
participant AUTH as authenticate
|
||
participant RBAC as authorize
|
||
participant VAL as validate
|
||
participant SVC as Service
|
||
participant DB as PostgreSQL
|
||
participant AUD as audit_logs
|
||
|
||
FE->>API: HTTP + Bearer JWT
|
||
API->>AUTH: Verify token, load user + roles
|
||
AUTH->>RBAC: Check module permission (view/create/edit/delete/approve/export)
|
||
RBAC->>VAL: Joi validate body/query
|
||
VAL->>SVC: Business logic
|
||
SVC->>DB: Prisma read/write (deleted_at: null)
|
||
SVC->>AUD: auditLog on mutations
|
||
SVC-->>FE: ApiResponse { success, message, data, meta }
|
||
</pre>
|
||
</div>
|
||
<p><strong>RBAC modules:</strong> <code>USERS</code>, <code>ROLES</code>, <code>MASTERS</code>, <code>VENDOR</code>, <code>PURCHASE_ORDER</code>, <code>GRN</code>, <code>ASSET</code>, <code>SETTINGS</code></p>
|
||
|
||
<h2 id="s4">4. Auth & session flow</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
stateDiagram-v2
|
||
[*] --> Login: POST /auth/login
|
||
Login --> Active: Valid credentials
|
||
Login --> Locked: MAX_LOGIN_ATTEMPTS exceeded
|
||
Login --> RateLimited: Too many failed logins (IP)
|
||
Active --> TokenIssued: accessToken 15m + refreshToken
|
||
TokenIssued --> APIAccess: Bearer on /api/v1/*
|
||
APIAccess --> Refresh: access expired
|
||
Refresh --> TokenIssued: POST /auth/refresh (rotate hash in DB)
|
||
APIAccess --> Logout: POST /auth/logout (revoke refresh)
|
||
Locked --> Login: After LOCKOUT_DURATION_MINUTES
|
||
</pre>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th>Layer</th><th>Storage</th><th>Reset</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>Global API rate limit</td><td>In-memory per IP</td><td>Restart server or wait 15 min</td></tr>
|
||
<tr><td>Login rate limit</td><td>In-memory per IP</td><td>Restart or wait 15 min</td></tr>
|
||
<tr><td>Account lockout</td><td><code>users.locked_until</code></td><td>Wait 30 min or DB reset</td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2 id="s5">5. Master data dependency graph</h2>
|
||
<p>Masters must exist <strong>before</strong> transactional modules use them.</p>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
DS[document_series] --> VCODE[vendor_code / po_number / grn_number / asset_code]
|
||
LOC[locations plants + warehouses] --> PO
|
||
LOC --> GRN
|
||
LOC --> ASSET
|
||
|
||
DEPT[departments] --> USERS
|
||
DEPT --> ASSET
|
||
DES[designations] --> USERS
|
||
|
||
UOM[uom] --> ITEMS
|
||
IC[item_categories] --> ISUB[item_subcategories] --> ITEMS
|
||
GST[gst_rates] --> ITEMS
|
||
BRANDS[brands] --> ITEMS
|
||
BRANDS --> PO
|
||
|
||
PT[payment_terms] --> VEND
|
||
PT --> PO
|
||
DT[delivery_terms] --> PO
|
||
|
||
ICAT[item_categories] --> ISUB[item_subcategories]
|
||
ISUB --> ITEMS[items]
|
||
ICAT --> ASSETS[assets]
|
||
ISUB --> ASSETS
|
||
ACAT --> ASSET
|
||
ACAT --> GRN_AUTO[GRN auto-asset creation]
|
||
ASUB --> ASSET
|
||
ASUB --> GRN_AUTO
|
||
|
||
ITEMS --> PO_ITEMS
|
||
ITEMS --> GRN_ITEMS
|
||
VEND --> PO
|
||
VEND --> GRN
|
||
VEND --> AMC
|
||
VEND --> SVC_VISIT
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s6">6. Procurement → Asset end-to-end flow (core business chain)</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
START([Setup Masters + Vendor]) --> PO_CREATE
|
||
|
||
PO_CREATE["POST /purchase-orders<br/>status: DRAFT"] --> PO_EDIT["PUT /purchase-orders/:id<br/>add line items"]
|
||
PO_EDIT --> PO_SUBMIT["POST .../submit<br/>→ PENDING_APPROVAL"]
|
||
PO_SUBMIT --> PO_APPROVE{"Approve?"}
|
||
PO_APPROVE -->|Yes| PO_APPROVED["status: APPROVED"]
|
||
PO_APPROVE -->|No| PO_REJECTED["status: REJECTED → edit & resubmit"]
|
||
PO_REJECTED --> PO_EDIT
|
||
|
||
PO_APPROVED --> GRN_CREATE["POST /grn<br/>receive against PO lines"]
|
||
|
||
subgraph GRN_TXN["GRN Transaction (repository)"]
|
||
G1[Create grn POSTED]
|
||
G2[Create grn_items per line]
|
||
G3[Increment PO item received_qty]
|
||
G4{is_asset_item?}
|
||
G5[Auto-create assets 1 per accepted qty]
|
||
G6[Recalculate PO status]
|
||
G1 --> G2 --> G3 --> G4
|
||
G4 -->|Yes| G5 --> G6
|
||
G4 -->|No| G6
|
||
end
|
||
|
||
GRN_CREATE --> GRN_TXN
|
||
G6 --> PO_STATUS{Receipt status}
|
||
PO_STATUS -->|partial| PARTIAL[PARTIALLY_RECEIVED]
|
||
PO_STATUS -->|full| FULL[FULLY_RECEIVED]
|
||
|
||
G5 --> MANUAL_ASSET["POST /assets manual create also allowed"]
|
||
PARTIAL --> GRN_CREATE
|
||
FULL --> ASSET_OPS[Asset lifecycle ops]
|
||
|
||
GRN_CANCEL["POST /grn/:id/cancel"] --> REV1[Decrement received_qty]
|
||
REV1 --> REV2[Soft-delete linked assets]
|
||
REV2 --> REV3[GRN status CANCELLED]
|
||
</pre>
|
||
</div>
|
||
|
||
<h3>PO status lifecycle</h3>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
stateDiagram-v2
|
||
[*] --> DRAFT
|
||
DRAFT --> PENDING_APPROVAL: submit
|
||
REJECTED --> PENDING_APPROVAL: resubmit
|
||
PENDING_APPROVAL --> APPROVED: approve
|
||
PENDING_APPROVAL --> REJECTED: reject
|
||
APPROVED --> PARTIALLY_RECEIVED: GRN partial
|
||
APPROVED --> FULLY_RECEIVED: GRN full
|
||
PARTIALLY_RECEIVED --> FULLY_RECEIVED: more GRN
|
||
APPROVED --> CLOSED: amend closes old PO
|
||
DRAFT --> CANCELLED: cancel
|
||
APPROVED --> CANCELLED: cancel if no receipts
|
||
</pre>
|
||
</div>
|
||
|
||
<h3>GRN goods receiving concept flow</h3>
|
||
<p>End-to-end warehouse receipt: PO-backed GRN posting, optional supporting documents, PO qty update, and asset auto-creation.</p>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
subgraph Physical["Physical receipt at warehouse"]
|
||
P1[Vendor delivery arrives]
|
||
P2[Verify PO / invoice / LR / vehicle]
|
||
P3[Inspect qty quality batch]
|
||
end
|
||
|
||
subgraph System["System — GRN posting"]
|
||
S1["GET open PO<br/>status APPROVED / PARTIALLY_RECEIVED"]
|
||
S2["POST /grn<br/>header + line items"]
|
||
S3["GRN txn: grn POSTED"]
|
||
S4["grn_items per accepted/rejected qty"]
|
||
S5["PO received_qty += accepted_qty"]
|
||
S6{Item is_asset_item?}
|
||
S7["Auto-create assets<br/>1 per accepted unit"]
|
||
S8["Recalculate PO status"]
|
||
end
|
||
|
||
subgraph Docs["Supporting documents"]
|
||
D1["POST /grn/:grnId/attachments"]
|
||
D2["grn_attachments row"]
|
||
D3["File in uploads/grn/{grnId}/"]
|
||
D4["GET .../download authenticated"]
|
||
end
|
||
|
||
P1 --> P2 --> P3 --> S1 --> S2 --> S3 --> S4 --> S5 --> S6
|
||
S6 -->|Yes| S7 --> S8
|
||
S6 -->|No| S8
|
||
S3 --> D1 --> D2 --> D3 --> D4
|
||
|
||
S8 --> OUT1{More qty pending?}
|
||
OUT1 -->|Yes| PARTIAL[PO PARTIALLY_RECEIVED]
|
||
OUT1 -->|No| FULL[PO FULLY_RECEIVED]
|
||
</pre>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th>Step</th><th>API / table</th><th>Notes</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>1</td><td><code>POST /grn</code></td><td>Requires APPROVED PO with pending qty</td></tr>
|
||
<tr><td>2</td><td><code>grn</code>, <code>grn_items</code></td><td><code>accepted_qty + rejected_qty = current_qty</code></td></tr>
|
||
<tr><td>3</td><td><code>purchase_order_items.received_qty</code></td><td>Incremented in same transaction</td></tr>
|
||
<tr><td>4</td><td><code>assets</code></td><td>When <code>items.is_asset_item = true</code></td></tr>
|
||
<tr><td>5</td><td><code>POST /grn/:grnId/attachments</code></td><td>Invoice PDF, LR copy, photos</td></tr>
|
||
<tr><td>6</td><td><code>GET .../attachments/:id/download</code></td><td>RBAC download — not public <code>/uploads</code></td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3>GRN auto-asset creation (when <code>items.is_asset_item = true</code>)</h3>
|
||
<table>
|
||
<thead><tr><th>Field on Asset</th><th>Source</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>asset_code</code></td><td><code>document_series</code> via <code>ASSET_{category.code}</code></td></tr>
|
||
<tr><td><code>asset_name</code></td><td>Item name (+ <code>#N</code> if qty > 1)</td></tr>
|
||
<tr><td><code>item_category_id</code> / <code>item_subcategory_id</code></td><td>From item master (optional GRN line override)</td></tr>
|
||
<tr><td><code>plant_id</code></td><td>From PO</td></tr>
|
||
<tr><td><code>warehouse_id</code>, <code>vendor_id</code>, <code>po_id</code>, <code>grn_id</code>, <code>grn_item_id</code></td><td>From GRN</td></tr>
|
||
<tr><td><code>purchase_date</code></td><td><code>grn.grn_date</code></td></tr>
|
||
<tr><td><code>purchase_cost</code></td><td><code>grn_items.rate</code></td></tr>
|
||
<tr><td><code>useful_life_years</code>, <code>depreciation_method</code></td><td>From <code>item_categories</code> defaults</td></tr>
|
||
<tr><td><code>condition</code></td><td><code>NEW</code></td></tr>
|
||
<tr><td><code>status</code></td><td><code>IN_USE</code></td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2 id="s7">7. Asset module — full lifecycle</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TB
|
||
subgraph Create["Asset Creation Paths"]
|
||
P1[Manual POST /assets]
|
||
P2[Auto from GRN receipt]
|
||
end
|
||
|
||
subgraph Validate["normalizeAssetPayload validations"]
|
||
V1[item_category + subcategory match]
|
||
V2[plant / dept / warehouse / user refs]
|
||
V3[vendor / PO / GRN / grn_item refs]
|
||
V4[disposal_date required if DISPOSED/SCRAPPED]
|
||
V5[depreciation_rate required if method=OTHER]
|
||
V6[resolve rate from category defaults]
|
||
end
|
||
|
||
Create --> Validate --> SAVE[(assets table)]
|
||
SAVE --> CODE[asset_code from document_series]
|
||
|
||
SAVE --> READ["GET /assets/:id<br/>includes depreciation summary"]
|
||
|
||
SAVE --> TRANSFER["POST /assets/:id/transfer"]
|
||
TRANSFER --> TH[(asset_transfers)]
|
||
TRANSFER --> UPDATE_LOC[Update plant/dept/user/warehouse on asset]
|
||
|
||
SAVE --> UPDATE["PUT /assets/:id"]
|
||
SAVE --> DELETE["DELETE /assets/:id soft delete"]
|
||
|
||
UPDATE --> DISPOSE{status DISPOSED/SCRAPPED?}
|
||
DISPOSE -->|Yes| NEED_DATE[disposal_date required]
|
||
</pre>
|
||
</div>
|
||
|
||
<h3>Asset status & condition enums</h3>
|
||
<table>
|
||
<thead><tr><th>Field</th><th>Values</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><code>status</code></td><td><code>IN_USE</code>, <code>IDLE</code>, <code>UNDER_MAINTENANCE</code>, <code>DISPOSED</code>, <code>SCRAPPED</code></td></tr>
|
||
<tr><td><code>condition</code></td><td><code>NEW</code>, <code>GOOD</code>, <code>FAIR</code>, <code>POOR</code></td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2 id="s8">8. AMC contracts flow + renewal concept</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
A[(assets)] --> AMC_LIST["GET /assets/:id/amc"]
|
||
A --> AMC_CREATE["POST /assets/:id/amc"]
|
||
|
||
AMC_CREATE --> CHECK1{is_active=true?}
|
||
CHECK1 -->|Yes| DEACT[Deactivate other active AMC for same asset]
|
||
CHECK1 --> AMC_ROW[(asset_amc_contracts)]
|
||
DEACT --> AMC_ROW
|
||
|
||
AMC_ROW --> FIELDS["vendor_id, contract_type, start/end dates,<br/>renewal_date, annual_cost, visits_per_year, etc."]
|
||
|
||
AMC_ROW --> AMC_UPDATE["PUT /assets/:id/amc/:contractId"]
|
||
AMC_ROW --> AMC_RENEW["PATCH .../renew"]
|
||
|
||
subgraph RENEW_FLOW["Renewal Flow (AMC)"]
|
||
R1[Load existing contract]
|
||
R2[Set old contract is_active=false]
|
||
R3[Create NEW contract row with new dates]
|
||
R4[New contract is_active=true]
|
||
R5[auditLog action=RENEW]
|
||
R1 --> R2 --> R3 --> R4 --> R5
|
||
end
|
||
|
||
AMC_RENEW --> RENEW_FLOW
|
||
|
||
AMC_ROW --> SVC_LINK["Service visits can link amc_contract_id"]
|
||
</pre>
|
||
</div>
|
||
<div class="note">
|
||
<strong>Key rule:</strong> Only one active AMC per asset (<code>deactivateOtherActive</code>).<br>
|
||
<strong>Renewal ≠ update dates on same row</strong> — renewal creates a new contract record and deactivates the old one (history preserved).
|
||
</div>
|
||
|
||
<h2 id="s9">9. Insurance policies flow + renewal concept</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
A[(assets)] --> INS_LIST["GET /assets/:id/insurance"]
|
||
A --> INS_CREATE["POST /assets/:id/insurance"]
|
||
|
||
INS_CREATE --> CHECK{is_active=true?}
|
||
CHECK -->|Yes| DEACT[Deactivate other active policies]
|
||
CHECK --> POL[(asset_insurance_policies)]
|
||
DEACT --> POL
|
||
|
||
POL --> FIELDS["policy_no, insurer, sum_insured,<br/>annual_premium, start/end dates,<br/>renewal_date, is_auto_renewal, premium_paid"]
|
||
|
||
POL --> INS_UPDATE["PUT /assets/:id/insurance/:policyId"]
|
||
POL --> INS_RENEW["PATCH .../renew"]
|
||
|
||
subgraph RENEW_INS["Renewal Flow (Insurance)"]
|
||
I1[Deactivate old policy]
|
||
I2[Create new policy with new dates]
|
||
I3[premium_paid defaults false on renew]
|
||
I4[auditLog action=RENEW]
|
||
I1 --> I2 --> I3 --> I4
|
||
end
|
||
|
||
INS_RENEW --> RENEW_INS
|
||
</pre>
|
||
</div>
|
||
<p>Same pattern as AMC: <strong>one active policy</strong>, renewal = <strong>new row + deactivate old</strong>.</p>
|
||
|
||
<h2 id="s10">10. Service visits flow</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
A[(assets)] --> SV_LIST["GET /assets/:id/service-visits"]
|
||
A --> SV_CREATE["POST /assets/:id/service-visits"]
|
||
|
||
SV_CREATE --> VAL_SV["Validate amc_contract_id belongs to asset<br/>optional vendor_id ref"]
|
||
VAL_SV --> SV_ROW[(asset_service_visits)]
|
||
|
||
SV_ROW --> FIELDS["visit_type, visit_date, complaint details,<br/>engineer, work_done, parts_replaced,<br/>next_service_date, downtime_hours,<br/>service_cost, is_under_amc,<br/>asset_condition_after"]
|
||
|
||
SV_ROW --> SV_UPDATE["PUT .../service-visits/:visitId"]
|
||
SV_ROW --> SV_STATUS["PATCH .../status<br/>SCHEDULED|IN_PROGRESS|COMPLETED|etc."]
|
||
|
||
SV_ROW --> ALERT_VIEW["Feeds v_asset_next_service view"]
|
||
</pre>
|
||
</div>
|
||
|
||
<h3>Visit types</h3>
|
||
<p><code>PREVENTIVE</code>, <code>BREAKDOWN</code>, <code>INSPECTION</code>, <code>INSTALLATION</code>, <code>CALIBRATION</code>, <code>OTHER</code></p>
|
||
|
||
<h3>Link to AMC</h3>
|
||
<ul>
|
||
<li><code>amc_contract_id</code> optional but validated against the asset</li>
|
||
<li><code>is_under_amc</code> flag for cost tracking</li>
|
||
<li><code>next_service_date</code> drives <strong>service alerts</strong></li>
|
||
</ul>
|
||
|
||
<h2 id="s11">11. Depreciation flow</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
subgraph Inputs
|
||
CAT[item_categories defaults]
|
||
FORM[User form fields]
|
||
end
|
||
|
||
CAT --> |default_useful_life_years<br/>default_depreciation_method| CREATE
|
||
FORM --> CREATE["POST /assets or PUT /assets/:id"]
|
||
FORM --> PREVIEW["POST /assets/depreciation/calculate"]
|
||
|
||
subgraph Methods["depreciation_method"]
|
||
SLM[SLM Straight Line]
|
||
WDV[WDV Written Down Value]
|
||
OTHER[OTHER manual rate required]
|
||
end
|
||
|
||
PREVIEW --> CALC[calculateDepreciation]
|
||
CREATE --> STORE[(assets: method, rate, cost, salvage, life, purchase_date)]
|
||
STORE --> READ["GET /assets/:id"]
|
||
READ --> CALC
|
||
|
||
CALC --> OUT["annual_depreciation<br/>accumulated_depreciation<br/>book_value<br/>years_elapsed<br/>resolved depreciation_rate"]
|
||
|
||
SLM --> CALC
|
||
WDV --> CALC
|
||
OTHER --> CALC
|
||
</pre>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th>Method</th><th>Rate auto-calc?</th><th>Formula concept</th></tr></thead>
|
||
<tbody>
|
||
<tr><td><strong>SLM</strong></td><td>Yes (if rate omitted)</td><td>Equal yearly depreciation on original cost</td></tr>
|
||
<tr><td><strong>WDV</strong></td><td>Yes (if rate omitted)</td><td>Depreciation on reducing book value year-by-year</td></tr>
|
||
<tr><td><strong>OTHER</strong></td><td>No — user must send <code>depreciation_rate</code></td><td>Custom % on cost</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<p><strong>FE usage:</strong></p>
|
||
<ul>
|
||
<li><strong>Live preview</strong> → <code>POST /assets/depreciation/calculate</code> (no save)</li>
|
||
<li><strong>Saved asset view</strong> → <code>GET /assets/:id</code> → <code>data.depreciation</code> object</li>
|
||
<li><strong>Method dropdown</strong> → <code>GET /assets/depreciation-methods</code></li>
|
||
</ul>
|
||
|
||
<h2 id="s12">12. Alerts flow (cross-asset dashboards)</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
subgraph Sources
|
||
AMC[(asset_amc_contracts end_date)]
|
||
INS[(asset_insurance_policies policy_end_date)]
|
||
WAR[(assets warranty_expiry_date)]
|
||
SVC[(asset_service_visits next_service_date)]
|
||
end
|
||
|
||
AMC --> V1[v_asset_expiry_alerts]
|
||
INS --> V1
|
||
WAR --> V1
|
||
SVC --> V2[v_asset_next_service]
|
||
|
||
V1 --> API1["GET /assets/alerts/expiry<br/>?days=30&type=AMC|INSURANCE|WARRANTY"]
|
||
V2 --> API2["GET /assets/alerts/service<br/>?status=OVERDUE|DUE_THIS_WEEK|..."]
|
||
|
||
V1 --> LEVELS["EXPIRED / CRITICAL / WARNING / INFO"]
|
||
V2 --> SSTAT["OVERDUE / DUE_THIS_WEEK / DUE_THIS_MONTH / UPCOMING"]
|
||
</pre>
|
||
</div>
|
||
<p><strong>Requires DB views:</strong> run <code>scripts/patch-assets-amc-insurance.sql</code> + <code>scripts/patch-assets-views.sql</code></p>
|
||
|
||
<h2 id="s13">13. Vendor module data flow</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart LR
|
||
V[(vendors)] --> ADDR[vendor_addresses]
|
||
V --> CONT[vendor_contacts]
|
||
V --> BANK[vendor_bank_details AES encrypted]
|
||
V --> MAP[vendor_item_mapping]
|
||
|
||
V --> PO[purchase_orders]
|
||
V --> GRN[grn]
|
||
V --> AMC[asset_amc_contracts]
|
||
V --> SVC[asset_service_visits]
|
||
|
||
MAP --> ITEMS[items preferred vendor rates]
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s14">14. Users & roles permission flow</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
U[(users)] --> UR[user_roles]
|
||
UR --> R[(roles)]
|
||
R --> RP[role_permissions]
|
||
RP --> P[(permissions)]
|
||
P --> M[(modules)]
|
||
|
||
U --> PO_APPROVE[PO approve/reject]
|
||
U --> ASSET_OPS[Asset CRUD/transfer]
|
||
U --> AUDIT[performed_by in audit_logs]
|
||
|
||
LOGIN["GET /auth/me"] --> PERM[Returns user + flat permissions list for FE menu/RBAC]
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s15">15. Settings module</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart LR
|
||
CO[(company singleton)] --> PDF[PO/GRN/Asset PDF headers]
|
||
EM[(email_settings SMTP encrypted)] --> MAIL[Future email notifications]
|
||
LOGO["POST /settings/company/logo"] --> UPLOADS[(uploads/)]
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s16">16. Cross-cutting concerns (every module)</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
flowchart TD
|
||
M[Every mutation] --> AUDIT[auditLog table_name, record_id, action, old/new JSON]
|
||
M --> SOFT[Soft delete deleted_at = now]
|
||
M --> USER[created_by / updated_by from req.user.id]
|
||
M --> REQ[request_id from middleware]
|
||
|
||
DOC[Document numbers] --> SERIES[document_series atomic increment]
|
||
SERIES --> VC[vendor_code]
|
||
SERIES --> PO_NUM[po_number]
|
||
SERIES --> GRN_NUM[grn_number]
|
||
SERIES --> ASSET_CODE[asset_code per category prefix]
|
||
|
||
PII[mobile, bank accounts] --> ENC[AES-256-GCM + HMAC blind index]
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s17">17. Complete entity relationship (simplified)</h2>
|
||
<div class="diagram">
|
||
<pre class="mermaid">
|
||
erDiagram
|
||
vendors ||--o{ purchase_orders : supplies
|
||
vendors ||--o{ grn : delivers
|
||
vendors ||--o{ asset_amc_contracts : maintains
|
||
vendors ||--o{ asset_service_visits : services
|
||
|
||
purchase_orders ||--o{ purchase_order_items : contains
|
||
purchase_orders ||--o{ grn : received_via
|
||
purchase_orders ||--o{ assets : sourced_from
|
||
|
||
grn ||--o{ grn_items : lines
|
||
grn ||--o{ grn_attachments : documents
|
||
grn_items ||--o{ assets : auto_created
|
||
|
||
items ||--o{ purchase_order_items : ordered
|
||
items ||--o{ grn_items : received
|
||
|
||
item_categories ||--o{ item_subcategories : has
|
||
item_categories ||--o{ items : classifies
|
||
item_categories ||--o{ assets : classifies
|
||
item_subcategories ||--o{ items : sub_classifies
|
||
item_subcategories ||--o{ assets : sub_classifies
|
||
|
||
assets ||--o{ asset_amc_contracts : has
|
||
assets ||--o{ asset_insurance_policies : insured_by
|
||
assets ||--o{ asset_service_visits : serviced
|
||
assets ||--o{ asset_transfers : moved
|
||
assets ||--o{ asset_attachments : files
|
||
|
||
asset_amc_contracts ||--o{ asset_service_visits : covers
|
||
|
||
locations ||--o{ assets : plant_warehouse
|
||
departments ||--o{ assets : assigned_dept
|
||
users ||--o{ assets : assigned_user
|
||
|
||
users ||--o{ user_roles : has
|
||
roles ||--o{ role_permissions : grants
|
||
permissions }o--|| modules : belongs_to
|
||
</pre>
|
||
</div>
|
||
|
||
<h2 id="s18">18. Asset sub-module API quick reference</h2>
|
||
<table>
|
||
<thead><tr><th>Area</th><th>Endpoints</th><th>Notes</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>Core</td><td><code>GET/POST /assets</code>, <code>GET/PUT/DELETE /assets/:id</code></td><td>Includes computed <code>depreciation</code> on read</td></tr>
|
||
<tr><td>Transfer</td><td><code>POST /assets/:id/transfer</code>, <code>GET .../transfer-history</code></td><td>Blocked if DISPOSED/SCRAPPED</td></tr>
|
||
<tr><td>AMC</td><td><code>GET/POST /assets/:id/amc</code>, <code>GET/PUT .../:contractId</code>, <code>PATCH .../renew</code></td><td>One active contract</td></tr>
|
||
<tr><td>Service</td><td><code>GET/POST /assets/:id/service-visits</code>, <code>PUT</code>, <code>PATCH .../status</code></td><td>Links optional AMC</td></tr>
|
||
<tr><td>Insurance</td><td><code>GET/POST /assets/:id/insurance</code>, <code>GET/PUT</code>, <code>PATCH .../renew</code></td><td>One active policy</td></tr>
|
||
<tr><td>Depreciation</td><td><code>GET /depreciation-methods</code>, <code>POST /depreciation/calculate</code></td><td>Preview only</td></tr>
|
||
<tr><td>Alerts</td><td><code>GET /alerts/expiry</code>, <code>GET /alerts/service</code></td><td>SQL views</td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2 id="s19">19. Typical FE screen → API mapping</h2>
|
||
<table>
|
||
<thead><tr><th>Screen</th><th>APIs used</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>Login</td><td><code>POST /auth/login</code>, <code>GET /auth/me</code></td></tr>
|
||
<tr><td>Master setup</td><td><code>/masters/*</code> CRUD</td></tr>
|
||
<tr><td>Vendor management</td><td><code>/vendors/*</code></td></tr>
|
||
<tr><td>Create PO</td><td>Masters dropdowns + <code>POST /purchase-orders</code></td></tr>
|
||
<tr><td>Approve PO</td><td><code>POST /purchase-orders/:id/approve</code></td></tr>
|
||
<tr><td>GRN receipt</td><td><code>POST /grn</code> (auto assets for asset items)</td></tr>
|
||
<tr><td>Asset list/detail</td><td><code>GET /assets</code>, <code>GET /assets/:id</code></td></tr>
|
||
<tr><td>Asset create/edit form</td><td>Masters + <code>POST /depreciation/calculate</code> (preview) + <code>POST/PUT /assets</code></td></tr>
|
||
<tr><td>AMC tab</td><td><code>/assets/:id/amc/*</code> + renew</td></tr>
|
||
<tr><td>Insurance tab</td><td><code>/assets/:id/insurance/*</code> + renew</td></tr>
|
||
<tr><td>Service log</td><td><code>/assets/:id/service-visits/*</code></td></tr>
|
||
<tr><td>Dashboard alerts</td><td><code>/assets/alerts/expiry</code>, <code>/assets/alerts/service</code></td></tr>
|
||
<tr><td>Transfer asset</td><td><code>POST /assets/:id/transfer</code></td></tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h2 id="s20">20. Renewal concept summary (for FE)</h2>
|
||
<table>
|
||
<thead><tr><th>Entity</th><th>What "Renew" does</th><th>Old record</th><th>New record</th></tr></thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><strong>AMC</strong></td>
|
||
<td><code>PATCH /assets/:id/amc/:contractId/renew</code></td>
|
||
<td><code>is_active = false</code></td>
|
||
<td>New row, <code>is_active = true</code>, new dates</td>
|
||
</tr>
|
||
<tr>
|
||
<td><strong>Insurance</strong></td>
|
||
<td><code>PATCH /assets/:id/insurance/:policyId/renew</code></td>
|
||
<td><code>is_active = false</code></td>
|
||
<td>New row, <code>is_active = true</code>, new policy period</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p>Both preserve <strong>full history</strong> — never overwrite the old contract/policy row.</p>
|
||
|
||
<hr />
|
||
<p class="meta">Generated from <a href="MODULE_DATA_FLOW.md">MODULE_DATA_FLOW.md</a> · Open in any browser · Mermaid diagrams require network for CDN</p>
|
||
</main>
|
||
</div>
|
||
|
||
<div id="diagram-fullscreen" class="diagram-fullscreen" aria-hidden="true">
|
||
<div class="diagram-fullscreen-header">
|
||
<span id="diagram-fullscreen-title" class="diagram-fullscreen-title">Diagram</span>
|
||
<div class="diagram-toolbar" id="diagram-fullscreen-toolbar"></div>
|
||
<button type="button" class="diagram-fullscreen-close" id="diagram-fullscreen-close" title="Exit fullscreen (Esc)">×</button>
|
||
</div>
|
||
<div class="diagram-fullscreen-viewport" id="diagram-fullscreen-viewport">
|
||
<div class="diagram-fullscreen-stage" id="diagram-fullscreen-stage">
|
||
<div class="diagram-fullscreen-canvas" id="diagram-fullscreen-canvas"></div>
|
||
</div>
|
||
</div>
|
||
<div class="diagram-fullscreen-hint">Drag to move · Scroll or Ctrl+scroll to zoom · + / − / 0 keys · Esc to exit</div>
|
||
</div>
|
||
|
||
<script>
|
||
const ZOOM_MIN = 0.2;
|
||
const ZOOM_MAX = 5;
|
||
const ZOOM_STEP = 0.15;
|
||
const ZOOM_DEFAULT = 1;
|
||
|
||
function clampZoom(value) {
|
||
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value * 100) / 100));
|
||
}
|
||
|
||
function createToolbar(onAction) {
|
||
const toolbar = document.createElement('div');
|
||
toolbar.className = 'diagram-toolbar';
|
||
toolbar.innerHTML = `
|
||
<button type="button" data-action="zoom-out" title="Zoom out (-)">−</button>
|
||
<span class="diagram-zoom-label" data-zoom-label>100%</span>
|
||
<button type="button" data-action="zoom-in" title="Zoom in (+)">+</button>
|
||
<button type="button" data-action="zoom-reset" title="Reset view (0)">Reset</button>
|
||
<button type="button" data-action="zoom-fit" title="Fit to view">Fit</button>
|
||
<span class="spacer"></span>
|
||
<button type="button" data-action="fullscreen" title="Fullscreen (F)">⛶ Fullscreen</button>
|
||
`;
|
||
toolbar.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('button[data-action]');
|
||
if (btn) onAction(btn.dataset.action);
|
||
});
|
||
return toolbar;
|
||
}
|
||
|
||
class DiagramViewer {
|
||
constructor({ viewport, stage, canvas, label, wheelNeedsModifier = true }) {
|
||
this.viewport = viewport;
|
||
this.stage = stage;
|
||
this.canvas = canvas;
|
||
this.label = label;
|
||
this.wheelNeedsModifier = wheelNeedsModifier;
|
||
this.zoom = ZOOM_DEFAULT;
|
||
this.panX = 0;
|
||
this.panY = 0;
|
||
this.isPanning = false;
|
||
this.pointerId = null;
|
||
this.lastPointer = { x: 0, y: 0 };
|
||
this.bindEvents();
|
||
}
|
||
|
||
render() {
|
||
this.canvas.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
|
||
if (this.label) {
|
||
this.label.textContent = `${Math.round(this.zoom * 100)}%`;
|
||
}
|
||
}
|
||
|
||
getViewportPoint(clientX, clientY) {
|
||
const rect = this.stage.getBoundingClientRect();
|
||
return { x: clientX - rect.left, y: clientY - rect.top };
|
||
}
|
||
|
||
zoomAt(clientX, clientY, delta) {
|
||
const point = this.getViewportPoint(clientX, clientY);
|
||
const prevZoom = this.zoom;
|
||
const nextZoom = clampZoom(prevZoom + delta);
|
||
if (nextZoom === prevZoom) return;
|
||
|
||
this.panX = point.x - ((point.x - this.panX) * nextZoom) / prevZoom;
|
||
this.panY = point.y - ((point.y - this.panY) * nextZoom) / prevZoom;
|
||
this.zoom = nextZoom;
|
||
this.render();
|
||
}
|
||
|
||
zoomBy(delta, centerToStage = true) {
|
||
const rect = this.stage.getBoundingClientRect();
|
||
const x = centerToStage ? rect.left + rect.width / 2 : rect.left;
|
||
const y = centerToStage ? rect.top + rect.height / 2 : rect.top;
|
||
this.zoomAt(x, y, delta);
|
||
}
|
||
|
||
reset() {
|
||
this.zoom = ZOOM_DEFAULT;
|
||
this.panX = 0;
|
||
this.panY = 0;
|
||
this.render();
|
||
this.centerContent();
|
||
}
|
||
|
||
fitToView() {
|
||
const svg = this.canvas.querySelector('svg');
|
||
if (!svg) {
|
||
this.reset();
|
||
return;
|
||
}
|
||
|
||
const stageRect = this.stage.getBoundingClientRect();
|
||
const bbox = svg.getBBox();
|
||
const pad = 24;
|
||
const scaleX = (stageRect.width - pad * 2) / bbox.width;
|
||
const scaleY = (stageRect.height - pad * 2) / bbox.height;
|
||
const fitZoom = clampZoom(Math.min(scaleX, scaleY, 1));
|
||
|
||
this.zoom = fitZoom;
|
||
this.panX = (stageRect.width - bbox.width * fitZoom) / 2 - bbox.x * fitZoom;
|
||
this.panY = (stageRect.height - bbox.height * fitZoom) / 2 - bbox.y * fitZoom;
|
||
this.render();
|
||
}
|
||
|
||
centerContent() {
|
||
const svg = this.canvas.querySelector('svg');
|
||
if (!svg) return;
|
||
|
||
const stageRect = this.stage.getBoundingClientRect();
|
||
const bbox = svg.getBBox();
|
||
this.panX = (stageRect.width - bbox.width * this.zoom) / 2 - bbox.x * this.zoom;
|
||
this.panY = (stageRect.height - bbox.height * this.zoom) / 2 - bbox.y * this.zoom;
|
||
this.render();
|
||
}
|
||
|
||
handleAction(action) {
|
||
switch (action) {
|
||
case 'zoom-in':
|
||
this.zoomBy(ZOOM_STEP);
|
||
break;
|
||
case 'zoom-out':
|
||
this.zoomBy(-ZOOM_STEP);
|
||
break;
|
||
case 'zoom-reset':
|
||
this.reset();
|
||
break;
|
||
case 'zoom-fit':
|
||
this.fitToView();
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
bindEvents() {
|
||
this.viewport.classList.add('can-pan');
|
||
|
||
this.viewport.addEventListener('pointerdown', (e) => {
|
||
if (e.button !== 0) return;
|
||
if (e.target.closest('button')) return;
|
||
this.isPanning = true;
|
||
this.pointerId = e.pointerId;
|
||
this.lastPointer = { x: e.clientX, y: e.clientY };
|
||
this.viewport.classList.add('is-panning');
|
||
this.viewport.setPointerCapture(e.pointerId);
|
||
e.preventDefault();
|
||
});
|
||
|
||
this.viewport.addEventListener('pointermove', (e) => {
|
||
if (!this.isPanning || e.pointerId !== this.pointerId) return;
|
||
this.panX += e.clientX - this.lastPointer.x;
|
||
this.panY += e.clientY - this.lastPointer.y;
|
||
this.lastPointer = { x: e.clientX, y: e.clientY };
|
||
this.render();
|
||
e.preventDefault();
|
||
});
|
||
|
||
const endPan = (e) => {
|
||
if (!this.isPanning || (e.pointerId !== undefined && e.pointerId !== this.pointerId)) return;
|
||
this.isPanning = false;
|
||
this.pointerId = null;
|
||
this.viewport.classList.remove('is-panning');
|
||
if (this.viewport.hasPointerCapture(e.pointerId)) {
|
||
this.viewport.releasePointerCapture(e.pointerId);
|
||
}
|
||
};
|
||
|
||
this.viewport.addEventListener('pointerup', endPan);
|
||
this.viewport.addEventListener('pointercancel', endPan);
|
||
this.viewport.addEventListener('lostpointercapture', () => {
|
||
this.isPanning = false;
|
||
this.pointerId = null;
|
||
this.viewport.classList.remove('is-panning');
|
||
});
|
||
|
||
this.viewport.addEventListener(
|
||
'wheel',
|
||
(e) => {
|
||
if (this.wheelNeedsModifier && !e.ctrlKey && !e.metaKey) return;
|
||
e.preventDefault();
|
||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||
this.zoomAt(e.clientX, e.clientY, delta);
|
||
},
|
||
{ passive: false }
|
||
);
|
||
}
|
||
}
|
||
|
||
const diagramViewers = [];
|
||
let fullscreenViewer = null;
|
||
let fullscreenToolbarReady = false;
|
||
|
||
function openFullscreen(sourceViewer) {
|
||
const overlay = document.getElementById('diagram-fullscreen');
|
||
const viewport = document.getElementById('diagram-fullscreen-viewport');
|
||
const stage = document.getElementById('diagram-fullscreen-stage');
|
||
const canvas = document.getElementById('diagram-fullscreen-canvas');
|
||
const title = document.getElementById('diagram-fullscreen-title');
|
||
const toolbarHost = document.getElementById('diagram-fullscreen-toolbar');
|
||
|
||
title.textContent = sourceViewer.title || 'Diagram';
|
||
canvas.innerHTML = sourceViewer.canvas.innerHTML;
|
||
|
||
if (!fullscreenToolbarReady) {
|
||
const toolbar = createToolbar((action) => {
|
||
if (action === 'fullscreen') return;
|
||
if (fullscreenViewer) fullscreenViewer.handleAction(action);
|
||
});
|
||
toolbarHost.appendChild(toolbar);
|
||
fullscreenToolbarReady = true;
|
||
}
|
||
|
||
if (!fullscreenViewer) {
|
||
fullscreenViewer = new DiagramViewer({
|
||
viewport,
|
||
stage,
|
||
canvas,
|
||
label: toolbarHost.querySelector('[data-zoom-label]'),
|
||
wheelNeedsModifier: false,
|
||
});
|
||
}
|
||
|
||
fullscreenViewer.fitToView();
|
||
|
||
overlay.classList.add('is-open');
|
||
overlay.setAttribute('aria-hidden', 'false');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closeFullscreen() {
|
||
const overlay = document.getElementById('diagram-fullscreen');
|
||
overlay.classList.remove('is-open');
|
||
overlay.setAttribute('aria-hidden', 'true');
|
||
document.body.style.overflow = '';
|
||
fullscreenViewer = null;
|
||
}
|
||
|
||
function wrapDiagrams() {
|
||
document.querySelectorAll('.diagram').forEach((diagram, index) => {
|
||
let title = `Diagram ${index + 1}`;
|
||
const prevHeading = diagram.previousElementSibling;
|
||
if (prevHeading && (prevHeading.tagName === 'H2' || prevHeading.tagName === 'H3')) {
|
||
title = prevHeading.textContent.trim();
|
||
}
|
||
|
||
const viewport = document.createElement('div');
|
||
viewport.className = 'diagram-viewport';
|
||
const stage = document.createElement('div');
|
||
stage.className = 'diagram-stage';
|
||
const canvas = document.createElement('div');
|
||
canvas.className = 'diagram-canvas';
|
||
|
||
while (diagram.firstChild) {
|
||
canvas.appendChild(diagram.firstChild);
|
||
}
|
||
|
||
stage.appendChild(canvas);
|
||
viewport.appendChild(stage);
|
||
|
||
const viewer = new DiagramViewer({
|
||
viewport,
|
||
stage,
|
||
canvas,
|
||
label: null,
|
||
wheelNeedsModifier: true,
|
||
});
|
||
viewer.title = title;
|
||
viewer.element = diagram;
|
||
|
||
const toolbar = createToolbar((action) => {
|
||
if (action === 'fullscreen') {
|
||
openFullscreen(viewer);
|
||
return;
|
||
}
|
||
viewer.handleAction(action);
|
||
});
|
||
viewer.label = toolbar.querySelector('[data-zoom-label]');
|
||
viewer.render();
|
||
|
||
diagram.appendChild(toolbar);
|
||
diagram.appendChild(viewport);
|
||
diagramViewers.push(viewer);
|
||
});
|
||
}
|
||
|
||
document.getElementById('diagram-fullscreen-close').addEventListener('click', closeFullscreen);
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
const fsOpen = document.getElementById('diagram-fullscreen').classList.contains('is-open');
|
||
const viewer = fsOpen
|
||
? fullscreenViewer
|
||
: diagramViewers.find((v) => {
|
||
const rect = v.element.getBoundingClientRect();
|
||
return rect.top < window.innerHeight && rect.bottom > 0;
|
||
});
|
||
|
||
if (!viewer) return;
|
||
|
||
if (e.key === 'Escape' && fsOpen) {
|
||
closeFullscreen();
|
||
return;
|
||
}
|
||
|
||
if (e.key === '+' || e.key === '=') {
|
||
e.preventDefault();
|
||
viewer.handleAction('zoom-in');
|
||
} else if (e.key === '-' || e.key === '_') {
|
||
e.preventDefault();
|
||
viewer.handleAction('zoom-out');
|
||
} else if (e.key === '0') {
|
||
e.preventDefault();
|
||
viewer.handleAction('zoom-reset');
|
||
} else if ((e.key === 'f' || e.key === 'F') && !fsOpen) {
|
||
e.preventDefault();
|
||
openFullscreen(viewer);
|
||
}
|
||
});
|
||
|
||
mermaid.initialize({
|
||
startOnLoad: false,
|
||
theme: 'neutral',
|
||
securityLevel: 'loose',
|
||
flowchart: { useMaxWidth: false, htmlLabels: true },
|
||
sequence: { useMaxWidth: false },
|
||
er: { useMaxWidth: false },
|
||
});
|
||
|
||
wrapDiagrams();
|
||
mermaid.run({ querySelector: '.mermaid' }).then(() => {
|
||
diagramViewers.forEach((viewer) => viewer.fitToView());
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|