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,99 @@
|
|
|
1
|
+
-- The jobs schema this version of Protobase needs, written by `protobase migration`.
|
|
2
|
+
-- Applied with the project's other migrations; edit or remove statements the project handles differently.
|
|
3
|
+
|
|
4
|
+
create or replace function jobs.enqueue(
|
|
5
|
+
job_name text,
|
|
6
|
+
payload jsonb default null,
|
|
7
|
+
run_at timestamptz default null,
|
|
8
|
+
key text default null,
|
|
9
|
+
group_key text default null,
|
|
10
|
+
priority integer default 0
|
|
11
|
+
) returns uuid
|
|
12
|
+
language plpgsql as $$
|
|
13
|
+
declare
|
|
14
|
+
queue jobs.queue;
|
|
15
|
+
start timestamptz := coalesce(run_at, jobs.job_now());
|
|
16
|
+
job_id uuid;
|
|
17
|
+
begin
|
|
18
|
+
if key is not null and group_key is not null then
|
|
19
|
+
raise exception 'Job "%": an ordered job takes a group_key and no key', job_name;
|
|
20
|
+
end if;
|
|
21
|
+
perform jobs.create_queue(job_name, jsonb_build_object('policy', case when group_key is null then 'standard' else 'key_strict_fifo' end));
|
|
22
|
+
select * into queue from jobs.queue where name = job_name;
|
|
23
|
+
if (queue.policy = 'key_strict_fifo') <> (group_key is not null) then
|
|
24
|
+
raise exception 'Job "%" %', job_name, case when group_key is null then 'is ordered: pass its group_key' else 'is not ordered: it takes no group_key' end;
|
|
25
|
+
end if;
|
|
26
|
+
if key is not null then
|
|
27
|
+
select id into job_id from jobs.job
|
|
28
|
+
where name = job_name and singleton_key = key and state < 'active'
|
|
29
|
+
order by created_on desc limit 1;
|
|
30
|
+
if found then
|
|
31
|
+
return job_id;
|
|
32
|
+
end if;
|
|
33
|
+
end if;
|
|
34
|
+
execute format(
|
|
35
|
+
'insert into jobs.%I (name, data, priority, start_after, singleton_key, retry_limit, retry_delay, retry_backoff,
|
|
36
|
+
retry_delay_max, expire_seconds, deletion_seconds, keep_until, policy, dead_letter, heartbeat_seconds, upsert_by_key, id)
|
|
37
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $4 + $12 * interval ''1s'', $13, $14, $15, $16, $17)
|
|
38
|
+
on conflict do nothing
|
|
39
|
+
returning id',
|
|
40
|
+
queue.table_name)
|
|
41
|
+
using job_name, payload, priority, start, coalesce(key, group_key), queue.retry_limit, queue.retry_delay,
|
|
42
|
+
queue.retry_backoff, queue.retry_delay_max, queue.expire_seconds, queue.deletion_seconds, queue.retention_seconds,
|
|
43
|
+
queue.policy, queue.dead_letter, queue.heartbeat_seconds, key is not null,
|
|
44
|
+
case when group_key is null then gen_random_uuid() else uuidv7() end
|
|
45
|
+
into job_id;
|
|
46
|
+
-- A concurrent enqueue of the same key inserted first: its job is the one waiting.
|
|
47
|
+
if job_id is null and key is not null then
|
|
48
|
+
select id into job_id from jobs.job
|
|
49
|
+
where name = job_name and singleton_key = key and state < 'active'
|
|
50
|
+
order by created_on desc limit 1;
|
|
51
|
+
end if;
|
|
52
|
+
return job_id;
|
|
53
|
+
end
|
|
54
|
+
$$;
|
|
55
|
+
comment on function jobs.enqueue(text, jsonb, timestamptz, text, text, integer) is 'Protobase jobs 3';
|
|
56
|
+
|
|
57
|
+
create or replace function jobs.next_due() returns timestamptz
|
|
58
|
+
language sql stable as $$
|
|
59
|
+
select min(due) from (
|
|
60
|
+
select j.start_after as due from jobs.job j
|
|
61
|
+
where j.state < 'active' and not j.blocked
|
|
62
|
+
and not (j.policy = 'key_strict_fifo' and exists (
|
|
63
|
+
select 1 from jobs.job h where h.name = j.name and h.singleton_key = j.singleton_key and h.state = 'failed'))
|
|
64
|
+
union all
|
|
65
|
+
select started_on + expire_seconds * interval '1s' from jobs.job where state = 'active'
|
|
66
|
+
) jobs
|
|
67
|
+
$$;
|
|
68
|
+
comment on function jobs.next_due() is 'Protobase jobs 3';
|
|
69
|
+
|
|
70
|
+
create index if not exists job_failed_i on jobs.job (completed_on) where state = 'failed';
|
|
71
|
+
drop view if exists jobs.failed;
|
|
72
|
+
create view jobs.failed as
|
|
73
|
+
select id, name, data as payload, retry_count + 1 as attempts,
|
|
74
|
+
coalesce(output ->> 'message', output -> 'value' ->> 'message', output ->> 'value') as error,
|
|
75
|
+
output, completed_on as failed_at, created_on as created_at, singleton_key as key, false as retry
|
|
76
|
+
from jobs.job where state = 'failed';
|
|
77
|
+
create or replace function jobs.failed_retry() returns trigger
|
|
78
|
+
language plpgsql as $$
|
|
79
|
+
begin
|
|
80
|
+
if new.retry then
|
|
81
|
+
update jobs.job
|
|
82
|
+
set state = 'retry', retry_limit = retry_limit + 1, completed_on = null,
|
|
83
|
+
start_after = greatest(start_after, jobs.job_now()), keep_until = greatest(keep_until, jobs.job_now() + interval '14 days')
|
|
84
|
+
where name = old.name and id = old.id and state = 'failed';
|
|
85
|
+
end if;
|
|
86
|
+
return new;
|
|
87
|
+
end
|
|
88
|
+
$$;
|
|
89
|
+
comment on function jobs.failed_retry() is 'Protobase jobs 3';
|
|
90
|
+
create or replace function jobs.failed_delete() returns trigger
|
|
91
|
+
language plpgsql as $$
|
|
92
|
+
begin
|
|
93
|
+
delete from jobs.job where name = old.name and id = old.id and state = 'failed';
|
|
94
|
+
return old;
|
|
95
|
+
end
|
|
96
|
+
$$;
|
|
97
|
+
comment on function jobs.failed_delete() is 'Protobase jobs 3';
|
|
98
|
+
create trigger failed_retry instead of update on jobs.failed for each row execute function jobs.failed_retry();
|
|
99
|
+
create trigger failed_delete instead of delete on jobs.failed for each row execute function jobs.failed_delete();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- A filter, sort or search reads only columns an index serves: search reads these through pg_trgm.
|
|
2
|
+
create index companies_city_trgm_idx on crm.companies using gin (city gin_trgm_ops);
|
|
3
|
+
create index orders_number_trgm_idx on sales.orders using gin (number gin_trgm_ops);
|
|
4
|
+
create index invoices_number_trgm_idx on sales.invoices using gin (number gin_trgm_ops);
|
|
5
|
+
create index locations_path_btree_idx on inventory.locations (organization_id, path);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
-- Each sortable column is served alone too, as a list sorted by it is; the tenant is pinned, so it leads.
|
|
2
|
+
create index categories_position_idx on catalog.categories (organization_id, position);
|
|
3
|
+
create index invoice_lines_position_idx on sales.invoice_lines (organization_id, position);
|
|
4
|
+
create index order_lines_line_no_idx on sales.order_lines (organization_id, line_no);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Everyone who signs in is one of the ERP's users: their Better Auth id is the next core.users id (`generateUserId` in
|
|
2
|
+
-- auth/auth.ts), which records name as their owner, and their row there comes with their first membership, in that
|
|
3
|
+
-- organization, whichever way they join. The roles they hold in the app live in the auth schema, so the ERP's own
|
|
4
|
+
-- role column stays empty for them.
|
|
5
|
+
alter table core.users alter column role drop not null;
|
|
6
|
+
|
|
7
|
+
create function core.add_member_user() returns trigger language plpgsql as $$
|
|
8
|
+
begin
|
|
9
|
+
insert into core.users (id, organization_id, email, name)
|
|
10
|
+
select u.id::integer, new.organization_id::integer, u.email, u.name
|
|
11
|
+
from auth."user" u
|
|
12
|
+
where u.id = new.user_id and u.id ~ '^[0-9]+$'
|
|
13
|
+
on conflict do nothing;
|
|
14
|
+
return new;
|
|
15
|
+
end
|
|
16
|
+
$$;
|
|
17
|
+
|
|
18
|
+
create trigger member_user after insert on auth.member for each row execute function core.add_member_user();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineFunction } from '@protobase/server'
|
|
2
|
+
|
|
3
|
+
// GET /api/functions/unpaid-orders: delivered orders that are not paid yet, oldest first, as the caller may see them
|
|
4
|
+
// (a sales rep, only their own).
|
|
5
|
+
export default defineFunction(async (_request, { records }) => {
|
|
6
|
+
const page = await records.list('orders', {
|
|
7
|
+
filter: 'status = "delivered" AND paid = false',
|
|
8
|
+
order_by: 'created_at',
|
|
9
|
+
page_size: 20,
|
|
10
|
+
fields: ['id', 'number', 'company_id', 'total', 'created_at'],
|
|
11
|
+
})
|
|
12
|
+
return Response.json({ orders: page.items.map(({ etag, permissions, ...order }) => order) })
|
|
13
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "erp",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"db:migrate": "bun db/migrate.ts",
|
|
8
|
+
"db:seed:base": "bun seed/base.ts",
|
|
9
|
+
"db:seed": "bun seed/cli.ts",
|
|
10
|
+
"db:reset": "bun db/drop.ts && bun db/migrate.ts && bun seed/cli.ts --scale small",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"protobase": "protobase",
|
|
14
|
+
"build": "protobase build",
|
|
15
|
+
"serve": "protobase build && protobase serve dist",
|
|
16
|
+
"dev": "protobase dev"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@protobase/cli": ">=0.24.0 <1",
|
|
20
|
+
"@protobase/layout": ">=0.24.0 <1",
|
|
21
|
+
"@protobase/schema": ">=0.24.0 <1",
|
|
22
|
+
"@protobase/server": ">=0.24.0 <1",
|
|
23
|
+
"@protobase/ui": ">=0.24.0 <1",
|
|
24
|
+
"better-auth": "^1.7.7",
|
|
25
|
+
"jose": "^6.2.12",
|
|
26
|
+
"kysely": "^0.29.6",
|
|
27
|
+
"pg": "^8.23.1",
|
|
28
|
+
"postgres": "^3.4.9",
|
|
29
|
+
"react": "^19.3.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.20.5",
|
|
33
|
+
"@types/pg": "^8.23.1",
|
|
34
|
+
"@types/react": "^19.3.0",
|
|
35
|
+
"typescript": "^6.0.3",
|
|
36
|
+
"vitest": "^5.0.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from '@protobase/server'
|
|
2
|
+
import * as config from './config'
|
|
3
|
+
import * as jobs from './config/jobs'
|
|
4
|
+
import { roles } from './config/roles'
|
|
5
|
+
import { auth, authenticate } from './auth/auth'
|
|
6
|
+
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
config,
|
|
9
|
+
auth,
|
|
10
|
+
authenticate,
|
|
11
|
+
jobs,
|
|
12
|
+
options: {
|
|
13
|
+
roles,
|
|
14
|
+
writeHooks: [
|
|
15
|
+
// In the write's own transaction: the invoice and the check of its payment commit together or not at all.
|
|
16
|
+
async (event, trx) => {
|
|
17
|
+
const check = jobs.overdueCheck(event)
|
|
18
|
+
if (check) await jobs.markInvoiceOverdue.enqueue(trx, { invoiceId: check.invoiceId }, { runAt: check.runAt, key: check.key, mode: 'replace' })
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineUi, type ActionContext } from '@protobase/ui'
|
|
2
|
+
|
|
3
|
+
// The custom components, each in a file of its own, exported by the name their layouts declare.
|
|
4
|
+
export { OrdersPerDay } from './components/orders-per-day'
|
|
5
|
+
|
|
6
|
+
/** "Send reminder" on an invoice: opens a mail to the customer, and marks a draft invoice as sent. */
|
|
7
|
+
const send = async ({ record, recordKey, etag, client }: ActionContext) => {
|
|
8
|
+
const company = await client.get('companies', String(record!.company_id))
|
|
9
|
+
const subject = encodeURIComponent(`Invoice ${String(record!.number)}`)
|
|
10
|
+
window.location.assign(`mailto:${String(company.record.email ?? '')}?subject=${subject}`)
|
|
11
|
+
if (record!.status === 'draft') await client.update('invoices', recordKey!, { status: 'sent' }, etag ?? '*')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default defineUi({ actions: { send } })
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TransactionSql } from 'postgres'
|
|
2
|
+
import type { Db } from '../db/connection'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Better Auth's rows for the ERP's own organizations, under the same ids, so people work in them once they are members.
|
|
6
|
+
* The first user of a new app becomes the owner of those that have no members yet (`protobase users create`).
|
|
7
|
+
*/
|
|
8
|
+
export const seedAuthOrganizations = async (sql: Db | TransactionSql, organizations: { id: number; name: string; slug: string }[]) => {
|
|
9
|
+
const rows = organizations.map(({ id, name, slug }) => ({ id: String(id), name, slug, created_at: new Date() }))
|
|
10
|
+
await sql`insert into auth.organization ${sql(rows)} on conflict do nothing`
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { baseOrganization } from './base-organization'
|
|
3
|
+
|
|
4
|
+
describe('baseOrganization', () => {
|
|
5
|
+
it('is organization 1, named after the app, in the Netherlands and in euros', () => {
|
|
6
|
+
expect(baseOrganization('Acme Supply')).toEqual({ id: 1, name: 'Acme Supply', slug: 'acme-supply', country_code: 'NL', currency_code: 'EUR' })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('makes a slug of any name', () => {
|
|
10
|
+
expect(baseOrganization('Müller & Söhne B.V.').slug).toBe('muller-sohne-b-v')
|
|
11
|
+
expect(baseOrganization('!!!').slug).toBe('organization')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('refuses an empty name', () => {
|
|
15
|
+
expect(() => baseOrganization(' ')).toThrow('name')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Lowercase ASCII words joined by dashes; diacritics are dropped, so Müller becomes muller.
|
|
2
|
+
const slugOf = (name: string) =>
|
|
3
|
+
name.normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'organization'
|
|
4
|
+
|
|
5
|
+
/** Organization 1, the one every user works in (auth/auth.ts), named after the app. */
|
|
6
|
+
export const baseOrganization = (name: string) => {
|
|
7
|
+
const trimmed = name.trim()
|
|
8
|
+
if (!trimmed) throw new Error('The organization needs a name: pass --name "<the app name>"')
|
|
9
|
+
return { id: 1, name: trimmed, slug: slugOf(trimmed), country_code: 'NL', currency_code: 'EUR' }
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { connect } from '../db/connection'
|
|
2
|
+
import { seedAuthOrganizations } from './auth-organizations'
|
|
3
|
+
import { baseOrganization } from './base-organization'
|
|
4
|
+
import { countries, currencies } from './data/reference'
|
|
5
|
+
|
|
6
|
+
// `db:seed:base [--name <app name>]`: what a new app starts with, the countries, the currencies and organization 1,
|
|
7
|
+
// in the ERP's tables and in the auth schema's.
|
|
8
|
+
// Rows already there stay as they are, so it can run again and after the sample seed (`db:seed`).
|
|
9
|
+
const flag = process.argv.indexOf('--name')
|
|
10
|
+
const organization = baseOrganization(flag === -1 ? 'ERP' : (process.argv[flag + 1] ?? ''))
|
|
11
|
+
|
|
12
|
+
const sql = connect({ max: 1 })
|
|
13
|
+
await sql.begin(async (tx) => {
|
|
14
|
+
await tx`insert into core.countries ${tx(countries.map(([code, name, euMember]) => ({ code, name, eu_member: euMember })))} on conflict do nothing`
|
|
15
|
+
await tx`insert into core.currencies ${tx(currencies.map(([code, name, symbol, decimals]) => ({ code, name, symbol, decimals })))} on conflict do nothing`
|
|
16
|
+
await tx`insert into core.organizations ${tx(organization)} on conflict do nothing`
|
|
17
|
+
// An explicit id leaves the identity sequence behind, so the next organization would take id 1 again.
|
|
18
|
+
await tx`select setval(pg_get_serial_sequence('core.organizations', 'id'), (select max(id) from core.organizations))`
|
|
19
|
+
await seedAuthOrganizations(tx, [organization])
|
|
20
|
+
})
|
|
21
|
+
await sql.end()
|
|
22
|
+
console.log(`seeded the base data of ${organization.name}`)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { tree } from '../data/catalogTree'
|
|
2
|
+
import { slugify } from '../format'
|
|
3
|
+
|
|
4
|
+
type Category = { name: string; slug: string; parent: number | null; position: number }
|
|
5
|
+
type Base = { name: string; price: number; category: number; code: string }
|
|
6
|
+
|
|
7
|
+
export const categories: Category[] = []
|
|
8
|
+
export const bases: Base[] = []
|
|
9
|
+
|
|
10
|
+
tree.forEach((root, rootPosition) => {
|
|
11
|
+
const rootIndex = categories.length
|
|
12
|
+
categories.push({ name: root.name, slug: slugify(root.name), parent: null, position: rootPosition })
|
|
13
|
+
root.children.forEach((child, childPosition) => {
|
|
14
|
+
const childIndex = categories.length
|
|
15
|
+
categories.push({ name: child.name, slug: slugify(child.name), parent: rootIndex, position: childPosition })
|
|
16
|
+
child.items.forEach(([name, price]) => bases.push({ name, price, category: childIndex, code: root.code }))
|
|
17
|
+
})
|
|
18
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { DAY, windowEnd, windowStart } from '../calendar'
|
|
2
|
+
import { jobTitles, locales, qualifiers } from '../data/names'
|
|
3
|
+
import { slugify } from '../format'
|
|
4
|
+
import { int, pick, rngAt, weighted } from '../rng'
|
|
5
|
+
import type { Org } from '../world'
|
|
6
|
+
|
|
7
|
+
const legacyShare = 0.6
|
|
8
|
+
|
|
9
|
+
export const legacyCompanies = (org: Org) => Math.floor(org.companyCount * legacyShare)
|
|
10
|
+
|
|
11
|
+
// Companies are ordered by creation time: the first 60% predate the window.
|
|
12
|
+
export const companyAt = (org: Org, ordinal: number) => {
|
|
13
|
+
const rand = rngAt(org.seed + 1, ordinal)
|
|
14
|
+
const country = weighted(rand, org.locales)
|
|
15
|
+
const locale = locales[country]
|
|
16
|
+
const city = pick(rand, locale.cities)
|
|
17
|
+
const surname = pick(rand, locale.surnames)
|
|
18
|
+
const qualifier = rand() < 0.12 ? ` ${pick(rand, qualifiers)}` : ''
|
|
19
|
+
const base = weighted(rand, [
|
|
20
|
+
[`${pick(rand, locale.stems)} ${pick(rand, locale.nouns)}`, 40],
|
|
21
|
+
[`${surname} ${pick(rand, locale.nouns)}`, 30],
|
|
22
|
+
[`${surname} ${locale.andWord}`, 15],
|
|
23
|
+
[`${city} ${pick(rand, locale.nouns)}`, 15],
|
|
24
|
+
])
|
|
25
|
+
const domain = `${slugify(base)}.${locale.tld}`
|
|
26
|
+
const legacy = legacyCompanies(org)
|
|
27
|
+
const createdAt =
|
|
28
|
+
ordinal < legacy
|
|
29
|
+
? Math.floor(windowStart - (1 - ordinal / legacy) * 3 * 365 * DAY)
|
|
30
|
+
: Math.floor(windowStart + ((ordinal - legacy) / (org.companyCount - legacy)) * (windowEnd - windowStart))
|
|
31
|
+
return {
|
|
32
|
+
id: org.companyFirstId + ordinal,
|
|
33
|
+
name: `${base}${qualifier} ${locale.suffix}`,
|
|
34
|
+
country,
|
|
35
|
+
city,
|
|
36
|
+
domain,
|
|
37
|
+
vatNumber: locale.vat(int(rand, 100_000_000, 999_999_999)),
|
|
38
|
+
email: `info@${domain}`,
|
|
39
|
+
phone: `${locale.dial} ${int(rand, 10, 99)} ${int(rand, 1_000_000, 9_999_999)}`,
|
|
40
|
+
status: weighted(rand, [['active', 85], ['dormant', 10], ['lead', 5]] as const),
|
|
41
|
+
createdAt,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const peopleOf = (org: Org, ordinal: number) => org.personStarts[ordinal + 1]! - org.personStarts[ordinal]!
|
|
46
|
+
|
|
47
|
+
export const personAt = (org: Org, company: ReturnType<typeof companyAt>, ordinal: number, slot: number) => {
|
|
48
|
+
const rand = rngAt(org.seed + 4, org.personStarts[ordinal]! + slot)
|
|
49
|
+
const locale = locales[company.country]
|
|
50
|
+
const first = pick(rand, locale.firstNames)
|
|
51
|
+
const last = pick(rand, locale.surnames)
|
|
52
|
+
return {
|
|
53
|
+
id: org.personFirstId + org.personStarts[ordinal]! + slot,
|
|
54
|
+
first,
|
|
55
|
+
last,
|
|
56
|
+
email: `${slugify(first)}.${slugify(last)}@${company.domain}`,
|
|
57
|
+
phone: rand() < 0.6 ? `${locale.dial} 6 ${int(rand, 10_000_000, 99_999_999)}` : null,
|
|
58
|
+
title: pick(rand, jobTitles),
|
|
59
|
+
createdAt: company.createdAt + int(rand, 0, 20) * 3_600_000,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DAY, windowEnd, within } from '../calendar'
|
|
2
|
+
import { pick, rngAt } from '../rng'
|
|
3
|
+
import type { Org } from '../world'
|
|
4
|
+
import type { SeededOrder } from './order'
|
|
5
|
+
|
|
6
|
+
const vatRates = { NL: 21, DE: 19, GB: 20 } as const
|
|
7
|
+
|
|
8
|
+
// Shipped and delivered orders are invoiced; the invoice id follows the order ordinal.
|
|
9
|
+
export const invoiceFor = (org: Org, ordinal: number, order: SeededOrder) => {
|
|
10
|
+
if (order.status !== 'shipped' && order.status !== 'delivered') return null
|
|
11
|
+
const rand = rngAt(org.seed + 9, ordinal)
|
|
12
|
+
const issuedAt = within(order.createdAt, (1 + Math.floor(rand() * 3)) * DAY, rand)
|
|
13
|
+
const dueAt = issuedAt + pick(rand, [14, 30, 30, 30, 60]) * DAY
|
|
14
|
+
const vatRate = vatRates[order.company.country]
|
|
15
|
+
const subtotalCents = order.totalCents
|
|
16
|
+
const vatCents = Math.round((subtotalCents * vatRate) / 100)
|
|
17
|
+
const recent = windowEnd - issuedAt < 2 * DAY
|
|
18
|
+
return {
|
|
19
|
+
id: org.orderFirstId + ordinal + 1,
|
|
20
|
+
number: `INV-${new Date(issuedAt).getUTCFullYear()}-${String(ordinal + 1).padStart(6, '0')}`,
|
|
21
|
+
status: order.paid ? 'paid' : dueAt < windowEnd ? 'overdue' : recent && rand() < 0.5 ? 'draft' : 'sent',
|
|
22
|
+
issuedAt,
|
|
23
|
+
dueAt,
|
|
24
|
+
vatRate,
|
|
25
|
+
subtotalCents,
|
|
26
|
+
vatCents,
|
|
27
|
+
totalCents: subtotalCents + vatCents,
|
|
28
|
+
createdAt: issuedAt,
|
|
29
|
+
updatedAt: within(issuedAt, Math.floor(rand() * 20 * DAY), rand),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DAY, windowEnd, windowStart, within, yearOf } from '../calendar'
|
|
2
|
+
import { int, pick, rngAt, weighted } from '../rng'
|
|
3
|
+
import type { Org } from '../world'
|
|
4
|
+
import { companyAt, legacyCompanies, peopleOf } from './company'
|
|
5
|
+
import { productName, productPriceCents } from './product'
|
|
6
|
+
|
|
7
|
+
const notes = ['Deliver before 10:00', 'Call on arrival', 'Partial delivery accepted', 'Customer pickup', 'Include delivery note in box', 'Urgent, production stopped']
|
|
8
|
+
|
|
9
|
+
const statusFor = (ageDays: number, rand: () => number) => {
|
|
10
|
+
if (ageDays < 2) return pick(rand, ['draft', 'confirmed', 'confirmed', 'picking'] as const)
|
|
11
|
+
if (ageDays < 10) return weighted(rand, [['confirmed', 20], ['picking', 25], ['shipped', 35], ['delivered', 20]] as const)
|
|
12
|
+
return weighted(rand, [['delivered', 88], ['shipped', 3], ['cancelled', 7], ['picking', 1], ['confirmed', 1]] as const)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const hex = (value: number, width: number) => value.toString(16).padStart(width, '0')
|
|
16
|
+
|
|
17
|
+
const uuidv7 = (ms: number, rand: () => number) => {
|
|
18
|
+
const time = hex(ms, 12)
|
|
19
|
+
const tail = hex(int(rand, 0, 0xffffffff), 8) + hex(int(rand, 0, 0xffff), 4)
|
|
20
|
+
return `${time.slice(0, 8)}-${time.slice(8)}-7${hex(int(rand, 0, 0xfff), 3)}-${hex(0x8000 + int(rand, 0, 0x3fff), 4)}-${tail}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A whole order, rebuilt from its ordinal alone. The order, line, invoice and
|
|
24
|
+
// invoice line seeds each call this, so no pass has to keep the others in memory.
|
|
25
|
+
export const orderAt = (org: Org, ordinal: number) => {
|
|
26
|
+
const rand = rngAt(org.seed + 7, ordinal)
|
|
27
|
+
const createdAt = org.orderTimes[ordinal]!
|
|
28
|
+
const progress = (createdAt - windowStart) / (windowEnd - windowStart)
|
|
29
|
+
const legacy = legacyCompanies(org)
|
|
30
|
+
const available = Math.max(1, legacy + Math.floor((org.companyCount - legacy) * progress))
|
|
31
|
+
const companyOrdinal = Math.floor(rand() ** 2 * available)
|
|
32
|
+
const company = companyAt(org, companyOrdinal)
|
|
33
|
+
const people = peopleOf(org, companyOrdinal)
|
|
34
|
+
const personId = rand() < 0.85 ? org.personFirstId + org.personStarts[companyOrdinal]! + int(rand, 0, people - 1) : null
|
|
35
|
+
const status = statusFor((windowEnd - createdAt) / DAY, rand)
|
|
36
|
+
const discount = weighted(rand, [[0, 55], [5, 20], [10, 15], [15, 10]] as const)
|
|
37
|
+
const lineCount = weighted(rand, [[1, 10], [2, 15], [3, 22], [4, 20], [5, 14], [6, 10], [7, 5], [8, 4]] as const)
|
|
38
|
+
const lines = Array.from({ length: lineCount }, (_, index) => {
|
|
39
|
+
const productOrdinal = Math.floor(rand() ** 1.6 * org.productCount)
|
|
40
|
+
return {
|
|
41
|
+
lineNo: index + 1,
|
|
42
|
+
productId: org.productFirstId + productOrdinal,
|
|
43
|
+
description: productName(productOrdinal),
|
|
44
|
+
quantity: weighted(rand, [[int(rand, 1, 5), 50], [int(rand, 6, 20), 35], [int(rand, 21, 60), 15]] as const),
|
|
45
|
+
unitPriceCents: Math.round((productPriceCents(productOrdinal) * (100 - discount)) / 100),
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
const paid = status === 'delivered' ? rand() < 0.88 : status === 'shipped' ? rand() < 0.3 : false
|
|
49
|
+
return {
|
|
50
|
+
id: uuidv7(createdAt, rand),
|
|
51
|
+
number: `SO-${yearOf(createdAt)}-${String(ordinal + 1).padStart(6, '0')}`,
|
|
52
|
+
company,
|
|
53
|
+
personId,
|
|
54
|
+
ownerId: pick(rand, org.salesUsers).id,
|
|
55
|
+
status,
|
|
56
|
+
discount,
|
|
57
|
+
totalCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0),
|
|
58
|
+
paid,
|
|
59
|
+
notes: rand() < 0.08 ? pick(rand, notes) : null,
|
|
60
|
+
createdAt,
|
|
61
|
+
updatedAt: within(createdAt, Math.floor(rand() * (status === 'draft' ? 0.1 : 8) * DAY), rand),
|
|
62
|
+
lines,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type SeededOrder = ReturnType<typeof orderAt>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { DAY, windowStart } from '../calendar'
|
|
2
|
+
import { int, pick, rngAt } from '../rng'
|
|
3
|
+
import type { Org } from '../world'
|
|
4
|
+
import { bases } from './categories'
|
|
5
|
+
|
|
6
|
+
const qualifiers = ['', 'Pro', 'Eco', 'Heavy duty', 'Food grade', 'Marine', 'Low profile', 'Extended', 'Compact', 'Industrial', 'Standard', 'Premium', 'Trade pack', 'Bulk', 'Value', 'Light', 'Reinforced', 'EU spec', 'UK spec', 'Retrofit']
|
|
7
|
+
const materials = ['steel', 'stainless steel', 'zinc plated steel', 'aluminium', 'nylon', 'rubber', 'polyethylene', 'cardboard', 'brass']
|
|
8
|
+
const origins = ['NL', 'DE', 'PL', 'IT', 'CN', 'TR']
|
|
9
|
+
|
|
10
|
+
const variantOf = (ordinal: number) => Math.floor(ordinal / bases.length) % qualifiers.length
|
|
11
|
+
|
|
12
|
+
export const productName = (ordinal: number) => {
|
|
13
|
+
const qualifier = qualifiers[variantOf(ordinal)]!
|
|
14
|
+
const round = Math.floor(ordinal / (bases.length * qualifiers.length))
|
|
15
|
+
const name = qualifier ? `${bases[ordinal % bases.length]!.name} - ${qualifier}` : bases[ordinal % bases.length]!.name
|
|
16
|
+
return round > 0 ? `${name} (series ${round + 1})` : name
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Rounded to 5 cents; later qualifiers cost a little more.
|
|
20
|
+
export const productPriceCents = (ordinal: number) =>
|
|
21
|
+
Math.round((bases[ordinal % bases.length]!.price * (1 + variantOf(ordinal) * 0.045)) / 5) * 5
|
|
22
|
+
|
|
23
|
+
export const productAt = (org: Org, ordinal: number) => {
|
|
24
|
+
const rand = rngAt(org.seed + 2, ordinal)
|
|
25
|
+
const base = bases[ordinal % bases.length]!
|
|
26
|
+
const name = productName(ordinal)
|
|
27
|
+
const createdAt = windowStart - 30 * DAY - Math.floor((1 - ordinal / org.productCount) * 3 * 365 * DAY)
|
|
28
|
+
const deleted = ordinal % 37 === 36
|
|
29
|
+
return {
|
|
30
|
+
id: org.productFirstId + ordinal,
|
|
31
|
+
categoryId: org.categoryFirstId + base.category,
|
|
32
|
+
sku: `${base.code}-${String(ordinal + 1).padStart(5, '0')}`,
|
|
33
|
+
name,
|
|
34
|
+
description: rand() < 0.4 ? null : `${name}. Stocked for industrial maintenance and assembly.`,
|
|
35
|
+
priceCents: productPriceCents(ordinal),
|
|
36
|
+
attributes: {
|
|
37
|
+
material: pick(rand, materials),
|
|
38
|
+
weight_g: int(rand, 5, 8000),
|
|
39
|
+
pack_size: pick(rand, [1, 10, 25, 50, 100, 250]),
|
|
40
|
+
origin: pick(rand, origins),
|
|
41
|
+
hazardous: rand() < 0.04,
|
|
42
|
+
},
|
|
43
|
+
deletedAt: deleted ? createdAt + int(rand, 30, 900) * DAY : null,
|
|
44
|
+
createdAt,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { connect } from '../db/connection'
|
|
2
|
+
import { isScale, scales } from './scales'
|
|
3
|
+
import { seedCatalog } from './steps/catalog'
|
|
4
|
+
import { seedCrm } from './steps/crm'
|
|
5
|
+
import { seedHr } from './steps/hr'
|
|
6
|
+
import { seedLedger } from './steps/ledger'
|
|
7
|
+
import { seedReference } from './steps/reference'
|
|
8
|
+
import { seedSales } from './steps/sales'
|
|
9
|
+
import { seedWarehouses } from './steps/warehouses'
|
|
10
|
+
import { analyzeAll, createIndexes, dropIndexes, resetSequences, setTriggers, truncateAll } from './maintenance'
|
|
11
|
+
import { buildWorld } from './world'
|
|
12
|
+
|
|
13
|
+
const flag = process.argv.indexOf('--scale')
|
|
14
|
+
const scale = flag === -1 ? 'small' : process.argv[flag + 1]
|
|
15
|
+
if (!isScale(scale)) throw new Error(`Unknown scale "${scale}", expected one of: ${Object.keys(scales).join(', ')}`)
|
|
16
|
+
|
|
17
|
+
const started = performance.now()
|
|
18
|
+
const step = async (name: string, run: () => Promise<unknown>) => {
|
|
19
|
+
const before = performance.now()
|
|
20
|
+
await run()
|
|
21
|
+
console.log(`${name} (${((performance.now() - before) / 1000).toFixed(1)}s)`)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const world = buildWorld(scale)
|
|
25
|
+
|
|
26
|
+
// One connection for the load, with the foreign keys checked: an app's own role may not skip them
|
|
27
|
+
// (session_replication_role), only switch its triggers off. The generators are consistent by construction and
|
|
28
|
+
// tests/examples/erp/smoke.test.ts checks the result.
|
|
29
|
+
const loader = connect({ max: 1 })
|
|
30
|
+
|
|
31
|
+
await step('truncate', () => truncateAll(loader))
|
|
32
|
+
const indexes = await dropIndexes(loader)
|
|
33
|
+
await setTriggers(loader, false)
|
|
34
|
+
try {
|
|
35
|
+
await step('reference', () => seedReference(loader, world))
|
|
36
|
+
await step('crm', () => seedCrm(loader, world))
|
|
37
|
+
await step('catalog', () => seedCatalog(loader, world))
|
|
38
|
+
await step('hr', () => seedHr(loader, world))
|
|
39
|
+
await step('warehouses', () => seedWarehouses(loader, world))
|
|
40
|
+
await step('sales', () => seedSales(loader, world))
|
|
41
|
+
await step('stock moves', () => seedLedger(loader, world))
|
|
42
|
+
} finally {
|
|
43
|
+
await setTriggers(loader, true)
|
|
44
|
+
}
|
|
45
|
+
await loader.end()
|
|
46
|
+
|
|
47
|
+
const sql = connect({ max: 4 })
|
|
48
|
+
await step('indexes', () => createIndexes(sql, indexes))
|
|
49
|
+
await step('sequences', () => resetSequences(sql))
|
|
50
|
+
await sql`insert into public.seed_info (scale) values (${scale})`
|
|
51
|
+
await step('analyze', () => analyzeAll(sql))
|
|
52
|
+
await sql.end()
|
|
53
|
+
|
|
54
|
+
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
|
+
}
|