Why: a lost write response was keeping the old duty version, so the next save replayed it, and a busy save could stay locked if another request advanced the sequence. Evidence: SELF vitest duty-draft 6 passed, tsc passed, knip 43, Playwright duty flows 3 passed. Impact: an unknown save reloads server roles, confirmation resets, and busy clears even if a later read is discarded. Refs: docs/proposals/2026-10-15-rdm-duty-matrix-rework.md Co-authored-by: Cursor <cursoragent@cursor.com>
198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
// 文档职责草稿的唯一存储。三个视图只投影这份数据;单元格值由三权反推。
|
||
export type CellDuty = 'none' | 'author' | 'reviewer' | 'approver'
|
||
|
||
interface DutyRoles {
|
||
author: string
|
||
reviewers: string[]
|
||
approver: string
|
||
}
|
||
|
||
export interface DutyDocDraft extends DutyRoles {
|
||
docId: string
|
||
title: string
|
||
code: string
|
||
typeKey: string
|
||
stage: string
|
||
dutyVersion: number
|
||
activeRound: number
|
||
legacySectionOverrides: number
|
||
dirty: boolean
|
||
}
|
||
|
||
export interface DutyMember {
|
||
owner: string
|
||
role: string
|
||
valid: boolean
|
||
canAuthor: boolean
|
||
canReview: boolean
|
||
canApprove: boolean
|
||
}
|
||
|
||
interface DutySaveItem {
|
||
docId: string
|
||
author: string
|
||
reviewers: string[]
|
||
approver: string
|
||
expectedDutyVersion: number
|
||
confirmInvalidation: boolean
|
||
}
|
||
|
||
export function cellDuty(roles: DutyRoles, owner: string): CellDuty {
|
||
if (roles.author === owner) return 'author'
|
||
if (roles.approver === owner) return 'approver'
|
||
if (roles.reviewers.includes(owner)) return 'reviewer'
|
||
return 'none'
|
||
}
|
||
|
||
export function applyCell(roles: DutyRoles, owner: string, duty: CellDuty): {
|
||
roles: DutyRoles
|
||
replacedAuthor: string
|
||
replacedApprover: string
|
||
} {
|
||
const replacedAuthor = duty === 'author' && roles.author !== '' && roles.author !== owner ? roles.author : ''
|
||
const replacedApprover = duty === 'approver' && roles.approver !== '' && roles.approver !== owner ? roles.approver : ''
|
||
let author = roles.author === owner ? '' : roles.author
|
||
let approver = roles.approver === owner ? '' : roles.approver
|
||
let reviewers = roles.reviewers.filter(person => person !== owner)
|
||
switch (duty) {
|
||
case 'author':
|
||
author = owner
|
||
break
|
||
case 'approver':
|
||
approver = owner
|
||
break
|
||
case 'reviewer':
|
||
reviewers = [...reviewers, owner]
|
||
break
|
||
case 'none':
|
||
break
|
||
default: {
|
||
const exhaustive: never = duty
|
||
throw new Error(exhaustive)
|
||
}
|
||
}
|
||
return { roles: { author, reviewers, approver }, replacedAuthor, replacedApprover }
|
||
}
|
||
|
||
export function replacementNote(roles: DutyRoles, owner: string, duty: CellDuty): string {
|
||
if (duty === 'author' && roles.author !== '' && roles.author !== owner) return `将替换${roles.author}`
|
||
if (duty === 'approver' && roles.approver !== '' && roles.approver !== owner) return `将替换${roles.approver}`
|
||
return ''
|
||
}
|
||
|
||
function dutyConflicts(roles: DutyRoles): number {
|
||
const seen = new Map<string, number>()
|
||
const mark = (owner: string) => {
|
||
if (!owner) return
|
||
seen.set(owner, (seen.get(owner) ?? 0) + 1)
|
||
}
|
||
mark(roles.author)
|
||
mark(roles.approver)
|
||
for (const reviewer of roles.reviewers) mark(reviewer)
|
||
let conflicts = 0
|
||
for (const count of seen.values()) if (count > 1) conflicts += 1
|
||
return conflicts
|
||
}
|
||
|
||
export function completeness(roles: DutyRoles): { authors: number; reviewers: number; approvers: number; conflicts: number } {
|
||
return {
|
||
authors: roles.author ? 1 : 0,
|
||
reviewers: roles.reviewers.length,
|
||
approvers: roles.approver ? 1 : 0,
|
||
conflicts: dutyConflicts(roles),
|
||
}
|
||
}
|
||
|
||
export function saveIssues(roles: DutyRoles): string[] {
|
||
const issues: string[] = []
|
||
if (!roles.author) issues.push('需指定编制人')
|
||
if (roles.reviewers.length === 0) issues.push('审核至少一人')
|
||
if (!roles.approver) issues.push('需指定批准人')
|
||
if (dutyConflicts(roles) > 0) issues.push('一人只能担一个职责')
|
||
return issues
|
||
}
|
||
|
||
function rolesFromCells(docId: string, cells: { docId: string; owner: string; duty: string }[]): DutyRoles {
|
||
const roles: DutyRoles = { author: '', reviewers: [], approver: '' }
|
||
for (const cell of cells) {
|
||
if (cell.docId !== docId) continue
|
||
if (cell.duty !== 'none' && cell.duty !== 'author' && cell.duty !== 'reviewer' && cell.duty !== 'approver') continue
|
||
const next = applyCell(roles, cell.owner, cell.duty)
|
||
roles.author = next.roles.author
|
||
roles.reviewers = next.roles.reviewers
|
||
roles.approver = next.roles.approver
|
||
}
|
||
return roles
|
||
}
|
||
|
||
export function absorbServerDocs(
|
||
current: DutyDocDraft[],
|
||
docs: {
|
||
docId: string; title: string; code?: string; typeKey: string; stage?: string
|
||
dutyVersion: number; activeRound?: number; legacySectionOverrides?: number
|
||
}[],
|
||
cells: { docId: string; owner: string; duty: string }[],
|
||
replaceDirty = false,
|
||
): DutyDocDraft[] {
|
||
const byId = new Map(current.map(doc => [doc.docId, doc]))
|
||
const pageIds = new Set(docs.map(doc => doc.docId))
|
||
const next: DutyDocDraft[] = []
|
||
for (const doc of docs) {
|
||
const previous = byId.get(doc.docId)
|
||
const serverRoles = rolesFromCells(doc.docId, cells)
|
||
const keepDraft = !replaceDirty && previous?.dirty === true
|
||
next.push({
|
||
docId: doc.docId,
|
||
title: doc.title,
|
||
code: doc.code ?? doc.docId,
|
||
typeKey: doc.typeKey,
|
||
stage: doc.stage ?? '',
|
||
dutyVersion: keepDraft ? previous.dutyVersion : doc.dutyVersion,
|
||
activeRound: doc.activeRound ?? 0,
|
||
legacySectionOverrides: doc.legacySectionOverrides ?? 0,
|
||
dirty: keepDraft,
|
||
author: keepDraft ? previous.author : serverRoles.author,
|
||
reviewers: keepDraft ? previous.reviewers : serverRoles.reviewers,
|
||
approver: keepDraft ? previous.approver : serverRoles.approver,
|
||
})
|
||
}
|
||
for (const doc of current) {
|
||
if (!replaceDirty && doc.dirty && !pageIds.has(doc.docId)) next.push(doc)
|
||
}
|
||
return next
|
||
}
|
||
|
||
export function editCell(docs: DutyDocDraft[], docId: string, owner: string, duty: CellDuty): DutyDocDraft[] {
|
||
return docs.map(doc => {
|
||
if (doc.docId !== docId) return doc
|
||
const next = applyCell(doc, owner, duty)
|
||
return { ...doc, ...next.roles, dirty: true }
|
||
})
|
||
}
|
||
|
||
export function dirtyDocs(docs: DutyDocDraft[]): DutyDocDraft[] {
|
||
return docs.filter(doc => doc.dirty)
|
||
}
|
||
|
||
export function savePayload(docs: DutyDocDraft[], confirmInvalidation: boolean): { docs: DutySaveItem[]; issues: string[] } {
|
||
const dirty = dirtyDocs(docs)
|
||
const issues: string[] = []
|
||
if (dirty.length === 0) issues.push('没有未保存的职责')
|
||
if (dirty.length > 20) issues.push('一次保存最多 20 份文档')
|
||
for (const doc of dirty) {
|
||
for (const issue of saveIssues(doc)) issues.push(`${doc.title}:${issue}`)
|
||
}
|
||
return {
|
||
docs: dirty.slice(0, 20).map(doc => ({
|
||
docId: doc.docId,
|
||
author: doc.author,
|
||
reviewers: doc.reviewers,
|
||
approver: doc.approver,
|
||
expectedDutyVersion: doc.dutyVersion,
|
||
confirmInvalidation: confirmInvalidation && doc.activeRound > 0,
|
||
})),
|
||
issues,
|
||
}
|
||
}
|
||
|