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.
Files changed (240) hide show
  1. package/dist/ask-preset.js +25 -0
  2. package/dist/create-project.js +7 -6
  3. package/dist/index.js +19 -9
  4. package/dist/next-steps.js +8 -2
  5. package/dist/presets.js +17 -0
  6. package/dist/templates/erp/.build-test-abf2a11f/protobase.config.ts +6 -0
  7. package/dist/templates/erp/.build-test-abf2a11f/resources.ts +2 -0
  8. package/dist/templates/erp/.build-test-b878b26f/config/index.ts +2 -0
  9. package/dist/templates/erp/.build-test-b878b26f/config/things/data.ts +2 -0
  10. package/dist/templates/erp/.build-test-b878b26f/config/things/ui.ts +2 -0
  11. package/dist/templates/erp/.env.example +14 -0
  12. package/dist/templates/erp/AGENTS.md +24 -0
  13. package/dist/templates/erp/auth/auth.ts +68 -0
  14. package/dist/templates/erp/components/orders-per-day.tsx +21 -0
  15. package/dist/templates/erp/config/bottom-bar.ts +9 -0
  16. package/dist/templates/erp/config/categories/data.ts +20 -0
  17. package/dist/templates/erp/config/categories/ui.ts +11 -0
  18. package/dist/templates/erp/config/companies/data.ts +23 -0
  19. package/dist/templates/erp/config/companies/ui.ts +22 -0
  20. package/dist/templates/erp/config/company_tags/data.ts +16 -0
  21. package/dist/templates/erp/config/company_tags/ui.ts +7 -0
  22. package/dist/templates/erp/config/countries/data.ts +13 -0
  23. package/dist/templates/erp/config/countries/ui.ts +6 -0
  24. package/dist/templates/erp/config/currencies/data.ts +14 -0
  25. package/dist/templates/erp/config/currencies/ui.ts +6 -0
  26. package/dist/templates/erp/config/emp_master/data.ts +19 -0
  27. package/dist/templates/erp/config/emp_master/ui.ts +7 -0
  28. package/dist/templates/erp/config/index.ts +42 -0
  29. package/dist/templates/erp/config/invoice_lines/data.ts +23 -0
  30. package/dist/templates/erp/config/invoice_lines/ui.ts +7 -0
  31. package/dist/templates/erp/config/invoices/data.ts +26 -0
  32. package/dist/templates/erp/config/invoices/ui.ts +38 -0
  33. package/dist/templates/erp/config/jobs.test.ts +23 -0
  34. package/dist/templates/erp/config/jobs.ts +29 -0
  35. package/dist/templates/erp/config/locations/data.ts +18 -0
  36. package/dist/templates/erp/config/locations/ui.ts +6 -0
  37. package/dist/templates/erp/config/order_lines/data.ts +20 -0
  38. package/dist/templates/erp/config/order_lines/ui.ts +7 -0
  39. package/dist/templates/erp/config/orders/data.ts +27 -0
  40. package/dist/templates/erp/config/orders/ui.ts +41 -0
  41. package/dist/templates/erp/config/organizations/data.ts +17 -0
  42. package/dist/templates/erp/config/organizations/ui.ts +6 -0
  43. package/dist/templates/erp/config/overview/page.tsx +80 -0
  44. package/dist/templates/erp/config/pages.test.ts +14 -0
  45. package/dist/templates/erp/config/people/data.ts +21 -0
  46. package/dist/templates/erp/config/people/ui.ts +6 -0
  47. package/dist/templates/erp/config/products/data.ts +25 -0
  48. package/dist/templates/erp/config/products/ui.ts +6 -0
  49. package/dist/templates/erp/config/roles.test.ts +56 -0
  50. package/dist/templates/erp/config/roles.ts +54 -0
  51. package/dist/templates/erp/config/shell.test.ts +24 -0
  52. package/dist/templates/erp/config/stock_levels/data.ts +16 -0
  53. package/dist/templates/erp/config/stock_levels/ui.ts +6 -0
  54. package/dist/templates/erp/config/stock_moves/data.ts +22 -0
  55. package/dist/templates/erp/config/stock_moves/ui.ts +25 -0
  56. package/dist/templates/erp/config/tags/data.ts +17 -0
  57. package/dist/templates/erp/config/tags/ui.ts +6 -0
  58. package/dist/templates/erp/config/user-menu.ts +9 -0
  59. package/dist/templates/erp/config/users/data.ts +19 -0
  60. package/dist/templates/erp/config/users/ui.ts +9 -0
  61. package/dist/templates/erp/config/warehouses/data.ts +19 -0
  62. package/dist/templates/erp/config/warehouses/ui.ts +6 -0
  63. package/dist/templates/erp/data.ts +3 -0
  64. package/dist/templates/erp/db/connection.ts +7 -0
  65. package/dist/templates/erp/db/drop.ts +9 -0
  66. package/dist/templates/erp/db/kysely.ts +13 -0
  67. package/dist/templates/erp/db/migrations/001_core.sql +48 -0
  68. package/dist/templates/erp/db/migrations/002_crm.sql +59 -0
  69. package/dist/templates/erp/db/migrations/003_catalog.sql +38 -0
  70. package/dist/templates/erp/db/migrations/004_sales.sql +86 -0
  71. package/dist/templates/erp/db/migrations/005_inventory.sql +65 -0
  72. package/dist/templates/erp/db/migrations/006_hr_legacy.sql +16 -0
  73. package/dist/templates/erp/db/migrations/007_filter_indexes.sql +4 -0
  74. package/dist/templates/erp/db/migrations/008_calculated_totals.sql +98 -0
  75. package/dist/templates/erp/db/migrations/011_auth_organizations.sql +22 -0
  76. package/dist/templates/erp/db/migrations/012_jobs.sql +531 -0
  77. package/dist/templates/erp/db/migrations/013_jobs.sql +98 -0
  78. package/dist/templates/erp/db/migrations/016_jobs.sql +99 -0
  79. package/dist/templates/erp/db/migrations/017_filter_indexes.sql +5 -0
  80. package/dist/templates/erp/db/migrations/018_sort_indexes.sql +4 -0
  81. package/dist/templates/erp/db/migrations/019_auth_users.sql +18 -0
  82. package/dist/templates/erp/functions/unpaid-orders.ts +13 -0
  83. package/dist/templates/erp/package.json +38 -0
  84. package/dist/templates/erp/protobase.config.ts +22 -0
  85. package/dist/templates/erp/protobase.ui.tsx +14 -0
  86. package/dist/templates/erp/seed/auth-organizations.ts +11 -0
  87. package/dist/templates/erp/seed/base-organization.test.ts +17 -0
  88. package/dist/templates/erp/seed/base-organization.ts +10 -0
  89. package/dist/templates/erp/seed/base.ts +22 -0
  90. package/dist/templates/erp/seed/builders/categories.ts +18 -0
  91. package/dist/templates/erp/seed/builders/company.ts +61 -0
  92. package/dist/templates/erp/seed/builders/invoice.ts +31 -0
  93. package/dist/templates/erp/seed/builders/order.ts +66 -0
  94. package/dist/templates/erp/seed/builders/product.ts +46 -0
  95. package/dist/templates/erp/seed/calendar.ts +60 -0
  96. package/dist/templates/erp/seed/cli.ts +54 -0
  97. package/dist/templates/erp/seed/copy.ts +22 -0
  98. package/dist/templates/erp/seed/data/catalogTree.ts +72 -0
  99. package/dist/templates/erp/seed/data/layout.ts +19 -0
  100. package/dist/templates/erp/seed/data/names.ts +44 -0
  101. package/dist/templates/erp/seed/data/reference.ts +53 -0
  102. package/dist/templates/erp/seed/format.ts +24 -0
  103. package/dist/templates/erp/seed/maintenance.ts +63 -0
  104. package/dist/templates/erp/seed/rng.ts +36 -0
  105. package/dist/templates/erp/seed/scales.ts +9 -0
  106. package/dist/templates/erp/seed/steps/catalog.ts +34 -0
  107. package/dist/templates/erp/seed/steps/crm.ts +62 -0
  108. package/dist/templates/erp/seed/steps/hr.ts +26 -0
  109. package/dist/templates/erp/seed/steps/ledger.ts +88 -0
  110. package/dist/templates/erp/seed/steps/reference.ts +24 -0
  111. package/dist/templates/erp/seed/steps/sales.ts +58 -0
  112. package/dist/templates/erp/seed/steps/warehouses.ts +42 -0
  113. package/dist/templates/erp/seed/world.ts +86 -0
  114. package/dist/templates/erp/vitest.config.ts +4 -0
  115. package/dist/templates/real-estate/.env.example +9 -0
  116. package/dist/templates/real-estate/AGENTS.md +24 -0
  117. package/dist/templates/real-estate/_.gitignore +3 -0
  118. package/dist/templates/real-estate/_bunfig.toml +4 -0
  119. package/dist/templates/real-estate/auth/auth.ts +36 -0
  120. package/dist/templates/real-estate/config/amenities/data.ts +12 -0
  121. package/dist/templates/real-estate/config/amenities/ui.ts +7 -0
  122. package/dist/templates/real-estate/config/arrears/data.ts +19 -0
  123. package/dist/templates/real-estate/config/arrears/ui.ts +21 -0
  124. package/dist/templates/real-estate/config/deposits/data.ts +21 -0
  125. package/dist/templates/real-estate/config/deposits/ui.ts +17 -0
  126. package/dist/templates/real-estate/config/index.ts +41 -0
  127. package/dist/templates/real-estate/config/inspections/data.ts +22 -0
  128. package/dist/templates/real-estate/config/inspections/ui.ts +15 -0
  129. package/dist/templates/real-estate/config/lease_tenants/data.ts +16 -0
  130. package/dist/templates/real-estate/config/lease_tenants/ui.ts +8 -0
  131. package/dist/templates/real-estate/config/leases/data.ts +29 -0
  132. package/dist/templates/real-estate/config/leases/ui.ts +33 -0
  133. package/dist/templates/real-estate/config/organizations/data.ts +17 -0
  134. package/dist/templates/real-estate/config/organizations/ui.ts +7 -0
  135. package/dist/templates/real-estate/config/overview/page.tsx +57 -0
  136. package/dist/templates/real-estate/config/owners/data.ts +22 -0
  137. package/dist/templates/real-estate/config/owners/ui.ts +17 -0
  138. package/dist/templates/real-estate/config/pages.test.ts +49 -0
  139. package/dist/templates/real-estate/config/payments/data.ts +20 -0
  140. package/dist/templates/real-estate/config/payments/ui.ts +14 -0
  141. package/dist/templates/real-estate/config/properties/data.ts +26 -0
  142. package/dist/templates/real-estate/config/properties/ui.ts +21 -0
  143. package/dist/templates/real-estate/config/rent_charges/data.ts +22 -0
  144. package/dist/templates/real-estate/config/rent_charges/ui.ts +27 -0
  145. package/dist/templates/real-estate/config/roles.test.ts +49 -0
  146. package/dist/templates/real-estate/config/roles.ts +45 -0
  147. package/dist/templates/real-estate/config/shell.test.ts +18 -0
  148. package/dist/templates/real-estate/config/tenants/data.ts +23 -0
  149. package/dist/templates/real-estate/config/tenants/ui.ts +27 -0
  150. package/dist/templates/real-estate/config/tickets/data.ts +33 -0
  151. package/dist/templates/real-estate/config/tickets/ui.ts +58 -0
  152. package/dist/templates/real-estate/config/unit_amenities/data.ts +15 -0
  153. package/dist/templates/real-estate/config/unit_amenities/ui.ts +8 -0
  154. package/dist/templates/real-estate/config/units/data.ts +24 -0
  155. package/dist/templates/real-estate/config/units/ui.ts +41 -0
  156. package/dist/templates/real-estate/config/user-menu.ts +9 -0
  157. package/dist/templates/real-estate/config/users/data.ts +19 -0
  158. package/dist/templates/real-estate/config/users/ui.ts +11 -0
  159. package/dist/templates/real-estate/config/valuations/data.ts +19 -0
  160. package/dist/templates/real-estate/config/valuations/ui.ts +13 -0
  161. package/dist/templates/real-estate/config/vendors/data.ts +21 -0
  162. package/dist/templates/real-estate/config/vendors/ui.ts +18 -0
  163. package/dist/templates/real-estate/config/work_orders/data.ts +22 -0
  164. package/dist/templates/real-estate/config/work_orders/ui.ts +18 -0
  165. package/dist/templates/real-estate/db/connection.ts +7 -0
  166. package/dist/templates/real-estate/db/drop.ts +9 -0
  167. package/dist/templates/real-estate/db/kysely.ts +13 -0
  168. package/dist/templates/real-estate/db/migrate.ts +30 -0
  169. package/dist/templates/real-estate/db/migrations/001_core.sql +33 -0
  170. package/dist/templates/real-estate/db/migrations/002_portfolio.sql +97 -0
  171. package/dist/templates/real-estate/db/migrations/003_leasing.sql +78 -0
  172. package/dist/templates/real-estate/db/migrations/004_billing.sql +88 -0
  173. package/dist/templates/real-estate/db/migrations/005_maintenance.sql +105 -0
  174. package/dist/templates/real-estate/db/migrations/006_outstanding.sql +14 -0
  175. package/dist/templates/real-estate/db/migrations/007_auth.sql +36 -0
  176. package/dist/templates/real-estate/db/migrations/008_auth_snake_case.sql +109 -0
  177. package/dist/templates/real-estate/db/migrations/009_auth.sql +6 -0
  178. package/dist/templates/real-estate/db/migrations/010_auth.sql +4 -0
  179. package/dist/templates/real-estate/db/migrations/011_filter_indexes.sql +8 -0
  180. package/dist/templates/real-estate/db/migrations/012_sort_indexes.sql +2 -0
  181. package/dist/templates/real-estate/db/migrations/013_auth_users.sql +17 -0
  182. package/dist/templates/real-estate/node-types.d.ts +1 -0
  183. package/dist/templates/real-estate/package.json +38 -0
  184. package/dist/templates/real-estate/protobase.config.ts +5 -0
  185. package/dist/templates/real-estate/protobase.ui.tsx +16 -0
  186. package/dist/templates/real-estate/seed/base-organization.test.ts +17 -0
  187. package/dist/templates/real-estate/seed/base-organization.ts +10 -0
  188. package/dist/templates/real-estate/seed/base.ts +18 -0
  189. package/dist/templates/real-estate/seed/builders/allocation.ts +47 -0
  190. package/dist/templates/real-estate/seed/builders/ledger.ts +52 -0
  191. package/dist/templates/real-estate/seed/builders/property.ts +98 -0
  192. package/dist/templates/real-estate/seed/builders/tenancy.ts +99 -0
  193. package/dist/templates/real-estate/seed/builders/ticket.ts +115 -0
  194. package/dist/templates/real-estate/seed/calendar.ts +77 -0
  195. package/dist/templates/real-estate/seed/cli.ts +50 -0
  196. package/dist/templates/real-estate/seed/copy.ts +22 -0
  197. package/dist/templates/real-estate/seed/data/names.ts +31 -0
  198. package/dist/templates/real-estate/seed/data/reference.ts +63 -0
  199. package/dist/templates/real-estate/seed/format.ts +24 -0
  200. package/dist/templates/real-estate/seed/iban.ts +15 -0
  201. package/dist/templates/real-estate/seed/maintenance.ts +63 -0
  202. package/dist/templates/real-estate/seed/rng.ts +36 -0
  203. package/dist/templates/real-estate/seed/scales.ts +9 -0
  204. package/dist/templates/real-estate/seed/steps/billing.ts +44 -0
  205. package/dist/templates/real-estate/seed/steps/leasing.ts +60 -0
  206. package/dist/templates/real-estate/seed/steps/maintenance.ts +77 -0
  207. package/dist/templates/real-estate/seed/steps/portfolio.ts +97 -0
  208. package/dist/templates/real-estate/seed/steps/reference.ts +21 -0
  209. package/dist/templates/real-estate/seed/world.ts +92 -0
  210. package/dist/templates/real-estate/tsconfig.json +16 -0
  211. package/dist/templates/real-estate/vitest.config.ts +4 -0
  212. package/dist/templates/scratch/_.gitignore +3 -0
  213. package/dist/templates/scratch/_bunfig.toml +4 -0
  214. package/dist/templates/scratch/db/migrate.ts +30 -0
  215. package/dist/templates/scratch/db/migrations/001_auth.sql +36 -0
  216. package/dist/templates/scratch/db/migrations/002_auth_snake_case.sql +109 -0
  217. package/dist/templates/scratch/db/migrations/003_auth.sql +6 -0
  218. package/dist/templates/scratch/db/migrations/004_auth.sql +4 -0
  219. package/dist/templates/scratch/node-types.d.ts +1 -0
  220. package/dist/{template → templates/scratch}/package.json +5 -5
  221. package/dist/templates/scratch/tsconfig.json +16 -0
  222. package/package.json +2 -2
  223. /package/dist/{template → templates/erp}/_.gitignore +0 -0
  224. /package/dist/{template → templates/erp}/_bunfig.toml +0 -0
  225. /package/dist/{template → templates/erp}/db/migrate.ts +0 -0
  226. /package/dist/{template/db/migrations/001_auth.sql → templates/erp/db/migrations/009_auth.sql} +0 -0
  227. /package/dist/{template/db/migrations/002_auth_snake_case.sql → templates/erp/db/migrations/010_auth_snake_case.sql} +0 -0
  228. /package/dist/{template/db/migrations/003_auth.sql → templates/erp/db/migrations/014_auth.sql} +0 -0
  229. /package/dist/{template/db/migrations/004_auth.sql → templates/erp/db/migrations/015_auth.sql} +0 -0
  230. /package/dist/{template → templates/erp}/node-types.d.ts +0 -0
  231. /package/dist/{template → templates/erp}/tsconfig.json +0 -0
  232. /package/dist/{template → templates/scratch}/.env.example +0 -0
  233. /package/dist/{template → templates/scratch}/AGENTS.md +0 -0
  234. /package/dist/{template → templates/scratch}/auth/auth.ts +0 -0
  235. /package/dist/{template → templates/scratch}/config/index.ts +0 -0
  236. /package/dist/{template → templates/scratch}/config/user-menu.ts +0 -0
  237. /package/dist/{template → templates/scratch}/db/connection.ts +0 -0
  238. /package/dist/{template → templates/scratch}/db/migrations/.gitkeep +0 -0
  239. /package/dist/{template → templates/scratch}/protobase.config.ts +0 -0
  240. /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
+ }