feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位

`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params /
summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts`
(与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema);
迁移 0035_activity_ops;《API文档-活动模块》按契约回写。
设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*`
整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。
2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-27 23:20:15 +08:00
co-authored by Claude Fable 5
parent 75553371b0
commit 0923fbfe4d
34 changed files with 5137 additions and 163 deletions
@@ -0,0 +1,181 @@
/**
* 沙箱看板的取数与作用域:声明怎么求值、行集怎么摊平 —— 零 IO,读表用一张内存表替。
*/
import { describe, expect, it } from 'vitest';
import type { ViewData } from '@contracts';
import { caseStamp, openedAtText } from '../case-stamp.ts';
import { resolveViewData, viewScopeOf, slugOf, localMinute } from '../activity-ops/view-scope.ts';
import type { ViewRead, ViewRow } from '../activity-ops/view-scope.ts';
const SPEC: ViewData = {
case: { ledger: 'sub_eval.verdict', key: 'eval_ref', prefix: 'W05-' },
rows: [
{ as: 'sub_eval.verdict', ledger: 'sub_eval.verdict', by: { column: 'eval_ref', from: 'case' } },
{ as: 'sub_eval.pn_check', ledger: 'sub_eval.pn_check', by: { column: 'eval_ref', from: 'case' }, orderBy: [{ field: 'role', dir: 'desc' }] },
{ as: 'parts.profile', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'sub_eval.pn_check.internal_pn' } },
{ as: 'substitution.relation', ledger: 'substitution.relation', by: { column: 'eval_ref', from: 'case' } },
{ as: 'validation.record', ledger: 'validation.record', by: { column: 'request_id', from: 'sub_eval.verdict.request_id' } },
{
as: 'validation.history',
ledger: 'validation.record',
by: { column: 'validation_internal_pn', from: 'sub_eval.pn_check.internal_pn' },
not: { column: 'request_id', from: 'sub_eval.verdict.request_id' },
},
],
};
const TABLES: Record<string, ViewRow[]> = {
'sub_eval.verdict': [
{ eval_ref: 'W05-20260827215103', request_id: 'REQ-20260827215103', original_internal_pn: 'LM2596S-5.0' },
{ eval_ref: 'W05-20260827000000', request_id: 'REQ-20260827000000', original_internal_pn: 'X' },
],
'sub_eval.pn_check': [
{ eval_ref: 'W05-20260827215103', internal_pn: 'XL2596S-5.0', role: 'candidate', exists_status: 'created' },
{ eval_ref: 'W05-20260827215103', internal_pn: 'LM2596S-5.0', role: 'original', exists_status: 'found' },
{ eval_ref: 'W05-20260827000000', internal_pn: 'X', role: 'original', exists_status: 'found' },
],
'parts.profile': [
{ internal_pn: 'LM2596S-5.0', brand_name: 'TI', lifecycle_status: 'nrnd' },
{ internal_pn: 'XL2596S-5.0', brand_name: 'XLSEMI', lifecycle_status: 'active' },
{ internal_pn: 'X', brand_name: '?', lifecycle_status: 'active' },
],
'substitution.relation': [
{ substitution_relation_id: 'REL-1', eval_ref: 'W05-20260827215103', recommendation_level: 'conditional' },
{ substitution_relation_id: 'REL-9', eval_ref: 'W05-20260827000000', recommendation_level: 'alternate' },
],
'validation.record': [
{ validation_id: 'VAL-A', request_id: 'REQ-20260827215103', validation_internal_pn: 'XL2596S-5.0' },
{ validation_id: 'VAL-OLD', request_id: 'REQ-20260101000000', validation_internal_pn: 'XL2596S-5.0' },
{ validation_id: 'VAL-OTHER', request_id: 'REQ-20260101000001', validation_internal_pn: 'Z' },
],
};
/** 内存表:等值过滤 + 单列排序;记下每一次读 */
function reader(tables: Record<string, ViewRow[]>, broken: ReadonlySet<string> = new Set()): { read: ViewRead; calls: string[] } {
const calls: string[] = [];
const read: ViewRead = async (address, where, orderBy) => {
const name = `${address.domain}.${address.object}`;
calls.push(`${name} ${JSON.stringify(where)}`);
if (broken.has(name)) return undefined;
const rows = (tables[name] ?? []).filter((r) => Object.entries(where).every(([k, v]) => String(r[k]) === v));
const order = orderBy[0];
if (order === undefined) return rows;
return [...rows].sort((a, b) => String(a[order.field]).localeCompare(String(b[order.field])) * (order.dir === 'asc' ? 1 : -1));
};
return { read, calls };
}
const DECLARED = [
{ domain: 'parts', object: 'profile', fields: [{ name: 'internal_pn' }, { name: 'lifecycle_status', tally: ['active', 'nrnd', 'eol'] }] },
{ domain: 'substitution', object: 'relation', fields: [{ name: 'recommendation_level', tally: ['recommended', 'conditional', 'alternate', 'not_recommended'] }] },
];
describe('caseStamp / openedAtText', () => {
it('同一串:文本带分隔符、案号只留 14 位数字;认不出的时刻两者都是空串', () => {
const iso = new Date(2026, 7, 27, 21, 51, 3).toISOString();
expect(openedAtText(iso)).toBe('2026-08-27 21:51:03');
expect(caseStamp(iso)).toBe('20260827215103');
expect(openedAtText('not-a-date')).toBe('');
expect(caseStamp('')).toBe('');
});
});
describe('resolveViewData', () => {
it('按序链式求值:案号 → 核对表 → 料号圈主数据;行序随 from 值的顺序(原 PN 排第一)', async () => {
const { read, calls } = reader(TABLES);
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
expect(out.caseFound).toBe(true);
expect(out.skipped).toEqual([]);
expect(out.sets['sub_eval.verdict']).toHaveLength(1);
expect(out.sets['sub_eval.pn_check']?.map((r) => r['internal_pn'])).toEqual(['LM2596S-5.0', 'XL2596S-5.0']);
expect(out.sets['parts.profile']?.map((r) => r['brand_name'])).toEqual(['TI', 'XLSEMI']);
expect(out.sets['substitution.relation']?.map((r) => r['substitution_relation_id'])).toEqual(['REL-1']);
// 每个值单独一次等值读:别单的行一条都不进来
expect(calls).toContain('parts.profile {"internal_pn":"LM2596S-5.0"}');
expect(calls).toContain('parts.profile {"internal_pn":"XL2596S-5.0"}');
expect(calls.filter((c) => c.startsWith('parts.profile'))).toHaveLength(2);
});
it('not 排除:同料号的历史验证记录不含本单挂的那条;别的料号不进来', async () => {
const { read } = reader(TABLES);
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
expect(out.sets['validation.record']?.map((r) => r['validation_id'])).toEqual(['VAL-A']);
expect(out.sets['validation.history']?.map((r) => r['validation_id'])).toEqual(['VAL-OLD']);
});
it('案号查无 → caseFound=false,下游集合全空、一次多余的读都不发', async () => {
const { read, calls } = reader(TABLES);
const out = await resolveViewData(SPEC, { caseRef: 'W05-19990101000000', read });
expect(out.caseFound).toBe(false);
expect(out.sets['parts.profile']).toEqual([]);
expect(calls.some((c) => c.startsWith('parts.profile'))).toBe(false);
});
it('某张表读不了:那个集合为空并点名,别的集合照常', async () => {
const { read } = reader(TABLES, new Set(['parts.profile']));
const out = await resolveViewData(SPEC, { caseRef: 'W05-20260827215103', read });
expect(out.sets['parts.profile']).toEqual([]);
expect(out.sets['substitution.relation']).toHaveLength(1);
expect(out.skipped).toEqual(['parts.profile']);
});
it('from 指向没定义过的集合 → 点名、集合为空(声明写错不炸)', async () => {
const { read } = reader(TABLES);
const spec: ViewData = { ...SPEC, rows: [{ as: 'x', ledger: 'parts.profile', by: { column: 'internal_pn', from: 'nope.internal_pn' } }] };
const out = await resolveViewData(spec, { caseRef: 'W05-20260827215103', read });
expect(out.sets['x']).toEqual([]);
expect(out.skipped).toEqual(['x']);
});
});
describe('viewScopeOf', () => {
it('行数组 / 首行标量 / 行数 / tally 计数(先铺 0)/ 实例事实 / 三态', () => {
const sets = {
'parts.profile': [
{ internal_pn: 'LM2596S-5.0', lifecycle_status: 'nrnd' },
{ internal_pn: 'XL2596S-5.0', lifecycle_status: 'active' },
],
'substitution.relation': [],
};
const scope = viewScopeOf({
spec: SPEC,
sets,
declared: DECLARED,
instance: { no: 'INS-20', startedAt: new Date(2026, 7, 27, 21, 51, 3).toISOString(), finishedAt: new Date(2026, 7, 27, 21, 57, 56).toISOString(), owner: '柴浩' },
caseRef: 'W05-20260827215103',
state: 'ready',
});
expect(scope['board:state']).toBe('ready');
expect(scope['case:instanceNo']).toBe('INS-20');
expect(scope['case:openedAt']).toBe('2026-08-27 21:51');
expect(scope['case:releasedAt']).toBe('2026-08-27 21:57');
expect(scope['case:owner']).toBe('柴浩');
expect(scope['case:ref']).toBe('W05-20260827215103');
expect(scope['parts.profile']).toBe(sets['parts.profile']);
expect(scope['parts.profile.internal_pn']).toBe('LM2596S-5.0');
expect(scope['parts_profile_rows']).toBe(2);
expect(scope['parts_profile_lifecycle_status_nrnd']).toBe(1);
expect(scope['parts_profile_lifecycle_status_active']).toBe(1);
expect(scope['parts_profile_lifecycle_status_eol']).toBe(0);
expect(scope['substitution_relation_rows']).toBe(0);
expect(scope['substitution_relation_recommendation_level_not_recommended']).toBe(0);
// 声明里没求出来的集合也铺空数组:模版的 data-rows 不会对着 undefined
expect(scope['sub_eval.verdict']).toEqual([]);
});
it('没有已放行实例:none 态,实例事实全空串', () => {
const scope = viewScopeOf({ spec: SPEC, sets: {}, declared: DECLARED, state: 'none' });
expect(scope['board:state']).toBe('none');
expect(scope['case:instanceNo']).toBe('');
expect(scope['case:releasedAt']).toBe('');
expect(scope['parts_profile_rows']).toBe(0);
});
it('slug 与时刻', () => {
expect(slugOf('sub_eval.verdict')).toBe('sub_eval_verdict');
expect(slugOf('validation.history')).toBe('validation_history');
expect(slugOf('not_recommended')).toBe('not_recommended');
expect(localMinute(null)).toBe('');
expect(localMinute('garbage')).toBe('');
});
});