From d8928e77d357d408f4b7646a6e65c751cd100f29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 04:28:02 +0000 Subject: [PATCH] Add catalog packs and the food-safety pack, selected by DULY_PACK A pack is duly_catalog_item seed rows only (positions as position_code, evidence as part of What done means), chosen at compile time by DULY_PACK the same way DULY_DEMO_SEED chooses the demo, refused loudly when it names no pack. pnpm demo and demo:zh load food-safety by default; pnpm dev stays empty. The first pack has 38 recurring duties across 8 positions in zh-CN and en, citing the PRC Food Safety Law, GB 14881-2013, SAMR Order No. 60 and HACCP. Fixes #134 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JQwxbXXneLu2NaS1dYFtum --- README.md | 23 +- scripts/demo.mjs | 13 + src/data/index.ts | 22 +- src/data/packs/README.md | 139 ++++++++ src/data/packs/food-safety/pack.ts | 499 +++++++++++++++++++++++++++++ src/data/packs/index.ts | 176 ++++++++++ test/catalog-pack.test.ts | 236 ++++++++++++++ 7 files changed, 1101 insertions(+), 7 deletions(-) create mode 100644 src/data/packs/README.md create mode 100644 src/data/packs/food-safety/pack.ts create mode 100644 src/data/packs/index.ts create mode 100644 test/catalog-pack.test.ts diff --git a/README.md b/README.md index f0bfead..5da7f30 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,16 @@ schema, so renaming a field fails the build instead of quietly importing a blank column. **[The full walk, screen by screen, with what each step was measured to do →](docs/import/walkthrough.md)** +### Or start from a catalog pack + +A **catalog pack** is a ready-made catalog for one industry, shipped as seed +rows: `DULY_PACK=food-safety pnpm dev` opens an otherwise empty Duly with 38 +recurring food-safety duties across 8 positions (食品安全周期性履职, written +from the PRC Food Safety Law, GB 14881-2013, SAMR Order No. 60 and HACCP), +ready to edit and apply to your people. It is a starting point, not legal +advice. **[What a pack is, how to choose, edit or remove one, and how to write +your own →](src/data/packs/README.md)** + ## Try the demo To see the finished picture before you load anything, start with the worked @@ -151,8 +161,15 @@ have already seeded gives you **both** organisations in it; Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is -named anywhere in it, in either language. Every reference the catalog cites is -an invented internal document, never a national or industry standard. +named anywhere in it, in either language. Every reference the organisation's +own catalog cites is an invented internal document, never a national or +industry standard. + +Both demos also load the **food-safety catalog pack** beside that catalog, so +the role catalog shows what a template for a real industry looks like. Those +rows are the exception to the paragraph above: they cite real public +frameworks, because that is what a pack is for. `DULY_PACK=none pnpm demo` +leaves the pack out; see [catalog packs](src/data/packs/README.md). ## Customise @@ -227,7 +244,7 @@ src/hooks/ object lifecycle hooks (collected here, not from object src/functions/ pure callables a `script` flow node resolves by name src/datasets/ src/dashboards/ the semantic layer and what reads it src/security/ positions, permission sets, sharing rules -src/mappings/ src/data/ catalog import and the demo fixture (src/data/demo-*) +src/mappings/ src/data/ catalog import, the demo fixture (src/data/demo-*) and catalog packs (src/data/packs/) src/translations/ en (source) · zh-CN scripts/ pnpm demo — start with the example loaded, in one boot samples/ CSVs for the platform's standard Import (see above) diff --git a/scripts/demo.mjs b/scripts/demo.mjs index 128af36..041cee2 100755 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -81,6 +81,19 @@ const SCHEDULED_WORK_ENV_VAR = 'OS_AUTOMATION_SCHEDULED_WORK_ENABLED'; if (process.env[SCHEDULED_WORK_ENV_VAR] === undefined) process.env[SCHEDULED_WORK_ENV_VAR] = 'true'; +// ── Why the demo loads the food-safety catalog pack ────────────────────── +// +// `DULY_PACK` chooses a starting catalog (src/data/packs/README.md). The demo +// loads `food-safety` beside its own organisation's catalog, so the first +// screen shows what a template catalog for a real industry looks like. Set +// here, and ONLY when the caller has not set it, for the same reason as the +// variable above: `DULY_PACK=none pnpm demo` is the demo without a pack, and +// an explicit value is the caller's decision. Not in package.json, because a +// shell default there does not run on Windows. +const PACK_ENV_VAR = 'DULY_PACK'; +const DEFAULT_DEMO_PACK = 'food-safety'; +if (process.env[PACK_ENV_VAR] === undefined) process.env[PACK_ENV_VAR] = DEFAULT_DEMO_PACK; + /** * `sys_user.name` the account you sign in as carries, per locale. * diff --git a/src/data/index.ts b/src/data/index.ts index f816720..75d0e79 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -20,6 +20,7 @@ import { catalogSeed } from './catalog.seed.js'; import { dutySeed } from './duty.seed.js'; import { logEntrySeed } from './log-entry.seed.js'; import { DEMO_STAFF } from './demo-staff.js'; +import { packSeeds } from './packs/index.js'; import { adminPermissionSetSeed, businessUnitMemberSeed, businessUnitSeed, userSeed } from './org.seed.js'; import type { DemoStaff } from '@objectstack/spec/system'; import { @@ -198,15 +199,28 @@ const OPT_IN = ['1', 'true', 'on', 'yes']; export const demoSeedRequested = (): boolean => OPT_IN.includes((process.env[DEMO_SEED_ENV_VAR] ?? '').trim().toLowerCase()); +// ─── …and a third chooses a catalog PACK (#134) ───────────────────────────── +// +// `DULY_PACK` (unset by default; `food-safety` for the first pack) adds a +// starting catalog of one industry's recurring duties: `duly_catalog_item` +// rows and nothing else. Read at compile time like the two above, refused +// loudly when it names no pack, and independent of the demo: a pack with no +// demo is an empty app with a catalog in it, which is the template path; the +// demo with no pack is the demo as it was. `pnpm demo` turns it on by default +// (`scripts/demo.mjs`). Everything about packs lives in `./packs/`. +export { CATALOG_PACKS, PACK_ENV_VAR, packSeeds } from './packs/index.js'; + /** - * What `defineStack({ data })` installs: nothing at all, unless the demo was - * asked for. + * What `defineStack({ data })` installs: nothing at all, unless the demo or a + * pack was asked for. * * `test/demo-seed-opt-in.test.ts` fails if this stops being empty by default — * which is what keeps the demo from drifting back into the default path one - * convenient row at a time. + * convenient row at a time. With no pack it is the demo array itself, not a + * copy of it. */ -export const dulySeeds: Seed[] = demoSeedRequested() ? demoSeeds : []; +const demoOrNothing: Seed[] = demoSeedRequested() ? demoSeeds : []; +export const dulySeeds: Seed[] = packSeeds.length === 0 ? demoOrNothing : [...demoOrNothing, ...packSeeds]; /** * What `defineStack({ demoStaff })` declares: the twelve demo people as diff --git a/src/data/packs/README.md b/src/data/packs/README.md new file mode 100644 index 0000000..f60a363 --- /dev/null +++ b/src/data/packs/README.md @@ -0,0 +1,139 @@ +# Catalog packs + +A **pack** is a ready-made catalog of recurring duties for one industry: who +does what, how often, and what record shows it was done. Turn one on, and a +new Duly opens with a real catalog you can edit, instead of an empty table. + +A pack is **data only**. It adds rows to the role catalog (`duly_catalog_item`), +the same rows you would otherwise import from a spreadsheet. It adds no +screens, fields or automation, and it creates no people, duties or tasks. + +> **A pack is a starting point, not legal advice.** The food-safety pack is +> written from public sources. It is not a compliance opinion and not any +> company's actual list. Check it against the rules that apply to your plant, +> then change it until it describes your plant. + +## The packs in this repo + +| Id | What it covers | Positions | Duties | +|:---|:---|:---|:---| +| `food-safety` | Recurring food-safety duties of a food manufacturer (食品生产企业食品安全周期性履职): the PRC Food Safety Law, GB 14881-2013, SAMR Order No. 60 (日管控 / 周排查 / 月调度) and HACCP | 8 | 38 | + +## Turn a pack on + +Packs are chosen with one environment variable, `DULY_PACK`. It is read when +the app is compiled, and every `pnpm dev` / `pnpm demo` compiles, so a pack takes +effect on the next start. + +| You want | Run | +|:---|:---| +| An empty app (the default) | `pnpm dev` | +| An empty app with the food-safety catalog in it | `DULY_PACK=food-safety pnpm dev` | +| The same, in Chinese | `DULY_PACK=food-safety DULY_DEMO_LOCALE=zh-CN pnpm dev` | +| The demo (it loads `food-safety` by itself) | `pnpm demo` / `pnpm demo:zh` | +| The demo without the pack | `DULY_PACK=none pnpm demo` | + +On Windows, set the variable first (`set DULY_PACK=food-safety`, or +`$env:DULY_PACK="food-safety"` in PowerShell), then run the command. + +- **Language.** A pack writes each row in one language: Chinese when + `DULY_DEMO_LOCALE=zh-CN`, English otherwise. The variable is the same one the + demo uses. Starting a second time in the other language adds the other + language's rows next to the first ones, so start clean + (`rm -rf .objectstack/data`) when you switch. +- **Several packs.** Separate their ids with commas: `DULY_PACK=food-safety,another-pack`. +- **A typo stops the start.** An id that names no pack is refused with the list + of ids there are. It never loads nothing quietly. + +## What happens to your edits + +The pack writes a row **only when no row of that name exists yet.** Once it is +in, the row is yours: change the wording, the frequency or the position, and a +later start with the pack still on leaves your version alone. The row's +**name** is how the pack recognises it. Rename a row, and the pack sees its +own row as missing and adds it again. + +A row you **delete** comes back on the next start with the pack on, because +it is missing again. So once the catalog is yours, **turn the pack off**: +start without `DULY_PACK` (or with `DULY_PACK=none`). Everything the pack +wrote stays; it is ordinary data. + +To remove a pack's rows entirely, turn the pack off, then delete the rows on +**Duly → Setup → Role catalog** (select them, then delete). On a development +machine, `rm -rf .objectstack/data` starts from nothing. + +## From the catalog to people's work + +A catalog row is a duty **template** for a position. Nobody owes it yet. To +hand it out, use **Apply role catalog** on the role catalog: pick the position +and the people who hold it, and each of them gets their own duty for every +active row of that position. Those duties then go through confirmation and +approval and are dispatched on their cadence. See [Getting started](../../../docs/getting-started.md). + +## The row shape + +Each pack is one file, `src/data/packs//pack.ts`. Every duty is one +entry in its `duties` list, one column per line, in this order: + +```ts +{ + position: 'qc', + name: { zh: '检验与监控仪器校准核查', en: 'Instrument calibration check' }, + done: { zh: '核对温度计、压力表……', en: 'Confirm that thermometers, pressure gauges…' }, + evidence: { zh: '仪器校准台账和校准证书。', en: 'The calibration register and the certificates.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 5, leadDays: 7, graceDays: 3, + reference: gb14881('5.2.2', '监控设备', 'monitoring equipment'), +}, +``` + +| Column | Becomes | What to write | +|:---|:---|:---| +| `position` | Position | A key from the pack's `positions` list, such as `qc`. The list gives each key its name in both languages, and that name is what the catalog shows. Positions here are plain names; you do not need to set them up anywhere first. | +| `name` | Duty | The duty in a few words, in Chinese (`zh`) and English (`en`). **It must be unique**, across this pack, every other pack and the demo catalog. It is the key the pack recognises the row by. | +| `done` | What "done" means | One or two sentences: what someone has actually done when this is done. | +| `evidence` | added to What "done" means, as **Evidence to keep: …** / **留存证据:…** | The record a plant keeps to show it was done. This is guidance. Duly never makes someone attach evidence to complete a task. | +| `form` | Form | `recurring` (has a cadence), `standing` (an ongoing responsibility that is never "done", only confirmed) or `one_off`. | +| `frequency` | Frequency | `daily`, `weekly`, `fortnightly`, `monthly`, `quarterly`, `semiannual` or `annual`. Recurring rows only. | +| `dueAnchor` | Due date anchored to | `period_start` or `period_end`. Recurring rows only. | +| `dueOffsetDays` | Offset | Whole days from the anchor day. From the start: 0 is the first day and 4 the fifth. From the end: 0 is the last day and -3 three days before it. Recurring rows only. | +| `leadDays` | Lead time | How many days before the due date the task appears. Recurring rows only. | +| `graceDays` | Grace | Days after the due date before the task counts as late, from 0 to 30. Not on standing rows. | +| `reference` | Reference | Optional. The law, standard or clause the duty comes from, in both languages. Give an article or clause number only where you checked it against the published text, and leave the column out where there is no single source. | + +A **standing** row leaves out all five cadence columns. A **one-off** row keeps +only `graceDays`. The app refuses a row that breaks either rule, so the start +tells you, rather than a duty quietly going wrong. + +### Add one duty + +1. Open `src/data/packs/food-safety/pack.ts`. +2. Copy an entry from the same position and change it. +3. Restart with the pack on. The new row is in the role catalog, and the rows + you already had are left alone. + +To take a duty out of the pack, delete its entry. That stops the pack adding +it to a new database. A database that already has the row keeps it until you +delete it there (see above). + +### From a spreadsheet + +A spreadsheet with one row per duty maps onto this file column for column. +Lay it out with the headers `position`, `name_zh`, `name_en`, `done_zh`, +`done_en`, `evidence_zh`, `evidence_en`, `form`, `frequency`, `dueAnchor`, +`dueOffsetDays`, `leadDays`, `graceDays`, `reference_zh`, `reference_en`, and +each row becomes one entry above. If you only want your own catalog and not a +pack, you do not need this file at all: import the spreadsheet on the role +catalog's **Import** button ([samples/catalog-items.csv](../../../samples/catalog-items.csv) +is the template). + +### A new pack + +1. Copy `food-safety/` to `src/data/packs//`. Ids are lowercase + and hyphenated. +2. Give it its own `id`, `title`, `positions` and `duties`. +3. Add it to `CATALOG_PACKS` in `src/data/packs/index.ts`. +4. Run `pnpm verify`. `test/catalog-pack.test.ts` checks every pack in that + list: both languages on every string, unique names, no name the demo + already uses, every position used, every column within its limit. It also + starts the app with the pack on, in both languages, and checks that every + row landed. diff --git a/src/data/packs/food-safety/pack.ts b/src/data/packs/food-safety/pack.ts new file mode 100644 index 0000000..9f20dfd --- /dev/null +++ b/src/data/packs/food-safety/pack.ts @@ -0,0 +1,499 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { CatalogPack } from '../index.js'; + +/** + * 食品安全周期性履职包 — the recurring food-safety duties of a food + * manufacturer, as a starting catalog. + * + * ── What this is, and what it is not ────────────────────────────────────── + * Generic public knowledge: what the PRC Food Safety Law, GB 14881-2013 (the + * general hygiene standard for food production), SAMR Order No. 60 (日管控 / + * 周排查 / 月调度) and HACCP ask a food plant to do again and again. It is + * no customer's list, and it is not legal advice. Every plant adapts it: which + * positions it has, which checks it runs daily rather than weekly, and which + * extra product standard applies. + * + * ── Citations: the number only where it was checked ─────────────────────── + * An article or clause number appears only where it was read against the + * published text (Food Safety Law as amended 2021-04-29: Arts. 44–48, 50–52, + * 63, 102; GB 14881-2013's clause list, incl. Appendix A; SAMR Order No. 60 + * Arts. 11–13 and 16). Everything else cites the framework alone (GB 2760, + * GB 5749, HACCP), and a duty with no single source cites nothing rather than + * a guess. Flagged for domain review on the PR that added it (#134). + * + * ── Shape ───────────────────────────────────────────────────────────────── + * One entry per duty, one line per column, zh-CN first because it is this + * pack's primary language. See `../README.md` for every column. + */ + +/** 《中华人民共和国食品安全法》 (2021 amendment), by article. */ +const law = (zh: string, en: number) => ({ zh: `《食品安全法》第${zh}条`, en: `PRC Food Safety Law, Art. ${en}` }); +/** GB 14881-2013 《食品生产通用卫生规范》, by clause. */ +const gb14881 = (clause: string, zh: string, en: string) => ({ + zh: `GB 14881-2013 ${clause} ${zh}`, + en: `GB 14881-2013 §${clause} (${en})`, +}); +/** 《企业落实食品安全主体责任监督管理规定》 (SAMR Order No. 60), by article. */ +const order60 = (zh: string, en: number, zhTopic: string, enTopic: string) => ({ + zh: `《企业落实食品安全主体责任监督管理规定》第${zh}条(${zhTopic})`, + en: `SAMR Order No. 60, Art. ${en} (${enTopic})`, +}); +/** Two citations on one line. */ +const and = (a: { zh: string; en: string }, b: { zh: string; en: string }) => ({ + zh: `${a.zh};${b.zh}`, + en: `${a.en}; ${b.en}`, +}); + +export const FOOD_SAFETY: CatalogPack = { + id: 'food-safety', + title: { zh: '食品安全周期性履职', en: 'Food safety recurring duties' }, + + positions: { + director: { zh: '食品安全总监', en: 'Food safety director' }, + officer: { zh: '食品安全员', en: 'Food safety officer' }, + qc: { zh: '质量检验员', en: 'Quality inspector' }, + production: { zh: '生产班组长', en: 'Production shift lead' }, + warehouse: { zh: '仓库管理员', en: 'Warehouse keeper' }, + sanitation: { zh: '卫生管理员', en: 'Sanitation lead' }, + maintenance: { zh: '设备维修主管', en: 'Maintenance lead' }, + procurement: { zh: '采购主管', en: 'Procurement lead' }, + }, + + duties: [ + // ── 食品安全总监 Food safety director ───────────────────────────────── + { + position: 'director', + name: { zh: '每周食品安全风险隐患排查', en: 'Weekly food safety risk review' }, + done: { + zh: '对照风险管控清单组织至少一次风险隐患排查,每个问题落实整改责任人和期限。', + en: 'Run at least one risk review against the risk control checklist; every finding gets an owner and a fix-by date.', + }, + evidence: { zh: '《每周食品安全排查治理报告》', en: 'The weekly risk review and remediation report.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 4, leadDays: 2, graceDays: 1, + reference: order60('十二', 12, '周排查', 'weekly review'), + }, + { + position: 'director', + name: { zh: '每月食品安全调度汇报', en: 'Monthly food safety briefing to top management' }, + done: { + zh: '向主要负责人汇报本月食品安全管理情况,研究解决存在的问题,明确下月工作重点。', + en: "Brief the chief responsible person on the month's food safety management, settle the open problems and agree next month's priorities.", + }, + evidence: { zh: '《每月食品安全调度会议纪要》', en: 'Minutes of the monthly food safety review meeting.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_end', dueOffsetDays: -3, leadDays: 7, graceDays: 3, + reference: order60('十三', 13, '月调度', 'monthly review'), + }, + { + position: 'director', + name: { zh: '食品安全自查评价', en: 'Quarterly food safety self-inspection' }, + done: { + zh: '按自查清单对生产条件、过程控制和记录全面检查评价;不符合要求的立即整改,有潜在事故风险的停止生产并报告监管部门。', + en: 'Inspect and evaluate premises, process controls and records against the self-inspection checklist; correct anything nonconforming at once, and where there is a risk of an incident, stop production and report it to the regulator.', + }, + evidence: { zh: '自查记录和整改清单。', en: 'The self-inspection record and the corrective-action list.' }, + form: 'recurring', frequency: 'quarterly', dueAnchor: 'period_end', dueOffsetDays: -10, leadDays: 21, graceDays: 5, + reference: law('四十七', 47), + }, + { + position: 'director', + name: { zh: 'HACCP 计划年度评审', en: 'Annual HACCP plan review' }, + done: { + zh: '复核危害分析、关键控制点和关键限值,确认与当前的产品、配方、工艺和设备一致;有变化的更新计划。', + en: "Recheck the hazard analysis, critical control points and critical limits against today's products, recipes, process and equipment; update the plan wherever anything changed.", + }, + evidence: { zh: '评审记录(参与人、结论)和更新后的 HACCP 计划版本。', en: 'The review record (who took part, what was decided) and the revised HACCP plan.' }, + form: 'recurring', frequency: 'annual', dueAnchor: 'period_end', dueOffsetDays: -30, leadDays: 60, graceDays: 14, + reference: { zh: '《食品安全法》第四十八条;HACCP 体系', en: 'PRC Food Safety Law, Art. 48; HACCP' }, + }, + { + position: 'director', + name: { zh: '食品安全事故应急演练', en: 'Food safety incident drill' }, + done: { + zh: '按本企业食品安全事故处置方案组织一次演练,检查各项防范措施是否落实,修订演练暴露的问题。', + en: 'Run one drill against the incident response plan, check that every preventive measure is really in place, and fix what the drill exposed.', + }, + evidence: { zh: '演练方案、签到表和演练总结。', en: 'The drill plan, the attendance sheet and the after-action notes.' }, + form: 'recurring', frequency: 'annual', dueAnchor: 'period_end', dueOffsetDays: -60, leadDays: 30, graceDays: 14, + reference: law('一百零二', 102), + }, + { + position: 'director', + name: { zh: '保持食品安全管理制度现行有效', en: 'Keep the food safety management system current' }, + done: { + zh: '制度文件与实际做法一致,法规、标准或工艺变化后及时修订,现场使用的都是现行版本。这一项没有“完成”,只做确认。', + en: 'The written procedures match what is actually done, are revised when a law, standard or process changes, and only current versions are in use. Never "done"; attested, not ticked.', + }, + evidence: { zh: '文件清单(版本号、生效日期)。', en: 'The document register, with versions and effective dates.' }, + form: 'standing', + reference: law('四十四', 44), + }, + + // ── 食品安全员 Food safety officer ──────────────────────────────────── + { + position: 'officer', + name: { zh: '每日食品安全检查', en: 'Daily food safety check' }, + done: { + zh: '对照风险管控清单检查当天的生产过程;没有发现问题也要记录。', + en: "Check the day's production against the risk control checklist; record it even when nothing is found.", + }, + evidence: { zh: '《每日食品安全检查记录》,无问题时记为“零风险”。', en: 'The daily food safety check record; a clean day is logged as a zero-risk report.' }, + form: 'recurring', frequency: 'daily', dueAnchor: 'period_start', dueOffsetDays: 0, leadDays: 0, graceDays: 0, + reference: order60('十一', 11, '日管控', 'daily control'), + }, + { + position: 'officer', + name: { zh: '从业人员健康证明核查', en: 'Staff health certificate check' }, + done: { + zh: '核对所有接触直接入口食品人员的健康证明,列出 30 天内到期的人员并通知本人和主管。', + en: 'Check the health certificate of everyone who handles ready-to-eat food; list anyone expiring in the next 30 days and tell them and their supervisor.', + }, + evidence: { zh: '健康证明核查表和到期提醒名单。', en: 'The health certificate check sheet and the expiry list.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 2, leadDays: 7, graceDays: 3, + reference: and(law('四十五', 45), gb14881('6.3.1', '食品加工人员健康管理', 'health management of food handlers')), + }, + { + position: 'officer', + name: { zh: '季度食品安全培训', en: 'Quarterly food safety training' }, + done: { + zh: '按年度培训计划组织一次培训,覆盖新员工和换岗人员,并进行考核。', + en: 'Run one session from the annual training plan, covering new starters and people who changed jobs, with an assessment.', + }, + evidence: { zh: '签到表、培训材料和考核结果。', en: 'The attendance sheet, the training material and the assessment results.' }, + form: 'recurring', frequency: 'quarterly', dueAnchor: 'period_end', dueOffsetDays: -15, leadDays: 21, graceDays: 7, + reference: and(law('四十四', 44), gb14881('12', '培训', 'training')), + }, + { + position: 'officer', + name: { zh: '食品安全管理人员年度培训考核', en: 'Annual training and assessment of food safety managers' }, + done: { + zh: '组织食品安全总监和食品安全员学习食品安全法律、法规、标准和专业知识,并进行考核。', + en: 'Train the food safety director and officers on food safety law, regulations, standards and professional knowledge, and assess them.', + }, + evidence: { zh: '培训和考核记录。', en: 'The training and assessment records.' }, + form: 'recurring', frequency: 'annual', dueAnchor: 'period_end', dueOffsetDays: -45, leadDays: 60, graceDays: 14, + reference: order60('十六', 16, '培训考核', 'training and assessment'), + }, + { + position: 'officer', + name: { zh: '产品追溯与模拟召回', en: 'Mock recall and traceability test' }, + done: { + zh: '任选一个已出厂批次,向上追溯到原料批次、向下追溯到客户,记录用时,确认召回程序能执行。', + en: 'Pick one shipped batch, trace it back to its raw-material lots and forward to its customers, time it, and confirm the recall procedure works.', + }, + evidence: { zh: '模拟召回记录(批次、用时、追回率)。', en: 'The mock recall record: batch, time taken, share recovered.' }, + form: 'recurring', frequency: 'annual', dueAnchor: 'period_end', dueOffsetDays: -90, leadDays: 30, graceDays: 14, + reference: and(law('六十三', 63), gb14881('11', '产品召回管理', 'product recall management')), + }, + { + position: 'officer', + name: { zh: '法规标准更新跟踪', en: 'Regulation and standard update watch' }, + done: { + zh: '检查本季度新发布或修订的食品安全法规和国家标准,判断对本企业的影响,需要时提出制度修订。', + en: 'Check the food safety regulations and national standards issued or revised this quarter, judge what they change for this plant, and propose procedure changes where needed.', + }, + evidence: { zh: '法规标准更新清单和影响评估。', en: 'The list of updates and the impact assessment.' }, + form: 'recurring', frequency: 'quarterly', dueAnchor: 'period_end', dueOffsetDays: -5, leadDays: 14, graceDays: 7, + }, + + // ── 质量检验员 Quality inspector ────────────────────────────────────── + { + position: 'qc', + name: { zh: '进货查验记录核对', en: 'Incoming goods verification review' }, + done: { + zh: '核对本周每批到货的原料、食品添加剂和包装材料都已查验供货者许可证和产品合格证明,并如实记录。', + en: "Confirm that every delivery of raw materials, additives and packaging this week was checked for the supplier's licence and certificate of conformity, and recorded.", + }, + evidence: { zh: '进货查验记录。', en: 'The incoming goods inspection records.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 1, leadDays: 2, graceDays: 1, + reference: and(law('五十', 50), gb14881('7', '食品原料、食品添加剂和食品相关产品', 'raw materials, additives and food-related products')), + }, + { + position: 'qc', + name: { zh: '出厂检验记录复核', en: 'Release inspection record review' }, + done: { + zh: '复核本周每个出厂批次都有检验合格证明和出厂检验记录,没有未经检验或检验不合格就放行的。', + en: 'Confirm that every batch released this week has a certificate of inspection and a release record, and that nothing left untested or after a failed test.', + }, + evidence: { zh: '出厂检验记录。', en: 'The release inspection records.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 4, leadDays: 2, graceDays: 1, + reference: law('五十一', 51), + }, + { + position: 'qc', + name: { zh: '环境微生物监控', en: 'Environmental microbiological monitoring' }, + done: { + zh: '按监控计划在食品接触面、加工环境和空气中取样检测;超出限值的追查原因并复测。', + en: 'Sample food-contact surfaces, the processing environment and the air to the monitoring plan; trace the cause of any result over limit and retest.', + }, + evidence: { zh: '取样记录、检测结果和超标处理记录。', en: 'The sampling log, the results, and what was done about any result over limit.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 2, leadDays: 2, graceDays: 1, + reference: { + zh: 'GB 14881-2013 8.2.2 食品加工过程的微生物监控及附录 A', + en: 'GB 14881-2013 §8.2.2 (microbial monitoring) and Appendix A', + }, + }, + { + position: 'qc', + name: { zh: '检验与监控仪器校准核查', en: 'Instrument calibration check' }, + done: { + zh: '核对温度计、压力表、天平等检验和监控设备都在校准有效期内;到期的送检,不合格的停用。', + en: 'Confirm that thermometers, pressure gauges, balances and other test and monitoring instruments are within calibration; send the due ones off and take failed ones out of use.', + }, + evidence: { zh: '仪器校准台账和校准证书。', en: 'The calibration register and the certificates.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 5, leadDays: 7, graceDays: 3, + reference: gb14881('5.2.2', '监控设备', 'monitoring equipment'), + }, + { + position: 'qc', + name: { zh: '成品留样检查', en: 'Finished-goods retained sample check' }, + done: { + zh: '检查每个批次的留样齐全、标识清楚、储存条件符合要求;超过留样期的按规定处置。', + en: 'Check that every batch has its retained sample, clearly labelled and stored as required; dispose of samples past their retention period as the procedure says.', + }, + evidence: { zh: '留样登记和处置记录。', en: 'The retained sample register and the disposal log.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 3, leadDays: 2, graceDays: 1, + }, + { + position: 'qc', + name: { zh: '产品年度全项检验', en: 'Annual full-specification product test' }, + done: { + zh: '按产品执行的食品安全标准,送有资质的检验机构做一次全项目检验;不合格项追查原因。', + en: "Send each product to an accredited laboratory for a test against every parameter of its food safety standard; trace the cause of any failure.", + }, + evidence: { zh: '检验报告。', en: 'The test reports.' }, + form: 'recurring', frequency: 'annual', dueAnchor: 'period_end', dueOffsetDays: -60, leadDays: 45, graceDays: 14, + reference: law('五十二', 52), + }, + { + position: 'qc', + name: { zh: '食品添加剂使用核查', en: 'Food additive use check' }, + done: { + zh: '核对配方和投料记录中食品添加剂的品种、用量和使用范围符合 GB 2760,使用台账与库存一致。', + en: 'Check that the additives in the recipes and batch records, their amounts and their uses comply with GB 2760, and that the usage log agrees with stock.', + }, + evidence: { zh: '食品添加剂使用台账和核查记录。', en: 'The additive usage log and the check record.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_end', dueOffsetDays: -5, leadDays: 7, graceDays: 3, + reference: { zh: 'GB 2760;GB 14881-2013 7.3 食品添加剂', en: 'GB 2760; GB 14881-2013 §7.3 (food additives)' }, + }, + + // ── 生产班组长 Production shift lead ────────────────────────────────── + { + position: 'production', + name: { zh: '关键控制点监控记录复核', en: 'CCP monitoring record check' }, + done: { + zh: '复核当天每个关键控制点的监控记录完整、签名齐全;偏离关键限值的已采取纠偏措施并记录。', + en: "Check that today's monitoring record for every critical control point is complete and signed, and that every deviation from a critical limit has a recorded corrective action.", + }, + evidence: { zh: 'CCP 监控记录和纠偏记录。', en: 'The CCP monitoring log and the corrective-action log.' }, + form: 'recurring', frequency: 'daily', dueAnchor: 'period_start', dueOffsetDays: 0, leadDays: 0, graceDays: 0, + reference: { zh: 'HACCP 体系(监控、纠偏、记录)', en: 'HACCP (monitoring, corrective action, records)' }, + }, + { + position: 'production', + name: { zh: '班前健康与卫生检查', en: 'Pre-shift health and hygiene check' }, + done: { + zh: '开班前检查上岗人员:有发热、腹泻、手部伤口等情况的调离接触食品岗位;工作服、发帽和洗手消毒符合要求。', + en: 'Before the shift, check everyone starting work: anyone with a fever, diarrhoea or a cut on the hands moves off food-handling work; workwear, hair covering and hand washing meet the rules.', + }, + evidence: { zh: '晨检记录。', en: 'The pre-shift check record.' }, + form: 'recurring', frequency: 'daily', dueAnchor: 'period_start', dueOffsetDays: 0, leadDays: 0, graceDays: 0, + reference: gb14881('6.3', '食品加工人员健康管理与卫生要求', 'health and hygiene of food handlers'), + }, + { + position: 'production', + name: { zh: '金属探测器灵敏度验证', en: 'Metal detector sensitivity test' }, + done: { + zh: '开机前和每班用标准测试块验证金属探测器能检出并剔除;验证失败时,隔离上次验证合格后生产的产品。', + en: 'At start-up and every shift, prove with the test pieces that the metal detector detects and rejects; if it fails, hold everything made since the last good test.', + }, + evidence: { zh: '金属探测器验证记录。', en: 'The metal detector test log.' }, + form: 'recurring', frequency: 'daily', dueAnchor: 'period_start', dueOffsetDays: 0, leadDays: 0, graceDays: 0, + reference: gb14881('8.4', '物理污染的控制', 'control of physical contamination'), + }, + { + position: 'production', + name: { zh: '生产批记录完整性检查', en: 'Batch record completeness review' }, + done: { + zh: '检查本周每个生产批次的投料、工艺参数和包装记录完整,并能追溯到原料批次。', + en: "Check that every batch made this week has complete ingredient, process and packing records that trace back to the raw-material lots.", + }, + evidence: { zh: '批生产记录。', en: 'The batch production records.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 4, leadDays: 2, graceDays: 1, + reference: and(law('四十六', 46), gb14881('14', '记录和文件管理', 'records and documents')), + }, + { + position: 'production', + name: { zh: '玻璃及硬质塑料制品巡查', en: 'Glass and brittle plastics audit' }, + done: { + zh: '对照登记清单检查生产区所有玻璃和硬质塑料制品完好;有破损的按破损处理程序处理。', + en: 'Walk the register of glass and brittle plastic items in the production area and confirm each is intact; handle any breakage by the breakage procedure.', + }, + evidence: { zh: '巡查记录。', en: 'The audit record.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 3, leadDays: 7, graceDays: 3, + reference: gb14881('8.4', '物理污染的控制', 'control of physical contamination'), + }, + + // ── 仓库管理员 Warehouse keeper ─────────────────────────────────────── + { + position: 'warehouse', + name: { zh: '库房温湿度记录', en: 'Store temperature and humidity log' }, + done: { + zh: '每天记录常温库、冷藏库和冷冻库的温湿度;超出范围的立即处理,并记录对产品的评估。', + en: 'Log the temperature and humidity of the ambient, chilled and frozen stores every day; act at once on anything out of range and record the assessment of the product.', + }, + evidence: { zh: '温湿度记录表。', en: 'The temperature and humidity log.' }, + form: 'recurring', frequency: 'daily', dueAnchor: 'period_start', dueOffsetDays: 0, leadDays: 0, graceDays: 0, + reference: gb14881('10', '食品的贮存和运输', 'storage and transport'), + }, + { + position: 'warehouse', + name: { zh: '库存先进先出与临期检查', en: 'Stock rotation and near-expiry check' }, + done: { + zh: '检查原料和成品按先进先出(先到期先出)发放;临期、过期和不合格品已隔离、标识并登记。', + en: 'Check that materials and finished goods go out first-expired-first-out, and that near-expiry, expired and nonconforming stock is segregated, labelled and logged.', + }, + evidence: { zh: '库存检查记录和隔离品台账。', en: 'The stock check record and the quarantine log.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 1, leadDays: 2, graceDays: 1, + reference: gb14881('10', '食品的贮存和运输', 'storage and transport'), + }, + { + position: 'warehouse', + name: { zh: '运输车辆卫生检查', en: 'Delivery vehicle hygiene check' }, + done: { + zh: '检查装运食品的车辆清洁、无异味,冷链车辆温度符合要求,不与有毒有害物品混装。', + en: 'Check that vehicles carrying food are clean and odour-free, that refrigerated vehicles hold temperature, and that food never travels with toxic or hazardous goods.', + }, + evidence: { zh: '车辆检查记录。', en: 'The vehicle check record.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 2, leadDays: 2, graceDays: 1, + reference: gb14881('10', '食品的贮存和运输', 'storage and transport'), + }, + + // ── 卫生管理员 Sanitation lead ──────────────────────────────────────── + { + position: 'sanitation', + name: { zh: '清洁消毒执行检查', en: 'Cleaning and sanitising check' }, + done: { + zh: '检查本周清洁消毒计划按区域和频次执行,消毒剂浓度有检测记录。', + en: "Check that this week's cleaning and sanitising schedule was carried out area by area at the set frequency, and that sanitiser strength was tested and logged.", + }, + evidence: { zh: '清洁消毒记录和消毒剂浓度记录。', en: 'The cleaning log and the sanitiser strength log.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 4, leadDays: 2, graceDays: 1, + reference: gb14881('8.2.1', '清洁和消毒', 'cleaning and disinfection'), + }, + { + position: 'sanitation', + name: { zh: '虫害控制检查', en: 'Pest control inspection' }, + done: { + zh: '检查捕鼠器、诱虫灯和防虫设施,记录捕获情况并分析趋势;发现虫害迹象的立即处理。', + en: 'Inspect the traps, insect lamps and proofing, log the catches and look at the trend; act at once on any sign of infestation.', + }, + evidence: { zh: '虫害控制检查记录和趋势分析。', en: 'The pest control log and the trend analysis.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 5, leadDays: 7, graceDays: 3, + reference: gb14881('6.4', '虫害控制', 'pest control'), + }, + { + position: 'sanitation', + name: { zh: '废弃物存放与清运检查', en: 'Waste storage and removal check' }, + done: { + zh: '检查废弃物按类别存放、容器加盖并及时清运,存放区域清洁。', + en: 'Check that waste is sorted, kept in covered containers and removed on time, and that the waste area is clean.', + }, + evidence: { zh: '废弃物清运记录。', en: 'The waste removal log.' }, + form: 'recurring', frequency: 'weekly', dueAnchor: 'period_start', dueOffsetDays: 3, leadDays: 2, graceDays: 1, + reference: gb14881('6.5', '废弃物处理', 'waste disposal'), + }, + { + position: 'sanitation', + name: { zh: '工作服清洗管理检查', en: 'Workwear laundering check' }, + done: { + zh: '检查工作服按区域区分、定期清洗消毒并有发放记录,不同清洁区的工作服不混放。', + en: 'Check that workwear is distinguished by area, laundered and sanitised on schedule and issued against a record, and that clothing from different hygiene zones is kept apart.', + }, + evidence: { zh: '工作服清洗和发放记录。', en: 'The laundering and issue log.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 8, leadDays: 7, graceDays: 3, + reference: gb14881('6.6', '工作服管理', 'workwear management'), + }, + { + position: 'sanitation', + name: { zh: '生产用水水质检测', en: 'Process water quality test' }, + done: { + zh: '生产用水送检,确认符合 GB 5749;使用自备水源的同时检查水处理设施。', + en: 'Send the process water for testing and confirm it meets GB 5749; with a private supply, inspect the treatment plant too.', + }, + evidence: { zh: '水质检测报告。', en: 'The water test report.' }, + form: 'recurring', frequency: 'semiannual', dueAnchor: 'period_end', dueOffsetDays: -15, leadDays: 30, graceDays: 10, + reference: { zh: 'GB 14881-2013 5.1.1 供水设施;GB 5749', en: 'GB 14881-2013 §5.1.1 (water supply); GB 5749' }, + }, + + // ── 设备维修主管 Maintenance lead ───────────────────────────────────── + { + position: 'maintenance', + name: { zh: '设备预防性维护', en: 'Preventive maintenance round' }, + done: { + zh: '按保养计划完成本月的设备保养,检查食品接触部件没有磨损、松动和锈蚀。', + en: "Complete this month's planned maintenance and check that no food-contact part is worn, loose or corroded.", + }, + evidence: { zh: '设备保养记录。', en: 'The maintenance records.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 6, leadDays: 7, graceDays: 3, + reference: gb14881('5.2.3', '设备的保养和维修', 'equipment maintenance and repair'), + }, + { + position: 'maintenance', + name: { zh: '食品级润滑剂使用核查', en: 'Food-grade lubricant check' }, + done: { + zh: '核对可能接触食品的部位只使用食品级润滑剂,润滑剂单独存放并有领用记录。', + en: 'Confirm that only food-grade lubricants are used wherever they could reach food, and that lubricants are stored apart and issued against a record.', + }, + evidence: { zh: '润滑剂清单和领用记录。', en: 'The lubricant list and the issue log.' }, + form: 'recurring', frequency: 'quarterly', dueAnchor: 'period_start', dueOffsetDays: 10, leadDays: 14, graceDays: 5, + reference: gb14881('8.3', '化学污染的控制', 'control of chemical contamination'), + }, + { + position: 'maintenance', + name: { zh: '厂房设施年度检查', en: 'Annual premises condition survey' }, + done: { + zh: '检查屋顶、墙面、地面、门窗、排水和通风设施有无渗漏、冷凝水、脱落和破损,并制定维修计划。', + en: 'Survey roofs, walls, floors, doors, windows, drains and ventilation for leaks, condensation, flaking and damage, and draw up the repair plan.', + }, + evidence: { zh: '检查记录和维修计划。', en: 'The survey record and the repair plan.' }, + form: 'recurring', frequency: 'annual', dueAnchor: 'period_end', dueOffsetDays: -90, leadDays: 45, graceDays: 14, + reference: gb14881('6.2', '厂房及设施卫生管理', 'hygiene of premises and facilities'), + }, + + // ── 采购主管 Procurement lead ───────────────────────────────────────── + { + position: 'procurement', + name: { zh: '合格供应商年度评价', en: 'Annual approved-supplier review' }, + done: { + zh: '逐个评价原料、食品添加剂和包装材料供应商的资质、检验报告和质量问题记录,更新合格供应商名录。', + en: 'Review every supplier of raw materials, additives and packaging, their credentials, test reports and quality history, and update the approved supplier list.', + }, + evidence: { zh: '供应商评价表和更新后的合格供应商名录。', en: 'The supplier evaluations and the updated approved supplier list.' }, + form: 'recurring', frequency: 'annual', dueAnchor: 'period_end', dueOffsetDays: -45, leadDays: 60, graceDays: 14, + reference: and(law('五十', 50), gb14881('7.1', '一般要求', 'general requirements')), + }, + { + position: 'procurement', + name: { zh: '供应商资质到期核查', en: 'Supplier licence expiry check' }, + done: { + zh: '核对在用供应商的许可证和资质证明,30 天内到期的提前索取新证。', + en: 'Check the licences and credentials of every supplier in use, and ask for the renewal of anything expiring in the next 30 days.', + }, + evidence: { zh: '供应商资质台账。', en: 'The supplier credentials register.' }, + form: 'recurring', frequency: 'monthly', dueAnchor: 'period_start', dueOffsetDays: 3, leadDays: 7, graceDays: 3, + reference: law('五十', 50), + }, + { + position: 'procurement', + name: { zh: '只从合格供应商采购', en: 'Buy only from approved suppliers' }, + done: { + zh: '每一笔采购都来自合格供应商名录;名录外的供应商先评价、后下单。这一项没有“完成”,只做确认。', + en: 'Every purchase is from a supplier on the approved list; a new supplier is evaluated before the first order. Never "done"; attested, not ticked.', + }, + evidence: { zh: '合格供应商名录。', en: 'The approved supplier list.' }, + form: 'standing', + reference: gb14881('7.1', '一般要求', 'general requirements'), + }, + ], +}; diff --git a/src/data/packs/index.ts b/src/data/packs/index.ts new file mode 100644 index 0000000..36749e7 --- /dev/null +++ b/src/data/packs/index.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineSeed, type Seed } from '@objectstack/spec/data'; + +import type { Frequency } from '../../functions/period.js'; +import { CatalogItem } from '../../objects/catalog-item.object.js'; +import { cadenceOf, type DemoCatalogItem, type Form } from '../demo-catalog.js'; +import { DEMO_LOCALE, type DemoLocale } from '../demo-locale.js'; + +import { FOOD_SAFETY } from './food-safety/pack.js'; + +/** + * Catalog packs — a starting list of recurring duties for one industry, as + * nothing but seed rows (#134). See `README.md` beside this file. + * + * ── What a pack is, in platform terms ───────────────────────────────────── + * Rows of `duly_catalog_item`, and nothing else. No object, no field, no hook, + * no metadata key. The pack's POSITIONS are the free-text `position_code` the + * catalog already carries (so a plant can load its catalog before it models a + * single position), and its EVIDENCE line is part of "What done means" — + * guidance, never a gate: completion never requires evidence, a note, or a + * percentage (an invariant, AGENTS.md). Duties need an owner, so a pack ships + * none; they are made from the catalog with **Apply role catalog**, per position. + * + * ── How one is chosen, and why it is an environment variable ────────────── + * Measured on @objectstack 18.2.0 for #134: `defineStack({ data })` is ONE + * flat `Seed[]`, and a `Seed` scopes itself only by `env` and `locale`. The + * platform has no named seed set to pick at boot. So the choice is made where + * this app already makes the demo's: a variable read when the artifact is + * compiled (`DULY_PACK`, beside `DULY_DEMO_SEED` in `../index.ts`), with every + * boot passing `--compile`. No loader of our own. + * + * `Seed.locale` would be the platform's way to ship both languages and let the + * boot pick — but the boot filters on `i18n.defaultLocale`, which this app + * fixes at `en`. So, like the demo, the language is chosen at compile time by + * `DULY_DEMO_LOCALE` (`zh-CN` for Chinese), and the other language's rows are + * never written. + */ + +/** One display string in both of the app's languages. zh-CN is the pack's primary. */ +export interface PackText { + readonly zh: string; + readonly en: string; +} + +/** One duty template: a row of `duly_catalog_item`. Columns are described in `README.md`. */ +export interface PackDuty { + /** A key of {@link CatalogPack.positions}. */ + readonly position: string; + readonly name: PackText; + /** What "done" means. */ + readonly done: PackText; + /** The record a plant would keep to show it was done. Guidance only. */ + readonly evidence: PackText; + readonly form: Form; + readonly frequency?: Frequency; + readonly dueAnchor?: 'period_start' | 'period_end'; + readonly dueOffsetDays?: number; + readonly leadDays?: number; + readonly graceDays?: number; + /** The public framework the duty comes from. Omitted where there is no single source. */ + readonly reference?: PackText; +} + +export interface CatalogPack { + /** What `DULY_PACK` names. Lowercase, hyphenated. */ + readonly id: string; + readonly title: PackText; + /** Position key → its name, which becomes `position_code`. */ + readonly positions: Readonly>; + readonly duties: readonly PackDuty[]; +} + +/** The environment variable that chooses packs: one id, or several separated by commas. */ +export const PACK_ENV_VAR = 'DULY_PACK'; + +/** Every pack this repo ships. A new pack is one import and one entry here. */ +export const CATALOG_PACKS: readonly CatalogPack[] = [FOOD_SAFETY]; + +/** Spellings that mean "no pack", besides leaving the variable unset. */ +const NO_PACK = ['', 'none']; + +declare const process: { env: Record }; + +/** + * The packs this compile was asked for, in the order named. + * + * An id that names no pack is REFUSED, loudly, before anything boots: a typo + * that quietly loaded nothing would look exactly like the empty first boot, + * and nobody would know the catalog they asked for is missing. + */ +export const requestedPacks = (raw: string | undefined = process.env[PACK_ENV_VAR]): CatalogPack[] => { + const ids = [...new Set((raw ?? '').split(',').map((id) => id.trim().toLowerCase()))].filter( + (id) => !NO_PACK.includes(id), + ); + return ids.map((id) => { + const pack = CATALOG_PACKS.find((candidate) => candidate.id === id); + if (pack === undefined) { + throw new Error( + `${PACK_ENV_VAR}=${JSON.stringify(raw)}: there is no catalog pack ${JSON.stringify(id)}. ` + + `Use one of: ${CATALOG_PACKS.map((p) => p.id).join(', ')} — or leave it unset for no pack.`, + ); + } + return pack; + }); +}; + +const pick = (text: PackText, locale: DemoLocale): string => (locale === 'zh-CN' ? text.zh : text.en); + +/** The label in front of the evidence line, per language. */ +const EVIDENCE_LABEL: PackText = { zh: '留存证据:', en: 'Evidence to keep:' }; + +/** + * A pack's catalog items in one language, as `duly_catalog_item` columns. + * + * Cadence goes through the demo's `cadenceOf`, the one place that decides + * which of the five cadence fields a form may carry (#61), so a pack cannot + * trip `standing_no_frequency` and friends by carrying a stray value. + */ +export const packCatalogRows = (pack: CatalogPack, locale: DemoLocale) => + pack.duties.map((duty) => { + const position = pack.positions[duty.position]; + /* c8 ignore next 3 -- a typo here would seed a row under a position nobody named */ + if (position === undefined) { + throw new Error(`catalog pack ${pack.id}: duty ${JSON.stringify(duty.name.en)} names no position ${JSON.stringify(duty.position)}`); + } + const cadence = cadenceOf({ ...duty, name: duty.name.en, position: '', description: '' } as DemoCatalogItem); + return { + name: pick(duty.name, locale), + position_code: pick(position, locale), + form: duty.form, + description: `${pick(duty.done, locale)}\n\n**${pick(EVIDENCE_LABEL, locale)}** ${pick(duty.evidence, locale)}`, + // Spread only when present, as the demo does: an explicit `undefined` is + // still an own property, and the loader's no-op replay check would churn + // the row on every boot. + ...(duty.reference === undefined ? {} : { regulation_ref: pick(duty.reference, locale) }), + active: true, + ...cadence, + }; + }); + +/** Every column a pack writes besides the `name` it is matched on. */ +const PACK_COLUMNS = [ + 'position_code', + 'form', + 'description', + 'regulation_ref', + 'active', + 'frequency', + 'due_anchor', + 'due_offset_days', + 'lead_days', + 'grace_days', +] as const; + +/** + * One pack as one dataset. + * + * `upsert` on `name`, with every other column `applyOn: 'create'`: the pack + * writes a row the first time it finds it missing, and a later boot with the + * pack still on leaves the row exactly as the plant has since edited it. A + * starting catalog is there to be changed; a replay that put the pack's + * wording back every boot would make it a catalog you cannot own. (A row you + * DELETE does come back on the next boot with the pack on: it is missing + * again. Turn the pack off once the catalog is yours — see `README.md`.) + */ +export const packSeed = (pack: CatalogPack, locale: DemoLocale): Seed => + defineSeed(CatalogItem, { + externalId: 'name', + mode: 'upsert', + columns: Object.fromEntries(PACK_COLUMNS.map((column) => [column, { applyOn: 'create' as const }])), + records: packCatalogRows(pack, locale), + }) as Seed; + +/** What `DULY_PACK` adds to `defineStack({ data })` for this compile. Empty unless asked. */ +export const packSeeds: Seed[] = requestedPacks().map((pack) => packSeed(pack, DEMO_LOCALE)); diff --git a/test/catalog-pack.test.ts b/test/catalog-pack.test.ts new file mode 100644 index 0000000..917f33d --- /dev/null +++ b/test/catalog-pack.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { readFileSync } from 'node:fs'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AppPlugin, ObjectKernel, createStandaloneStack } from '@objectstack/runtime'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; + +import { CATALOG_PACKS, PACK_ENV_VAR, packCatalogRows, requestedPacks, type CatalogPack } from '../src/data/packs/index.js'; +import { FOOD_SAFETY } from '../src/data/packs/food-safety/pack.js'; + +/** + * Catalog packs (#134): the format, the first pack, and the switch. + * + * What is pinned, and why each half matters: + * - **Both languages, on every row.** zh-CN is the pack's primary language + * and en the app's source language; a row missing either is a catalog that + * renders blank in one demo. The authored-text gate does not see seed rows, + * so this suite is the pack's i18n gate. + * - **No name the demo already uses.** `name` is the upsert key: a pack row + * sharing it would silently overwrite a demo catalog item, or be overwritten. + * - **Off unless asked, and a typo is refused.** An unknown id that loaded + * nothing would be indistinguishable from the empty first boot. + * - **It boots.** The rows go through the real loader and the real + * validation rules, in both languages, with no demo underneath. + */ + +const DEMO_SEED_ENV_VAR = 'DULY_DEMO_SEED'; +const DEMO_LOCALE_ENV_VAR = 'DULY_DEMO_LOCALE'; + +const CJK = /[一-鿿]/; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +const texts = (pack: CatalogPack) => + pack.duties.flatMap((duty) => [duty.name, duty.done, duty.evidence, ...(duty.reference ? [duty.reference] : [])]); + +// ─────────────────────────────────────────────────────────────────────────── +describe.each(CATALOG_PACKS.map((pack) => [pack.id, pack] as const))('pack %s — format', (_id, pack) => { + it('has a lowercase, hyphenated id', () => { + expect(pack.id).toMatch(/^[a-z][a-z0-9-]*$/); + }); + + it('carries every display string in both languages, and they differ', () => { + for (const text of [pack.title, ...Object.values(pack.positions), ...texts(pack)]) { + expect(text.zh.trim(), JSON.stringify(text)).not.toBe(''); + expect(text.en.trim(), JSON.stringify(text)).not.toBe(''); + expect(text.zh, `zh is Chinese: ${JSON.stringify(text)}`).toMatch(CJK); + expect(text.en, `en is not Chinese: ${JSON.stringify(text)}`).not.toMatch(CJK); + } + }); + + it('names a declared position on every duty, and uses every position it declares', () => { + const keys = Object.keys(pack.positions); + for (const duty of pack.duties) expect(keys, duty.name.en).toContain(duty.position); + expect(new Set(pack.duties.map((duty) => duty.position))).toEqual(new Set(keys)); + }); + + it.each(['zh-CN', 'en'] as const)('gives every row a unique name, in %s', (locale) => { + const names = packCatalogRows(pack, locale).map((row) => row.name); + expect(new Set(names).size).toBe(names.length); + }); + + it.each(['zh-CN', 'en'] as const)('fits every column the object bounds, in %s', (locale) => { + for (const row of packCatalogRows(pack, locale)) { + expect(row.name.length, row.name).toBeLessThanOrEqual(200); + expect(row.position_code.length, row.position_code).toBeLessThanOrEqual(100); + expect((row.regulation_ref ?? '').length, row.name).toBeLessThanOrEqual(200); + } + }); + + it('writes the evidence line into "What done means", never a gate', () => { + for (const row of packCatalogRows(pack, 'en')) expect(row.description).toContain('**Evidence to keep:**'); + for (const row of packCatalogRows(pack, 'zh-CN')) expect(row.description).toContain('**留存证据:**'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the food-safety pack — what the card asks of it', () => { + it('has 6–10 positions and 25–40 duties', () => { + expect(Object.keys(FOOD_SAFETY.positions).length).toBeGreaterThanOrEqual(6); + expect(Object.keys(FOOD_SAFETY.positions).length).toBeLessThanOrEqual(10); + expect(FOOD_SAFETY.duties.length).toBeGreaterThanOrEqual(25); + expect(FOOD_SAFETY.duties.length).toBeLessThanOrEqual(40); + }); + + it('spans daily, weekly, monthly, quarterly and annual work', () => { + const frequencies = new Set(FOOD_SAFETY.duties.map((duty) => duty.frequency)); + for (const frequency of ['daily', 'weekly', 'monthly', 'quarterly', 'annual'] as const) { + expect(frequencies, frequency).toContain(frequency); + } + }); + + it('carries no cadence on a standing row, so #61 cannot refuse it', () => { + for (const row of packCatalogRows(FOOD_SAFETY, 'en').filter((row) => row.form === 'standing')) { + expect(Object.keys(row)).not.toEqual(expect.arrayContaining(['frequency'])); + for (const column of ['frequency', 'due_anchor', 'due_offset_days', 'lead_days', 'grace_days']) { + expect(row, `${row.name}.${column}`).not.toHaveProperty(column); + } + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('no pack name or position collides with the demo catalog', () => { + it.each(['en', 'zh-CN'] as const)('in %s', async (locale) => { + vi.resetModules(); + vi.stubEnv(DEMO_LOCALE_ENV_VAR, locale); + const { CATALOG_ITEMS } = await import('../src/data/demo-catalog.js'); + const demoNames = new Set(CATALOG_ITEMS.map((item) => item.name)); + const demoPositions = new Set(CATALOG_ITEMS.map((item) => item.position)); + for (const pack of CATALOG_PACKS) { + for (const row of packCatalogRows(pack, locale)) { + expect(demoNames.has(row.name), `${pack.id}: ${row.name}`).toBe(false); + expect(demoPositions.has(row.position_code), `${pack.id}: ${row.position_code}`).toBe(false); + } + } + }); + + it('nor two packs with each other', () => { + for (const locale of ['en', 'zh-CN'] as const) { + const names = CATALOG_PACKS.flatMap((pack) => packCatalogRows(pack, locale).map((row) => row.name)); + expect(new Set(names).size).toBe(names.length); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('DULY_PACK chooses, and refuses what it does not know', () => { + it.each([undefined, '', ' ', 'none', 'NONE'])('%j is no pack', (raw) => { + expect(requestedPacks(raw)).toEqual([]); + }); + + it.each(['food-safety', ' Food-Safety ', 'food-safety,food-safety'])('%j is the food-safety pack, once', (raw) => { + expect(requestedPacks(raw)).toEqual([FOOD_SAFETY]); + }); + + it.each(['bogus', 'food_safety', 'food-safety,bogus'])('%j is refused, naming the packs there are', (raw) => { + expect(() => requestedPacks(raw)).toThrow(/there is no catalog pack .*Use one of: food-safety/); + }); + + it('is refused at compile time, not quietly at boot', async () => { + vi.resetModules(); + vi.stubEnv(PACK_ENV_VAR, 'bogus'); + await expect(import('../src/data/index.js')).rejects.toThrow(/DULY_PACK="bogus"/); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('what the stack is handed', () => { + type Dataset = { object: string; records: readonly Record[] }; + + const loadStackData = async (): Promise => { + vi.resetModules(); + const stack = (await import('../objectstack.config.js')).default; + return ((stack as { data?: readonly Dataset[] }).data ?? []) as readonly Dataset[]; + }; + + it('a pack alone is catalog rows and nothing else — no people, no duties', async () => { + vi.stubEnv(PACK_ENV_VAR, 'food-safety'); + const data = await loadStackData(); + expect(data.map((dataset) => dataset.object)).toEqual(['duly_catalog_item']); + expect(data[0]!.records).toHaveLength(FOOD_SAFETY.duties.length); + }); + + it('a pack with the demo is the whole demo plus the pack', async () => { + vi.stubEnv(DEMO_SEED_ENV_VAR, '1'); + vi.stubEnv(PACK_ENV_VAR, 'food-safety'); + const { demoSeeds } = await import('../src/data/index.js'); + const data = await loadStackData(); + expect(data).toHaveLength(demoSeeds.length + 1); + expect(data.at(-1)!.records).toHaveLength(FOOD_SAFETY.duties.length); + }); + + it('pnpm demo turns the pack on, but only when the caller has not chosen', () => { + const demoScript = readFileSync(new URL('../scripts/demo.mjs', import.meta.url), 'utf8'); + expect(demoScript).toContain("const PACK_ENV_VAR = 'DULY_PACK';"); + expect(demoScript).toContain("const DEFAULT_DEMO_PACK = 'food-safety';"); + expect(demoScript).toContain( + 'if (process.env[PACK_ENV_VAR] === undefined) process.env[PACK_ENV_VAR] = DEFAULT_DEMO_PACK;', + ); + // And not as a shell default in package.json, which does not run on Windows. + expect(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).not.toContain('DULY_PACK'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe.each(['en', 'zh-CN'] as const)('the pack boots on an empty database, in %s', (locale) => { + it('lands every row through the real loader and validation rules', async () => { + vi.resetModules(); + vi.stubEnv(PACK_ENV_VAR, 'food-safety'); + vi.stubEnv(DEMO_LOCALE_ENV_VAR, locale); + const stack = (await import('../objectstack.config.js')).default as unknown as Record; + + const { plugins } = await createStandaloneStack({ + databaseDriver: 'sqlite', + databaseUrl: ':memory:', + skipSeedData: true, + // As in test/seed.test.ts: never let a local `pnpm build` artifact stand in for the config. + artifactPath: 'dist/objectstack.this-suite-must-not-load-an-artifact.json', + }); + const kernel = new ObjectKernel(); + for (const plugin of plugins) await kernel.use(plugin); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SharingServicePlugin()); + await kernel.use(new SecurityPlugin()); + await kernel.use(new AppPlugin(stack as any, undefined, { skipSeedData: false })); + await kernel.bootstrap(); + + try { + const data = kernel.getService('data') as any; + const items = async () => + ((await data.find('duly_catalog_item', {}, { context: { isSystem: true } })) ?? []) as Record[]; + const deadline = Date.now() + 60_000; + while ((await items()).length < FOOD_SAFETY.duties.length && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + const rows = await items(); + const expected = packCatalogRows(FOOD_SAFETY, locale); + expect(rows).toHaveLength(expected.length); + expect(new Set(rows.map((row) => row.name))).toEqual(new Set(expected.map((row) => row.name))); + expect(new Set(rows.map((row) => row.position_code)).size).toBe(Object.keys(FOOD_SAFETY.positions).length); + // Nothing else: the pack writes no duty, no task and no person. + expect(await data.find('duly_duty', {}, { context: { isSystem: true } })).toHaveLength(0); + if (locale === 'zh-CN') for (const row of rows) expect(String(row.name)).toMatch(CJK); + } finally { + await kernel.shutdown?.(); + } + }, 120_000); +});