Files
admin-web/src/modules/self-operated/state.ts
T

150 lines
12 KiB
TypeScript

import type { BusinessQuery, Progress, QueryContext, RetryCommand, Scope, Task } from './types'
// Aborting saves work; generation fencing also protects against adapters that return after abort.
export class RequestFence {
private generation = 0
private controller: AbortController | null = null
begin() {
this.cancel()
const generation = this.generation
const controller = new AbortController()
this.controller = controller
return { signal: controller.signal, current: () => generation === this.generation && !controller.signal.aborted }
}
cancel() { this.controller?.abort(); this.controller = null; this.generation += 1 }
}
export function contextParams(context: QueryContext | null): Partial<BusinessQuery> {
if (!context) return {}
return {
salesRunId: context.salesRunId ?? '0', inventoryRunId: context.inventoryRunId ?? '0',
salesScopeHash: context.salesScopeHash, inventoryScopeHash: context.inventoryScopeHash,
queryAsOf: context.queryAsOf,
}
}
export function sameContext(a: QueryContext, b: QueryContext) {
return a.salesRunId === b.salesRunId && a.inventoryRunId === b.inventoryRunId
&& a.salesScopeHash === b.salesScopeHash && a.inventoryScopeHash === b.inventoryScopeHash
&& a.queryAsOf === b.queryAsOf && a.queryExpiresAt === b.queryExpiresAt
}
export function validateCustomRange(start: string, end: string, anchor?: string) {
const validDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value)
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value
if (!validDate(start) || !validDate(end)) return '请选择有效的起止日期'
if (start > end) return '开始日期不得晚于结束日期'
if (anchor && end > anchor) return '结束日期不得晚于本次查询基准日'
if ((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000 + 1 > 366) return '最多查询 366 个自然日'
return ''
}
export function shownQuantity(row: { outboundQuantity: string | number | null; observedQuantity: string | number | null; dataStatus: string }) {
return row.dataStatus === 'COMPLETE' ? row.outboundQuantity : row.observedQuantity
}
export function quantityText(value: string | number | null | undefined) { return value === null || value === undefined ? '—' : String(value) }
export function groupedQuantityText(value: string | number | null | undefined) {
return quantityText(value).replace(/^(-?\d+)(\.\d+)?$/, (_match, integer: string, fraction: string | undefined) => `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${fraction ?? ''}`)
}
export function beijingTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '时间待核对'
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).format(date)
}
export const statusLabels: Record<string, string> = {
COMPLETE: '完整', PARTIAL: '未完整', UNKNOWN: '未知', QUEUED: '排队中', RUNNING: '执行中',
WAITING: '等待中', WAITING_UPSTREAM: '等待上游', WAITING_SOURCE: '等待来源数据', RETRY_WAIT: '待重试', YIELDED: '预算等待',
FAILED: '失败', DEAD: '待处理', SUCCEEDED: '执行成功', ROUTED: '账号共享采集完成',
SAMPLE_DONE: '测试样本结束', PUBLISHED: '已发布', REUSED: '沿用已有结果', NOT_PUBLISHED: '未发布',
PENDING: '待处理', BLOCKED: '修订阻断', OFF: '关闭', VALIDATION: '验证采样', BUSINESS: '正式采集',
FRESH: '有效', STALE: '过期', INVALID: '异常', OBSERVING: '观察中', HEALTHY: '正常',
STALE_HEARTBEAT: '心跳过期,执行状态待确认',
UNCONFIRMED: '执行状态待确认', TERMINATED: '尝试已结束', ABORTED: '已停止',
NOT_STARTED: '尚未开始', FETCHING: '采集中', FETCHED: '采集完成', VALIDATING: '校验中',
BUILDING: '计算中', READY: '待发布', INITIALIZING: '首采中', COVERED: '覆盖完整',
ACCOUNT_DISCOVERY: '账号共享出库目录', WAREHOUSE_SYNC: '本仓采集',
VALID: '有效', CURRENT: '已核对', LAGGING: '核对滞后', MISSING: '尚无可用数据',
UNCOVERED: '覆盖不足', INCOMPLETE: '未完整', REBUILD_REQUIRED: '销量待重新统计发布',
NO_AVAILABLE_INVENTORY: '无可售库存', UNIT_UNKNOWN_OR_CONFLICT: '数量单位待核对',
SALES_UNTRUSTED: '销量待核对', COVERAGE_INSUFFICIENT: '覆盖不足', OBSERVATION_INSUFFICIENT: '观察期不足',
SALES_UNKNOWN: '销量未知', NO_OUTBOUND_IN_WINDOW: '所选窗口无出库', NO_OUTBOUND_30_DAYS: '近30天无出库', INVENTORY_INVALID: '库存异常',
INVENTORY_STALE: '库存过期', INVENTORY_UNKNOWN: '库存未知',
}
export const reasonLabels: Record<string, string> = {
UNASSESSABLE_SKUS: '存在待评估SKU', INVENTORY_UNAVAILABLE: '库存资料不可用', SALES_UNAVAILABLE: '销量资料不可用',
PHYSICAL_INVENTORY_UNKNOWN: '实物库存待核验', AVAILABLE_INVENTORY_UNKNOWN: '可售库存待核验',
SALES_QUANTITY_OR_UNIT_INVALID: '出库数量或单位异常', NO_ELIGIBLE_SKUS: '暂无符合条件的SKU',
NO_OUTBOUND_IN_WINDOW: '所选窗口无出库', NO_OUTBOUND_30D: '近30天无出库', COVERAGE_GT_THRESHOLD: '可售天数超过本次阈值',
FROZEN_STOCK: '存在冻结库存', DEFECTIVE_STOCK: '存在次品实物库存', UNASSESSABLE: '数据不足/待观察',
THRESHOLD_NOT_SET: '未设置查询阈值', NO_PHYSICAL_INVENTORY: '无实物库存',
METRIC_DEPENDENCY_UNKNOWN: '该指标依赖资料未知', SALES_WINDOW_UNAVAILABLE: '所选窗口完整销量不可用', SALES_WINDOW_INCOMPLETE: '所选时段销量尚未采齐',
RISK_DEPENDENCY_UNKNOWN: '部分SKU关注判断未知', NO_OUTBOUND_IN_COLLECTED_HISTORY: '已采集历史内未见出库',
SALES_NOT_COVERED: '销量未覆盖', SKU_HISTORY_UNKNOWN: 'SKU历史未知', QUANTITY_UNIT_UNKNOWN: '数量单位未知',
INVENTORY_NOT_PUBLISHED: '库存尚未发布', SALES_NOT_PUBLISHED: '销量尚未发布', INVENTORY_STALE: '库存快照过期',
INVENTORY_UNKNOWN: '库存未知', INVENTORY_INVALID: '库存异常', OBSERVATION_INCOMPLETE: '观察期不足',
SCOPE_CHANGED: '配置范围已变化', UPSTREAM_NOT_READY: '账号共享上游未就绪', TASK_NOT_RETRYABLE: '任务当前不可恢复',
COLLECTION_MODE_NOT_BUSINESS: '当前不是正式采集模式', VALIDATION_NOT_RETRYABLE: '验证会话不可恢复',
ACCOUNT_SHARED_TASK: '账号共享任务由协调器恢复', SOURCE_TIMEZONE_UNKNOWN: '来源时区未确认',
UNIT_UNKNOWN_OR_CONFLICT: '数量单位未知或不一致', SALES_DAY_INCOMPLETE: '销量日期未完整',
SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN: '销量未覆盖或SKU历史未知', SCOPE_LEASE_BUSY: '本仓任务正在执行',
COLLECTION_OFF: '采集模式关闭', COLLECTION_MODE_INVALID: '采集模式配置无效', BUSINESS_ENVIRONMENT_REJECTED: '正式采集只允许生产环境',
EXECUTION_DISABLED: '采集执行未启用', WAREHOUSE_NOT_CONFIGURED: '仓库未配置', TIMEZONE_OR_UNIT_NOT_CONFIRMED: '时间或件数口径配置不符合确认值',
ACCOUNT_PAUSED: '账号采集已暂停', ACCOUNT_COOLDOWN: '账号处于冷却期', EXCLUSIVE_GRANT_NOT_VALID: '账号独占窗口授权无效',
DAILY_BUDGET_NOT_CONFIGURED: '账号日额度未配置', DAILY_BUDGET_EXHAUSTED: '账号日额度已用完',
SALES_MISSING: '尚无销量发布版本', INVENTORY_MISSING: '尚无库存发布版本', SALES_BLOCKED: '销量修订阻断',
SALES_STALE: '销量数据过期', REVISION_REVIEW_UNKNOWN: '修订核对状态未知', REVISION_REVIEW_LAGGING: '修订核对滞后',
SALES_REBUILD_REQUIRED: '销量有变动,待重新统计发布', TODAY_INCOMPLETE: '今日尚未结束,不能作为完整自然日',
SALES_SOURCE_TIMEZONE_UNKNOWN: '销量来源时区未确认',
}
export function reasonsText(reasons: readonly string[] | null | undefined) { return reasons?.map(reason => reasonLabels[reason] ?? statusLabels[reason] ?? '存在待核对项').join('、') || '—' }
export function phaseText(phase: string | null | undefined) {
const phases: Record<string, string> = { CUSTOMERS: '获取客户资料', PRODUCTS: '获取商品资料', DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '处理出库订单', DETAILS: '处理出库订单', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '更新本仓出库数据', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' }
return phase ? phases[phase] ?? '阶段待核对' : '阶段待确认'
}
export function label(value: string | null | undefined) { return value ? statusLabels[value] ?? '待核对' : '—' }
const unitLabels: Record<string, string> = { PAGE: '页', ORDER: '单', INVENTORY_ROW: '库存行', BUSINESS_KEY: '业务键', SKU: 'SKU', BATCH: '批', NONE: '无计数' }
export function unitText(unit: string | null | undefined) { return unitLabels[unit ?? ''] ?? '计数单位待确认' }
export function progressText(progress: Partial<Progress> | null | undefined) {
if (!progress) return '阶段进度待确认'
const scope = progress.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : progress.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认'
const basis = progress.countBasis === 'TASK_CUMULATIVE' ? '任务累计' : progress.countBasis === 'ATTEMPT_ONLY' ? '本次尝试' : '计数口径待确认'
if (progress.unit === 'NONE') return `${scope} · ${phaseText(progress.phase)} · 无对象计数,保留已提交阶段摘要`
const total = progress.totalKnown && progress.total != null ? ` / ${progress.total}` : ',总量待确认'
return `${scope} · ${phaseText(progress.phase)} · ${basis}${progress.scanRound == null ? '' : ` · 第 ${progress.scanRound} 轮`} · ${quantityText(progress.completed)}${total} ${unitLabels[progress.unit ?? ''] ?? '单位待确认'}`
}
export function taskRetryAllowed(task: Task | null, canSync: boolean, scope: Scope, reason: string) {
return Boolean(canSync && task?.taskNo && task.warehouseCode === scope.warehouseCode
&& task.allowedActions.includes('RETRY') && !task.actionBlockReason && reason.trim())
}
export class RetryIntent {
private pending: RetryCommand | null = null
create(taskNo: string, warehouseCode: string, reason: string, key: () => string = () => crypto.randomUUID()): RetryCommand {
if (this.pending?.taskNo === taskNo && this.pending.warehouseCode === warehouseCode && this.pending.reason === reason.trim()) return this.pending
this.pending = { action: 'RETRY', requestKey: key(), taskNo, warehouseCode, reason: reason.trim() }
return this.pending
}
accepted() { this.pending = null }
}
export function hasActiveTask(tasks: readonly Task[]) {
return tasks.some(task => ['QUEUED', 'RUNNING', 'WAITING', 'WAITING_UPSTREAM', 'WAITING_SOURCE', 'RETRY_WAIT', 'YIELDED'].includes(task.taskStatus))
}
export class RuntimePoller {
private timer: ReturnType<typeof setTimeout> | null = null
private active = false
private visible = true
private generation = 0
constructor(private readonly read: () => Promise<boolean>, private readonly cancel: () => void) {}
start() { this.active = true; this.restart() }
setVisible(visible: boolean) { this.visible = visible; this.restart() }
stop() { this.active = false; this.restart() }
private restart() {
this.generation += 1
if (this.timer) clearTimeout(this.timer)
this.timer = null
this.cancel()
if (this.active && this.visible) void this.tick(this.generation)
}
private async tick(generation: number) {
let busy = false
try { busy = await this.read() } catch { /* Caller records failure without replacing last data. */ }
if (generation === this.generation && this.active && this.visible) this.timer = setTimeout(() => void this.tick(generation), busy ? 10000 : 60000)
}
}