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 { 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 = { 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 = { 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 = { 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 = { 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 | 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 | null = null private active = false private visible = true private generation = 0 constructor(private readonly read: () => Promise, 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) } }