create-protobase 0.23.13 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ask-preset.js +25 -0
- package/dist/create-project.js +7 -6
- package/dist/index.js +19 -9
- package/dist/next-steps.js +8 -2
- package/dist/presets.js +17 -0
- package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
- package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
- package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
- package/dist/templates/erp/.env.example +14 -0
- package/dist/templates/erp/AGENTS.md +24 -0
- package/dist/templates/erp/auth/auth.ts +68 -0
- package/dist/templates/erp/components/orders-per-day.tsx +21 -0
- package/dist/templates/erp/config/bottom-bar.ts +9 -0
- package/dist/templates/erp/config/categories/data.ts +20 -0
- package/dist/templates/erp/config/categories/ui.ts +11 -0
- package/dist/templates/erp/config/companies/data.ts +23 -0
- package/dist/templates/erp/config/companies/ui.ts +22 -0
- package/dist/templates/erp/config/company_tags/data.ts +16 -0
- package/dist/templates/erp/config/company_tags/ui.ts +7 -0
- package/dist/templates/erp/config/countries/data.ts +13 -0
- package/dist/templates/erp/config/countries/ui.ts +6 -0
- package/dist/templates/erp/config/currencies/data.ts +14 -0
- package/dist/templates/erp/config/currencies/ui.ts +6 -0
- package/dist/templates/erp/config/emp_master/data.ts +19 -0
- package/dist/templates/erp/config/emp_master/ui.ts +7 -0
- package/dist/templates/erp/config/index.ts +42 -0
- package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
- package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
- package/dist/templates/erp/config/invoices/data.ts +26 -0
- package/dist/templates/erp/config/invoices/ui.ts +38 -0
- package/dist/templates/erp/config/jobs.test.ts +23 -0
- package/dist/templates/erp/config/jobs.ts +29 -0
- package/dist/templates/erp/config/locations/data.ts +18 -0
- package/dist/templates/erp/config/locations/ui.ts +6 -0
- package/dist/templates/erp/config/order_lines/data.ts +20 -0
- package/dist/templates/erp/config/order_lines/ui.ts +7 -0
- package/dist/templates/erp/config/orders/data.ts +27 -0
- package/dist/templates/erp/config/orders/ui.ts +41 -0
- package/dist/templates/erp/config/organizations/data.ts +17 -0
- package/dist/templates/erp/config/organizations/ui.ts +6 -0
- package/dist/templates/erp/config/overview/page.tsx +80 -0
- package/dist/templates/erp/config/pages.test.ts +14 -0
- package/dist/templates/erp/config/people/data.ts +21 -0
- package/dist/templates/erp/config/people/ui.ts +6 -0
- package/dist/templates/erp/config/products/data.ts +25 -0
- package/dist/templates/erp/config/products/ui.ts +6 -0
- package/dist/templates/erp/config/roles.test.ts +56 -0
- package/dist/templates/erp/config/roles.ts +54 -0
- package/dist/templates/erp/config/shell.test.ts +24 -0
- package/dist/templates/erp/config/stock_levels/data.ts +16 -0
- package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
- package/dist/templates/erp/config/stock_moves/data.ts +22 -0
- package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
- package/dist/templates/erp/config/tags/data.ts +17 -0
- package/dist/templates/erp/config/tags/ui.ts +6 -0
- package/dist/templates/erp/config/user-menu.ts +9 -0
- package/dist/templates/erp/config/users/data.ts +19 -0
- package/dist/templates/erp/config/users/ui.ts +9 -0
- package/dist/templates/erp/config/warehouses/data.ts +19 -0
- package/dist/templates/erp/config/warehouses/ui.ts +6 -0
- package/dist/templates/erp/data.ts +3 -0
- package/dist/templates/erp/db/connection.ts +7 -0
- package/dist/templates/erp/db/drop.ts +9 -0
- package/dist/templates/erp/db/kysely.ts +13 -0
- package/dist/templates/erp/db/migrations/001_core.sql +48 -0
- package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
- package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
- package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
- package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
- package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
- package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
- package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
- package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
- package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
- package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
- package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
- package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
- package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
- package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
- package/dist/templates/erp/package.json +38 -0
- package/dist/templates/erp/protobase.config.ts +22 -0
- package/dist/templates/erp/protobase.ui.tsx +14 -0
- package/dist/templates/erp/seed/auth-organizations.ts +11 -0
- package/dist/templates/erp/seed/base-organization.test.ts +17 -0
- package/dist/templates/erp/seed/base-organization.ts +10 -0
- package/dist/templates/erp/seed/base.ts +22 -0
- package/dist/templates/erp/seed/builders/categories.ts +18 -0
- package/dist/templates/erp/seed/builders/company.ts +61 -0
- package/dist/templates/erp/seed/builders/invoice.ts +31 -0
- package/dist/templates/erp/seed/builders/order.ts +66 -0
- package/dist/templates/erp/seed/builders/product.ts +46 -0
- package/dist/templates/erp/seed/calendar.ts +60 -0
- package/dist/templates/erp/seed/cli.ts +54 -0
- package/dist/templates/erp/seed/copy.ts +22 -0
- package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
- package/dist/templates/erp/seed/data/layout.ts +19 -0
- package/dist/templates/erp/seed/data/names.ts +44 -0
- package/dist/templates/erp/seed/data/reference.ts +53 -0
- package/dist/templates/erp/seed/format.ts +24 -0
- package/dist/templates/erp/seed/maintenance.ts +63 -0
- package/dist/templates/erp/seed/rng.ts +36 -0
- package/dist/templates/erp/seed/scales.ts +9 -0
- package/dist/templates/erp/seed/steps/catalog.ts +34 -0
- package/dist/templates/erp/seed/steps/crm.ts +62 -0
- package/dist/templates/erp/seed/steps/hr.ts +26 -0
- package/dist/templates/erp/seed/steps/ledger.ts +88 -0
- package/dist/templates/erp/seed/steps/reference.ts +24 -0
- package/dist/templates/erp/seed/steps/sales.ts +58 -0
- package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
- package/dist/templates/erp/seed/world.ts +86 -0
- package/dist/templates/erp/vitest.config.ts +4 -0
- package/dist/templates/real-estate/.env.example +9 -0
- package/dist/templates/real-estate/AGENTS.md +24 -0
- package/dist/templates/real-estate/_.gitignore +3 -0
- package/dist/templates/real-estate/_bunfig.toml +4 -0
- package/dist/templates/real-estate/auth/auth.ts +36 -0
- package/dist/templates/real-estate/config/amenities/data.ts +12 -0
- package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
- package/dist/templates/real-estate/config/arrears/data.ts +19 -0
- package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
- package/dist/templates/real-estate/config/deposits/data.ts +21 -0
- package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
- package/dist/templates/real-estate/config/index.ts +41 -0
- package/dist/templates/real-estate/config/inspections/data.ts +22 -0
- package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
- package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
- package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
- package/dist/templates/real-estate/config/leases/data.ts +29 -0
- package/dist/templates/real-estate/config/leases/ui.ts +33 -0
- package/dist/templates/real-estate/config/organizations/data.ts +17 -0
- package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
- package/dist/templates/real-estate/config/overview/page.tsx +57 -0
- package/dist/templates/real-estate/config/owners/data.ts +22 -0
- package/dist/templates/real-estate/config/owners/ui.ts +17 -0
- package/dist/templates/real-estate/config/pages.test.ts +49 -0
- package/dist/templates/real-estate/config/payments/data.ts +20 -0
- package/dist/templates/real-estate/config/payments/ui.ts +14 -0
- package/dist/templates/real-estate/config/properties/data.ts +26 -0
- package/dist/templates/real-estate/config/properties/ui.ts +21 -0
- package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
- package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
- package/dist/templates/real-estate/config/roles.test.ts +49 -0
- package/dist/templates/real-estate/config/roles.ts +45 -0
- package/dist/templates/real-estate/config/shell.test.ts +18 -0
- package/dist/templates/real-estate/config/tenants/data.ts +23 -0
- package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
- package/dist/templates/real-estate/config/tickets/data.ts +33 -0
- package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
- package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
- package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
- package/dist/templates/real-estate/config/units/data.ts +24 -0
- package/dist/templates/real-estate/config/units/ui.ts +41 -0
- package/dist/templates/real-estate/config/user-menu.ts +9 -0
- package/dist/templates/real-estate/config/users/data.ts +19 -0
- package/dist/templates/real-estate/config/users/ui.ts +11 -0
- package/dist/templates/real-estate/config/valuations/data.ts +19 -0
- package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
- package/dist/templates/real-estate/config/vendors/data.ts +21 -0
- package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
- package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
- package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
- package/dist/templates/real-estate/db/connection.ts +7 -0
- package/dist/templates/real-estate/db/drop.ts +9 -0
- package/dist/templates/real-estate/db/kysely.ts +13 -0
- package/dist/templates/real-estate/db/migrate.ts +30 -0
- package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
- package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
- package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
- package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
- package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
- package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
- package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
- package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
- package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
- package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
- package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
- package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
- package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
- package/dist/templates/real-estate/node-types.d.ts +1 -0
- package/dist/templates/real-estate/package.json +38 -0
- package/dist/templates/real-estate/protobase.config.ts +5 -0
- package/dist/templates/real-estate/protobase.ui.tsx +16 -0
- package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
- package/dist/templates/real-estate/seed/base-organization.ts +10 -0
- package/dist/templates/real-estate/seed/base.ts +18 -0
- package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
- package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
- package/dist/templates/real-estate/seed/builders/property.ts +98 -0
- package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
- package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
- package/dist/templates/real-estate/seed/calendar.ts +77 -0
- package/dist/templates/real-estate/seed/cli.ts +50 -0
- package/dist/templates/real-estate/seed/copy.ts +22 -0
- package/dist/templates/real-estate/seed/data/names.ts +31 -0
- package/dist/templates/real-estate/seed/data/reference.ts +63 -0
- package/dist/templates/real-estate/seed/format.ts +24 -0
- package/dist/templates/real-estate/seed/iban.ts +15 -0
- package/dist/templates/real-estate/seed/maintenance.ts +63 -0
- package/dist/templates/real-estate/seed/rng.ts +36 -0
- package/dist/templates/real-estate/seed/scales.ts +9 -0
- package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
- package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
- package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
- package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
- package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
- package/dist/templates/real-estate/seed/world.ts +92 -0
- package/dist/templates/real-estate/tsconfig.json +16 -0
- package/dist/templates/real-estate/vitest.config.ts +4 -0
- package/dist/templates/scratch/_.gitignore +3 -0
- package/dist/templates/scratch/_bunfig.toml +4 -0
- package/dist/templates/scratch/db/migrate.ts +30 -0
- package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
- package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
- package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
- package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
- package/dist/templates/scratch/node-types.d.ts +1 -0
- package/dist/{template → templates/scratch}/package.json +5 -5
- package/dist/templates/scratch/tsconfig.json +16 -0
- package/package.json +2 -2
- /package/dist/{template → templates/erp}/_.gitignore +0 -0
- /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
- /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
- /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
- /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
- /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
- /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
- /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
- /package/dist/{template → templates/erp}/tsconfig.json +0 -0
- /package/dist/{template → templates/scratch}/.env.example +0 -0
- /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
- /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
- /package/dist/{template → templates/scratch}/config/index.ts +0 -0
- /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
- /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
- /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
- /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
- /package/dist/{template → templates/scratch}/vitest.config.ts +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { DAY, windowEnd } from '../calendar'
|
|
2
|
+
import type { Trade } from '../data/reference'
|
|
3
|
+
import { int, pick, rngAt, weighted } from '../rng'
|
|
4
|
+
import type { Org } from '../world'
|
|
5
|
+
import { propertyAcquiredAt, propertyShapeAt, tenanciesOf } from './allocation'
|
|
6
|
+
|
|
7
|
+
const HOUR = 3_600_000
|
|
8
|
+
|
|
9
|
+
// What tenants report, the trade that fixes it, and a few ways they put it.
|
|
10
|
+
const categories = {
|
|
11
|
+
heating: { weight: 20, trades: ['heating'], titles: ['Boiler shows an error code', 'No hot water', 'Radiators stay cold', 'Boiler pressure keeps dropping', 'Thermostat does not respond'] },
|
|
12
|
+
plumbing: { weight: 18, trades: ['plumbing'], titles: ['Leaking tap in the kitchen', 'Toilet keeps running', 'Blocked drain in the shower', 'Low water pressure', 'Washing machine tap leaks'] },
|
|
13
|
+
electrical: { weight: 9, trades: ['electrical'], titles: ['Group keeps tripping', 'Socket in the living room is dead', 'Doorbell broken', 'Extractor fan does not work'] },
|
|
14
|
+
leak: { weight: 8, trades: ['roofing', 'plumbing'], titles: ['Water stain on the ceiling', 'Roof leaks after rain', 'Damp coming through the wall'] },
|
|
15
|
+
mould: { weight: 7, trades: ['general'], titles: ['Mould in the bathroom', 'Black spots behind the wardrobe', 'Condensation on the windows'] },
|
|
16
|
+
locks: { weight: 6, trades: ['locksmith'], titles: ['Front door lock is stiff', 'Locked out, key broken off', 'Storage room lock broken'] },
|
|
17
|
+
appliances: { weight: 10, trades: ['electrical', 'general'], titles: ['Oven does not heat', 'Dishwasher leaks', 'Fridge is not cooling', 'Hob does not switch on'] },
|
|
18
|
+
windows_doors: { weight: 10, trades: ['carpentry'], titles: ['Window does not close', 'Balcony door sticks', 'Draught along the window frame', 'Kitchen cabinet door came off'] },
|
|
19
|
+
garden: { weight: 5, trades: ['general'], titles: ['Fence blown over', 'Shared garden overgrown', 'Tree branches over the path'] },
|
|
20
|
+
pests: { weight: 7, trades: ['pest_control'], titles: ['Mice in the kitchen', 'Wasps nest under the roof', 'Bedbugs', 'Pigeons nesting on the balcony'] },
|
|
21
|
+
} satisfies Record<string, { weight: number; trades: Trade[]; titles: string[] }>
|
|
22
|
+
|
|
23
|
+
type Category = keyof typeof categories
|
|
24
|
+
const categoryEntries = Object.entries(categories).map(([name, spec]) => [name as Category, spec.weight] as const)
|
|
25
|
+
|
|
26
|
+
// Days from report to planning, and from planning to the visit, by priority.
|
|
27
|
+
const triageDays = { urgent: [0, 0], high: [0, 2], normal: [1, 5], low: [2, 10] } as const
|
|
28
|
+
const leadDays = { urgent: [0, 1], high: [1, 4], normal: [4, 14], low: [10, 35] } as const
|
|
29
|
+
|
|
30
|
+
// The vendors of the organization that do this trade, falling back to the handyman.
|
|
31
|
+
const vendorFor = (org: Org, category: Category, rand: () => number) => {
|
|
32
|
+
const trade = pick(rand, categories[category].trades)
|
|
33
|
+
const matching = org.vendors.filter((vendor) => vendor.trade === trade)
|
|
34
|
+
return pick(rand, matching.length > 0 ? matching : org.vendors.filter((vendor) => vendor.trade === 'general'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A unit of a property that was already managed when the ticket came in.
|
|
38
|
+
const unitFor = (org: Org, reportedAt: number, rand: () => number) => {
|
|
39
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
40
|
+
const unit = int(rand, 0, org.unitCount - 1)
|
|
41
|
+
if (propertyAcquiredAt(org.seed, org.unitProperty[unit]!) <= reportedAt) return unit
|
|
42
|
+
}
|
|
43
|
+
return int(rand, 0, org.unitCount - 1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A ticket and its work orders, rebuilt from its ordinal. Time decides the status: a ticket not planned by the window's
|
|
48
|
+
* end is still reported, one whose last visit lies ahead is scheduled, the rest are done or (a few) cancelled.
|
|
49
|
+
*/
|
|
50
|
+
export const ticketAt = (org: Org, ordinal: number) => {
|
|
51
|
+
const rand = rngAt(org.seed + 13, ordinal)
|
|
52
|
+
const reportedAt = org.ticketTimes[ordinal]!
|
|
53
|
+
const unit = unitFor(org, reportedAt, rand)
|
|
54
|
+
const property = org.unitProperty[unit]!
|
|
55
|
+
const category = weighted(rand, categoryEntries)
|
|
56
|
+
const winter = [10, 11, 0, 1, 2].includes(new Date(reportedAt).getUTCMonth())
|
|
57
|
+
const priority = category === 'heating' && winter && rand() < 0.5 ? 'urgent' : weighted(rand, [['urgent', 4], ['high', 18], ['normal', 58], ['low', 20]] as const)
|
|
58
|
+
// Roofs, gardens and pests of a building are reported for the whole property.
|
|
59
|
+
const wholeProperty = propertyShapeAt(org.seed, property).kind !== 'single_family' && ['leak', 'garden', 'pests'].includes(category) && rand() < 0.4
|
|
60
|
+
|
|
61
|
+
const tenancies = tenanciesOf(org.seed, unit, propertyAcquiredAt(org.seed, property))
|
|
62
|
+
const current = tenancies.findIndex((tenancy) => tenancy.start <= reportedAt && (tenancy.end === null || tenancy.end + DAY > reportedAt))
|
|
63
|
+
const lease = current === -1 ? null : org.leaseStarts[unit]! + current
|
|
64
|
+
const reportedBy = lease === null || rand() < 0.1 ? null : org.tenantFirstId + org.tenantStarts[lease]!
|
|
65
|
+
|
|
66
|
+
const day = (ms: number) => Math.floor(ms / DAY) * DAY
|
|
67
|
+
const [triageMin, triageMax] = triageDays[priority]
|
|
68
|
+
const [leadMin, leadMax] = leadDays[priority]
|
|
69
|
+
const plannedAt = reportedAt + int(rand, triageMin, triageMax) * DAY + int(rand, 1, 6) * HOUR
|
|
70
|
+
const firstVisit = day(plannedAt) + int(rand, leadMin, leadMax) * DAY
|
|
71
|
+
const visits = rand() < 0.15 ? [firstVisit, firstVisit + int(rand, 3, 14) * DAY] : [firstVisit]
|
|
72
|
+
const cancelled = rand() < 0.06
|
|
73
|
+
const cancelledAt = reportedAt + int(rand, 1, 10) * DAY + int(rand, 1, 8) * HOUR
|
|
74
|
+
|
|
75
|
+
const status =
|
|
76
|
+
cancelled && cancelledAt <= windowEnd ? 'cancelled'
|
|
77
|
+
: cancelled || plannedAt > windowEnd ? 'reported'
|
|
78
|
+
: visits.at(-1)! + 17 * HOUR > windowEnd ? 'scheduled'
|
|
79
|
+
: 'done'
|
|
80
|
+
const workOrders = status === 'scheduled' || status === 'done'
|
|
81
|
+
? visits.map((visit) => {
|
|
82
|
+
const vendor = vendorFor(org, category, rand)
|
|
83
|
+
const completed = visit + 17 * HOUR <= windowEnd
|
|
84
|
+
const hours = int(rand, 1, 12) / 2
|
|
85
|
+
return {
|
|
86
|
+
vendorId: vendor.id,
|
|
87
|
+
scheduledFor: visit,
|
|
88
|
+
completedOn: completed ? visit : null,
|
|
89
|
+
hours: completed ? hours : null,
|
|
90
|
+
costCents: completed ? Math.round(hours * vendor.rate * 100) + int(rand, 0, 40) * 1000 : null,
|
|
91
|
+
notes: completed && visits.length > 1 && visit === visits[0] ? 'Temporary fix, part ordered' : null,
|
|
92
|
+
createdAt: plannedAt,
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
: []
|
|
96
|
+
const closedAt = status === 'done' ? visits.at(-1)! + int(rand, 14, 17) * HOUR : status === 'cancelled' ? cancelledAt : null
|
|
97
|
+
const title = pick(rand, categories[category].titles)
|
|
98
|
+
return {
|
|
99
|
+
id: org.ticketFirstId + ordinal,
|
|
100
|
+
number: `MT-${new Date(reportedAt).getUTCFullYear()}-${String(ordinal + 1).padStart(6, '0')}`,
|
|
101
|
+
propertyId: org.propertyFirstId + property,
|
|
102
|
+
unitId: wholeProperty ? null : org.unitFirstId + unit,
|
|
103
|
+
reportedBy: wholeProperty ? null : reportedBy,
|
|
104
|
+
assignedTo: status === 'reported' && rand() < 0.5 ? null : pick(rand, org.maintenanceUsers).id,
|
|
105
|
+
category,
|
|
106
|
+
priority,
|
|
107
|
+
status,
|
|
108
|
+
title,
|
|
109
|
+
description: rand() < 0.6 ? `${title}. ${pick(rand, ['Tenant is home after 17:00.', 'Key is with the neighbours.', 'Please call before coming.', 'Started last week.', 'Second time this year.'])}` : null,
|
|
110
|
+
reportedAt,
|
|
111
|
+
closedAt,
|
|
112
|
+
workOrders,
|
|
113
|
+
updatedAt: closedAt ?? (workOrders.length > 0 ? plannedAt : reportedAt),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { makeRng, type Rand } from './rng'
|
|
2
|
+
|
|
3
|
+
export const DAY = 86_400_000
|
|
4
|
+
|
|
5
|
+
export const windowEnd = Date.UTC(2026, 9, 6, 9, 41)
|
|
6
|
+
export const windowStart = windowEnd - 3 * 365 * DAY
|
|
7
|
+
|
|
8
|
+
export const yearOf = (ms: number) => new Date(ms).getUTCFullYear()
|
|
9
|
+
|
|
10
|
+
// Sunday first. Weekdays carry the traffic, Saturday a fifth, Sunday almost nothing.
|
|
11
|
+
const dayShare = [0.05, 1.15, 1.1, 1.05, 1.0, 0.85, 0.2]
|
|
12
|
+
|
|
13
|
+
const HOUR = 3_600_000
|
|
14
|
+
|
|
15
|
+
// Time of day in ms: mostly business hours with a midday-ish peak, some early and evening traffic.
|
|
16
|
+
const timeOfDay = (weekday: number, rand: Rand) => {
|
|
17
|
+
const roll = rand()
|
|
18
|
+
if (weekday === 0 || weekday === 6) return (9 + 6 * rand()) * HOUR
|
|
19
|
+
if (roll < 0.82) return (8 + (9 * (rand() + rand())) / 2) * HOUR
|
|
20
|
+
if (roll < 0.9) return (6 + 2 * rand()) * HOUR
|
|
21
|
+
return (17 + 5 * rand()) * HOUR
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A moment `delay` after `at`; when that would pass the end of the window, somewhere between `at` and the end.
|
|
25
|
+
export const within = (at: number, delay: number, rand: Rand) => (at + delay <= windowEnd ? at + delay : at + Math.floor(rand() * (windowEnd - at)))
|
|
26
|
+
|
|
27
|
+
// Ascending timestamps (ms) spread over the window: weekday/weekend pattern,
|
|
28
|
+
// slow growth over three years, a quieter December and day-to-day noise.
|
|
29
|
+
// Times never leave the window, and the first and last day are partial days.
|
|
30
|
+
export const timestamps = (count: number, seed: number) => {
|
|
31
|
+
const rand = makeRng(seed)
|
|
32
|
+
const firstDay = Math.floor(windowStart / DAY) * DAY
|
|
33
|
+
const days = Math.ceil((windowEnd - firstDay) / DAY)
|
|
34
|
+
const bounds = (d: number) => [Math.max(firstDay + d * DAY, windowStart), Math.min(firstDay + (d + 1) * DAY, windowEnd)] as const
|
|
35
|
+
const weights = Array.from({ length: days }, (_, d) => {
|
|
36
|
+
const date = new Date(firstDay + d * DAY)
|
|
37
|
+
const [lo, hi] = bounds(d)
|
|
38
|
+
const season = date.getUTCMonth() === 11 ? 0.8 : 1
|
|
39
|
+
return dayShare[date.getUTCDay()]! * ((hi - lo) / DAY) * (0.7 + (0.3 * d) / days) * season * (0.85 + 0.3 * rand())
|
|
40
|
+
})
|
|
41
|
+
const total = weights.reduce((sum, weight) => sum + weight, 0)
|
|
42
|
+
|
|
43
|
+
const times = new Float64Array(count)
|
|
44
|
+
let assigned = 0
|
|
45
|
+
let cumulative = 0
|
|
46
|
+
for (let d = 0; d < days; d++) {
|
|
47
|
+
cumulative += weights[d]!
|
|
48
|
+
const dayCount = Math.round((count * cumulative) / total) - assigned
|
|
49
|
+
const [lo, hi] = bounds(d)
|
|
50
|
+
const day = firstDay + d * DAY
|
|
51
|
+
const weekday = new Date(day).getUTCDay()
|
|
52
|
+
const sampled = Array.from({ length: dayCount }, () => {
|
|
53
|
+
const at = Math.floor(day + timeOfDay(weekday, rand))
|
|
54
|
+
return at >= lo && at <= hi ? at : lo + Math.floor(rand() * (hi - lo))
|
|
55
|
+
}).sort((a, b) => a - b)
|
|
56
|
+
times.set(sampled, assigned)
|
|
57
|
+
assigned += dayCount
|
|
58
|
+
}
|
|
59
|
+
return times
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Months as the UTC midnight of their first day. Leases start on a first and end on a last day of a month.
|
|
63
|
+
export const monthOf = (ms: number) => {
|
|
64
|
+
const date = new Date(ms)
|
|
65
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const addMonths = (month: number, count: number) => {
|
|
69
|
+
const date = new Date(month)
|
|
70
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + count, 1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const lastDayOf = (month: number) => addMonths(month, 1) - DAY
|
|
74
|
+
|
|
75
|
+
/** The first rent period inside the window, and the last one (the month the window ends in). */
|
|
76
|
+
export const firstPeriod = addMonths(monthOf(windowStart), 1)
|
|
77
|
+
export const lastPeriod = monthOf(windowEnd)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { connect } from '../db/connection'
|
|
2
|
+
import { isScale, scales } from './scales'
|
|
3
|
+
import { seedBilling } from './steps/billing'
|
|
4
|
+
import { seedLeasing } from './steps/leasing'
|
|
5
|
+
import { seedMaintenance } from './steps/maintenance'
|
|
6
|
+
import { seedPortfolio } from './steps/portfolio'
|
|
7
|
+
import { seedReference } from './steps/reference'
|
|
8
|
+
import { analyzeAll, createIndexes, dropIndexes, resetSequences, setTriggers, truncateAll } from './maintenance'
|
|
9
|
+
import { buildWorld } from './world'
|
|
10
|
+
|
|
11
|
+
const flag = process.argv.indexOf('--scale')
|
|
12
|
+
const scale = flag === -1 ? 'small' : process.argv[flag + 1]
|
|
13
|
+
if (!isScale(scale)) throw new Error(`Unknown scale "${scale}", expected one of: ${Object.keys(scales).join(', ')}`)
|
|
14
|
+
|
|
15
|
+
const started = performance.now()
|
|
16
|
+
const step = async (name: string, run: () => Promise<unknown>) => {
|
|
17
|
+
const before = performance.now()
|
|
18
|
+
await run()
|
|
19
|
+
console.log(`${name} (${((performance.now() - before) / 1000).toFixed(1)}s)`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const world = buildWorld(scale)
|
|
23
|
+
|
|
24
|
+
// One connection for the load, with foreign keys, check and exclusion constraints on: an app's own role may not skip
|
|
25
|
+
// them (session_replication_role), only switch its triggers off, so the seed writes `paid_amount` itself. Postgres
|
|
26
|
+
// still refuses an overlapping lease or an overpaid charge; tests/examples/real-estate/smoke.test.ts checks the rest.
|
|
27
|
+
const loader = connect({ max: 1 })
|
|
28
|
+
|
|
29
|
+
await step('truncate', () => truncateAll(loader))
|
|
30
|
+
const indexes = await dropIndexes(loader)
|
|
31
|
+
await setTriggers(loader, false)
|
|
32
|
+
try {
|
|
33
|
+
await step('reference', () => seedReference(loader, world))
|
|
34
|
+
await step('portfolio', () => seedPortfolio(loader, world))
|
|
35
|
+
await step('leasing', () => seedLeasing(loader, world))
|
|
36
|
+
await step('billing', () => seedBilling(loader, world))
|
|
37
|
+
await step('maintenance', () => seedMaintenance(loader, world))
|
|
38
|
+
} finally {
|
|
39
|
+
await setTriggers(loader, true)
|
|
40
|
+
}
|
|
41
|
+
await loader.end()
|
|
42
|
+
|
|
43
|
+
const sql = connect({ max: 4 })
|
|
44
|
+
await step('indexes', () => createIndexes(sql, indexes))
|
|
45
|
+
await step('sequences', () => resetSequences(sql))
|
|
46
|
+
await sql`insert into public.seed_info (scale) values (${scale})`
|
|
47
|
+
await step('analyze', () => analyzeAll(sql))
|
|
48
|
+
await sql.end()
|
|
49
|
+
|
|
50
|
+
console.log(`seeded ${scale} in ${((performance.now() - started) / 1000).toFixed(1)}s`)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Readable } from 'node:stream'
|
|
2
|
+
import { pipeline } from 'node:stream/promises'
|
|
3
|
+
import type { Db } from '../db/connection'
|
|
4
|
+
|
|
5
|
+
const batchSize = 2000
|
|
6
|
+
|
|
7
|
+
function* batches(rows: Iterable<string>) {
|
|
8
|
+
let batch: string[] = []
|
|
9
|
+
for (const line of rows) {
|
|
10
|
+
batch.push(line)
|
|
11
|
+
if (batch.length < batchSize) continue
|
|
12
|
+
yield batch.join('')
|
|
13
|
+
batch = []
|
|
14
|
+
}
|
|
15
|
+
if (batch.length > 0) yield batch.join('')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Streams COPY text-format rows into `schema.table (columns)`.
|
|
19
|
+
export const copyRows = async (sql: Db, target: string, rows: Iterable<string>) => {
|
|
20
|
+
const writable = await sql.unsafe(`copy ${target} from stdin`).writable()
|
|
21
|
+
await pipeline(Readable.from(batches(rows)), writable)
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const firstNames = [
|
|
2
|
+
'Sanne', 'Daan', 'Lieke', 'Jeroen', 'Femke', 'Bram', 'Anouk', 'Ruben', 'Eva', 'Thijs', 'Noor', 'Pieter', 'Mirjam', 'Joost',
|
|
3
|
+
'Fleur', 'Lars', 'Iris', 'Sem', 'Julia', 'Luuk', 'Tess', 'Milan', 'Lotte', 'Stijn', 'Yara', 'Mohamed', 'Fatima', 'Ahmed',
|
|
4
|
+
'Sara', 'Kevin', 'Esmee', 'Niels', 'Roos', 'Wouter', 'Maaike', 'Bas', 'Ilse', 'Gijs', 'Merel', 'Koen',
|
|
5
|
+
]
|
|
6
|
+
|
|
7
|
+
export const surnames = [
|
|
8
|
+
'de Vries', 'van den Berg', 'Bakker', 'Janssen', 'Visser', 'Smit', 'Meijer', 'de Boer', 'Mulder', 'de Groot', 'Bos', 'Vos',
|
|
9
|
+
'Peters', 'Hendriks', 'van Dijk', 'Dekker', 'Kok', 'Jacobs', 'de Jong', 'Jansen', 'van Leeuwen', 'Willems', 'van der Meer',
|
|
10
|
+
'Brouwer', 'de Wit', 'Dijkstra', 'Smits', 'de Graaf', 'van der Linden', 'Kramer', 'Yilmaz', 'El Amrani', 'Bouzid', 'Kaya',
|
|
11
|
+
'Verhoeven', 'Postma', 'Hoekstra', 'Koster', 'Prins', 'Huisman',
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export const streets = [
|
|
15
|
+
'Kerkstraat', 'Molenweg', 'Dorpsstraat', 'Julianalaan', 'Beatrixstraat', 'Wilhelminastraat', 'Lindenlaan', 'Eikenlaan',
|
|
16
|
+
'Parkweg', 'Schoolstraat', 'Nieuwstraat', 'Kastanjelaan', 'Prinsengracht', 'Oranjestraat', 'Stationsweg', 'Zuiderkade',
|
|
17
|
+
'Havenstraat', 'Burgemeester de Vlugtlaan', 'Rembrandtlaan', 'Vondelstraat', 'Spoorlaan', 'Brugstraat', 'Tulpstraat',
|
|
18
|
+
'Mauritsweg', 'Singel', 'Leidsestraat', 'Hoflaan', 'Van Goghstraat', 'Kanaalweg', 'Marktplein',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// Apartment buildings and mixed-use blocks carry a name; single-family homes are known by their address.
|
|
22
|
+
export const buildingNames = [
|
|
23
|
+
'De Linde', 'Parkzicht', 'Het Baken', 'De Kade', 'Zonnehof', 'De Wilgen', 'Het Anker', 'Waterfront', 'De Meander',
|
|
24
|
+
'Oostpoort', 'De Werf', 'Lindenhof', 'Het Atrium', 'De Plataan', 'Havenkwartier', 'De Molen', 'Stadstuin', 'De Esch',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export const buildingPrefixes = ['Residentie', 'Complex', 'Wooncomplex', 'Appartementen']
|
|
28
|
+
|
|
29
|
+
export const ownerCompanyWords = ['Vastgoed', 'Beleggingen', 'Woningen', 'Huisvesting', 'Real Estate']
|
|
30
|
+
|
|
31
|
+
export const banks = ['ABNA', 'INGB', 'RABO', 'SNSB', 'TRIO', 'ASNB', 'BUNQ', 'KNAB']
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const amenities = [
|
|
2
|
+
['balcony', 'Balcony'], ['garden', 'Garden'], ['roof_terrace', 'Roof terrace'], ['lift', 'Lift'],
|
|
3
|
+
['parking', 'Parking space'], ['bike_storage', 'Bike storage'], ['storage_room', 'Storage room'],
|
|
4
|
+
['solar_panels', 'Solar panels'], ['heat_pump', 'Heat pump'], ['furnished', 'Furnished'],
|
|
5
|
+
] as const
|
|
6
|
+
|
|
7
|
+
export type AmenityCode = (typeof amenities)[number][0]
|
|
8
|
+
|
|
9
|
+
// Where each organization lets homes: a city centre (lat/lng), its postal code range and the market rent per m2 in 2026.
|
|
10
|
+
export type City = readonly [name: string, lat: number, lng: number, postalFrom: number, postalTo: number, rentPerM2: number]
|
|
11
|
+
|
|
12
|
+
export const trades = ['plumbing', 'heating', 'electrical', 'roofing', 'carpentry', 'locksmith', 'pest_control', 'general'] as const
|
|
13
|
+
|
|
14
|
+
export type Trade = (typeof trades)[number]
|
|
15
|
+
|
|
16
|
+
export const orgSpecs = [
|
|
17
|
+
{
|
|
18
|
+
id: 1,
|
|
19
|
+
name: 'Grachtenhof Vastgoedbeheer B.V.',
|
|
20
|
+
slug: 'grachtenhof',
|
|
21
|
+
city: 'Amsterdam',
|
|
22
|
+
kvk: '34187726',
|
|
23
|
+
share: 0.7,
|
|
24
|
+
seed: 301,
|
|
25
|
+
cities: [
|
|
26
|
+
['Amsterdam', 52.3676, 4.9041, 1011, 1109, 25], ['Haarlem', 52.3874, 4.6462, 2011, 2037, 21],
|
|
27
|
+
['Utrecht', 52.0907, 5.1214, 3511, 3585, 22], ['Amstelveen', 52.3114, 4.8701, 1181, 1189, 23.5],
|
|
28
|
+
['Zaandam', 52.4420, 4.8292, 1501, 1509, 18.5], ['Hilversum', 52.2292, 5.1669, 1211, 1223, 19],
|
|
29
|
+
] as readonly City[],
|
|
30
|
+
users: [
|
|
31
|
+
['Sanne', 'M.', 'admin'], ['Daan', 'K.', 'manager'], ['Femke', 'D.', 'manager'], ['Joost', 'R.', 'finance'],
|
|
32
|
+
['Mirjam', 'V.', 'finance'], ['Thijs', 'W.', 'maintenance'], ['Bram', 'B.', 'maintenance'], ['Lieke', 'P.', 'manager'],
|
|
33
|
+
] as const,
|
|
34
|
+
vendors: [
|
|
35
|
+
['Loodgietersbedrijf De Waal', 'plumbing', 68], ['Installatietechniek Van Dam', 'heating', 74], ['Elektro Mulder', 'electrical', 65],
|
|
36
|
+
['Dakwerken Brouwer', 'roofing', 70], ['Timmerbedrijf Kok', 'carpentry', 58], ['Slotenservice Amstel', 'locksmith', 82],
|
|
37
|
+
['Ongediertebestrijding Noord', 'pest_control', 90], ['Klusbedrijf Jansen', 'general', 52], ['CV-Service Haarlem', 'heating', 71],
|
|
38
|
+
['Loodgieter Vos & Zn.', 'plumbing', 64],
|
|
39
|
+
] as const,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 2,
|
|
43
|
+
name: 'Maaskant Verhuur & Beheer B.V.',
|
|
44
|
+
slug: 'maaskant',
|
|
45
|
+
city: 'Rotterdam',
|
|
46
|
+
kvk: '24411509',
|
|
47
|
+
share: 0.3,
|
|
48
|
+
seed: 402,
|
|
49
|
+
cities: [
|
|
50
|
+
['Rotterdam', 51.9244, 4.4777, 3011, 3089, 19.5], ['Den Haag', 52.0705, 4.3007, 2491, 2597, 20],
|
|
51
|
+
['Delft', 52.0116, 4.3571, 2611, 2629, 20.5], ['Dordrecht', 51.8133, 4.6901, 3311, 3329, 15.5],
|
|
52
|
+
['Schiedam', 51.9192, 4.3988, 3111, 3125, 16.5], ['Breda', 51.5719, 4.7683, 4811, 4838, 17],
|
|
53
|
+
] as readonly City[],
|
|
54
|
+
users: [
|
|
55
|
+
['Anouk', 'L.', 'admin'], ['Ruben', 'S.', 'manager'], ['Eva', 'H.', 'finance'], ['Pieter', 'T.', 'maintenance'], ['Noor', 'G.', 'maintenance'],
|
|
56
|
+
] as const,
|
|
57
|
+
vendors: [
|
|
58
|
+
['Loodgietersbedrijf Maasstad', 'plumbing', 66], ['Warmtetechniek Rijnmond', 'heating', 72], ['Elektrotechniek De Jong', 'electrical', 63],
|
|
59
|
+
['Dakdekkersbedrijf Visser', 'roofing', 69], ['Klus & Timmerwerk Smit', 'carpentry', 55], ['Sleutelspecialist Zuid', 'locksmith', 80],
|
|
60
|
+
['Plaagdierbeheer West', 'pest_control', 88], ['Allround Onderhoud Bakker', 'general', 50],
|
|
61
|
+
] as const,
|
|
62
|
+
},
|
|
63
|
+
] as const
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const escapes: Record<string, string> = { '\\': '\\\\', '\t': '\\t', '\n': '\\n', '\r': '\\r' }
|
|
2
|
+
|
|
3
|
+
const cell = (value: unknown) => {
|
|
4
|
+
if (value === null || value === undefined) return '\\N'
|
|
5
|
+
return String(value).replace(/[\\\t\n\r]/g, (char) => escapes[char]!)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// One COPY text-format row.
|
|
9
|
+
export const row = (...values: unknown[]) => values.map(cell).join('\t') + '\n'
|
|
10
|
+
|
|
11
|
+
export const money = (cents: number) => `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, '0')}`
|
|
12
|
+
|
|
13
|
+
export const iso = (ms: number) => new Date(ms).toISOString()
|
|
14
|
+
|
|
15
|
+
export const isoDate = (ms: number) => iso(ms).slice(0, 10)
|
|
16
|
+
|
|
17
|
+
export const slugify = (text: string) =>
|
|
18
|
+
text
|
|
19
|
+
.normalize('NFD')
|
|
20
|
+
.replace(/[̀-ͯ]/g, '')
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/&/g, 'and')
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-|-$/g, '')
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { banks } from './data/names'
|
|
2
|
+
import { int, pick, type Rand } from './rng'
|
|
3
|
+
|
|
4
|
+
// ISO 13616 check digits: the account with "NL00" moved to the end, letters as numbers, mod 97.
|
|
5
|
+
const checkDigits = (bban: string) => {
|
|
6
|
+
const digits = `${bban}NL00`.replace(/[A-Z]/g, (letter) => String(letter.charCodeAt(0) - 55))
|
|
7
|
+
const remainder = digits.split('').reduce((rest, digit) => (rest * 10 + Number(digit)) % 97, 0)
|
|
8
|
+
return String(98 - remainder).padStart(2, '0')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A Dutch IBAN with valid check digits: NLkk BANK 0123456789. */
|
|
12
|
+
export const iban = (rand: Rand) => {
|
|
13
|
+
const bban = `${pick(rand, banks)}${String(int(rand, 100_000_000, 999_999_999)).padStart(10, '0')}`
|
|
14
|
+
return `NL${checkDigits(bban)}${bban}`
|
|
15
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Db } from '../db/connection'
|
|
2
|
+
|
|
3
|
+
const schemas = ['core', 'portfolio', 'leasing', 'billing', 'maintenance']
|
|
4
|
+
|
|
5
|
+
export const truncateAll = async (sql: Db) => {
|
|
6
|
+
const tables = await sql`
|
|
7
|
+
select format('%I.%I', schemaname, tablename) as name from pg_tables
|
|
8
|
+
where schemaname in ${sql(schemas)} or tablename = 'seed_info'
|
|
9
|
+
`
|
|
10
|
+
await sql.unsafe(`truncate ${tables.map((table) => table.name).join(', ')} restart identity cascade`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Secondary indexes only: primary keys and unique constraints stay in place.
|
|
14
|
+
const secondaryIndexes = (sql: Db) => sql`
|
|
15
|
+
select format('%I.%I', n.nspname, c.relname) as name, pg_get_indexdef(i.indexrelid) as definition
|
|
16
|
+
from pg_index i
|
|
17
|
+
join pg_class c on c.oid = i.indexrelid
|
|
18
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
19
|
+
where n.nspname in ${sql(schemas)}
|
|
20
|
+
and not i.indisprimary
|
|
21
|
+
and not exists (select 1 from pg_constraint k where k.conindid = i.indexrelid)
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
// Loading into indexed tables is several times slower than building the indexes afterwards.
|
|
25
|
+
export const dropIndexes = async (sql: Db) => {
|
|
26
|
+
const indexes = await secondaryIndexes(sql)
|
|
27
|
+
for (const index of indexes) await sql.unsafe(`drop index ${index.name}`)
|
|
28
|
+
return indexes.map((index) => index.definition as string)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const createIndexes = async (sql: Db, definitions: string[]) => {
|
|
32
|
+
await Promise.all(definitions.map((definition) => sql.unsafe(definition)))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The triggers that keep stored totals in step with their rows. The seed writes those totals itself, and recomputing
|
|
36
|
+
// them during the load, with the secondary indexes gone, takes minutes at small scale and hours above it. A table's
|
|
37
|
+
// owner may switch its own triggers off, unlike the foreign key checks, which stay on.
|
|
38
|
+
export const setTriggers = async (sql: Db, enabled: boolean) => {
|
|
39
|
+
const tables = await sql`
|
|
40
|
+
select distinct format('%I.%I', n.nspname, c.relname) as name
|
|
41
|
+
from pg_trigger t
|
|
42
|
+
join pg_class c on c.oid = t.tgrelid
|
|
43
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
44
|
+
where not t.tgisinternal and n.nspname in ${sql(schemas)}
|
|
45
|
+
`
|
|
46
|
+
for (const table of tables) await sql.unsafe(`alter table ${table.name} ${enabled ? 'enable' : 'disable'} trigger user`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// COPY with explicit ids leaves identity sequences at 1.
|
|
50
|
+
export const resetSequences = async (sql: Db) => {
|
|
51
|
+
const columns = await sql`
|
|
52
|
+
select table_schema as schema, table_name as table, column_name as column from information_schema.columns
|
|
53
|
+
where is_identity = 'YES' and table_schema in ${sql(schemas)}
|
|
54
|
+
`
|
|
55
|
+
for (const { schema, table, column } of columns) {
|
|
56
|
+
const target = `${schema}.${table}`
|
|
57
|
+
await sql.unsafe(`select setval(pg_get_serial_sequence('${target}', '${column}'), coalesce(max(${column}), 1), max(${column}) is not null) from ${target}`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const analyzeAll = async (sql: Db) => {
|
|
62
|
+
await sql.unsafe('analyze')
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const hash = (seed: number, index: number) => {
|
|
2
|
+
let h = (seed ^ Math.imul(index + 0x9e3779b9, 0x85ebca6b)) >>> 0
|
|
3
|
+
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b)
|
|
4
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35)
|
|
5
|
+
return (h ^ (h >>> 16)) >>> 0
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Independent stream per (seed, index): lets any row be rebuilt without generating its neighbours.
|
|
9
|
+
export const rngAt = (seed: number, index: number) => {
|
|
10
|
+
let state = hash(seed, index)
|
|
11
|
+
return () => {
|
|
12
|
+
state = (state + 0x6d2b79f5) >>> 0
|
|
13
|
+
let t = state
|
|
14
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
15
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
16
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Rand = ReturnType<typeof rngAt>
|
|
21
|
+
|
|
22
|
+
export const pick = <T>(rand: Rand, items: readonly T[]) => items[Math.floor(rand() * items.length)]!
|
|
23
|
+
|
|
24
|
+
export const int = (rand: Rand, min: number, max: number) => min + Math.floor(rand() * (max - min + 1))
|
|
25
|
+
|
|
26
|
+
export const weighted = <T>(rand: Rand, entries: ReadonlyArray<readonly [T, number]>) => {
|
|
27
|
+
const total = entries.reduce((sum, [, weight]) => sum + weight, 0)
|
|
28
|
+
let roll = rand() * total
|
|
29
|
+
for (const [value, weight] of entries) {
|
|
30
|
+
roll -= weight
|
|
31
|
+
if (roll < 0) return value
|
|
32
|
+
}
|
|
33
|
+
return entries[entries.length - 1]![0]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const makeRng = (seed: number) => rngAt(seed, 0)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const scales = {
|
|
2
|
+
small: { properties: 60, ticketsPerUnit: 1.2 },
|
|
3
|
+
medium: { properties: 2_000, ticketsPerUnit: 1.2 },
|
|
4
|
+
large: { properties: 10_000, ticketsPerUnit: 1.2 },
|
|
5
|
+
} as const
|
|
6
|
+
|
|
7
|
+
export type ScaleName = keyof typeof scales
|
|
8
|
+
|
|
9
|
+
export const isScale = (name: string | undefined): name is ScaleName => name !== undefined && name in scales
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Db } from '../../db/connection'
|
|
2
|
+
import { ledgerOf } from '../builders/ledger'
|
|
3
|
+
import { DAY, windowEnd } from '../calendar'
|
|
4
|
+
import { copyRows } from '../copy'
|
|
5
|
+
import { iso, isoDate, money, row } from '../format'
|
|
6
|
+
import type { World } from '../world'
|
|
7
|
+
import { leasesOf } from './leasing'
|
|
8
|
+
|
|
9
|
+
// Charges and payments are numbered as they stream; both passes walk the leases in the same order, so a payment
|
|
10
|
+
// finds its charge's id by counting along.
|
|
11
|
+
function* ledgers(world: World) {
|
|
12
|
+
let chargeId = 1
|
|
13
|
+
let paymentId = 1
|
|
14
|
+
for (const org of world.orgs) {
|
|
15
|
+
for (const lease of leasesOf(org)) {
|
|
16
|
+
for (const charge of ledgerOf(org, lease)) {
|
|
17
|
+
yield { org, lease, charge, chargeId: chargeId++, firstPaymentId: paymentId }
|
|
18
|
+
paymentId += charge.payments.length
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function* chargeRows(world: World) {
|
|
25
|
+
for (const { org, lease, charge, chargeId } of ledgers(world)) {
|
|
26
|
+
const updatedAt = charge.payments.at(-1)?.paidOn ?? charge.createdAt
|
|
27
|
+
yield row(chargeId, org.id, lease.id, isoDate(charge.period), isoDate(charge.dueOn), charge.description, money(charge.amountCents), money(charge.paidCents), iso(charge.createdAt), iso(Math.min(updatedAt + 10 * 3_600_000, windowEnd)))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function* paymentRows(world: World) {
|
|
32
|
+
for (const { org, lease, charge, chargeId, firstPaymentId } of ledgers(world)) {
|
|
33
|
+
for (const [index, payment] of charge.payments.entries()) {
|
|
34
|
+
const bookedAt = Math.min(payment.paidOn + DAY / 2, windowEnd)
|
|
35
|
+
const reference = payment.method === 'direct_debit' ? `SEPA ${lease.number}` : `${lease.number} ${charge.description.toLowerCase()}`
|
|
36
|
+
yield row(firstPaymentId + index, org.id, chargeId, isoDate(payment.paidOn), money(payment.amountCents), payment.method, reference, iso(bookedAt), iso(bookedAt))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const seedBilling = async (sql: Db, world: World) => {
|
|
42
|
+
await copyRows(sql, 'billing.rent_charges (id, organization_id, lease_id, period, due_on, description, amount, paid_amount, created_at, updated_at)', chargeRows(world))
|
|
43
|
+
await copyRows(sql, 'billing.payments (id, organization_id, charge_id, paid_on, amount, method, reference, created_at, updated_at)', paymentRows(world))
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Db } from '../../db/connection'
|
|
2
|
+
import { tenanciesOf } from '../builders/allocation'
|
|
3
|
+
import { depositFor, leaseAt, tenantsOf, type SeededLease } from '../builders/tenancy'
|
|
4
|
+
import { copyRows } from '../copy'
|
|
5
|
+
import { iso, isoDate, money, row } from '../format'
|
|
6
|
+
import type { Org, World } from '../world'
|
|
7
|
+
import { unitsOf } from './portfolio'
|
|
8
|
+
|
|
9
|
+
// Every lease of the organization, in lease order (unit by unit, oldest first).
|
|
10
|
+
export function* leasesOf(org: Org): Generator<SeededLease> {
|
|
11
|
+
for (const { property, unit, ordinal } of unitsOf(org)) {
|
|
12
|
+
const tenancies = tenanciesOf(org.seed, ordinal, property.createdAt)
|
|
13
|
+
for (const [index, tenancy] of tenancies.entries()) yield leaseAt(org, property, unit, ordinal, index, tenancy)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function* leaseRows(world: World) {
|
|
18
|
+
for (const org of world.orgs) {
|
|
19
|
+
for (const lease of leasesOf(org)) {
|
|
20
|
+
const { start, end, term } = lease.tenancy
|
|
21
|
+
yield row(lease.id, org.id, lease.number, lease.unitId, term, isoDate(start), end === null ? null : isoDate(end), money(lease.rentCents), money(lease.serviceCents), lease.notice === null ? null : isoDate(lease.notice), iso(lease.createdAt), iso(lease.updatedAt))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function* tenantRows(world: World) {
|
|
27
|
+
for (const org of world.orgs) {
|
|
28
|
+
for (const lease of leasesOf(org)) {
|
|
29
|
+
for (const t of tenantsOf(org, lease)) {
|
|
30
|
+
yield row(t.id, org.id, t.firstName, t.lastName, t.email, t.phone, isoDate(t.dateOfBirth), t.iban, money(t.incomeCents), iso(t.createdAt), iso(t.createdAt))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function* leaseTenantRows(world: World) {
|
|
37
|
+
for (const org of world.orgs) {
|
|
38
|
+
for (const lease of leasesOf(org)) {
|
|
39
|
+
for (const tenant of tenantsOf(org, lease)) yield row(lease.id, tenant.id, org.id, tenant.role, iso(lease.createdAt))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One deposit per lease, so it takes the lease's id.
|
|
45
|
+
function* depositRows(world: World) {
|
|
46
|
+
for (const org of world.orgs) {
|
|
47
|
+
for (const lease of leasesOf(org)) {
|
|
48
|
+
const d = depositFor(org, lease)
|
|
49
|
+
const updatedAt = d.returnedOn ?? d.receivedOn
|
|
50
|
+
yield row(lease.id, org.id, lease.id, money(d.amountCents), isoDate(d.receivedOn), d.status, money(d.returnedCents), d.returnedOn === null ? null : isoDate(d.returnedOn), iso(d.receivedOn), iso(updatedAt))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const seedLeasing = async (sql: Db, world: World) => {
|
|
56
|
+
await copyRows(sql, 'leasing.leases (id, organization_id, number, unit_id, term, start_date, end_date, monthly_rent, service_costs, notice_given_on, created_at, updated_at)', leaseRows(world))
|
|
57
|
+
await copyRows(sql, 'leasing.tenants (id, organization_id, first_name, last_name, email, phone, date_of_birth, iban, monthly_income, created_at, updated_at)', tenantRows(world))
|
|
58
|
+
await copyRows(sql, 'leasing.lease_tenants (lease_id, tenant_id, organization_id, role, added_at)', leaseTenantRows(world))
|
|
59
|
+
await copyRows(sql, 'leasing.deposits (id, organization_id, lease_id, amount, received_on, status, returned_amount, returned_on, created_at, updated_at)', depositRows(world))
|
|
60
|
+
}
|