gongxiangp2/lib/work-review-policy.ts
2026-08-05 16:49:54 +08:00

233 lines
8.4 KiB
TypeScript

export type WorkReviewDecision = 'approved' | 'revision_required'
export type WorkReviewReviewerRole = 'manager' | 'final_reviewer'
export type NormalizedWorkReviewAnnotation = {
id: string
x: number
y: number
x2?: number
y2?: number
shape?: 'point' | 'rectangle' | 'ellipse' | 'arrow'
color?: string
comment: string
}
export type WorkReviewAnnotationValidationResult =
| { ok: true; annotations: NormalizedWorkReviewAnnotation[] }
| { ok: false; error: string }
const WORK_REVIEW_ANNOTATION_LIMIT = 50
const WORK_REVIEW_ANNOTATION_COMMENT_LIMIT = 1000
const WORK_REVIEW_ANNOTATION_SHAPES = new Set(['point', 'rectangle', 'ellipse', 'arrow'])
const WORK_REVIEW_ANNOTATION_COLORS = new Set([
'#0050cb',
'#d92d20',
'#087a3c',
'#8a5200',
'#101828',
'#ffffff',
])
function normalizedCoordinate(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1
? Math.round(value * 1_000_000) / 1_000_000
: null
}
export function validateWorkReviewAnnotations(value: unknown): WorkReviewAnnotationValidationResult {
if (value == null) return { ok: true, annotations: [] }
if (!Array.isArray(value)) return { ok: false, error: '图片批注格式无效' }
if (value.length > WORK_REVIEW_ANNOTATION_LIMIT) {
return { ok: false, error: `单张图片最多添加 ${WORK_REVIEW_ANNOTATION_LIMIT} 个批注` }
}
const ids = new Set<string>()
const annotations: NormalizedWorkReviewAnnotation[] = []
for (const raw of value) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { ok: false, error: '图片批注格式无效' }
}
const item = raw as Record<string, unknown>
const id = String(item.id ?? '').trim()
const x = normalizedCoordinate(item.x)
const y = normalizedCoordinate(item.y)
const shape = item.shape == null ? undefined : String(item.shape).trim().toLowerCase()
const color = item.color == null ? undefined : String(item.color).trim().toLowerCase()
const x2 = item.x2 == null ? undefined : normalizedCoordinate(item.x2)
const y2 = item.y2 == null ? undefined : normalizedCoordinate(item.y2)
const comment = String(item.comment ?? '').trim()
if (!id || id.length > 64 || ids.has(id)) {
return { ok: false, error: '图片批注编号无效或重复' }
}
if (x == null || y == null) {
return { ok: false, error: '图片批注位置无效' }
}
if ((item.x2 != null && x2 == null) || (item.y2 != null && y2 == null)) {
return { ok: false, error: '图片框选范围无效' }
}
if (shape != null && !WORK_REVIEW_ANNOTATION_SHAPES.has(shape)) {
return { ok: false, error: '图片批注形状无效' }
}
if (color != null && !WORK_REVIEW_ANNOTATION_COLORS.has(color)) {
return { ok: false, error: '图片批注颜色无效' }
}
if (shape && shape !== 'point' && (x2 == null || y2 == null)) {
return { ok: false, error: '图片框选范围无效' }
}
if (!comment) return { ok: false, error: '请填写每个标注点的修改意见' }
if (comment.length > WORK_REVIEW_ANNOTATION_COMMENT_LIMIT) {
return { ok: false, error: `单个标注点的修改意见不能超过 ${WORK_REVIEW_ANNOTATION_COMMENT_LIMIT} 个字` }
}
ids.add(id)
annotations.push({
id,
x,
y,
...(x2 == null ? {} : { x2 }),
...(y2 == null ? {} : { y2 }),
...(shape == null ? {} : { shape: shape as NormalizedWorkReviewAnnotation['shape'] }),
...(color == null ? {} : { color }),
comment,
})
}
return { ok: true, annotations }
}
type WorkReviewEmployeeIdentity = {
id?: unknown
dingtalkUserId?: unknown
dingEmail?: unknown
jobNumber?: unknown
flowerName?: unknown
realName?: unknown
displayName?: unknown
sourcePayload?: unknown
}
function normalizeIdentityValue(value: unknown) {
const normalized = String(value ?? '').trim().toLowerCase()
return normalized || null
}
function normalizeEmployeeAlias(value: unknown) {
const normalized = String(value ?? '')
.normalize('NFKC')
.trim()
.toLowerCase()
.replace(/\s+/g, '')
.replace(/無/g, '无')
return normalized || null
}
export function getWorkReviewEmployeeMaterialUserIds(employee: WorkReviewEmployeeIdentity | null | undefined) {
const payload = employee?.sourcePayload
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []
const record = payload as Record<string, unknown>
return Array.from(new Set([
normalizeIdentityValue(record.materialUserId),
normalizeIdentityValue(record.userId),
].filter((value): value is string => Boolean(value))))
}
export function getWorkReviewEmployeeIdentityKeys(employee: WorkReviewEmployeeIdentity | null | undefined) {
if (!employee) return []
const payload = employee.sourcePayload
const materialEmail = payload && typeof payload === 'object' && !Array.isArray(payload)
? normalizeIdentityValue((payload as Record<string, unknown>).materialUserEmail)
: null
const values = [
['profile', normalizeIdentityValue(employee.id)],
['dingtalk', normalizeIdentityValue(employee.dingtalkUserId)],
['email', normalizeIdentityValue(employee.dingEmail)],
['job', normalizeIdentityValue(employee.jobNumber)],
['email', materialEmail],
...getWorkReviewEmployeeMaterialUserIds(employee).map((value) => ['user', value]),
] as Array<[string, string | null]>
return Array.from(new Set(values.flatMap(([type, value]) => value ? [`${type}:${value}`] : [])))
}
export function getWorkReviewEmployeeAliasKeys(employee: WorkReviewEmployeeIdentity | null | undefined) {
if (!employee) return []
return Array.from(new Set([
normalizeEmployeeAlias(employee.flowerName),
normalizeEmployeeAlias(employee.displayName),
normalizeEmployeeAlias(employee.realName),
].filter((value): value is string => Boolean(value))))
}
export function isSameWorkReviewEmployeeIdentity(
left: WorkReviewEmployeeIdentity | null | undefined,
right: WorkReviewEmployeeIdentity | null | undefined,
) {
const leftKeys = new Set(getWorkReviewEmployeeIdentityKeys(left))
return getWorkReviewEmployeeIdentityKeys(right).some((key) => leftKeys.has(key))
}
export function isSameWorkReviewEmployeeAlias(
left: WorkReviewEmployeeIdentity | null | undefined,
right: WorkReviewEmployeeIdentity | null | undefined,
) {
const leftAliases = new Set(getWorkReviewEmployeeAliasKeys(left))
return getWorkReviewEmployeeAliasKeys(right).some((alias) => leftAliases.has(alias))
}
export function canSubmitWorkReview(employee: unknown) {
return Boolean(employee)
}
export function canDeleteWorkReviewProject(user: { username?: unknown } | null | undefined) {
const username = String(user?.username ?? '').trim().toLowerCase()
return username === 'wuxie' || username === '无邪'
}
export function getWorkReviewReviewerSelectionError(input: {
reviewerEmployeeId: string | null
submitterEmployeeId: string
finalReviewerEmployeeId: string
}) {
if (!input.reviewerEmployeeId) return '请选择评审人'
if (input.reviewerEmployeeId === input.submitterEmployeeId) {
return '评审人不能选择自己,请选择其他人员'
}
if (input.reviewerEmployeeId === input.finalReviewerEmployeeId) {
return '元泽已是固定评审人,请选择另一名评审人'
}
return null
}
export function deriveWorkReviewDecision(
decisions: ReadonlyArray<{ decision: WorkReviewDecision }>,
): WorkReviewDecision {
return decisions.some((item) => item.decision === 'revision_required')
? 'revision_required'
: 'approved'
}
export function deriveCompletedWorkReviewStatus(
reviews: ReadonlyArray<{
reviewerRole: string
decision: WorkReviewDecision
}>,
requiredRoles: ReadonlyArray<WorkReviewReviewerRole> = ['manager', 'final_reviewer'],
): WorkReviewDecision | null {
const requiredReviews = requiredRoles.map((role) => (
reviews.find((review) => review.reviewerRole === role)
))
if (requiredReviews.some((review) => !review)) return null
return deriveWorkReviewDecision(requiredReviews as Array<{ decision: WorkReviewDecision }>)
}
export function canResubmitWorkReviewProject(project: {
status: string
versions: ReadonlyArray<{ status: string }>
permissions: { canResubmit: boolean }
}) {
const latestVersion = project.versions[0]
return Boolean(
latestVersion
&& project.permissions.canResubmit
&& project.status === 'revision_required'
&& latestVersion.status === 'revision_required',
)
}