"""Chapters 24-40 and appendices."""
import csv
import collections
from pathlib import Path

from helpers import Builder, ACCENT, MUTED, WARN, OK, INFO
import facts as F

AUD = Path(__file__).resolve().parents[2] / "docs" / "audit"


def _csv(name):
    with open(AUD / name, newline="", encoding="utf-8") as fh:
        return list(csv.DictReader(fh))


def ch24_user_manual(b: Builder) -> None:
    b.h1("24. User manual and practical examples")
    b.p("Instructions below are written against the screens that exist. Menu names are the "
        "Indonesian labels from src/config/navigation.js; the parenthesised path is the URL "
        "you land on. Where a step depends on a permission, the key is named so an "
        "administrator can grant it.")

    b.h2("24.1 Signing in (all applications)")
    b.numbered([
        "Open the application hostname for your role (ERP for back-office, POS for the till, B2B for the storefront).",
        "Enter email and password. The form posts to POST /api/v1/auth/login.",
        "ERP and POS remember the session only if 'remember me' is chosen; otherwise the token lives in the tab's session storage and disappears when the tab closes.",
        "The shell immediately calls GET /api/v1/auth/me to load effective permissions and, for POS, the store list.",
        "A disabled account shows 'Akun Anda telah dinonaktifkan' (403). Five failures in a row from one IP will hit the 10/min limiter and show a rate-limit message.",
    ])
    b.table(["Problem", "Cause", "What to do"],
            [["Bounced back to login with ?denied=1", "The identity's application_access does not match this application", "Ask an administrator to check users.application_access"],
             ["Logged in but every menu is empty", "The role has no permission rows (deny by omission)", "Administrator: Peran & Hak Akses → grant the role its keys"],
             ["'Rate limiter unavailable'", "Redis is down and the login limiter is fail-closed", "Restart Redis; this is expected fail-closed behaviour"],
             ["Password reset mail never arrives", "SMTP_HOST unset (log-only mailer) or an empty From header (F-07)", "Set SMTP_HOST/USER/PASS/FROM and restart the backend"]],
            caption="Login troubleshooting", widths=[1.9, 2.3, 2.3], font_size=7.8)

    b.h2("24.2 Managing products (ERP)")
    b.numbered([
        "Master Data → Produk (/master-data/products).",
        "Tambah Produk. The code is generated from the category prefix and the date unless you supply one.",
        "Fill name, category, base unit, purchase price and selling price. Purchase price is only visible to holders of inventory.cost.view.",
        "Add selling units (e.g. KARTON with conversion factor 24) under the product's units, and barcodes under barcodes.",
        "Save. A duplicate code returns a validation error; a category that does not exist returns 404.",
    ])
    b.note("Common mistake", "Changing a product's selling price does not change past "
           "transactions: every sale line stores its own price, unit and conversion snapshot, "
           "and the database rejects edits to those columns.", "note")

    b.h2("24.3 Stock operations (ERP)")
    b.table(["Task", "Path", "Steps", "Permission"],
            [["Stock in", "Inventory → Posisi Stok → Masuk", "Pick warehouse and product, enter quantity, save. A movement SM-… is written.", "inventory.stock-in.create"],
             ["Stock out", "Inventory → Posisi Stok → Keluar", "Same form; the row is locked and the request is refused if stock is short (unless negative stock is enabled).", "inventory.stock-out.create"],
             ["Opname", "Inventory → Opname", "Create the opname (snapshot), enter physical quantities, finalise. Each difference posts an adjustment movement.", "inventory.opnames.create / .finalize"],
             ["Transfer", "Inventory → Transfer Stok", "Choose source and destination warehouse, lines, then approve → send → receive.", "inventory.transfers.create"]],
            caption="Stock operations", widths=[1.0, 1.7, 2.8, 1.0], font_size=7.8)

    b.h2("24.4 Selling at the till (POS)")
    b.numbered([
        "Log in and open a shift with the opening cash amount (Shift → Buka Shift).",
        "Scan a barcode or search the product grid; the line appears in the cart.",
        "Optionally pick a customer. Leaving it empty sells to Pelanggan Umum at list price; a member gets their tier discount, snapshotted on the line.",
        "Choose the payment method. For cash, the tender must cover the total.",
        "Confirm. The receipt preview opens; print or download the PDF.",
        "At the end of the shift: Shift → Tutup Shift, enter the counted cash. The variance is stored and shown.",
    ])
    b.note("What the till cannot do", "There is no product, price, stock or user management "
           "in POS by design. If a cashier needs a product created, it is an ERP task.",
           "note")

    b.h2("24.5 Returning a sale (POS or ERP)")
    b.numbered([
        "Open Retur (/returns in POS, Sales → Sales Order → Retur in ERP).",
        "Find the original transaction by number; only delivered, invoiced or paid orders — or a fully paid POS sale — are eligible.",
        "Select lines and quantities. The system refuses more than the unreturned quantity.",
        "Choose the condition (good / damaged). Damaged items are recorded but not restocked.",
        "Choose the refund method (cash, transfer, store credit). A cash refund also writes a drawer cash-out entry.",
    ])

    b.h2("24.6 Buying on the storefront (B2B)")
    b.numbered([
        "Browse /catalog or search; open a product at /products/<slug>.",
        "Add to cart, then open /cart and continue to /checkout.",
        "Pick the fulfilling warehouse, the delivery address and the payment method.",
        "Place the order. For a transfer you upload a proof on /payments/<id>; for Snap you are redirected to the provider.",
        "Track the order at /orders/<id> and /orders/<id>/tracking. Confirm receipt when it arrives; that is what moves the order to delivered.",
    ])

    b.h2("24.7 Generating a report (ERP)")
    b.numbered([
        "Open the report from Analitik (for example Laporan Penjualan, /reports/sales).",
        "Set the date range and any filters; the table refreshes.",
        "Use the print button to request a PDF from /api/v1/reports/render. If the report code has no active template the API answers 404 'Active template not found'.",
        "For a different layout, System → Template Laporan opens the designer; changes are versioned and activated explicitly.",
    ])

    b.h2("24.8 Managing users and permissions (ERP)")
    b.numbered([
        "Sistem → Pengguna (/system/users): create the user, choose the role and the application access.",
        "Sistem → Peran (/system/roles): tick permissions per role. The matrix saves immediately and the backend hot-reloads it, so the next request uses the new grants.",
        "Per-user overrides live on the user record; an explicit false beats any role grant.",
        "If you are admin or owner and the matrix is completely empty, the shell forces you to this page (?setup=1) until at least one permission exists.",
    ])
    b.note("Lockout risk", "Removing system.roles.view from every role leaves the matrix "
           "reachable only by admin/owner (who bypass checks). Do not remove the last "
           "administrative account's access at the same time.", "warning")

    b.h2("24.9 Recovering from a failed operation")
    b.table(["Symptom", "Meaning", "Recovery"],
            [["409 CONFLICT on a money form", "The same Idempotency-Key was reused with a different payload", "Reload the form so a fresh key is minted, then resubmit"],
             ["409 BUSINESS_RULE_VIOLATION", "A rule rejected the write (voucher unusable, stock short, order not in a modifiable state)", "Read the message; correct the input. Do not retry unchanged"],
             ["503 GATEWAY_DISABLED", "Midtrans keys are not configured", "Use an offline method, or ask an administrator to configure the gateway"],
             ["404 on a record you can see in a list", "Tenant scope: the record belongs to another outlet", "Switch to the correct outlet account"],
             ["PDF print returns 404", "No active report template for that code", "System → Template Laporan: activate a template"],
             ["Stale numbers after a release", "The browser cached index.html", "Hard refresh; nginx sends no-store for index.html by design"]],
            caption="Recovery table for common failures", widths=[1.7, 2.4, 2.4], font_size=7.8)

    b.h2("24.10 When to contact support")
    b.bullets([
        "A financial document is wrong and cannot be corrected through a reversal — stop and escalate; do not edit rows.",
        "A stock count disagrees with the physical count by more than the opname tolerance.",
        "A login is locked out and no administrator account is available.",
        "The API answers 500 repeatedly with the same request id — quote the X-Request-Id, which is also in the error envelope.",
        "Support tickets can be raised in-app from ERP (Tiket Kendala) or B2B (/support); they are stored in tickets/ticket_comments.",
    ])


def ch25_developer(b: Builder) -> None:
    b.h1("25. Developer setup and contributor manual")
    b.h2("25.1 Prerequisites (verified against the manifests)")
    b.table(["Tool", "Version actually required", "Evidence"],
            [["Go", "1.26.5", "apps/backend/go.mod line 3; the CI pin of 1.24 is wrong (F-03)"],
             ["Node.js", "22.x", "Dockerfiles use node:22; CI uses 22"],
             ["pnpm", "12.11.2", "package.json packageManager"],
             ["Python", "3.11+ (CI 3.11; audit host 3.14)", "check-drift.py uses only stdlib + PyYAML"],
             ["PostgreSQL", "16 (prod) — the migrations were verified on 16", "compose.prod.yml"],
             ["Redis", "7", "compose"],
             ["Podman or Docker with compose", "any recent", "infra/containers/*.yml"],
             ["python-docx + a Mermaid CLI", "only for regenerating this document", "docs/docgen, docs/diagrams"]],
            caption="Prerequisites", widths=[1.7, 2.0, 2.8])

    b.h2("25.2 First run")
    b.code("""git clone <repository-url> keluargamart-monorepo
cd keluargamart-monorepo
pnpm install                     # workspace install (lockfile committed)

# 1. Database + Redis (isolated; do not point at anything real)
podman run -d --name km-pg -e POSTGRES_USER=erp -e POSTGRES_PASSWORD=erp \\
  -e POSTGRES_DB=erp -p 127.0.0.1:55432:5432 docker.io/library/postgres:16-alpine
podman run -d --name km-redis -p 127.0.0.1:56379:6379 docker.io/library/redis:7-alpine

# 2. Schema (the backend can also do this itself: AUTO_MIGRATE=true)
cd apps/backend/migrations
for f in $(ls [0-9]*.sql | sort); do
  podman exec -i km-pg psql -U erp -d erp -v ON_ERROR_STOP=1 -q \\
    --single-transaction < "$f"
done
cd ..
podman exec -i km-pg psql -U erp -d erp -v ON_ERROR_STOP=1 -q < seed.sql

# 3. Backend
DATABASE_URL='postgres://erp:erp@127.0.0.1:55432/erp?sslmode=disable' \\
REDIS_URL='redis://127.0.0.1:56379/0' go run ./cmd/server

# 4. Frontends (separate terminals)
pnpm --filter @keluargamart/erp dev        # http://localhost:5173
pnpm --filter @keluargamart/pos dev        # http://localhost:5174
pnpm --filter @keluargamart/b2b dev        # http://localhost:3000""",
           caption="Verified first-run sequence")
    b.note("Two corrections to the existing guide", "docs/development/getting-started.md "
           "tells you to start db/redis/minio/mailpit from apps/backend/docker-compose.dev.yml. "
           "That file defines backend, pos-dev, erp-dev and b2b-dev (plus the base compose's "
           "postgres and redis); it has no minio or mailpit service. The sequence above works "
           "without either.", "gap")

    b.h2("25.3 Seed data")
    b.table(["File", "Contents", "When to use"],
            [["seed.sql", "1 admin (admin@keluargamart.local / Admin@1234), 12 units, categories, 6 empty warehouses, membership tiers, roles, fleet, exactly 1 product (Galon Indodes) with base unit and barcode", "Bootstrap; idempotent via WHERE NOT EXISTS"],
             ["seed_testdata.sql", "Realistic products, customers, suppliers, orders, stock", "Local development and demos"],
             ["seed_location_demo.sql", "Location/tracking/fleet demo layer", "Map and tracking work"],
             ["apps/backend/scripts/seed.sh", "Wrapper: bootstrap | testdata | demo | all", "Convenience"]],
            caption="Seed layers", widths=[1.6, 3.4, 1.5], font_size=7.8)
    b.note("Seed credentials", "The bootstrap admin password is committed in seed.sql for "
           "local use. It must never be used on a reachable deployment; change it immediately "
           "after the first login.", "warning")

    b.h2("25.4 Testing")
    b.code("""# Go — unit tests only (integration skips without DATABASE_URL)
cd apps/backend && go test ./...

# Go — including integration, against a THROWAWAY database
DATABASE_URL='postgres://erp:erp@127.0.0.1:55432/km_test?sslmode=disable' \\
KG_ALLOW_DESTRUCTIVE_DB_TESTS=1 go test ./...

# Race detector (slow; use before a risky change)
go test -race ./internal/sales/... ./internal/finance/...

# JS workspace
pnpm turbo run test --force
pnpm --filter @keluargamart/erp test      # single package""",
           caption="Test commands")
    b.note("Destructive consent", "KG_ALLOW_DESTRUCTIVE_DB_TESTS=1 authorises TRUNCATE of "
           "66 tables (transitively). Point DATABASE_URL at a database you are willing to "
           "lose. See finding F-05.", "warning")

    b.h2("25.5 Adding a feature")
    b.table(["Task", "Steps", "Files"],
            [["New endpoint", "1) add the query to db/query/*.sql (or hand-write it) 2) repository → service → handler 3) mount in cmd/server/main.go with the permission gate 4) document in apps/backend/docs/openapi.yaml 5) pnpm contracts:generate 6) pnpm drift:check", "apps/backend/internal/<module>/, cmd/server/main.go, apps/backend/docs/openapi.yaml"],
             ["New table", "1) migrations/NNNNNN_name.sql (six-digit prefix, one transaction) 2) add the query 3) refresh db/schema.sql 4) consider a CHECK for state vocabularies", "apps/backend/migrations/"],
             ["New permission", "1) add the key to internal/auth/catalog.go 2) grant it in a migration 3) pnpm permissions:generate 4) add the UI gate (meta.perm / nav perm)", "catalog.go, packages/permissions"],
             ["New ERP page", "1) pages/<area>/XPage.vue 2) route with meta.perm 3) nav item with the same perm", "apps/erp/src/{pages,router,config}"],
             ["New POS workflow", "1) page + route with meta.permission 2) service in src/services/api 3) store action 4) test", "apps/pos/src/"],
             ["New B2B page", "1) app/pages/<path>.vue 2) composable in app/composables 3) middleware if authenticated 4) test", "apps/b2b/app/"],
             ["New report", "1) template JSON in a migration (report_templates) 2) templatePerms entry 3) data shaper in reportproxy 4) verify with format=pdf", "internal/reportproxy, migrations"],
             ["New shared component", "Add to the app's own component layer (k/ in ERP, ui/ in POS); do not add to packages/ui unless you also wire the imports (it is currently unused)", "apps/*/src/components"],
             ["New application or package", "1) apps/<name>/package.json with name @keluargamart/<name> 2) Dockerfile 3) add to compose + deploy matrix 4) document it", "apps/, packages/, .github/workflows"]],
            caption="Extension recipes", widths=[1.3, 3.6, 1.6], font_size=7.6)

    b.h2("25.6 Conventions observed in the code")
    b.bullets([
        "Money is decimal everywhere; float64 is not used for currency.",
        "Every mutation that touches money, stock or a status runs in a transaction and locks the contended row.",
        "Errors are sentinel values mapped in the handler; internal error text is never returned to the client.",
        "Comments carry the reason and the date of the change (e.g. 'client feedback 2026-08-26'), not a restatement of the code.",
        "Tests are table-driven and named as claims (TestBuyerValidatesVoucherWithoutGainingTheList).",
        "Commit messages are conventional (feat(erp/reports): …).",
    ])


def ch26_testing(b: Builder) -> None:
    b.h1("26. Development and testing workflow")
    b.h2("26.1 Test inventory")
    b.table(["Suite", "Framework", "Files", "Tests", "Executed in this audit"],
            [["Go unit + package", "go test", "139 files", "1176 pass, 38 skip", "Yes — full suite"],
             ["Go integration", "go test (internal/integration)", "26 files", "88 pass", "Yes — with consent, against the audit DB"],
             ["ERP", "Vitest + @vue/test-utils + jsdom", "39", "344", "Yes"],
             ["POS", "Vitest", "20", "111", "Yes"],
             ["B2B", "Vitest", "20", "170", "Yes"],
             ["api-client", "Vitest", "1", "3", "Yes"],
             ["contracts", "Vitest", "1", "4", "Yes"],
             ["permissions", "Vitest", "1", "5", "Yes"],
             ["ui", "Vitest", "1", "10", "Yes"],
             ["utils", "Vitest", "1", "6", "Yes"],
             ["Drift guard", "Python", "1 script", "2 checks", "Yes — both pass"]],
            caption="Test inventory and execution status", widths=[1.4, 1.9, 0.8, 1.3, 1.2], font_size=7.8)

    b.h2("26.2 What the suites actually cover")
    b.table(["Workflow", "Coverage", "Representative tests"],
            [["RBAC / permissions", "Strong", "catalog_test, matrix_test, chain_test, permissions_scope_test, rbac_visit_schedule_test, plus a route→gate table that fails if a route loses its permission"],
             ["Route ↔ OpenAPI contract", "Strong", "TestMountedRoutesMatchOpenAPI (373 vs 373), TestMountedRouteGates, TestGateTableIsNotVacuous"],
             ["Pricing snapshots", "Strong", "catalog cost_view_test, pricing service_test, integration/pricing_test"],
             ["POS checkout", "Strong", "integration/pos_test, cart_test, idempotency_test, sales/service_test"],
             ["Idempotency", "Strong", "integration/idempotency_test (replay + conflict), pkg/redis/idempotency_test"],
             ["Document numbering", "Strong", "TestConcurrentSalesOrderNumbers, TestMonthlyDocumentNumbers, sequence-anchor migrations"],
             ["Finance / cashflow", "Strong", "finance service_test, cashflow_scope_test, handler_contract_test, midtrans_test"],
             ["Returns", "Good", "sales service_test return paths; no dedicated end-to-end return test"],
             ["Accounting", "Good", "accounting service_test; journal posting/reversal rules"],
             ["Distribution", "Good", "distribution service_flow_test, dispatch/deliver guards, tracking"],
             ["HR payroll", "Good", "payroll_allocator_test, payroll_counter_test, employee_number_test"],
             ["Reports/NextReport", "Moderate", "reportproxy shaper tests, templates service_test; PDF path verified manually"],
             ["B2B storefront", "Moderate", "20 Vitest files over composables and services; no browser E2E"],
             ["ERP pages", "Moderate", "39 files, mostly component/unit; no browser E2E"],
             ["Migrations", "Moderate", "dbmigrate runner_test, integration/migrate_test, phantom_schema_test; no full replay test in CI"],
             ["Backup/restore", "None", "No test or drill exists"]],
            caption="Workflow coverage assessment (based on test names and contents, not line coverage)",
            widths=[1.4, 0.8, 4.3], font_size=7.6)
    b.note("No coverage measurement", "Neither the Go nor the JS suites emit a coverage "
           "report in CI, so this table is a qualitative mapping, not a percentage. Treat "
           "'Strong' as 'the failure mode has a named test', not as proof of correctness.",
           "note")

    b.h2("26.3 CI order")
    b.figure(Path("../diagrams/rendered/fig-ci-cd.png"),
             "CI and image pipeline as configured. The dashed self-loop marks the job that "
             "cannot currently satisfy its own toolchain requirement.")
    b.table(["Job", "Steps", "Blocks"],
            [["contracts-drift-check", "python 3.11, pip install pyyaml, python3 scripts/check-drift.py", "Nothing"],
             ["backend-checks", "go 1.24, go vet ./..., go test -v ./..., CGO_ENABLED=0 go build", "Nothing (runs in parallel) — but fails on go.mod 1.26.5"],
             ["frontend-and-packages-checks", "node 22, pnpm 12.11.2, pnpm install, nuxt prepare, turbo test, turbo build", "Needs contracts-drift-check"]],
            caption="CI jobs", widths=[2.0, 3.2, 1.3], font_size=7.8)
    b.note("What CI does not do", "No lint job (turbo run lint would only run pos and b2b), "
           "no typecheck job, no migration replay, no database-backed Go test (no Postgres "
           "service is defined), and no coverage upload. The Go job therefore runs with no "
           "DATABASE_URL, which means internal/integration silently skips.", "gap")


def ch27_cicd(b: Builder) -> None:
    b.h1("27. CI/CD and release process")
    b.h2("27.1 Triggers and permissions")
    b.table(["Workflow", "Triggers", "Permissions", "Concurrency"],
            [["ci.yml", "push to main, pull_request to main, workflow_dispatch", "default (read)", "none"],
             ["deploy-ghcr.yml", "push to main, tags v*.*.*, workflow_dispatch", "contents: read, packages: write", "group per ref, cancel-in-progress"]],
            caption="Workflow triggers", widths=[1.4, 2.6, 1.6, 1.4])

    b.h2("27.2 Image build matrix")
    b.table(["Service", "Context", "Dockerfile", "Image"],
            [["backend", "./apps/backend", "./apps/backend/Dockerfile", "ghcr.io/tarulab/km-backend"],
             ["erp", ".", "./apps/erp/Dockerfile", "ghcr.io/tarulab/km-erp"],
             ["pos", ".", "./apps/pos/Dockerfile", "ghcr.io/tarulab/km-pos"],
             ["b2b", ".", "./apps/b2b/Dockerfile", "ghcr.io/tarulab/km-b2b"],
             ["nextreport", "./apps/nextreport", "./apps/nextreport/Dockerfile", "ghcr.io/tarulab/km-nextreport"]],
            caption="Image matrix", widths=[1.2, 1.7, 2.1, 2.0])
    b.p("Tags are always latest and the commit SHA; a v*.*.* tag also publishes the tag "
        "name. Build cache uses the GitHub Actions cache with a per-service scope. "
        "fail-fast is false, so one broken image does not cancel the others.")
    b.note("Deployment is not automated", "The workflow stops at the registry. The runbooks "
           "describe a manual `podman compose -f compose.prod.yml up -d` on the VPS with "
           "IMAGE_TAG set. There is no automatic rollback and no post-deploy smoke test in "
           "the pipeline.", "gap")

    b.h2("27.3 Release and rollback")
    b.table(["Step", "Command / action", "Verification"],
            [["Tag a release", "git tag v1.2.3 && git push origin v1.2.3", "Workflow publishes three tags per image"],
             ["Deploy on the VPS", "IMAGE_TAG=<sha> podman compose -f compose.prod.yml up -d", "Backend healthcheck every 10 s; `ss -tulpn | grep -E '3001|3002|3003|3004'`"],
             ["Verify", "curl -I http://127.0.0.1:3001 … and curl http://127.0.0.1:3004/health", "200 + {\"status\":\"ok\"}"],
             ["Rollback", "set IMAGE_TAG to the previous SHA and re-run `up -d`", "Same checks; migrations are forward-only, so a rollback across a schema change needs a restore"],
             ["Database rollback", "restore the last dump", "No DOWN migrations exist"]],
            caption="Release and rollback procedure", widths=[1.3, 3.2, 2.0], font_size=7.8)


def ch28_deploy(b: Builder) -> None:
    b.h1("28. Deployment and infrastructure")
    b.h2("28.1 Topology")
    b.figure(Path("../diagrams/rendered/fig-deployment.png"),
             "Deployment topology as configured. Loopback bindings and the container network "
             "are from infra/containers/compose.prod.yml; the tunnel agent is a host service.")
    b.h2("28.2 Services")
    b.table(["Service", "Image", "Host binding", "CPU / memory limit", "Notes"],
            [[s[0], s[1], s[2], s[3], s[4]] for s in F.INFRA_SERVICES],
            caption="Compose services (infra/containers/compose.prod.yml)",
            widths=[0.9, 1.9, 1.6, 1.2, 1.0], font_size=7.6)
    b.p("Every service logs through the json-file driver with max-size 10m and max-file 3, "
        "so each container is capped at 30 MB of logs. ERP, POS and backend bind only to "
        "127.0.0.1. PostgreSQL, Redis and NextReport publish no host port at all.")

    b.h2("28.3 Container images")
    b.table(["Image", "Build stages", "Runtime user", "Healthcheck"],
            [["km-backend", "golang:alpine (cross-compiled CGO_ENABLED=0) → alpine:3.20 with ca-certificates + wget", "appuser uid 10001", "wget /health (image + compose)"],
             ["km-erp / km-pos", "node:22-alpine + pnpm install + vite build → nginx:1.29-alpine", "nginx", "none in the image; compose waits on backend health"],
             ["km-b2b", "node:22-alpine + nuxt build → node:22 runtime", "node", "none"],
             ["km-nextreport", "node:22-slim + Chromium apt packages + npm install --omit=dev", "node", "none"]],
            caption="Image construction", widths=[1.3, 3.0, 1.0, 1.2], font_size=7.8)

    b.h2("28.4 Resource allocation")
    b.table(["Service", "CPU limit", "Memory limit", "Memory reservation"],
            [[s[0], s[3].split(" / ")[0], s[3].split(" / ")[1], "—"] for s in F.INFRA_SERVICES],
            caption="Configured limits (sum of memory limits ≈ 3.2 GB on an 8 GB host)",
            widths=[1.5, 1.2, 1.5, 1.5])
    b.note("Configured, not measured", "These are compose limits and runtime flags. This "
           "audit did not measure production RSS, so no claim is made about headroom or OOM "
           "risk. docs/performance/baseline.md estimates idle usage but labels the figures "
           "as estimates.", "note")

    b.h2("28.5 Reverse proxy")
    b.p("Two proxy layers exist. Inside the ERP and POS containers, nginx re-resolves the "
        "backend hostname per request using the container's resolv.conf nameserver "
        "(valid=10s), which fixes the stale-IP 502 that a literal proxy_pass causes when "
        "the backend container is recreated. Caddy exists only as an optional 'gateway' "
        "profile for direct 80/443 serving without Cloudflare.")


def ch29_tunnel(b: Builder) -> None:
    b.h1("29. Cloudflare Tunnel and routing")
    b.p("The public hostname → loopback port mapping is fixed by the deployment contract and "
        "repeated in three places: docs/deployment/cloudflare-tunnel.md, the port bindings in "
        "infra/containers/compose.prod.yml, and the comments at the top of each service block.")
    b.table(["Hostname", "Origin", "Container", "Function"],
            [["erp-km.trl.my.id", "http://127.0.0.1:3001", "keluargamart-erp:8080", "Back-office ERP SPA"],
             ["b2b-km.trl.my.id", "http://127.0.0.1:3002", "keluargamart-b2b:3000", "B2B storefront (SSR)"],
             ["pos-km.trl.my.id", "http://127.0.0.1:3003", "keluargamart-pos:8080", "POS register SPA"],
             ["api-km.trl.my.id", "http://127.0.0.1:3004", "keluargamart-backend:8080", "REST API"]],
            caption="Tunnel ingress mapping (documented contract; not read from a live config)",
            widths=[1.8, 1.7, 1.9, 1.1])
    b.note("Verification limits", "The cloudflared configuration file is deliberately not "
           "committed, so this mapping is documented intention. It is consistent with the "
           "compose port bindings and with the hostname defaults in the backend config "
           "(B2B_BASE_URL, PUBLIC_API_BASE_URL). No live tunnel was inspected.", "inferred")
    b.p("The agent runs as a host systemd unit (systemctl status cloudflared), not as a "
        "container, so it can reach the loopback bindings directly. Because the origin is "
        "127.0.0.1, the container network namespace is irrelevant to ingress: the tunnel "
        "talks to the host's loopback, and the host's published ports forward into the "
        "container network.")
    b.table(["Check", "Command", "Expected"],
            [["Listeners", "ss -tulpn | grep -E '3001|3002|3003|3004'", "Four 127.0.0.1 listeners"],
             ["ERP", "curl -I http://127.0.0.1:3001", "200"],
             ["B2B", "curl -I http://127.0.0.1:3002", "200"],
             ["POS", "curl -I http://127.0.0.1:3003", "200"],
             ["API", "curl http://127.0.0.1:3004/health", "{\"status\":\"ok\",\"env\":\"…\"}"],
             ["Agent", "systemctl status cloudflared / journalctl -u cloudflared -f", "Connected, no 502s"]],
            caption="Ingress verification commands", widths=[1.2, 3.6, 1.7])


def ch30_observability(b: Builder) -> None:
    b.h1("30. Logging, monitoring and diagnostics")
    b.figure(Path("../diagrams/rendered/fig-observability.png"),
             "Observability paths: request logs, audit log, health probes and the report "
             "engine health endpoint.")
    b.table(["Signal", "Where", "What it tells you"],
            [["Request log", "container stdout (slog)", "method, path, status, bytes, duration, request id"],
             ["X-Request-Id", "response header + error envelope", "correlates a user report with a log line"],
             ["/health", "backend", "{status, env}; always 200 while the process lives"],
             ["/ready", "backend", "{database, redis, status}; 503 when a dependency is down"],
             ["/api/v1/reports/health", "backend → NextReport", "{nextreport: up|down}"],
             ["activity_logs", "database", "who changed what, with old/new JSON"],
             ["compose healthcheck", "backend container", "10 s interval, 3 s timeout, 3 retries"],
             ["podman logs", "host", "per-container, rotated at 30 MB"]],
            caption="Monitoring and diagnostic signals", widths=[1.5, 1.6, 3.4])
    b.note("No metrics endpoint", "There is no Prometheus/OpenTelemetry exporter, no "
           "structured error tracker and no alerting rule in the repository. Detection "
           "depends on the compose healthcheck and a human reading logs.", "gap")
    b.h2("30.1 Reading a failure")
    b.numbered([
        "Take the X-Request-Id from the error response (also in meta.request_id).",
        "podman logs keluargamart-backend 2>&1 | grep <request-id>",
        "If the log shows a 5xx, look for the recovered panic line (it includes the top of the stack) or the SQL error.",
        "If the request never reached the backend, check the ERP/POS nginx container: a 502 there usually means the backend name did not resolve (see the entrypoint's resolver logic).",
        "If /ready reports redis unavailable, logins will fail closed with 503 while normal traffic continues.",
    ])


def ch31_backup(b: Builder) -> None:
    b.h1("31. Backup, recovery and data retention")
    b.figure(Path("../diagrams/rendered/fig-backup.png"),
             "Documented backup and restore procedure. Nothing in this diagram is automated "
             "by the repository; the cron entry and offsite copy are runbook steps.")
    b.table(["Asset", "Method", "Frequency (documented)", "Retention"],
            [["PostgreSQL", "podman exec pg_dump | gzip", "every 6 hours (cron)", "7 days local, 30 days offsite"],
             ["Uploads volume", "tar.gz or rclone sync", "daily 02:00 WIB", "3 days local, 90 days offsite"],
             ["Environment/secrets", "manual, encrypted (GPG/Bitwarden)", "on change", "secure vault"]],
            caption="Backup strategy (docs/deployment/backup-and-restore.md)", widths=[1.3, 2.2, 1.6, 1.4])
    b.note("Gap", "The runbook references scripts/backup-db.sh, which is not in the "
           "repository. The procedure is documented but not shipped, so a new operator must "
           "retype it from the document. There is also no restore drill and no verification "
           "step beyond 'the command exited 0'.", "gap")
    b.h2("31.1 Restore procedure (safe order)")
    b.numbered([
        "Stop the backend so no writes occur (podman stop keluargamart-backend).",
        "Take a fresh dump of the current state first, even if it is believed corrupt.",
        "Restore into a new database and inspect it before replacing the live one.",
        "gunzip -c <dump> | podman exec -i keluargamart-postgres psql -U erp -d erp.",
        "Start the backend; dbmigrate must log 'schema up to date'. If it applies migrations instead, the dump is older than the binary.",
        "Verify: row counts against the dump, login, one POS sale in a test outlet, one report render.",
    ])
    b.h2("31.2 Retention and deletion")
    b.p("No retention job exists in the code. Financial rows are append-only by design "
        "(journal entries, payments, stock movements, generated cashflows) and the schema has "
        "no cascade deletes from those tables. Soft deletes are used only on products, "
        "suppliers, customers and employees (deleted_at). Customer PII has no automated "
        "purge; deletion is a manual, audited operation.")


def ch32_troubleshoot(b: Builder) -> None:
    b.h1("32. Error handling and troubleshooting")
    b.figure(Path("../diagrams/rendered/fig-error-handling.png"),
             "Error taxonomy and the client's recovery decision.")
    b.h2("32.1 Error codes")
    b.table(["HTTP", "Code", "Typical cause", "Client action"],
            [["400", "VALIDATION_ERROR", "Missing/invalid field", "Show the message on the field"],
             ["401", "AUTHENTICATION_REQUIRED", "No token, bad token, expired token", "Clear the session, go to login"],
             ["403", "FORBIDDEN", "Permission not held", "Hide the action; do not retry"],
             ["403", "ACCOUNT_DISABLED", "users.is_active = false", "Contact an administrator"],
             ["404", "NOT_FOUND", "Missing row or out-of-scope tenant", "Refresh the list"],
             ["409", "CONFLICT", "Idempotency key reused with a different payload", "Reload the form"],
             ["409", "BUSINESS_RULE_VIOLATION", "Voucher unusable, stock short, bad state", "Correct the input"],
             ["422", "VALIDATION_ERROR", "Idempotency-Key missing on a money endpoint", "Mint a key and resubmit"],
             ["429", "RATE_LIMITED", "Per-IP limit exceeded", "Wait and retry"],
             ["500", "INTERNAL_ERROR", "Unhandled error or panic", "Report with the request id"],
             ["503", "INTERNAL_ERROR", "Redis unavailable on a fail-closed limiter/idempotency path", "Retry after infrastructure recovers"],
             ["503", "GATEWAY_DISABLED", "Midtrans key not configured", "Use an offline payment method"]],
            caption="Error taxonomy", widths=[0.6, 1.9, 2.4, 1.6], font_size=7.8)

    b.h2("32.2 Diagnostic scenarios")
    b.table(["Symptom", "Likely cause (evidence)", "Diagnose", "Fix"],
            [["Backend exits at startup", "dbmigrate failure — startup aborts by design", "podman logs: 'dbmigrate: apply <file>: …'", "Fix the failing migration; never serve with a wrong schema"],
             ["/health ok but every data route 404", "DATABASE_URL empty → health-only mode", "log line 'DATABASE_URL empty; running without DB'", "Set DATABASE_URL and restart"],
             ["Login returns 503", "Redis down and the login limiter is fail-closed", "/ready shows redis unavailable", "Start Redis"],
             ["Login returns 429 immediately", "Shared proxy IP with no TRUSTED_PROXIES set", "Check whether the client IP in logs is the proxy", "Set TRUSTED_PROXIES to the proxy CIDR"],
             ["409 on a form that was submitted once", "A stale Idempotency-Key from a previous attempt", "Network tab: same key, different body", "Reload the form"],
             ["PDF render 404", "No active template for the code", "SELECT count(*) FROM report_templates WHERE report_code='…'", "Activate a template or re-run the seed migrations"],
             ["ERP/POS 502 after a stack restart", "nginx cached a stale backend IP (pre-entrypoint behaviour)", "podman logs keluargamart-erp", "The entrypoint re-resolves; if it persists, restart the container"],
             ["Icons missing in B2B", "Icon not in the client bundle", "Browser console '[Icon] failed to load icon'", "Use a bundled i-lucide-* name or extend icon.clientBundle"],
             ["B2B protected page logs out on refresh", "Cookie not converted to a bearer header", "Check server/api/v1/[...path].ts is the active proxy (not routeRules)", "Keep the catch-all proxy; routeRules bypasses it"],
             ["Container restart loop", "Memory limit exceeded or a failing healthcheck", "podman inspect → State, podman logs", "Raise the limit or fix the healthcheck"],
             ["Migration 000058 fails", "Applied without a transaction (temp table dropped early)", "Error mentions catalog_spec", "Re-apply with --single-transaction (F-09)"]],
            caption="Troubleshooting scenarios", widths=[1.5, 2.2, 1.6, 1.5], font_size=7.4)


def ch33_rules(b: Builder) -> None:
    b.h1("33. Business rules and data integrity")
    b.p("Rules below are taken from the implementation, not from retail convention. Where "
        "the frontend and backend disagree, the backend is authoritative and the "
        "discrepancy is named.")
    b.h2("33.1 Document numbering")
    b.table(["Document", "Format", "Scope", "Source"],
            [["Sales order", "SO-YYYYMM-000001", "month", "sales.orderCode (seq.MonthStart)"],
             ["Invoice", "INV-YYYYMM-000001", "month", "sales.orderCode"],
             ["Payment", "PAY-YYYYMM-000001", "month", "sales.prefixedCode (seqKeyPay)"],
             ["Return", "RTN-YYYYMM-000001", "month", "sales.orderCode"],
             ["Stock movement", "SM-YYYYMMDD-0001", "day", "inventory"],
             ["Opname / transfer", "OPN- / TRF- YYYYMMDD-0001", "day", "inventory"],
             ["Purchase order / GR", "PO- / GR- YYYYMMDD-0001", "day", "purchasing"],
             ["Cashflow", "CF-YYYYMMDD-0001", "day", "finance.cfCode"],
             ["Customer / supplier", "CUS-001 / SUP-001", "global", "partners"],
             ["Employee", "EMP-0001", "global (anchored)", "hr"],
             ["Ticket", "TKT-YYYYMMDD-0001", "day", "tickets"]],
            caption="Document-number formats (all allocated atomically inside the caller's transaction)",
            widths=[1.3, 2.0, 0.9, 2.3], font_size=7.8)

    b.h2("33.2 Money and rounding")
    b.bullets([
        "Storage: NUMERIC(15,2) for money, NUMERIC(12,4) for quantities, NUMERIC(14,4) for conversion factors.",
        "Arithmetic: shopspring/decimal throughout; no float64 in any money path.",
        "Order totals: tax = subtotal × tax% / 100; discount clamped to subtotal + tax; total = subtotal + tax − discount + shipping.",
        "Line totals: subtotal = unit_price × quantity − discount_amount, where discount_amount = unit_price × quantity × discount% / 100.",
        "Refunds: pro-rata from the immutable line subtotal, rounded to 2 dp at the line level.",
        "The frontend shows the server's numbers; it does not recompute a total that will be persisted.",
    ])

    b.h2("33.3 Stock rules")
    b.table(["Rule", "Implementation", "Consequence"],
            [["One stock row per (product, warehouse)", "UNIQUE constraint", "Adjustments create the row on first use"],
             ["Regular orders reserve, POS/e-commerce deduct", "reserveStock vs deductStock", "A draft order does not remove sellable stock; it reduces availability"],
             ["Available = quantity − reserved_quantity", "sales.available()", "POS sees only what is not reserved"],
             ["Negative stock is policy, not a constraint", "ALLOW_NEGATIVE_STOCK (default true)", "POS can oversell; set false to refuse"],
             ["Every change writes a movement", "stock_movements with before/after", "Stock history is reconstructible"],
             ["Opname finalisation posts the difference", "adjustStock(diff, 'opname')", "Physical count becomes the ledger truth"]],
            caption="Stock rules", widths=[2.0, 2.0, 2.5], font_size=7.8)

    b.h2("33.4 Financial immutability")
    b.bullets([
        "A generated cashflow (reference_type + reference_id set) cannot have its amount or reference changed and cannot be deleted (trigger).",
        "A cashflow whose date falls in a locked accounting period cannot be inserted, updated or deleted (trigger).",
        "Journal entries are posted once and corrected only by a linked reversal; the service refuses to post an unbalanced or zero entry.",
        "A cancelled invoice keeps its original total_amount: the status marks a write-off, not a zeroing (documented in the OpenAPI schema description).",
        "Order payments and completed POS transactions have no update or delete endpoint.",
    ])

    b.h2("33.5 Pricing precedence")
    b.table(["Layer", "Rule", "Where"],
            [["Base price", "product_units.selling_price for the chosen unit", "pricing service"],
             ["Unit conversion", "base_quantity = quantity × conversion_factor", "snapshot on the line"],
             ["Membership", "tier discount applied only when an explicit customer with a membership is chosen", "membership resolver; walk-in placeholder is excluded"],
             ["Voucher", "order-level discount; mutually exclusive with a manual discount", "ProcessPOS / orderTotals"],
             ["Manual discount", "clamped to subtotal + tax", "orderTotals"],
             ["Client price", "ignored on create — the server resolves it", "sales.Create / ProcessPOS"]],
            caption="Price precedence, highest authority last", widths=[1.2, 3.7, 1.6])

    b.h2("33.6 Idempotency and concurrency")
    b.bullets([
        "Money endpoints require Idempotency-Key; the scope is user + method + route; identical payload replays the stored response, a different payload is 409.",
        "The idempotency store is Redis and is fail-closed: without Redis, the endpoint refuses (503) rather than risking a double write.",
        "Row locks (SELECT … FOR UPDATE) protect order status transitions, stock reservation/deduction, shifts and journals.",
        "Items are sorted by product_id before stock work so concurrent carts acquire locks in the same order.",
    ])

    b.h2("33.7 Frontend/backend discrepancies")
    b.table(["Area", "Frontend behaviour", "Backend behaviour", "Assessment"],
            [["Idempotency keys", "Random fallback key when the caller supplies none", "Requires a stable key to deduplicate", "Unprotected double-submit (F-14)"],
             ["Admin permission map", "Wildcard handled with explicit fallbacks", "Publishes '*' for admin/owner", "Works today, fragile for new clients (F-06)"],
             ["Journal sub-page role list", "Menu hides pages for roles outside a list", "No such role list; only permissions and journal-type validation", "Menu narrower than the API"],
             ["Order totals", "Displays server totals", "Computes and persists totals", "Consistent"],
             ["Offline POS", "Documentation claims an offline queue", "No queue exists anywhere", "Documentation is wrong (F-17)"]],
            caption="Where the UI and the API disagree", widths=[1.2, 2.0, 2.0, 1.3], font_size=7.6)


def ch34_thirdparty(b: Builder) -> None:
    b.h1("34. Third-party dependencies and integrations")
    b.table(["Integration", "Used by", "Mechanism", "Auth", "Failure behaviour"],
            [["Midtrans Snap / Core API", "backend (B2B Snap, POS QRIS)", "HTTPS JSON", "Server key (Basic auth)", "Key absent → 503 GATEWAY_DISABLED; provider error → order stays unpaid and the sweep retries"],
             ["SMTP relay", "backend (password reset)", "smtp.SendMail", "USER/PASS if set", "No host → log-only mailer; bad From → mail rejected (F-07)"],
             ["OpenStreetMap tiles", "erp, pos, b2b", "Raster tile HTTP", "none (attribution required)", "Map renders blank; the rest of the app works"],
             ["OSRM routing (optional)", "backend geo", "HTTP", "none", "ROUTING_PROVIDER empty → no routing"],
             ["Nominatim geocoding (optional)", "backend geo", "HTTP", "User-Agent required", "GEOCODER_ENABLED false → geocode reports disabled"],
             ["GitHub Container Registry", "CI → VPS", "OCI pull", "GITHUB_TOKEN in CI", "Pull failure → deploy cannot proceed"],
             ["Cloudflare Tunnel", "host", "outbound QUIC/HTTP2", "tunnel credentials (not committed)", "Hostnames unreachable; containers unaffected"],
             ["PostgreSQL / Redis", "backend", "TCP", "password / none", "Backend boots health-only (DB) or degrades (Redis)"]],
            caption="External integrations", widths=[1.5, 1.2, 1.4, 1.2, 2.2], font_size=7.4)
    b.p("Library-level dependencies are listed in chapter 5. All are permissively licensed "
        "(MIT/Apache-2.0) except the proprietary in-house packages. No dependency audit "
        "(npm audit / govulncheck) runs in CI.")


def ch35_security(b: Builder) -> None:
    b.h1("35. Security and privacy review")
    b.h2("35.1 Controls that are present")
    b.table(["Control", "Status", "Evidence"],
            [["Password hashing", "Present", "bcrypt with a dummy-hash timing equaliser"],
             ["Token entropy and storage", "Present", "crypto/rand.Text, sha256 at rest, expiry enforced"],
             ["Authorization on every data route", "Present", "373/373 routes carry a gate or are deliberately public"],
             ["Tenant scoping", "Present", "store_users → stores → warehouses; 404 out of scope"],
             ["SQL injection", "No exposure found", "Parameterised queries only"],
             ["Body size limits", "Present", "10 MiB default"],
             ["Security headers", "Present on API and SPA", "nosniff, DENY, no-referrer, permissions policy"],
             ["Rate limiting", "Present", "Login fail-closed, generic fail-open, tracking separate"],
             ["Idempotency", "Present and mandatory on money paths", "Redis-backed, fail-closed"],
             ["Upload authorization", "Present", "Session required; proofs ownership-checked"],
             ["Financial immutability", "Present", "Two triggers + service rules"],
             ["Secret handling", "No secret in the repository", "Only .env.example with placeholders"]],
            caption="Security controls verified in source", widths=[1.9, 1.4, 3.2], font_size=7.8)

    b.h2("35.2 Weaknesses and residual risk")
    b.table(["Risk", "Severity", "Detail", "Mitigation"],
            [["No RLS; service layer is the only tenancy boundary", "Medium", "One missed scope check exposes another outlet's data", "Add RLS or a repository-level scope guard; at minimum, keep the tenant tests"],
             ["CORS default is localhost; wildcard allowed", "Medium", "A misconfigured deployment either blocks the SPAs or opens the API to any origin", "Set CORS_ORIGINS explicitly per environment (F-12)"],
             ["No dependency scanning", "Medium", "A vulnerable transitive package would not be flagged", "Add govulncheck and pnpm audit to CI"],
             ["No CSRF token, by design", "Low", "Safe today because no state-changing call relies on a cookie; the B2B cookie is converted server-side", "Keep the conversion server-side; never let the browser send the cookie directly to the API"],
             ["Login rate limit keyed on IP only", "Low", "Behind a proxy without TRUSTED_PROXIES every client shares one bucket", "Set TRUSTED_PROXIES"],
             ["Audit log is application-level only", "Low", "Direct SQL changes are invisible", "Restrict database access; enable pgaudit if the host allows"],
             ["Seeded admin password in seed.sql", "Low", "Published in the repository", "Change on first login; never seed a reachable deployment"],
             ["Payment proof served from the same namespace as public images", "Low", "Authorization is per-request inside the handler", "Keep the payments-table check; add a test that an anonymous GET of a proof key fails"]],
            caption="Residual security risks", widths=[2.1, 0.7, 2.2, 1.5], font_size=7.4)

    b.h2("35.3 Privacy")
    b.table(["Data", "Table/column", "Sensitivity", "Handling"],
            [["Customer identity", "customers (name, phone, email, address, tax_number)", "PII", "Scoped by permission; no public endpoint returns the full row"],
             ["Buyer account", "users.email, buyer_addresses", "PII", "Own-record only (RequireApp + ownership)"],
             ["Employee data", "employees (nik, npwp, bank account, salary)", "PII + financial", "HR permissions only; self-service /me endpoints"],
             ["Payroll", "payrolls", "Financial/PII", "hr.payrolls.view"],
             ["Payment proofs", "payments.proof_url + uploads", "Financial evidence", "Ownership-checked serving"],
             ["Tokens", "personal_access_tokens.token (hash)", "Credential", "Hash only; expiry enforced"],
             ["Audit trail", "activity_logs.old_values/new_values", "Potentially PII", "system.activity-logs.view; admin can clear"],
             ["Support tickets", "tickets (reporter email, metadata)", "PII", "support.tickets.view; is_internal comments hidden from buyers"]],
            caption="Personal data inventory", widths=[1.3, 2.2, 1.1, 2.0], font_size=7.6)


def ch36_performance(b: Builder) -> None:
    b.h1("36. Performance and resource constraints")
    b.table(["Constraint", "Configured value", "Where"],
            [["Host", "2 vCPU / 8 GB / 100 GB NVMe (documented)", "docs/performance/baseline.md"],
             ["Go memory", "GOMEMLIMIT=512MiB, GOGC=100", "compose.prod.yml backend env"],
             ["Go HTTP timeouts", "read header 10 s, read 30 s, write 60 s, idle 120 s", "cmd/server/main.go"],
             ["DB pool", "MaxConns 25, MinConns 2", "pkg/database/database.go"],
             ["Nuxt heap", "--max-old-space-size=256", "compose b2b env"],
             ["NextReport heap", "--max-old-space-size=256, one Chromium instance", "compose + server.mjs"],
             ["Redis", "maxmemory 96mb, volatile-lru, AOF on", "compose redis command"],
             ["Logs", "10 MB × 3 files per container", "every service"],
             ["Report render timeout", "30 s (backend proxy), 15 s (page load)", "reportproxy, server.mjs"]],
            caption="Configured resource constraints", widths=[1.6, 3.0, 1.9], font_size=7.8)
    b.note("Not measured", "No load test, no p95 latency and no production RSS figure was "
           "produced by this audit. The only measured runtime facts are: the backend started "
           "and answered health/readiness within a second of the DB being ready; a POS "
           "checkout completed in a single request; a PDF render of the POS receipt took "
           "under 2 seconds locally.", "inferred")
    b.h2("36.1 Scalability notes")
    b.bullets([
        "The backend is stateless apart from Redis; horizontal scaling would require sticky-free tokens (already true) and a shared uploads store (currently a local volume).",
        "Uploads are local-disk only (pkg/storage/local.go). A second backend replica would not see the first replica's files; docs/architecture/overview.md mentions MinIO/S3, but no S3/MinIO client appears anywhere under apps/backend (verified by searching for s3/minio/aws-sdk).",
        "Reports are synchronous: the HTTP request waits for Chromium. Under load, NextReport (0.8 CPU) is the bottleneck.",
        "The generic rate limiter is per-IP and fail-open, so it does not protect the database from an authenticated flood.",
    ])


def ch37_testing_report(b: Builder) -> None:
    b.h1("37. Testing strategy and test coverage")
    b.p("The strategy visible in the code is: unit-test the rule, integration-test the SQL, "
        "and assert the contract (routes, permissions) mechanically. The strongest artefacts "
        "are the route→gate table and the drift guard, because they fail when someone adds "
        "an endpoint without a policy or changes a route without regenerating the client "
        "contract.")
    b.h2("37.1 Execution results")
    b.table(["Suite", "Files", "Pass", "Fail", "Skip", "Notes"],
            [["Go (all packages)", "139", "1176", "0", "38", "Integration skips without DATABASE_URL"],
             ["Go integration (consented)", "26", "88", "0", "1", "Truncates 66 tables; one HTTP test skipped"],
             ["ERP", "39", "344", "0", "0", "171 s"],
             ["POS", "20", "111", "0", "0", "—"],
             ["B2B", "20", "170", "0", "0", "—"],
             ["Packages", "5", "28", "0", "0", "api-client 3, contracts 4, permissions 5, ui 10, utils 6"]],
            caption="Executed test results (this audit run)",
            widths=[1.6, 0.7, 0.7, 0.6, 0.6, 2.3], font_size=7.8)
    b.note("A note on the first run", "The first full Go run failed 13 tests in "
           "internal/sales because the audit database container had stopped between runs. "
           "After restarting the container, the same command passed. The failure was "
           "environmental, not a code defect, and is recorded here so the evidence trail is "
           "honest.", "note")
    b.h2("37.2 Gaps")
    b.table(["Gap", "Impact", "Recommendation"],
            [["No browser/E2E tests", "A broken click path is caught only manually", "Add a small Playwright smoke suite (login, POS sale, report render)"],
             ["No coverage report", "Coverage cannot be tracked over time", "Emit coverprofile + vitest coverage and publish as an artefact"],
             ["CI has no database", "Integration tests never run in CI", "Add a postgres service and set KG_ALLOW_DESTRUCTIVE_DB_TESTS=1"],
             ["No migration replay test", "A migration that only works in one transaction would not be caught", "Replay all migrations on a fresh DB in CI"],
             ["No restore drill", "Backups are unproven", "Quarterly restore into a scratch database"]],
            caption="Test-strategy gaps", widths=[1.7, 2.2, 2.6])


def ch38_findings(b: Builder) -> None:
    b.h1("38. Audit findings and known limitations")
    b.p("Severity reflects the combination of blast radius and likelihood, not effort to fix.")
    b.table(["Severity", "Criterion"],
            [["Critical", "Data loss, financial corruption or full authorisation bypass in normal operation."],
             ["High", "Breaks a quality gate or destroys data under a plausible mistake; or a security control that is absent where it is needed."],
             ["Medium", "Wrong behaviour or documentation in a path real users follow, with a workaround."],
             ["Low", "Hygiene, drift or a latent trap that does not affect today's operation."],
             ["Informational", "Observation worth recording; no action required now."]],
            caption="Severity criteria", widths=[1.1, 5.4])
    b.landscape()
    b.table(["ID", "Severity", "Area", "Title", "Evidence", "Impact", "Recommendation"],
            [[f[0], f[2], f[3], f[1], f[4], f[5], f[6]] for f in F.FINDINGS],
            caption=f"Findings register ({len(F.FINDINGS)} entries)",
            widths=[0.45, 0.75, 0.9, 1.75, 2.6, 2.0, 1.35], font_size=6.8)
    b.portrait()

    b.h2("38.1 Limitations of this audit")
    b.table(["Limitation", "Effect on conclusions"],
            [["No production access", "Runtime behaviour is verified on a fresh database with seed data only; production data distributions are unknown."],
             ["No browser session", "UI behaviour is read from source; no screenshot or click-path was captured."],
             ["No live tunnel", "The ingress mapping is documented intention."],
             ["No load test", "No performance claim beyond 'it started and served requests'."],
             ["Samples, not exhaustiveness", "The data dictionary covers every column, but per-column business meaning is inferred from code and naming for 546 columns that lack an explicit comment."]],
            caption="Stated limitations", widths=[1.8, 4.7])


def ch39_roadmap(b: Builder) -> None:
    b.h1("39. Recommended improvements and prioritized roadmap")
    b.h2("39.1 Priority 1 — correctness and safety (do first)")
    b.table(["Action", "Addresses", "Effort", "Verification"],
            [["Fix the CI Go version (go-version-file)", "F-03", "minutes", "Backend job builds"],
             ["Guard the destructive test package by database name", "F-05", "hours", "A mis-pointed URL refuses to run"],
             ["Read SMTP_FROM from the environment", "F-07", "minutes", "Password-reset mail arrives"],
             ["Set CORS_ORIGINS explicitly in compose.prod.yml", "F-12", "minutes", "Preflight from the four hostnames succeeds; others fail"],
             ["Make the ERP idempotency fallback throw in development", "F-14", "hours", "Unmigrated call sites surface immediately"],
             ["Correct the documentation paths and the offline-POS claim", "F-02, F-17", "hours", "Docs match the tree"]],
            caption="Priority 1 actions", widths=[2.5, 0.9, 0.7, 2.4], font_size=7.8)

    b.h2("39.2 Priority 2 — structural integrity")
    b.table(["Action", "Addresses", "Effort", "Verification"],
            [["Retire the duplicate compose stacks; keep compose.prod.yml", "F-10", "days", "One documented deploy path"],
             ["Refresh db/schema.sql and move the raw-SQL modules to sqlc", "F-19", "days", "sqlc generate is clean"],
             ["Generate ROLES and the OpenAPI RoleName enum from AllRoles", "F-08, F-21", "hours", "Client role list equals the backend"],
             ["Add a migration replay job and a Postgres service to CI", "37.2", "days", "Integration tests run in CI"],
             ["Add a startup/template health check for report templates", "F-15", "hours", "Health reports template counts"]],
            caption="Priority 2 actions", widths=[2.5, 0.9, 0.7, 2.4], font_size=7.8)

    b.h2("39.3 Priority 3 — hardening and operability")
    b.table(["Action", "Addresses", "Effort", "Verification"],
            [["Add govulncheck + pnpm audit to CI", "35.2", "hours", "Findings gate the build"],
             ["Ship scripts/backup-db.sh and a restore drill", "31", "days", "A restore into a scratch DB passes the checklist"],
             ["Add a Playwright smoke suite", "37.2", "days", "Three critical paths covered"],
             ["Publish the permission set explicitly for admin/owner", "F-06", "hours", "A client testing a concrete key works"],
             ["Wire packages/ui or remove the dependency", "12.2", "days", "No declared-but-unused package"],
             ["Add RLS or a repository-level tenant guard", "35.2", "weeks", "Cross-tenant read attempts fail at the data layer"],
             ["Implement or remove the offline POS claim", "F-17", "weeks", "Either a queue exists or the docs are corrected"],
             ["Add metrics/tracing", "30", "weeks", "p95 latency and error rate are observable"]],
            caption="Priority 3 actions", widths=[2.5, 0.9, 0.7, 2.4], font_size=7.8)

    b.h2("39.4 Explicitly not recommended")
    b.bullets([
        "Do not add DOWN migrations to satisfy a rollback checkbox. With this schema, a restore is the honest rollback; a half-tested DOWN is worse than none.",
        "Do not replace the permission system with role checks in the frontend. The permission matrix is the only boundary and it is already DB-driven.",
        "Do not move money arithmetic to the frontend for 'responsiveness'. The server resolves prices for a reason; the client's unit_price is ignored by design.",
        "Do not enable negative stock on a deployment where the owner has not agreed to backorders; the default is permissive and that is a business decision, not a technical one.",
    ])


def ch40_maintenance(b: Builder) -> None:
    b.h1("40. Documentation maintenance guide")
    b.h2("40.1 What regenerates what")
    b.table(["Artefact", "Generator", "Input", "When to run"],
            [["docs/audit/database-inventory.csv", "docs/audit/tools/build_inventories.py", "docs/audit/raw/*.csv (introspection)", "After a schema change"],
             ["docs/audit/api-inventory.csv", "same", "docs/audit/raw/api_ops.json + routes_mounted.json", "After adding or changing an endpoint"],
             ["docs/audit/route-inventory.csv", "same", "docs/audit/raw/ui_routes.json", "After adding a UI route"],
             ["docs/diagrams/erd-*.mmd", "docs/diagrams/gen_erd.py", "docs/audit/raw/columns_full.csv + constraints.csv", "After a schema change"],
             ["docs/diagrams/rendered/*.png", "mmdc (Mermaid CLI)", "docs/diagrams/*.mmd", "After editing a diagram"],
             ["This document", "docs/docgen/build_doc.py", "all of the above + facts.py", "After any of the above, or a release"]],
            caption="Regeneration map", widths=[2.2, 2.0, 1.8, 1.4], font_size=7.8)
    b.h2("40.2 Refresh procedure")
    b.code("""# 1. Introspect a freshly migrated database
podman exec km-audit-pg psql -U audit -d km -c "\\copy (SELECT ...) TO STDOUT WITH CSV HEADER" \\
  > docs/audit/raw/columns_full.csv      # see docs/audit/tools/README for every query

# 2. Rebuild inventories and diagrams
python3 docs/audit/tools/build_inventories.py
python3 docs/diagrams/gen_erd.py

# 3. Re-render diagrams (Mermaid CLI; any recent version works)
for f in docs/diagrams/*.mmd; do
  mmdc -i "$f" -o "docs/diagrams/rendered/$(basename "${f%.mmd}").png" -b white -s 2 --size 1600
done

# 4. Rebuild the document
cd docs/docgen && python3 build_doc.py""", caption="Refresh commands")
    b.h2("40.3 Rules for future edits")
    b.bullets([
        "Update facts.py rather than editing prose numbers: every count in the document is read from that module.",
        "Keep the evidence class labels. A claim without a label is a defect in this document, not a shortcut.",
        "When a finding is fixed, move it to a 'Resolved' table with the commit that fixed it rather than deleting it; the register is also the project's memory.",
        "Regenerate the ERDs after every migration, even a permission-only one, so the census counts stay right.",
        "Do not add screenshots unless they come from a real running instance; the repository has one (apps/erp/erp-login.png) and it is not used here because it cannot be verified as current.",
    ])
    b.h2("40.4 Ownership")
    b.p("CODEOWNERS exists but does not assign a documentation owner. Until it does, treat "
        "docs/docgen and docs/audit as owned by whoever last changed the schema or the "
        "router, and make regeneration part of that change.")


def appendices(b: Builder) -> None:
    b.h1("Appendix A. Complete directory inventory")
    b.table(["Path", "Files", "Purpose"],
            [["apps/backend/cmd", "4 Go packages", "server, seedctl, importer + tests"],
             ["apps/backend/internal", "296 Go files", "27 domain modules, db layer, migrations runner"],
             ["apps/backend/pkg", "8 packages", "cache, config, database, decimal, httpresponse, logger, middleware, redis, seq, storage"],
             ["apps/backend/migrations", "123 SQL + embed.go + atlas.sum", "Schema history"],
             ["apps/backend/db", "schema.sql + 24 query files", "sqlc input"],
             ["apps/backend/docs", "13 markdown + openapi.yaml", "Backend specification set"],
             ["apps/backend/deploy", "Caddyfile, dev Dockerfile, 5 runbooks", "Deployment helpers"],
             ["apps/erp/src", "90 components, 72 pages, router, stores, services, utils", "ERP SPA"],
             ["apps/pos/src", "42 components (3 layout, 26 pos, 13 ui), 8 pages, router, stores, services, utils", "POS SPA"],
             ["apps/b2b/app", "42 pages, 17 components, 16 composables, 3 middleware, locales", "B2B app"],
             ["apps/b2b/server", "1 catch-all proxy route", "Nitro server"],
             ["apps/nextreport", "server.mjs + engine/", "Render service"],
             ["packages/*", "5 packages, 20 source files", "Shared libraries"],
             ["scripts", "5 scripts", "Generators, drift guard, icon audit, VPS helper"],
             ["infra", "2 compose files, 1 Caddyfile", "Container stacks"],
             [".github", "2 workflows, 3 issue templates, PR template, CODEOWNERS", "Automation"],
             ["docs", "26 pre-existing markdown docs + this audit set", "Project documentation"]],
            caption="Directory inventory", widths=[1.8, 2.3, 2.4], font_size=7.8)

    b.h1("Appendix B. Complete table and column inventory")
    b.p("The machine-readable inventory is docs/audit/database-inventory.csv (1031 rows, one "
        "per column). It carries, per column: table, domain, table purpose, column, ordinal, "
        "data type, semantic type, nullability, default, primary-key flag, foreign keys, "
        "unique constraints, check constraints, indexes, sensitivity classification and a "
        "column note. Chapter 18 renders the same data grouped by domain.")
    b.table(["Column", "Rows"],
            [["table", "92 distinct"], ["column", "1031"], ["nullable", "derived from attnotnull"],
             ["default", "from pg_get_expr"], ["foreign_keys", "181 definitions, repeated per table"],
             ["check_constraints", "112 definitions"], ["indexes", "269 names"], ["classification", "Sensitive / Operational / Framework"]],
            caption="Inventory schema", widths=[1.6, 4.9])

    b.h1("Appendix C. Database constraints, indexes, triggers, functions, views, enums, policies")
    b.p("Full lists are in docs/audit/raw/constraints.csv (440 rows) and "
        "docs/audit/raw/indexes.csv (269 rows). Summary by table for the tables with the most "
        "constraints:")
    inv = _csv("database-inventory.csv")
    per = collections.Counter()
    fk = collections.Counter()
    for r in inv:
        if r["unique_constraints"]:
            per[r["table"]] += 1
        if r["foreign_keys"]:
            fk[r["table"]] += 1
    rows = [[t, str(fk[t]), "yes" if per[t] else "—"] for t, _ in fk.most_common(25)]
    b.table(["Table", "FK definitions", "Unique/check constraints present"], rows,
            caption="Tables with the most referential structure", widths=[2.2, 1.4, 2.9])

    b.h1("Appendix D. Complete API endpoint index")
    b.p("The complete list is docs/audit/api-inventory.csv (373 rows) and is rendered in "
        "chapter 20.4. Each row carries method, path, tag, operationId, summary, auth class, "
        "permission gate, application gate, request-body schema name, parameters, response "
        "codes and the source file.")

    b.h1("Appendix E. Complete environment-variable reference")
    b.p("Chapter 23 lists all 36 backend variables and 17 frontend variables. The machine "
        "readable source is the ENV_BACKEND / ENV_FRONTEND tables in docs/docgen/facts.py, "
        "which are the single place to edit when a variable is added.")

    b.h1("Appendix F. Permission and role index")
    b.table(["Group", "Count"], [[g, str(n)] for g, n in F.PERMISSION_GROUPS] +
            [["Total", "201"]], caption="Permission groups", widths=[4.0, 1.0])
    b.table(["Role", "Rows"], [[r, str(n)] for r, n in F.ROLE_ROWS],
            caption="Seeded roles and row counts", widths=[2.0, 1.0])
    b.p("The authoritative list is packages/permissions/src/permissions.txt (201 keys) and "
        "the role matrix in the database (role_permissions). GET /api/v1/permissions returns "
        "the grouped catalogue with labels.")

    b.h1("Appendix G. Application route index")
    b.p("docs/audit/route-inventory.csv holds 146 rows (ERP 78 page routes + 15 redirects + 2 layout wrappers, POS 9, B2B 42) with path, "
        "name, component, title, module, permission and public flag. Chapters 8.2, 9.2 and "
        "13.2 render them grouped by application.")

    b.h1("Appendix H. Business workflow index")
    b.table(["ID", "Workflow", "Module"],
            [[w[0], w[1], w[2]] for w in F.WORKFLOWS],
            caption="Workflow index", widths=[0.6, 3.4, 1.5])

    b.h1("Appendix I. Migration history")
    b.p("Chapter 16.5 lists all 123 migrations with purpose and flags the four that change "
        "data rather than schema.")

    b.h1("Appendix J. Test inventory and verification results")
    b.table(["Suite", "Files", "Tests", "Result"],
            [[s[0], str(s[1]), str(s[2]), "pass"] for s in F.JS_SUITES] +
            [["Go (all)", "139", "1176 pass / 38 skip", "pass"],
             ["Go integration (consented)", "26", "88 pass / 1 skip", "pass"],
             ["drift:check", "1", "2 checks", "pass"]],
            caption="Executed suites", widths=[2.4, 0.9, 1.6, 0.9])

    b.h1("Appendix K. Glossary")
    b.table(["Term", "Definition"],
            [["Aggregate", "A cluster of rows that changes together under one invariant (e.g. sales order + items)."],
             ["Application access", "The axis that decides which frontend an identity may enter (platform/erp/pos/ecommerce/ecommerce_admin)."],
             ["Base quantity", "The quantity in the product's base unit, after applying the line's conversion factor."],
             ["Effective permission", "The resolved allow-set: role globs plus the per-user override."],
             ["Glob", "A permission pattern ending in '.*' that matches a family of keys."],
             ["Idempotency key", "A client-supplied key that makes a repeated write return the first result instead of writing twice."],
             ["LHPP", "Route settlement report grouping delivery orders per driver and date."],
             ["Opname", "Physical stock count reconciled against system quantity."],
             ["Platform identity", "admin/owner: bypasses permission checks and tenant scoping."],
             ["Snapshot (line)", "The immutable copy of price, unit and conversion stored on a sales order line."],
             ["Tenant", "An outlet (stores row) and the warehouse bound to it."]],
            caption="Glossary", widths=[1.6, 4.9])

    b.h1("Appendix L. References to source files and evidence")
    b.table(["Topic", "Primary evidence"],
            [["Routing", "apps/backend/cmd/server/main.go; internal/system/handler.go"],
             ["Permissions", "apps/backend/internal/auth/{catalog.go,matrix.go,model.go,middleware.go}"],
             ["Tokens", "apps/backend/internal/auth/{token.go,service.go,repository.go}"],
             ["Migrations", "apps/backend/migrations/*.sql; internal/dbmigrate/dbmigrate.go"],
             ["Schema snapshot", "apps/backend/db/schema.sql; db/query/*.sql"],
             ["Sales/POS", "apps/backend/internal/sales/{service.go,handler.go}"],
             ["Finance", "apps/backend/internal/finance/*.go"],
             ["Accounting", "apps/backend/internal/accounting/{service.go,repository.go}"],
             ["Inventory", "apps/backend/internal/inventory/*.go"],
             ["Cashier", "apps/backend/internal/cashier/service.go"],
             ["Reports", "apps/backend/internal/reportproxy/reportproxy.go; internal/reports/templates/*"],
             ["Business rules", "apps/backend/docs/business-rules.md (authoritative prose)"],
             ["API contract", "apps/backend/docs/openapi.yaml"],
             ["ERP UI", "apps/erp/src/{router/index.js,config/navigation.js,services/http.js}"],
             ["POS UI", "apps/pos/src/{router/index.js,services/api/http.js,utils/print.js}"],
             ["B2B UI", "apps/b2b/{nuxt.config.ts,server/api/v1/[...path].ts,app/middleware/*}"],
             ["Deployment", "infra/containers/compose.prod.yml; apps/*/Dockerfile; apps/erp/deploy/nginx-entrypoint.sh"],
             ["CI", ".github/workflows/{ci.yml,deploy-ghcr.yml}"],
             ["Docs", "docs/**/*.md; apps/*/AGENTS.md"]],
            caption="Evidence map", widths=[1.5, 5.0], font_size=7.8)

    b.h1("Appendix M. Coverage and traceability matrix")
    b.p("The matrix below is the reconciliation this document claims. Each row was checked "
        "against an inventory, not against the presence of a heading.")
    cov = _csv("coverage-matrix.csv")
    b.landscape()
    b.table(["Audit area", "Required evidence", "Status", "Where documented"],
            [[r["audit_area"], r["required_evidence"], r["status"], r["where"]]
             for r in cov],
            caption="Coverage matrix (reconciled with the repository inventory)",
            widths=[1.8, 3.4, 1.1, 3.6], font_size=7.4)
    b.portrait()

    b.h1("Appendix N. Unverified components and access limitations")
    b.table(["Component", "Why unverified", "What would verify it"],
            [["Cloudflare tunnel config", "Not committed (carries credentials)", "Read config.yml on the VPS"],
             ["Production container state", "No host access", "podman ps + podman stats on the VPS"],
             ["GHCR images", "No registry access", "docker manifest inspect"],
             ["Browser rendering", "Not exercised in a browser", "Playwright against a running stack"],
             ["Third-party provider behaviour", "No credentials", "Sandbox Midtrans + SMTP capture"],
             ["Production data volumes", "No production DB contact", "Read-only row counts on a replica"],
             ["Mobile clients", "Referenced in the OpenAPI description, not present in the repo", "Locate the Android/iOS repositories"],
             ["packages/ui runtime behaviour", "Not imported by any app", "Wire it, then test"],
             ["`abilities` on tokens", "Column exists; no code reads it", "Search would need a consumer"]],
            caption="Unverified components", widths=[2.0, 2.3, 2.2], font_size=7.8)
