mirror of
https://gh-proxy.org/https://github.com/klinsmaya/portal-template-default.git
synced 2026-11-08 00:57:57 +08:00
feat(dingze): add shared artifact catalog, lifecycle and validators
The catalog defines the three-stage step order, unlock rules and dependencies; the lifecycle module resolves status transitions and role checks; validators implement the hard checks for the six-part table and strategy house, path systems and the RACI matrix. Both the server plugin and the Portal import the same module through @dingze/shared. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MMDFmVTvRksi1ZT8aPGXoL
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// Artifact catalog for the three responsibility stages (spec V1.2 §4.1, §4.2).
|
||||
// Shared by the server plugin and the Portal so the step order, unlock rules and
|
||||
// dependencies are defined in exactly one place.
|
||||
|
||||
export type StageKey = 'strategy' | 'goal' | 'action';
|
||||
|
||||
export type Priority = 'P0' | 'P1' | 'method';
|
||||
|
||||
export type EditorKind =
|
||||
| 'strategy-content'
|
||||
| 'strategy-map'
|
||||
| 'table'
|
||||
| 'goal-target'
|
||||
| 'decode-map'
|
||||
| 'path-system'
|
||||
| 'raci'
|
||||
| 'project-charter'
|
||||
| 'schedule'
|
||||
| 'resource-match'
|
||||
| 'plan-book'
|
||||
| 'worksheet';
|
||||
|
||||
export interface ArtifactDef {
|
||||
/** Stable code stored on records, e.g. `S1-01`. Method worksheets use `M-*`. */
|
||||
code: string;
|
||||
/** Spec ID shown to users. Several internal artifacts can share a spec ID (S2-03 target and path zones). */
|
||||
specId: string;
|
||||
name: string;
|
||||
stage: StageKey;
|
||||
/** Checklist columns: 工作任务 and 实施步骤. */
|
||||
task: string;
|
||||
step: string;
|
||||
bookRef: string;
|
||||
priority: Priority;
|
||||
editor: EditorKind;
|
||||
/** Artifacts that must reach "本步完成" (or later) before this one unlocks. P1 and method items never block. */
|
||||
unlockAfter: string[];
|
||||
/** Upstream artifacts whose content this one reads. Used for "上游已变更" propagation. */
|
||||
dependsOn: string[];
|
||||
/** Method worksheet attached to another artifact. */
|
||||
attachedTo?: string;
|
||||
}
|
||||
|
||||
/** Views and embedded tables that keep a spec ID but have no lifecycle of their own. */
|
||||
export interface DerivedDef {
|
||||
specId: string;
|
||||
name: string;
|
||||
stage: StageKey;
|
||||
/** The artifact whose data and status this view shares. */
|
||||
sourceCode: string;
|
||||
kind: 'view' | 'embedded' | 'snapshot';
|
||||
}
|
||||
|
||||
export const STAGES: { key: StageKey; name: string; goal: string }[] = [
|
||||
{ key: 'strategy', name: '定战略责', goal: '清晰共识' },
|
||||
{ key: 'goal', name: '定目标责', goal: '纵横贯通' },
|
||||
{ key: 'action', name: '定行动责', goal: '可视可控' },
|
||||
];
|
||||
|
||||
export const ARTIFACTS: ArtifactDef[] = [
|
||||
// ── 定战略责 ──
|
||||
{
|
||||
code: 'S1-01', specId: 'S1-01', name: '战略简约六分法表 / 战略屋', stage: 'strategy',
|
||||
task: '内容共识:纷繁战略简约化', step: '战略简约六分法 / 战略屋', bookRef: '表 2-1、图 2-3',
|
||||
priority: 'P0', editor: 'strategy-content', unlockAfter: [], dependsOn: [],
|
||||
},
|
||||
{
|
||||
code: 'M-MISSION', specId: 'S1-01', name: '使命五要素萃取', stage: 'strategy',
|
||||
task: '逻辑共识:简约战略逻辑化', step: '使命共识', bookRef: '表 2-3',
|
||||
priority: 'method', editor: 'worksheet', unlockAfter: [], dependsOn: [], attachedTo: 'S1-01',
|
||||
},
|
||||
{
|
||||
code: 'M-VISION', specId: 'S1-01', name: '愿景三法', stage: 'strategy',
|
||||
task: '逻辑共识:简约战略逻辑化', step: '愿景共识', bookRef: '战略推导法、对标一流法、引以为傲法',
|
||||
priority: 'method', editor: 'worksheet', unlockAfter: [], dependsOn: [], attachedTo: 'S1-01',
|
||||
},
|
||||
{
|
||||
code: 'S1-02', specId: 'S1-02', name: '战略地图', stage: 'strategy',
|
||||
task: '逻辑共识:简约战略逻辑化', step: '战略内容的逻辑共识', bookRef: '图 2-8、图 2-9',
|
||||
priority: 'P0', editor: 'strategy-map', unlockAfter: ['S1-01'], dependsOn: ['S1-01'],
|
||||
},
|
||||
{
|
||||
code: 'S1-03', specId: 'S1-03', name: '企业战略、策略逻辑表', stage: 'strategy',
|
||||
task: '衡量共识:逻辑战略指标化', step: '第一步 导出企业的战略和策略', bookRef: '表 2-7',
|
||||
priority: 'P0', editor: 'table', unlockAfter: ['S1-02'], dependsOn: ['S1-01'],
|
||||
},
|
||||
{
|
||||
code: 'S1-04', specId: 'S1-04', name: 'IPOOC 指标设计表', stage: 'strategy',
|
||||
task: '衡量共识:逻辑战略指标化', step: '第二步 设定衡量指标', bookRef: '表 2-8',
|
||||
priority: 'P1', editor: 'table', unlockAfter: ['S1-03'], dependsOn: ['S1-03'],
|
||||
},
|
||||
{
|
||||
code: 'S1-05', specId: 'S1-05', name: 'KPI 筛选评价表', stage: 'strategy',
|
||||
task: '衡量共识:逻辑战略指标化', step: '第三步 筛选战略 KPI', bookRef: '表 2-10、表 2-11',
|
||||
priority: 'P1', editor: 'table', unlockAfter: ['S1-04'], dependsOn: ['S1-04'],
|
||||
},
|
||||
{
|
||||
code: 'S1-06', specId: 'S1-06', name: '战略 KPI 表', stage: 'strategy',
|
||||
task: '衡量共识:逻辑战略指标化', step: '第三步 筛选战略 KPI', bookRef: '表 2-10、表 2-11 筛选结果',
|
||||
priority: 'P0', editor: 'table', unlockAfter: ['S1-03'], dependsOn: ['S1-03', 'S1-05'],
|
||||
},
|
||||
{
|
||||
code: 'S1-07', specId: 'S1-07', name: '战略 KPI 3—5 年年度分解表', stage: 'strategy',
|
||||
task: '衡量共识:逻辑战略指标化', step: '第四步 设定指标值并分解至 3—5 年', bookRef: '表 2-12',
|
||||
priority: 'P0', editor: 'table', unlockAfter: ['S1-06'], dependsOn: ['S1-06'],
|
||||
},
|
||||
|
||||
// ── 定目标责 ──
|
||||
{
|
||||
code: 'S2-03-T', specId: 'S2-03', name: '公司级年度目标(路径系统表目标区)', stage: 'goal',
|
||||
task: '路径支撑,确保结果必赢', step: '第一步 定目标', bookRef: '表 3-4、表 3-5',
|
||||
priority: 'P0', editor: 'goal-target', unlockAfter: [], dependsOn: ['S1-02', 'S1-07'],
|
||||
},
|
||||
{
|
||||
code: 'M-BUDGET', specId: 'S2-03', name: '年度经营预算', stage: 'goal',
|
||||
task: '路径支撑,确保结果必赢', step: '第一步 定目标', bookRef: '表 3-5',
|
||||
priority: 'method', editor: 'worksheet', unlockAfter: [], dependsOn: [], attachedTo: 'S2-03-T',
|
||||
},
|
||||
{
|
||||
code: 'S2-01', specId: 'S2-01', name: '年度战略解码地图', stage: 'goal',
|
||||
task: '路径支撑,确保结果必赢', step: '第二步 找路径(战略地图)', bookRef: '图 3-3、图 3-7~3-10',
|
||||
priority: 'P0', editor: 'decode-map', unlockAfter: ['S2-03-T'], dependsOn: ['S2-03-T'],
|
||||
},
|
||||
{
|
||||
code: 'S2-03', specId: 'S2-03', name: '公司级目标路径系统表', stage: 'goal',
|
||||
task: '路径支撑,确保结果必赢', step: '第二步 找路径(导入路径系统表并分解)', bookRef: '表 3-9、表 3-10',
|
||||
priority: 'P0', editor: 'path-system', unlockAfter: ['S2-01'], dependsOn: ['S2-03-T', 'S2-01'],
|
||||
},
|
||||
{
|
||||
code: 'S2-08', specId: 'S2-08', name: '关键项目列表', stage: 'goal',
|
||||
task: '路径支撑,确保结果必赢', step: '第三步 抓关键 · 第四步 立项目', bookRef: '表 3-13',
|
||||
priority: 'P0', editor: 'table', unlockAfter: ['S2-03'], dependsOn: ['S2-03'],
|
||||
},
|
||||
{
|
||||
code: 'S2-04', specId: 'S2-04', name: 'RACI 责任分解矩阵', stage: 'goal',
|
||||
task: '横向到边,责任全域协同', step: '列出责任者 → 构建 RACI → 验证与优化', bookRef: '表 3-17、表 3-18、表 3-20',
|
||||
priority: 'P0', editor: 'raci', unlockAfter: ['S2-03'], dependsOn: ['S2-03', 'S2-08'],
|
||||
},
|
||||
{
|
||||
code: 'S2-05', specId: 'S2-05', name: '部门目标承接表', stage: 'goal',
|
||||
task: '纵向到底,目标层层击穿', step: '步骤 2 分部门', bookRef: '表 3-20',
|
||||
priority: 'P0', editor: 'table', unlockAfter: ['S2-04'], dependsOn: ['S2-03', 'S2-04'],
|
||||
},
|
||||
{
|
||||
code: 'S2-06', specId: 'S2-06', name: '部门级目标路径系统图 / 表', stage: 'goal',
|
||||
task: '纵向到底,目标层层击穿', step: '部门级再找路径', bookRef: '同表 3-9',
|
||||
priority: 'P0', editor: 'path-system', unlockAfter: ['S2-05'], dependsOn: ['S2-04', 'S2-05'],
|
||||
},
|
||||
{
|
||||
code: 'S2-07', specId: 'S2-07', name: '绩效计分卡', stage: 'goal',
|
||||
task: '纵向到底,目标层层击穿', step: '步骤 3 挂考核', bookRef: '表 3-21',
|
||||
priority: 'P0', editor: 'table', unlockAfter: ['S2-06'], dependsOn: ['S2-04', 'S2-05'],
|
||||
},
|
||||
|
||||
// ── 定行动责 ──
|
||||
{
|
||||
code: 'S3-02', specId: 'S3-02', name: '项目任务书(含 WBS)', stage: 'action',
|
||||
task: '任务明确,全局可视', step: '项目与项目制管理', bookRef: '表 4-4',
|
||||
priority: 'P0', editor: 'project-charter', unlockAfter: [], dependsOn: ['S2-08', 'S2-06', 'S2-04'],
|
||||
},
|
||||
{
|
||||
code: 'S3-05', specId: 'S3-05', name: '计划实施推进表(含节点表)', stage: 'action',
|
||||
task: '节点明确,进程可控', step: '划分节点 → 输出《计划实施推进表》', bookRef: '表 4-5、表 4-6',
|
||||
priority: 'P0', editor: 'schedule', unlockAfter: ['S3-02'], dependsOn: ['S3-02'],
|
||||
},
|
||||
{
|
||||
code: 'S3-06', specId: 'S3-06', name: '项目资源匹配表', stage: 'action',
|
||||
task: '资源明确,支撑可见', step: '算清需求 → 核对存量 → 补齐缺口', bookRef: '表 4-8',
|
||||
priority: 'P0', editor: 'resource-match', unlockAfter: ['S3-05'], dependsOn: ['S3-02', 'S3-05'],
|
||||
},
|
||||
{
|
||||
code: 'S3-07', specId: 'S3-07', name: '公司级年度经营计划书', stage: 'action',
|
||||
task: '年度经营计划书', step: '公司计划', bookRef: '公司级计划书模板(8 章)',
|
||||
priority: 'P0', editor: 'plan-book', unlockAfter: ['S3-06'], dependsOn: ['S1-07', 'S2-03', 'S2-04', 'S2-05', 'S2-07', 'S3-02', 'S3-05', 'S3-06'],
|
||||
},
|
||||
{
|
||||
code: 'S3-08', specId: 'S3-08', name: '部门年度经营计划书', stage: 'action',
|
||||
task: '年度经营计划书', step: '部门计划', bookRef: '部门级计划书模板(8 章)',
|
||||
priority: 'P0', editor: 'plan-book', unlockAfter: ['S3-06'], dependsOn: ['S2-05', 'S2-06', 'S2-07', 'S3-02', 'S3-05', 'S3-06'],
|
||||
},
|
||||
];
|
||||
|
||||
export const DERIVED: DerivedDef[] = [
|
||||
{ specId: 'S2-02', name: '公司级目标路径系统图', stage: 'goal', sourceCode: 'S2-03', kind: 'view' },
|
||||
{ specId: 'S3-01', name: '年度重点项目清单', stage: 'action', sourceCode: 'S2-08', kind: 'snapshot' },
|
||||
{ specId: 'S3-03', name: '项目 WBS', stage: 'action', sourceCode: 'S3-02', kind: 'embedded' },
|
||||
{ specId: 'S3-04', name: '项目 / 任务节点表', stage: 'action', sourceCode: 'S3-05', kind: 'embedded' },
|
||||
];
|
||||
|
||||
const byCode = new Map(ARTIFACTS.map((a) => [a.code, a]));
|
||||
|
||||
export function getArtifactDef(code: string): ArtifactDef {
|
||||
const def = byCode.get(code);
|
||||
if (!def) throw new Error(`Unknown artifact code: ${code}`);
|
||||
return def;
|
||||
}
|
||||
|
||||
export function isArtifactCode(code: string): boolean {
|
||||
return byCode.has(code);
|
||||
}
|
||||
|
||||
export function artifactsOfStage(stage: StageKey): ArtifactDef[] {
|
||||
return ARTIFACTS.filter((a) => a.stage === stage);
|
||||
}
|
||||
|
||||
/** Artifacts that must all be Locked before the next stage opens. */
|
||||
export function stageGateCodes(stage: StageKey): string[] {
|
||||
return artifactsOfStage(stage)
|
||||
.filter((a) => a.priority === 'P0')
|
||||
.map((a) => a.code);
|
||||
}
|
||||
|
||||
export function previousStage(stage: StageKey): StageKey | null {
|
||||
const index = STAGES.findIndex((s) => s.key === stage);
|
||||
return index > 0 ? STAGES[index - 1].key : null;
|
||||
}
|
||||
|
||||
/** Direct downstream artifacts that read `code`. */
|
||||
export function dependentsOf(code: string): string[] {
|
||||
return ARTIFACTS.filter((a) => a.dependsOn.includes(code)).map((a) => a.code);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './catalog';
|
||||
export * from './lifecycle';
|
||||
export * from './validators';
|
||||
@@ -0,0 +1,193 @@
|
||||
// Artifact lifecycle (spec V1.2 §二(九), Q1, Q10): states, transitions, who may
|
||||
// perform them, and how unlocking is computed from the catalog.
|
||||
|
||||
import { ARTIFACTS, ArtifactDef, StageKey, getArtifactDef, previousStage, stageGateCodes } from './catalog';
|
||||
|
||||
export type ArtifactStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'step_done'
|
||||
| 'in_review'
|
||||
| 'pending_confirm'
|
||||
| 'locked'
|
||||
| 'archived';
|
||||
|
||||
export const STATUS_LABELS: Record<ArtifactStatus, string> = {
|
||||
not_started: '未开始',
|
||||
in_progress: '进行中',
|
||||
step_done: '本步完成',
|
||||
in_review: '待复核',
|
||||
pending_confirm: '待确认',
|
||||
locked: '已定版',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
/** Roles inside one project. Enterprise confirmation belongs to `ent_lead` only (Q2). */
|
||||
export type ProjectRole = 'ent_lead' | 'dept_head' | 'member' | 'readonly' | 'lead_consultant' | 'co_consultant';
|
||||
|
||||
export const PROJECT_ROLE_LABELS: Record<ProjectRole, string> = {
|
||||
ent_lead: '企业项目负责人',
|
||||
dept_head: '部门负责人',
|
||||
member: '项目成员',
|
||||
readonly: '只读成员',
|
||||
lead_consultant: '主咨询师',
|
||||
co_consultant: '协作咨询师',
|
||||
};
|
||||
|
||||
export const ENTERPRISE_EDITORS: ProjectRole[] = ['ent_lead', 'dept_head', 'member'];
|
||||
export const CONSULTANTS: ProjectRole[] = ['lead_consultant', 'co_consultant'];
|
||||
|
||||
export type LifecycleAction =
|
||||
| 'save'
|
||||
| 'stepDone'
|
||||
| 'submitReview'
|
||||
| 'approve'
|
||||
| 'returnToEdit'
|
||||
| 'confirm'
|
||||
| 'reopen'
|
||||
| 'forceLock'
|
||||
| 'forceReturn';
|
||||
|
||||
export interface Actor {
|
||||
projectRole: ProjectRole | null;
|
||||
/** System-level consulting admin may force transitions with a reason. */
|
||||
isConsultAdmin: boolean;
|
||||
}
|
||||
|
||||
interface TransitionRule {
|
||||
from: ArtifactStatus[];
|
||||
/** Target status; `save` depends on who edits, see `nextStatus`. */
|
||||
to: ArtifactStatus | 'by-actor';
|
||||
roles: ProjectRole[];
|
||||
consultAdmin?: boolean;
|
||||
requiresReason?: boolean;
|
||||
/** Run hard validation before the transition. */
|
||||
validates?: boolean;
|
||||
}
|
||||
|
||||
const RULES: Record<LifecycleAction, TransitionRule> = {
|
||||
save: {
|
||||
from: ['not_started', 'in_progress', 'step_done', 'in_review', 'pending_confirm'],
|
||||
to: 'by-actor',
|
||||
roles: [...ENTERPRISE_EDITORS, ...CONSULTANTS],
|
||||
},
|
||||
stepDone: { from: ['in_progress'], to: 'step_done', roles: ['ent_lead', 'dept_head'], validates: true },
|
||||
submitReview: { from: ['step_done'], to: 'in_review', roles: ['ent_lead', 'dept_head', ...CONSULTANTS] },
|
||||
approve: { from: ['in_review'], to: 'pending_confirm', roles: ['lead_consultant'], validates: true },
|
||||
returnToEdit: { from: ['in_review', 'pending_confirm'], to: 'in_progress', roles: ['lead_consultant', 'ent_lead'], requiresReason: true },
|
||||
confirm: { from: ['pending_confirm'], to: 'locked', roles: ['ent_lead'], validates: true },
|
||||
reopen: { from: ['locked'], to: 'in_progress', roles: ['lead_consultant'], consultAdmin: true, requiresReason: true },
|
||||
forceLock: {
|
||||
from: ['in_progress', 'step_done', 'in_review', 'pending_confirm'],
|
||||
to: 'locked', roles: [], consultAdmin: true, requiresReason: true,
|
||||
},
|
||||
forceReturn: {
|
||||
from: ['step_done', 'in_review', 'pending_confirm', 'locked'],
|
||||
to: 'in_progress', roles: [], consultAdmin: true, requiresReason: true,
|
||||
},
|
||||
};
|
||||
|
||||
export class LifecycleError extends Error {
|
||||
constructor(
|
||||
readonly code: 'FORBIDDEN' | 'INVALID_STATE' | 'REASON_REQUIRED' | 'LOCKED_STEP' | 'VALIDATION_FAILED',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function canPerform(action: LifecycleAction, actor: Actor): boolean {
|
||||
const rule = RULES[action];
|
||||
if (rule.consultAdmin && actor.isConsultAdmin) return true;
|
||||
return actor.projectRole !== null && rule.roles.includes(actor.projectRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the status after an action, or throw a LifecycleError. Validation itself is
|
||||
* done by the caller (see `requiresValidation`) because it needs the payload.
|
||||
*/
|
||||
export function nextStatus(
|
||||
action: LifecycleAction,
|
||||
current: ArtifactStatus,
|
||||
actor: Actor,
|
||||
reason?: string,
|
||||
): ArtifactStatus {
|
||||
const rule = RULES[action];
|
||||
if (!canPerform(action, actor)) {
|
||||
throw new LifecycleError('FORBIDDEN', '当前角色不能执行这个操作');
|
||||
}
|
||||
if (!rule.from.includes(current)) {
|
||||
throw new LifecycleError('INVALID_STATE', `成果处于“${STATUS_LABELS[current]}”,不能执行这个操作`);
|
||||
}
|
||||
if (rule.requiresReason && !reason?.trim()) {
|
||||
throw new LifecycleError('REASON_REQUIRED', '需要填写原因');
|
||||
}
|
||||
if (rule.to !== 'by-actor') return rule.to;
|
||||
|
||||
// Saving: a consultant revising during review keeps the review going; any other edit
|
||||
// turns the artifact back into a working draft, so earlier confirmations no longer apply.
|
||||
const isConsultant = actor.projectRole !== null && CONSULTANTS.includes(actor.projectRole);
|
||||
if (isConsultant && current === 'in_review') return 'in_review';
|
||||
return 'in_progress';
|
||||
}
|
||||
|
||||
export function requiresValidation(action: LifecycleAction): boolean {
|
||||
return RULES[action].validates === true;
|
||||
}
|
||||
|
||||
// ── Unlocking ──
|
||||
|
||||
export interface ArtifactState {
|
||||
status: ArtifactStatus;
|
||||
stale?: boolean;
|
||||
/** Set when a consultant granted a step exception for this artifact. */
|
||||
exception?: boolean;
|
||||
}
|
||||
|
||||
export type StateMap = Partial<Record<string, ArtifactState>>;
|
||||
|
||||
const REACHED_STEP_DONE: ArtifactStatus[] = ['step_done', 'in_review', 'pending_confirm', 'locked', 'archived'];
|
||||
|
||||
export function hasReachedStepDone(state: ArtifactState | undefined): boolean {
|
||||
return !!state && (REACHED_STEP_DONE.includes(state.status) || !!state.exception);
|
||||
}
|
||||
|
||||
export function isStageComplete(stage: StageKey, states: StateMap): boolean {
|
||||
return stageGateCodes(stage).every((code) => {
|
||||
const state = states[code];
|
||||
return !!state && (state.status === 'locked' || state.status === 'archived');
|
||||
});
|
||||
}
|
||||
|
||||
export function isStageOpen(stage: StageKey, states: StateMap): boolean {
|
||||
const prev = previousStage(stage);
|
||||
return prev === null || isStageComplete(prev, states);
|
||||
}
|
||||
|
||||
export interface UnlockInfo {
|
||||
unlocked: boolean;
|
||||
/** Human-readable reason when locked. */
|
||||
waitingFor?: string;
|
||||
}
|
||||
|
||||
export function unlockInfo(def: ArtifactDef, states: StateMap): UnlockInfo {
|
||||
if (!isStageOpen(def.stage, states)) {
|
||||
return { unlocked: false, waitingFor: '上一阶段全部 P0 成果定版后开启' };
|
||||
}
|
||||
if (def.attachedTo) {
|
||||
return unlockInfo(getArtifactDef(def.attachedTo), states);
|
||||
}
|
||||
const pending = def.unlockAfter.filter((code) => !hasReachedStepDone(states[code]));
|
||||
if (pending.length > 0) {
|
||||
const names = pending.map((code) => `${getArtifactDef(code).specId} ${getArtifactDef(code).name}`);
|
||||
return { unlocked: false, waitingFor: `${names.join('、')}“本步完成”后解锁` };
|
||||
}
|
||||
return { unlocked: true };
|
||||
}
|
||||
|
||||
/** Downstream artifacts to mark “上游已变更” after `code` gets a new step-done or locked version. */
|
||||
export function staleTargets(code: string, states: StateMap): string[] {
|
||||
return ARTIFACTS.filter(
|
||||
(a) => a.dependsOn.includes(code) && states[a.code] && states[a.code]!.status !== 'not_started',
|
||||
).map((a) => a.code);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Structural validation of artifact payloads. `error` issues block “本步完成” and
|
||||
// confirmation (Q3); `warning` issues are shown while editing but never block.
|
||||
|
||||
export type IssueLevel = 'error' | 'warning';
|
||||
|
||||
export interface Issue {
|
||||
level: IssueLevel;
|
||||
message: string;
|
||||
/** Item ID or field key the issue points at, for jump-to-cell. */
|
||||
anchor?: string;
|
||||
}
|
||||
|
||||
const PENDING_MARK = '待补';
|
||||
|
||||
function filled(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
const text = value.trim();
|
||||
return text.length > 0 && text !== PENDING_MARK;
|
||||
}
|
||||
|
||||
// ── S1-01 六分法 / 战略屋 ──
|
||||
|
||||
export type StrategyExpression = 'sixfold' | 'house';
|
||||
|
||||
export interface Battlefield {
|
||||
id: string;
|
||||
/** 主要战场,如“第一增长曲线:城市燃气”。 */
|
||||
name: string;
|
||||
/** 如何致胜:可持续核心优势。 */
|
||||
advantage: string;
|
||||
/** 年度必赢之战。 */
|
||||
mustWin: string;
|
||||
}
|
||||
|
||||
export interface StrategyContent {
|
||||
primary: StrategyExpression;
|
||||
/** Shared by both expressions: 目的 = 使命, 方向 = 愿景. */
|
||||
mission: string;
|
||||
vision: string;
|
||||
// 六分法
|
||||
strategicGoals: string;
|
||||
strategyChoice: string;
|
||||
stepsAndMeasures: string;
|
||||
indicatorSystem: string;
|
||||
// 战略屋
|
||||
values: string;
|
||||
goals: { y1: string; y3: string; y5: string };
|
||||
battlefields: Battlefield[];
|
||||
foundation: { organization: string; mechanism: string; talent: string };
|
||||
}
|
||||
|
||||
export const SIXFOLD_FIELDS: { key: keyof StrategyContent; label: string }[] = [
|
||||
{ key: 'mission', label: '目的(使命)' },
|
||||
{ key: 'vision', label: '方向(愿景)' },
|
||||
{ key: 'strategicGoals', label: '战略目标' },
|
||||
{ key: 'strategyChoice', label: '策略选择' },
|
||||
{ key: 'stepsAndMeasures', label: '步骤举措' },
|
||||
{ key: 'indicatorSystem', label: '指标体系' },
|
||||
];
|
||||
|
||||
export function emptyStrategyContent(primary: StrategyExpression): StrategyContent {
|
||||
return {
|
||||
primary,
|
||||
mission: '', vision: '',
|
||||
strategicGoals: '', strategyChoice: '', stepsAndMeasures: '', indicatorSystem: '',
|
||||
values: '', goals: { y1: '', y3: '', y5: '' }, battlefields: [],
|
||||
foundation: { organization: '', mechanism: '', talent: '' },
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSixfold(content: StrategyContent): Issue[] {
|
||||
return SIXFOLD_FIELDS.filter((f) => !filled(content[f.key])).map((f) => ({
|
||||
level: 'error' as const,
|
||||
message: `六分法缺“${f.label}”`,
|
||||
anchor: String(f.key),
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateHouse(content: StrategyContent): Issue[] {
|
||||
const issues: Issue[] = [];
|
||||
const need = (value: string, label: string, anchor: string) => {
|
||||
if (!filled(value)) issues.push({ level: 'error', message: `战略屋缺“${label}”`, anchor });
|
||||
};
|
||||
need(content.mission, '屋顶 · 使命', 'mission');
|
||||
need(content.vision, '屋顶 · 愿景', 'vision');
|
||||
need(content.values, '屋顶 · 价值观', 'values');
|
||||
need(content.goals.y1, '经营目标 · 一年', 'goals.y1');
|
||||
need(content.goals.y3, '经营目标 · 三年', 'goals.y3');
|
||||
need(content.goals.y5, '经营目标 · 五年', 'goals.y5');
|
||||
if (content.battlefields.length === 0) {
|
||||
issues.push({ level: 'error', message: '战略屋至少需要一个主要战场', anchor: 'battlefields' });
|
||||
}
|
||||
content.battlefields.forEach((b, i) => {
|
||||
const col = `第 ${i + 1} 个战场`;
|
||||
need(b.name, `${col} · 主要战场`, `battlefields.${b.id}.name`);
|
||||
need(b.advantage, `${col} · 如何致胜`, `battlefields.${b.id}.advantage`);
|
||||
need(b.mustWin, `${col} · 必赢之战`, `battlefields.${b.id}.mustWin`);
|
||||
});
|
||||
need(content.foundation.organization, '落地保障 · 组织', 'foundation.organization');
|
||||
need(content.foundation.mechanism, '落地保障 · 机制', 'foundation.mechanism');
|
||||
need(content.foundation.talent, '落地保障 · 人才', 'foundation.talent');
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Only the primary expression gates “本步完成”; the other one is reported as warnings. */
|
||||
export function validateStrategyContent(content: StrategyContent): Issue[] {
|
||||
const [primary, secondary] =
|
||||
content.primary === 'house'
|
||||
? [validateHouse(content), validateSixfold(content)]
|
||||
: [validateSixfold(content), validateHouse(content)];
|
||||
return [...primary, ...secondary.map((i) => ({ ...i, level: 'warning' as const }))];
|
||||
}
|
||||
|
||||
// ── 路径系统表(S2-03 / S2-06)──
|
||||
|
||||
export type Perspective = 'financial' | 'customer' | 'process' | 'learning';
|
||||
|
||||
export interface PathNode {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
perspective?: Perspective;
|
||||
/** 1–4. */
|
||||
level: number;
|
||||
path: string;
|
||||
metric: string;
|
||||
/** Displayed value, e.g. “700 万方”, “≤10 分钟”. */
|
||||
value: string;
|
||||
/** Numeric part when the value is additive; used for “推得回来”. */
|
||||
amount?: number | null;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface PathSystem {
|
||||
nodes: PathNode[];
|
||||
}
|
||||
|
||||
export const MAX_PATH_LEVEL = 4;
|
||||
|
||||
export function validatePathSystem(system: PathSystem): Issue[] {
|
||||
const issues: Issue[] = [];
|
||||
const children = new Map<string | null, PathNode[]>();
|
||||
for (const node of system.nodes) {
|
||||
const list = children.get(node.parentId) ?? [];
|
||||
list.push(node);
|
||||
children.set(node.parentId, list);
|
||||
}
|
||||
const byId = new Map(system.nodes.map((n) => [n.id, n]));
|
||||
|
||||
for (const node of system.nodes) {
|
||||
const label = node.path.trim() || '未命名路径';
|
||||
if (node.level < 1 || node.level > MAX_PATH_LEVEL) {
|
||||
issues.push({ level: 'error', message: `“${label}”的层级超出 1–${MAX_PATH_LEVEL} 级`, anchor: node.id });
|
||||
}
|
||||
if (node.parentId && !byId.has(node.parentId)) {
|
||||
issues.push({ level: 'error', message: `“${label}”的上级路径不存在`, anchor: node.id });
|
||||
}
|
||||
if (!filled(node.path)) issues.push({ level: 'error', message: '有路径没有填写名称', anchor: node.id });
|
||||
if (!filled(node.metric)) issues.push({ level: 'error', message: `“${label}”缺衡量指标`, anchor: node.id });
|
||||
if (!filled(node.value)) issues.push({ level: 'error', message: `“${label}”缺指标值`, anchor: node.id });
|
||||
|
||||
const kids = children.get(node.id) ?? [];
|
||||
if (node.level === 1 && kids.length === 0) {
|
||||
issues.push({ level: 'warning', message: `一级路径“${label}”还没有分解到二级`, anchor: node.id });
|
||||
}
|
||||
// 推得回来: only comparable when parent and all children share metric and unit and carry amounts.
|
||||
const comparable =
|
||||
kids.length > 0 &&
|
||||
typeof node.amount === 'number' &&
|
||||
kids.every((k) => typeof k.amount === 'number' && k.metric.trim() === node.metric.trim() && (k.unit ?? '') === (node.unit ?? ''));
|
||||
if (comparable) {
|
||||
const sum = kids.reduce((acc, k) => acc + (k.amount as number), 0);
|
||||
if (Math.abs(sum - (node.amount as number)) > 1e-9) {
|
||||
issues.push({
|
||||
level: 'error',
|
||||
message: `“${label}”推不回来:下级合计 ${sum},本级 ${node.amount}`,
|
||||
anchor: node.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── RACI(S2-04)──
|
||||
|
||||
export type RaciLetter = 'R' | 'A' | 'C' | 'I';
|
||||
|
||||
export interface RaciMatrix {
|
||||
columns: { id: string; name: string }[];
|
||||
rows: { id: string; name: string; cells: Record<string, RaciLetter[]> }[];
|
||||
}
|
||||
|
||||
export const A_LOAD_WARNING_RATIO = 0.5;
|
||||
|
||||
export function validateRaci(matrix: RaciMatrix): Issue[] {
|
||||
const issues: Issue[] = [];
|
||||
const aLoad = new Map<string, number>();
|
||||
for (const row of matrix.rows) {
|
||||
let a = 0;
|
||||
let r = 0;
|
||||
for (const col of matrix.columns) {
|
||||
const letters = row.cells[col.id] ?? [];
|
||||
if (letters.includes('A')) {
|
||||
a += 1;
|
||||
aLoad.set(col.id, (aLoad.get(col.id) ?? 0) + 1);
|
||||
}
|
||||
if (letters.includes('R')) r += 1;
|
||||
if (letters.includes('R') && letters.includes('A')) {
|
||||
issues.push({ level: 'warning', message: `“${row.name}”由${col.name}同时担任 R 与 A,确认是否有意为之`, anchor: row.id });
|
||||
}
|
||||
}
|
||||
if (a === 0) issues.push({ level: 'error', message: `“${row.name}”没有 A(最终负责人)`, anchor: row.id });
|
||||
if (a > 1) issues.push({ level: 'error', message: `“${row.name}”有 ${a} 个 A,A 只能有一个`, anchor: row.id });
|
||||
if (r === 0) issues.push({ level: 'error', message: `“${row.name}”没有 R(执行者)`, anchor: row.id });
|
||||
if (r > 1) issues.push({ level: 'warning', message: `“${row.name}”有 ${r} 个 R,注意分工边界`, anchor: row.id });
|
||||
}
|
||||
if (matrix.rows.length >= 4) {
|
||||
for (const col of matrix.columns) {
|
||||
const load = aLoad.get(col.id) ?? 0;
|
||||
if (load / matrix.rows.length > A_LOAD_WARNING_RATIO) {
|
||||
issues.push({ level: 'warning', message: `${col.name}担任 A 的事项有 ${load} 个,注意负荷`, anchor: col.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ── Dispatcher ──
|
||||
|
||||
export function validateArtifact(code: string, payload: unknown): Issue[] {
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
return [{ level: 'error', message: '成果内容为空' }];
|
||||
}
|
||||
switch (code) {
|
||||
case 'S1-01':
|
||||
return validateStrategyContent(payload as StrategyContent);
|
||||
case 'S2-03':
|
||||
case 'S2-06':
|
||||
return validatePathSystem(payload as PathSystem);
|
||||
case 'S2-04':
|
||||
return validateRaci(payload as RaciMatrix);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function hasBlockingIssues(issues: Issue[]): boolean {
|
||||
return issues.some((i) => i.level === 'error');
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ARTIFACTS,
|
||||
type Actor,
|
||||
LifecycleError,
|
||||
type StateMap,
|
||||
getArtifactDef,
|
||||
isStageOpen,
|
||||
nextStatus,
|
||||
staleTargets,
|
||||
unlockInfo,
|
||||
} from "@dingze/shared";
|
||||
|
||||
const lead: Actor = { projectRole: "ent_lead", isConsultAdmin: false };
|
||||
const dept: Actor = { projectRole: "dept_head", isConsultAdmin: false };
|
||||
const member: Actor = { projectRole: "member", isConsultAdmin: false };
|
||||
const consultant: Actor = { projectRole: "lead_consultant", isConsultAdmin: false };
|
||||
const coConsultant: Actor = { projectRole: "co_consultant", isConsultAdmin: false };
|
||||
const admin: Actor = { projectRole: null, isConsultAdmin: true };
|
||||
|
||||
const lockedStage = (stage: "strategy" | "goal"): StateMap =>
|
||||
Object.fromEntries(
|
||||
ARTIFACTS.filter((a) => a.stage === stage).map((a) => [a.code, { status: "locked" as const }])
|
||||
);
|
||||
|
||||
describe("artifact lifecycle", () => {
|
||||
it("walks the normal path from draft to locked", () => {
|
||||
let status = nextStatus("save", "not_started", member);
|
||||
expect(status).toBe("in_progress");
|
||||
status = nextStatus("stepDone", status, dept);
|
||||
expect(status).toBe("step_done");
|
||||
status = nextStatus("submitReview", status, lead);
|
||||
expect(status).toBe("in_review");
|
||||
status = nextStatus("approve", status, consultant);
|
||||
expect(status).toBe("pending_confirm");
|
||||
status = nextStatus("confirm", status, lead);
|
||||
expect(status).toBe("locked");
|
||||
});
|
||||
|
||||
it("only lets the enterprise project lead confirm", () => {
|
||||
expect(() => nextStatus("confirm", "pending_confirm", dept)).toThrow(LifecycleError);
|
||||
expect(() => nextStatus("confirm", "pending_confirm", consultant)).toThrow(LifecycleError);
|
||||
});
|
||||
|
||||
it("does not let members finish a step or co-consultants approve", () => {
|
||||
expect(() => nextStatus("stepDone", "in_progress", member)).toThrow(/当前角色/);
|
||||
expect(() => nextStatus("approve", "in_review", coConsultant)).toThrow(/当前角色/);
|
||||
});
|
||||
|
||||
it("refuses edits to a locked artifact until it is reopened with a reason", () => {
|
||||
expect(() => nextStatus("save", "locked", lead)).toThrow(/已定版/);
|
||||
expect(() => nextStatus("reopen", "locked", consultant)).toThrow(/原因/);
|
||||
expect(nextStatus("reopen", "locked", consultant, "质询后调整目标")).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("invalidates earlier confirmation when the enterprise edits again", () => {
|
||||
expect(nextStatus("save", "pending_confirm", lead)).toBe("in_progress");
|
||||
expect(nextStatus("save", "step_done", member)).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("keeps the review open while the consultant revises", () => {
|
||||
expect(nextStatus("save", "in_review", consultant)).toBe("in_review");
|
||||
});
|
||||
|
||||
it("lets the consulting admin force a transition only with a reason", () => {
|
||||
expect(() => nextStatus("forceLock", "in_review", admin)).toThrow(/原因/);
|
||||
expect(nextStatus("forceLock", "in_review", admin, "训练营现场统一定版")).toBe("locked");
|
||||
expect(() => nextStatus("forceLock", "in_review", consultant, "x")).toThrow(/当前角色/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlocking", () => {
|
||||
it("opens the first table of the first stage only", () => {
|
||||
expect(unlockInfo(getArtifactDef("S1-01"), {}).unlocked).toBe(true);
|
||||
const s102 = unlockInfo(getArtifactDef("S1-02"), {});
|
||||
expect(s102.unlocked).toBe(false);
|
||||
expect(s102.waitingFor).toContain("S1-01");
|
||||
});
|
||||
|
||||
it("unlocks the next table once the previous one reaches step done", () => {
|
||||
const states: StateMap = { "S1-01": { status: "step_done" } };
|
||||
expect(unlockInfo(getArtifactDef("S1-02"), states).unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let optional P1 worksheets block the strategy KPI table", () => {
|
||||
const states: StateMap = {
|
||||
"S1-01": { status: "locked" },
|
||||
"S1-02": { status: "locked" },
|
||||
"S1-03": { status: "step_done" },
|
||||
};
|
||||
expect(unlockInfo(getArtifactDef("S1-06"), states).unlocked).toBe(true);
|
||||
expect(unlockInfo(getArtifactDef("S1-05"), states).unlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("unlocks key projects and RACI together after the path system", () => {
|
||||
const states: StateMap = { ...lockedStage("strategy"), "S2-03-T": { status: "locked" }, "S2-01": { status: "step_done" }, "S2-03": { status: "step_done" } };
|
||||
expect(unlockInfo(getArtifactDef("S2-08"), states).unlocked).toBe(true);
|
||||
expect(unlockInfo(getArtifactDef("S2-04"), states).unlocked).toBe(true);
|
||||
expect(unlockInfo(getArtifactDef("S2-05"), states).unlocked).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the next stage only after every P0 artifact is locked", () => {
|
||||
const states = lockedStage("strategy");
|
||||
expect(isStageOpen("goal", states)).toBe(true);
|
||||
states["S1-07"] = { status: "pending_confirm" };
|
||||
expect(isStageOpen("goal", states)).toBe(false);
|
||||
expect(unlockInfo(getArtifactDef("S2-03-T"), states).waitingFor).toContain("上一阶段");
|
||||
});
|
||||
|
||||
it("lets a consultant step exception stand in for step done", () => {
|
||||
const states: StateMap = { "S1-01": { status: "in_progress", exception: true } };
|
||||
expect(unlockInfo(getArtifactDef("S1-02"), states).unlocked).toBe(true);
|
||||
});
|
||||
|
||||
it("marks started downstream artifacts as affected by an upstream change", () => {
|
||||
const states: StateMap = {
|
||||
"S1-01": { status: "locked" },
|
||||
"S1-02": { status: "in_progress" },
|
||||
"S1-03": { status: "not_started" },
|
||||
};
|
||||
expect(staleTargets("S1-01", states)).toEqual(["S1-02"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
type PathNode,
|
||||
type RaciMatrix,
|
||||
type StrategyContent,
|
||||
emptyStrategyContent,
|
||||
hasBlockingIssues,
|
||||
validateArtifact,
|
||||
} from "@dingze/shared";
|
||||
|
||||
const fullHouse = (): StrategyContent => ({
|
||||
...emptyStrategyContent("house"),
|
||||
mission: "让城市用上清洁、稳定、可负担的能源",
|
||||
vision: "成为本省领先的综合清洁能源服务商",
|
||||
values: "安全第一 · 客户至上",
|
||||
goals: { y1: "售气量增长 1000 万方", y3: "营收 12 亿", y5: "综合能源占比 40%" },
|
||||
battlefields: [{ id: "b1", name: "城市燃气与车用气", advantage: "站点网络密度", mustWin: "车用气新客户 700 万方" }],
|
||||
foundation: { organization: "点供事业部", mechanism: "项目考核", talent: "培养 20 名骨干" },
|
||||
});
|
||||
|
||||
describe("S1-01 strategy content", () => {
|
||||
it("passes a complete strategy house even when the six-part table is empty", () => {
|
||||
const issues = validateArtifact("S1-01", fullHouse());
|
||||
expect(hasBlockingIssues(issues)).toBe(false);
|
||||
expect(issues.some((i) => i.level === "warning" && i.message.includes("六分法"))).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a house with a pending cell", () => {
|
||||
const content = fullHouse();
|
||||
content.foundation.mechanism = "待补";
|
||||
const issues = validateArtifact("S1-01", content);
|
||||
expect(issues).toContainEqual(expect.objectContaining({ level: "error", anchor: "foundation.mechanism" }));
|
||||
});
|
||||
|
||||
it("requires all six aspects when the six-part table is primary", () => {
|
||||
const content = { ...fullHouse(), primary: "sixfold" as const, strategicGoals: "2030 年营收 30 亿" };
|
||||
const errors = validateArtifact("S1-01", content).filter((i) => i.level === "error");
|
||||
expect(errors.map((e) => e.anchor)).toEqual(["strategyChoice", "stepsAndMeasures", "indicatorSystem"]);
|
||||
});
|
||||
});
|
||||
|
||||
const node = (id: string, parentId: string | null, level: number, amount: number | null, metric = "新增售气量"): PathNode => ({
|
||||
id, parentId, level, path: `路径 ${id}`, metric, value: amount === null ? "≥98%" : `${amount} 万方`, amount, unit: "万方",
|
||||
});
|
||||
|
||||
describe("path system", () => {
|
||||
it("accepts children that add back up to their parent", () => {
|
||||
const nodes = [node("1", null, 1, 700), node("1.1", "1", 2, 500), node("1.2", "1", 2, 200)];
|
||||
expect(hasBlockingIssues(validateArtifact("S2-03", { nodes }))).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks children that do not add back up", () => {
|
||||
const nodes = [node("1", null, 1, 700), node("1.1", "1", 2, 500), node("1.2", "1", 2, 220)];
|
||||
const issues = validateArtifact("S2-03", { nodes });
|
||||
expect(issues).toContainEqual(expect.objectContaining({ level: "error", anchor: "1", message: expect.stringContaining("推不回来") }));
|
||||
});
|
||||
|
||||
it("does not compare children measured by a different metric", () => {
|
||||
const nodes = [node("1", null, 1, 700), node("1.1", "1", 2, 2000, "新增客户数量")];
|
||||
expect(validateArtifact("S2-03", { nodes }).some((i) => i.message.includes("推不回来"))).toBe(false);
|
||||
});
|
||||
|
||||
it("requires a metric and a value on every path and caps the depth at four levels", () => {
|
||||
const nodes = [{ ...node("1", null, 1, 700), metric: "" }, node("x", "1", 5, null, "新增售气量")];
|
||||
const messages = validateArtifact("S2-03", { nodes }).map((i) => i.message);
|
||||
expect(messages.some((m) => m.includes("缺衡量指标"))).toBe(true);
|
||||
expect(messages.some((m) => m.includes("层级超出"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const matrix = (cells: Record<string, RaciMatrix["rows"][number]["cells"]>): RaciMatrix => ({
|
||||
columns: [
|
||||
{ id: "ops", name: "运营部" },
|
||||
{ id: "dispatch", name: "调度中心" },
|
||||
{ id: "it", name: "信息部" },
|
||||
],
|
||||
rows: Object.entries(cells).map(([id, c]) => ({ id, name: `项目 ${id}`, cells: c })),
|
||||
});
|
||||
|
||||
describe("RACI matrix", () => {
|
||||
it("passes a row with exactly one A and at least one R", () => {
|
||||
const issues = validateArtifact("S2-04", matrix({ p1: { ops: ["A"], dispatch: ["R"], it: ["C"] } }));
|
||||
expect(hasBlockingIssues(issues)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no A", { ops: ["R"], dispatch: ["C"] }, "没有 A"],
|
||||
["two A", { ops: ["A"], dispatch: ["A"], it: ["R"] }, "有 2 个 A"],
|
||||
["no R", { ops: ["A"], dispatch: ["C"] }, "没有 R"],
|
||||
] as const)("blocks a row with %s", (_name, cells, text) => {
|
||||
const issues = validateArtifact("S2-04", matrix({ p1: cells as never }));
|
||||
expect(issues).toContainEqual(expect.objectContaining({ level: "error", message: expect.stringContaining(text) }));
|
||||
});
|
||||
|
||||
it("only warns about multiple R, R with A in one cell, and R doubling as C", () => {
|
||||
const issues = validateArtifact("S2-04", matrix({ p1: { ops: ["R", "A"], dispatch: ["R"], it: ["R", "C"] } }));
|
||||
expect(hasBlockingIssues(issues)).toBe(false);
|
||||
expect(issues.filter((i) => i.level === "warning").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -18,9 +18,10 @@
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@dingze/shared": ["./server-plugins/plugin-dingze/src/shared"],
|
||||
"@/extensions/*": ["./registry/*", "./src/extensions/*"],
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "registry"]
|
||||
"include": ["src", "registry", "server-plugins/plugin-dingze/src/shared"]
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ export default defineConfig(({ command, mode }) => {
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@dingze/shared": path.resolve(
|
||||
__dirname,
|
||||
"./server-plugins/plugin-dingze/src/shared"
|
||||
),
|
||||
"@/extensions": extensionsRoot,
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@dingze/shared": fileURLToPath(
|
||||
new URL("./server-plugins/plugin-dingze/src/shared", import.meta.url)
|
||||
),
|
||||
"@/extensions": extensionsRoot,
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user