From cde4d59afbb0787a341f6d8c183e8de00f562955 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 8 Oct 2026 11:36:02 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(supply-chain):=20=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=8F=90=E9=86=92=E6=AF=8F=E6=97=A5=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E6=8E=A8=E9=80=81=EF=BC=8811:00=EF=BC=89+=20=E8=B4=9F?= =?UTF-8?q?=E8=B4=A3=E4=BA=BA=E4=B8=8D=E5=9C=A8=E5=86=85=E9=83=A8=E6=95=B4?= =?UTF-8?q?=E5=BA=97=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tore-account-authorization-notice-timer.sh | 74 ++++++++++++++++ ...push-store-account-authorization-notice.ts | 81 +++++++++++++++++ .../StoreAccountAuthorizationNoticeDialog.tsx | 4 +- ...count-authorization-notice.service.test.ts | 37 +++++++- ...re-account-authorization-notice.service.ts | 86 +++++++++++++------ 5 files changed, 252 insertions(+), 30 deletions(-) create mode 100644 furniture-3d-system/scripts/install-store-account-authorization-notice-timer.sh create mode 100644 furniture-3d-system/scripts/push-store-account-authorization-notice.ts diff --git a/furniture-3d-system/scripts/install-store-account-authorization-notice-timer.sh b/furniture-3d-system/scripts/install-store-account-authorization-notice-timer.sh new file mode 100644 index 00000000..f8b8a481 --- /dev/null +++ b/furniture-3d-system/scripts/install-store-account-authorization-notice-timer.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${STORE_ACCOUNT_AUTH_NOTICE_SERVICE_NAME:-furniture-store-account-authorization-notice}" +APP_ROOT="$BASE/current/furniture-3d-system" +APP_ENV="$BASE/shared/.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +# 授权数据每天上午约 10:00 从聚水潭刷新进公司报表库,任务安排在 11:00 取当天新数据。 +SCHEDULE="${STORE_ACCOUNT_AUTH_NOTICE_SCHEDULE:-*-*-* 11:00:00 Asia/Shanghai}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +# 授权状态来自公司报表库,推送走通知中心;两组环境变量缺一不可。 +if ! grep -Eq '^JST_REPORT_DB_HOST=.+' "$APP_ENV"; then + echo "JST_REPORT_DB_HOST must be configured in $APP_ENV." >&2 + exit 1 +fi +if ! grep -Eq '^YYD_NOTIFICATION_CENTER_URL=.+' "$APP_ENV"; then + echo "YYD_NOTIFICATION_CENTER_URL must be configured in $APP_ENV." >&2 + exit 1 +fi + +if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze calendar "$SCHEDULE" >/dev/null +fi + +cat >"$SERVICE_FILE" <"$TIMER_FILE" < record.storeName), + ) + const byStore = new Map() + for (const record of records) { + if (authorizationStatusByStoreName.get(record.storeName.trim()) !== 'UNAUTHORIZED') continue + const previous = byStore.get(record.storeName) + if (!previous || (previous.accountType !== 'MAIN' && record.accountType === 'MAIN')) { + byStore.set(record.storeName, record) + } + } + const ids = [...byStore.values()].map((record) => record.id) + console.log(`[auth-notice] 店铺状态=正常 且 授权状态=未授权 的店铺(按店铺去重):${ids.length} 家`) + if (ids.length === 0) { + console.log('[auth-notice] 今日无需推送。') + return + } + if (!apply) { + const preview = [...byStore.values()].map((record) => `${record.storeName}(负责人:${record.operationOwner || '未填'})`) + console.log(`[auth-notice] [dry-run] 未发送;确认后加 --apply 执行推送。今天会推送:\n${preview.join('\n')}`) + return + } + + const result = await storeAccountAuthorizationNoticeService.notifyUnauthorizedStores({ + ids, + initiatedByName: '店铺授权提醒定时任务', + }) + console.log(`[auth-notice] 汇总:${JSON.stringify(result.summary)}`) + for (const item of result.items.filter((entry) => entry.status !== 'sent')) { + console.log(`[auth-notice] ${item.status} | ${item.storeName} | ${item.reason}`) + } +} + +main().catch((error) => { + console.error(`店铺授权提醒推送失败:${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountAuthorizationNoticeDialog.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountAuthorizationNoticeDialog.tsx index fbe22fb2..bf50d393 100644 --- a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountAuthorizationNoticeDialog.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountAuthorizationNoticeDialog.tsx @@ -110,8 +110,8 @@ export function StoreAccountAuthorizationNoticeDialog({ ) : ( <>

- 仅推送「店铺状态=正常 且 授权状态=未授权」的账号;正常推送给店铺运营负责人, - 负责人离职或无法接收时按 店铺运营部门负责人 → 店铺运营总监 顺延。 + 仅推送「店铺状态=正常 且 授权状态=未授权」的账号;推送给店铺运营负责人, + 负责人不在内部账号时整店不推送,负责人离职或无法接收时按 店铺运营部门负责人 → 店铺运营总监 顺延。

{rows.length === 0 ? ( diff --git a/furniture-3d-system/src/server/services/__tests__/store-account-authorization-notice.service.test.ts b/furniture-3d-system/src/server/services/__tests__/store-account-authorization-notice.service.test.ts index ef8c0496..2179c27a 100644 --- a/furniture-3d-system/src/server/services/__tests__/store-account-authorization-notice.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/store-account-authorization-notice.service.test.ts @@ -201,9 +201,32 @@ describe('store account authorization notice recipients', () => { expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({ recipientUserId: 'director-1' })) }) + it('skips the whole store when the owner is not an internal account', async () => { + const record = buildRecord() + const head = buildRecipient({ id: 'head-1', name: '李四' }) + const { service, sendToEmployee } = setupService([record], [head]) + const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now }) + expect(result.items[0]).toMatchObject({ status: 'skipped', recipientName: '张三' }) + expect(result.items[0].reason).toContain('不在内部账号,整店不推送') + expect(sendToEmployee).not.toHaveBeenCalled() + }) + + it('skips the whole store when the owner is an external account', async () => { + const record = buildRecord() + const supplierOwner = buildRecipient({ + id: 'owner-1', name: '张三', businessRoles: ['SUPPLIER' as never], + }) + const { service, sendToEmployee } = setupService([record], [supplierOwner]) + const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now }) + expect(result.items[0]).toMatchObject({ status: 'skipped' }) + expect(result.items[0].reason).toContain('不在内部账号,整店不推送') + expect(sendToEmployee).not.toHaveBeenCalled() + }) + it('marks failure with an audit record when nobody in the chain can receive', async () => { const record = buildRecord() - const { service, sendToEmployee, recordUndeliverable } = setupService([record], []) + const departedOwner = buildRecipient({ id: 'owner-1', name: '张三', status: 'INACTIVE' }) + const { service, sendToEmployee, recordUndeliverable } = setupService([record], [departedOwner]) const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now }) expect(result.items[0]).toMatchObject({ status: 'failed' }) expect(result.items[0].reason).toContain('均无法接收') @@ -213,6 +236,18 @@ describe('store account authorization notice recipients', () => { })) }) + it('runs without a user for scheduled jobs using SYSTEM source', async () => { + const record = buildRecord() + const owner = buildRecipient() + const { service, sendToEmployee } = setupService([record], [owner]) + const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], now }) + expect(result.summary).toEqual({ total: 1, sent: 1, skipped: 0, failed: 0 }) + expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({ + source: 'SYSTEM', + initiatedByName: '店铺授权提醒任务', + })) + }) + it('reports delivery failures per account without stopping the batch', async () => { const first = buildRecord({ id: 'account-1', storeName: '拼多多-测试店铺' }) const second = buildRecord({ id: 'account-2', storeName: '拼多多-另一家店', operationOwner: '李四' }) diff --git a/furniture-3d-system/src/server/services/store-account-authorization-notice.service.ts b/furniture-3d-system/src/server/services/store-account-authorization-notice.service.ts index 7f78b98b..e8f7f39e 100644 --- a/furniture-3d-system/src/server/services/store-account-authorization-notice.service.ts +++ b/furniture-3d-system/src/server/services/store-account-authorization-notice.service.ts @@ -29,7 +29,7 @@ import { export const STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT = 'store_account_authorization_notice' -// 推送目标解析顺序:正常只推店铺运营负责人;负责人离职或无在职内部账号时按部门负责人、运营总监顺延。 +// 推送目标解析顺序:店铺运营负责人不在内部账号时整店跳过;负责人在职但离职/无法接收时按部门负责人、运营总监顺延。 const RECIPIENT_CHAIN = [ { key: 'operationOwner', label: '店铺运营负责人' }, { key: 'operationDepartmentHead', label: '店铺运营部门负责人' }, @@ -72,16 +72,22 @@ export class StoreAccountAuthorizationNoticeService { async notifyUnauthorizedStores(params: { ids: string[] testRecipientName?: string - user: AuthUser + user?: AuthUser + initiatedByName?: string now?: Date }): Promise { - const access = await supplyChainPermissionSettingsService.getUserAccess( - params.user, - 'storeAccountManagement', - ) - if (!access.edit) { - throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有触发店铺授权提醒推送的权限') + if (params.user) { + const access = await supplyChainPermissionSettingsService.getUserAccess( + params.user, + 'storeAccountManagement', + ) + if (!access.edit) { + throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有触发店铺授权提醒推送的权限') + } } + const source = params.user ? 'USER' as const : 'SYSTEM' as const + const initiatedById = params.user?.id + const initiatedByName = params.user?.name ?? params.initiatedByName ?? '店铺授权提醒任务' const now = params.now ?? new Date() const day = formatDayBucket(now) @@ -93,9 +99,9 @@ export class StoreAccountAuthorizationNoticeService { ) const testRecipient = await this.resolveTestRecipient(params.testRecipientName) - const deliverableUserByName = testRecipient - ? new Map() - : await this.loadDeliverableUsers(records) + const recipientIndex = testRecipient + ? null + : await this.loadRecipientIndex(records) const items: StoreAccountAuthorizationNoticeItem[] = [] for (const id of ids) { @@ -134,9 +140,19 @@ export class StoreAccountAuthorizationNoticeService { const target = testRecipient ? { recipient: testRecipient, role: '测试收件人' } - : this.resolveChainRecipient(record, deliverableUserByName) + : this.resolveChainRecipient(record, recipientIndex) + if (target && 'skip' in target) { + items.push({ + storeName: record.storeName, + recipientName: record.operationOwner.trim(), + recipientRole: '店铺运营负责人', + status: 'skipped', + reason: target.reason, + }) + continue + } if (!target) { - await this.recordNoRecipientAudit(record, day, params.user) + await this.recordNoRecipientAudit(record, day, initiatedById, initiatedByName) items.push({ storeName: record.storeName, recipientName: '', @@ -158,9 +174,9 @@ export class StoreAccountAuthorizationNoticeService { idempotencyKey, businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT, businessRecordId: record.id, - source: 'USER', - initiatedById: params.user.id, - initiatedByName: params.user.name, + source, + initiatedById, + initiatedByName, recipientUserId: target.recipient.id, title: '店铺授权提醒', content: formatNoticeContent(record.storeName), @@ -203,7 +219,7 @@ export class StoreAccountAuthorizationNoticeService { return matched } - private async loadDeliverableUsers(records: StoreAccountRecord[]) { + private async loadRecipientIndex(records: StoreAccountRecord[]) { const names = [ ...new Set( records @@ -213,27 +229,43 @@ export class StoreAccountAuthorizationNoticeService { ] const users = names.length > 0 ? await this.userReader.findUsersByNames(names) : [] const deliverableByName = new Map() + const internalNames = new Set() for (const user of users) { - if (!isDeliverableRecipient(user)) continue - if (!deliverableByName.has(user.name)) deliverableByName.set(user.name, user) + // 供应商等外部账号不算内部人员,既不推送也不算“离职顺延”。 + if (user.businessRoles.some((role) => EXTERNAL_ACCOUNT_ROLES.has(role))) continue + internalNames.add(user.name) + if (isDeliverableRecipient(user) && !deliverableByName.has(user.name)) { + deliverableByName.set(user.name, user) + } } - return deliverableByName + return { deliverableByName, internalNames } } + // 返回 { recipient, role } 表示可推送;{ skip, reason } 表示负责人不在内部、整店跳过;null 表示三级都收不了。 private resolveChainRecipient( record: StoreAccountRecord, - deliverableUserByName: Map, - ) { + index: Awaited> | null, + ): { recipient: StoreAccountNotificationRecipient; role: string } | { skip: true; reason: string } | null { + if (!index) return null + const ownerName = record.operationOwner.trim() + if (ownerName && !index.internalNames.has(ownerName)) { + return { skip: true, reason: `店铺运营负责人「${ownerName}」不在内部账号,整店不推送` } + } for (const { key, label } of RECIPIENT_CHAIN) { const name = record[key].trim() if (!name) continue - const recipient = deliverableUserByName.get(name) + const recipient = index.deliverableByName.get(name) if (recipient) return { recipient, role: label } } return null } - private async recordNoRecipientAudit(record: StoreAccountRecord, day: string, user: AuthUser) { + private async recordNoRecipientAudit( + record: StoreAccountRecord, + day: string, + initiatedById: string | undefined, + initiatedByName: string, + ) { const chainNames = RECIPIENT_CHAIN.map(({ key }) => record[key].trim()).filter(Boolean) const auditId = `missing:store-account-auth-notice:${record.id}` try { @@ -242,9 +274,9 @@ export class StoreAccountAuthorizationNoticeService { idempotencyKey: `${STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT}:${record.id}:${day}:no-recipient`, businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT, businessRecordId: record.id, - source: 'USER', - initiatedById: user.id, - initiatedByName: user.name, + source: initiatedById ? 'USER' : 'SYSTEM', + initiatedById, + initiatedByName, recipient: { id: auditId, name: chainNames[chainNames.length - 1] ?? '未维护负责人', From 605fb1347b27e6c527ed5311619f7e1a1fa2292c Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 8 Oct 2026 12:01:11 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(supply-chain):=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E8=BF=90=E8=90=A5=E5=B1=82=E7=BA=A7=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E5=8A=A0=E5=9B=BA=EF=BC=88=E4=B8=8D=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E5=AD=97=E7=AC=A6=E6=B8=85=E6=B4=97+=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E8=BF=90=E8=90=A5=E8=B4=9F=E8=B4=A3=E4=BA=BA?= =?UTF-8?q?=E5=85=9C=E5=BA=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ct-archive-enrichment-sync.service.test.ts | 98 ++++++++++++++++++- ...-product-archive-enrichment-sync.mapper.ts | 18 +++- ...product-archive-enrichment-sync.service.ts | 9 +- 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts index a5cd20cc..41d0d4e2 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts @@ -35,7 +35,10 @@ jest.mock('@/server/repositories/jst-inventory-public-available.repository', () import { recordProductArchiveFieldChanges } from '../supply-chain-product-archive-change-log.service' import { SupplyChainProductArchiveEnrichmentSyncService } from '../supply-chain-product-archive-enrichment-sync.service' -import { buildProductArchiveEnrichmentUpdates } from '../supply-chain-product-archive-enrichment-sync.mapper' +import { + buildProductArchiveEnrichmentUpdates, + normalizeIdentity, +} from '../supply-chain-product-archive-enrichment-sync.mapper' describe('supply-chain product archive enrichment sync', () => { it('does not clear archive warehouse fields when no configuration matches', () => { @@ -314,6 +317,99 @@ describe('supply-chain product archive enrichment sync', () => { expect(hierarchyUpdate?.patch).toEqual({ opsDeptHead: '东邪', opsDirector: '無尘' }) }) + it('normalizes invisible characters so identical names always match', () => { + expect(normalizeIdentity('琼\u200B琚')).toBe(normalizeIdentity('琼琚')) + expect(normalizeIdentity(' 子\uFEFF芩 ')).toBe('子芩') + expect(normalizeIdentity('比干')).toBe('比干') + }) + + it('fills the ops hierarchy from the stored archive opsHead when no source derives one', () => { + const lookups = { + clearanceStocks: [], + publicAvailableStocks: [], + suppliers: [], + stocks: [], + warehouses: [], + prices: [], + dwsGoodsCosts: [], + skumapPics: [], + skumapFillPics: [], + dwsOpsOwners: [], + spuOpsOwners: [], + intakeSpuOwners: new Map(), + ownerHierarchyByPerson: new Map([ + ['子芩', { departmentOwner: '子芩', generalOwner: '江陵' }], + ]), + catalogCosts: [], + warehouseConfigs: [], + } + + const updates = buildProductArchiveEnrichmentUpdates( + { + id: 'archive-stale', + barcode: 'BJ022601N1280V01', + spu: 'BJ0226', + sku: 'BJ022601', + supplierName: '', + inOutWarehouse: '', + image: '', + opsHead: '子芩', + opsDeptHead: '', + opsDirector: '', + }, + lookups, + ) + + const hierarchyUpdate = updates.find((update) => 'opsDeptHead' in update.patch) + expect(hierarchyUpdate?.patch).toEqual({ opsDeptHead: '子芩', opsDirector: '江陵' }) + expect(hierarchyUpdate?.source).toEqual( + expect.objectContaining({ sourceRecordId: '子芩' }), + ) + // 推不出运营负责人时只补层级,不动 opsHead 本身 + expect(updates.some((update) => 'opsHead' in update.patch)).toBe(false) + }) + + it('matches the ops hierarchy even when the stored name carries invisible characters', () => { + const lookups = { + clearanceStocks: [], + publicAvailableStocks: [], + suppliers: [], + stocks: [], + warehouses: [], + prices: [], + dwsGoodsCosts: [], + skumapPics: [], + skumapFillPics: [], + dwsOpsOwners: [], + spuOpsOwners: [], + intakeSpuOwners: new Map(), + ownerHierarchyByPerson: new Map([ + ['琼琚', { departmentOwner: '半夏', generalOwner: '江陵' }], + ]), + catalogCosts: [], + warehouseConfigs: [], + } + + const updates = buildProductArchiveEnrichmentUpdates( + { + id: 'archive-dirty-name', + barcode: 'BAR-ZW-001', + spu: 'SPU-ZW', + sku: 'SKU-ZW', + supplierName: '', + inOutWarehouse: '', + image: '', + opsHead: '琼\u200B琚', + opsDeptHead: '', + opsDirector: '', + }, + lookups, + ) + + const hierarchyUpdate = updates.find((update) => 'opsDeptHead' in update.patch) + expect(hierarchyUpdate?.patch).toEqual({ opsDeptHead: '半夏', opsDirector: '江陵' }) + }) + it('takes ops owners from the dws sales table only when dws has a value', () => { const lookups = { clearanceStocks: [], diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts index 5cf46770..8445b6d3 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts @@ -71,8 +71,13 @@ function normalize(value: string | null | undefined) { return value?.trim().toLocaleLowerCase('zh-CN') ?? '' } -function normalizeIdentity(value: string | null | undefined) { - return value?.trim().toLocaleUpperCase('zh-CN') ?? '' +// 零宽字符等不可见字符会让同一个人名永远匹配不上(源数据偶发混入), +// 身份匹配前统一清洗。人员信息表侧与档案侧都必须走同一个归一化。 +const INVISIBLE_IDENTITY_CHARS = /[\u200B\u200C\u200D\u2060\uFEFF\u00AD]/g + +export function normalizeIdentity(value: string | null | undefined) { + const cleaned = value?.replace(INVISIBLE_IDENTITY_CHARS, '') ?? '' + return cleaned.trim().toLocaleUpperCase('zh-CN') } function supplierStatusLabel(code: string) { @@ -224,8 +229,11 @@ export function buildProductArchiveEnrichmentUpdates( : [] // 部门负责人/总负责人层级只来自人员信息表(SupplyChainManagementOwnerConfig): // 按运营负责人花名匹配人员,取其 departmentOwner/generalOwner;未匹配则不动这两个字段。 - const ownerConfig = effectiveOpsHead - ? lookups.ownerHierarchyByPerson.get(normalizeIdentity(effectiveOpsHead)) + // 匹配基准优先本轮推出的运营负责人(dws 销售/新品流程);推不出时回退档案已存的 + // 运营负责人——只要人员信息里能匹配到人,层级字段就不允许留空(不动运营负责人本身)。 + const ownerConfigBasis = effectiveOpsHead || (target.opsHead ?? '').trim() + const ownerConfig = ownerConfigBasis + ? lookups.ownerHierarchyByPerson.get(normalizeIdentity(ownerConfigBasis)) : undefined const ownerConfigUpdates: ProductArchiveEnrichmentUpdate[] = ownerConfig && (ownerConfig.departmentOwner || ownerConfig.generalOwner) @@ -240,7 +248,7 @@ export function buildProductArchiveEnrichmentUpdates( CONFIG_SOURCE_SYSTEM, PUBLIC_SOURCE_SCHEMA, 'SupplyChainManagementOwnerConfig', - effectiveOpsHead, + ownerConfigBasis, { opsDeptHead: 'departmentOwner', opsDirector: 'generalOwner' }, ), }, diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts index 42314c8e..fc5a5fb9 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.service.ts @@ -44,7 +44,10 @@ import { type SupplyChainProductArchiveEnrichmentSyncRepository, } from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository' import { recordProductArchiveFieldChanges } from './supply-chain-product-archive-change-log.service' -import { buildProductArchiveEnrichmentUpdates } from './supply-chain-product-archive-enrichment-sync.mapper' +import { + buildProductArchiveEnrichmentUpdates, + normalizeIdentity, +} from './supply-chain-product-archive-enrichment-sync.mapper' export type SupplyChainProductArchiveEnrichmentSyncResult = { batches: number @@ -184,9 +187,11 @@ export class SupplyChainProductArchiveEnrichmentSyncService { ) // One shot: 人员信息表的「人员 → 部门负责人/总负责人」层级。运营负责人花名 // 匹配到人员后,部门负责人/总负责人以这张表为准(覆盖 dws 的同名猜测值)。 + // 键与档案侧统一走 normalizeIdentity(含不可见字符清洗),避免同一个人因 + // 源数据混入零宽字符而永远匹配不上。 const ownerHierarchyByPerson = new Map( (await this.dependencies.ownerConfigRepository.listOwnerHierarchy()).map((owner) => [ - owner.operationOwner.trim().toUpperCase(), + normalizeIdentity(owner.operationOwner), { departmentOwner: owner.departmentOwner, generalOwner: owner.generalOwner }, ]), ) From 4e594a4eeec2d1171992103aaf6706c9bd0918dd Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 8 Oct 2026 12:59:04 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(supply-chain):=20=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=A6=BB=E8=81=8C=E8=B4=9F=E8=B4=A3=E4=BA=BA?= =?UTF-8?q?=E7=BA=A2=E8=89=B2=E6=A0=87=E6=B3=A8=E4=B8=8E=E8=B4=9F=E8=B4=A3?= =?UTF-8?q?=E4=BA=BA=E7=8A=B6=E6=80=81=E7=AD=9B=E9=80=89=EF=BC=88=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E4=B8=8B=E6=8B=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StoreAccountManagementView.tsx | 110 +++++++++++++++++- .../src/lib/store-account-management.test.ts | 31 +++++ .../src/lib/store-account-management.ts | 43 +++++++ .../store-account-management.service.ts | 80 ++++++++++--- 4 files changed, 250 insertions(+), 14 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx index b11ad875..8302a6bd 100644 --- a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx @@ -42,6 +42,7 @@ import { STORE_ACCOUNT_FIELDS, STORE_ACCOUNT_AUTHORIZATION_COLUMN, STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS, + STORE_ACCOUNT_OWNER_STATUS_FILTER_OPTIONS, STORE_ACCOUNT_PROFILE_FIELDS, STORE_ACCOUNT_SHOP_STATE_COLUMN, STORE_ACCOUNT_SHOP_STATE_FILTER_OPTIONS, @@ -568,6 +569,17 @@ export function StoreAccountManagementView({ } placeholder="运营负责人" /> + { + const nextFilters = { + ...filters, + operationOwnerStatus: value, + }; + setFilters(nextFilters); + void load(nextFilters, 1, data.pageInfo.pageSize); + }} + /> @@ -764,6 +776,11 @@ export function StoreAccountManagementView({ formatStoreAccountAuthorizationStatus( row.authorizationStatus, ) + ) : field.key === "operationOwner" && + row.operationOwnerDeparted ? ( + + {row.operationOwner || "-"} + ) : field.key === "password" ? ( {row.password || (row.passwordConfigured ? "••••••" : "未设置")} @@ -949,6 +966,86 @@ export function StoreAccountManagementView({ ); } +// 原生 select 的系统下拉在内置浏览器(webview)里打不开,负责人状态筛选用自定义面板实现。 +function OwnerStatusSelect({ + value, + onApply, +}: { + value: string; + onApply: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const pickerRef = useRef(null); + const selectedLabel = + STORE_ACCOUNT_OWNER_STATUS_FILTER_OPTIONS.find( + (option) => option.value === value, + )?.label ?? ""; + + useEffect(() => { + if (!open) return; + + const closeOnOutsideClick = (event: MouseEvent) => { + if (!pickerRef.current?.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + + document.addEventListener("mousedown", closeOnOutsideClick); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutsideClick); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + return ( +
+ + {open && ( +
+ + {STORE_ACCOUNT_OWNER_STATUS_FILTER_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ ); +} + function StoreNameMultiSelect({ options, value, @@ -1100,11 +1197,22 @@ function StoreNameMultiSelect({ const styles = ` .store-account-table-wrap{overscroll-behavior:contain} .account-profile-section{margin-top:20px;padding-top:18px;border-top:1px solid #e4e4e7}.account-profile-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:12px}.account-profile-heading h3{margin:0;color:#27272a;font-size:14px}.account-profile-heading p{margin:4px 0 0;color:#71717a;font-size:11px}.account-profile-heading>span{color:#a1a1aa;font-size:11px}.account-profile-message{margin:10px 0 0;color:#b45309;font-size:11px} -.store-account-page{display:flex;flex:1;min-height:0;flex-direction:column;background:#fafafa;color:#27272a}.store-account-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:20px 28px 16px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-toolbar h1{margin:0;font-size:20px;font-weight:650}.store-account-toolbar p{margin:7px 0 0;color:#71717a;font-size:12px}.store-account-actions{display:flex;align-items:center;gap:8px;color:#71717a;font-size:12px}.store-account-actions button,.store-account-filters button,.store-account-pagination button,.store-account-batch button,.modal-actions button{display:inline-flex;height:34px;align-items:center;justify-content:center;gap:5px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;cursor:pointer}.store-account-actions .primary,.primary{border-color:#27272a!important;background:#27272a!important;color:#fff!important}.store-account-filters{display:flex;align-items:center;gap:8px;padding:12px 28px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-filters input,.store-account-filters select,.store-account-batch input,.store-account-batch select{height:34px;min-width:130px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#3f3f46;font-size:12px}.store-account-filters input:first-child{width:250px}.icon-button{width:34px;padding:0!important}.store-account-batch{display:flex;align-items:center;gap:8px;padding:8px 28px;border-bottom:1px solid #e4e4e7;background:#fff;color:#52525b;font-size:12px}.store-account-batch input{width:240px}.store-account-batch button:disabled{opacity:.45;cursor:not-allowed}.store-account-table-wrap{flex:1;min-height:0;overflow:auto;padding:0 28px}.store-account-table-wrap table{width:100%;min-width:1320px;border-collapse:collapse;background:#fff;font-size:12px}.store-account-table-wrap th{position:sticky;top:0;height:42px;padding:0 12px;border-bottom:1px solid #d4d4d8;background:#f4f4f5;color:#52525b;text-align:left;white-space:nowrap}.store-account-table-wrap td{height:46px;padding:0 12px;border-bottom:1px solid #f0f0f1;color:#3f3f46;white-space:nowrap}.store-account-table-wrap tr:hover td{background:#fafafa}.select-column{width:38px;padding:0 8px!important;text-align:center}.row-actions{display:flex;gap:10px}.row-actions button,.password-cell button{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#52525b;cursor:pointer;font-size:12px}.password-cell{display:inline-flex;align-items:center;gap:5px}.password-cell button{padding:2px}.empty{text-align:center;height:180px!important;color:#a1a1aa!important}.store-account-pagination{position:absolute;bottom:24px;left:50%;z-index:30;display:flex;align-items:center;gap:10px;padding:8px 16px;transform:translateX(-50%);border:1px solid #e4e4e7;border-radius:999px;background:rgba(255,255,255,.92);box-shadow:0 4px 12px rgba(0,0,0,.06),0 2px 4px rgba(0,0,0,.04);color:#52525b;font-size:13px;white-space:nowrap;backdrop-filter:blur(16px)}.store-account-page-total{margin-right:8px}.store-account-page-btn{display:inline-flex;align-items:center;gap:4px;height:30px;padding:0 12px!important;border:1px solid #e4e4e7!important;border-radius:4px!important;background:#fff!important;color:#27272a!important;font-size:13px!important;cursor:pointer;transition:all .15s}.store-account-page-btn:hover:not(:disabled){border-color:#52525b!important;background:#f4f4f5!important}.store-account-page-btn:disabled{color:#a1a1aa!important;cursor:not-allowed;opacity:.5}.store-account-pagination > .store-account-page-btn{height:30px!important}.store-account-page-num{display:inline-flex;align-items:center;gap:4px;color:#52525b}.store-account-page-input{width:52px;height:28px;padding:0 6px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;font-weight:600;text-align:center;outline:none}.store-account-page-input:focus{border-color:#52525b;box-shadow:0 0 0 2px rgba(82,82,91,.12)}.store-account-page-input:disabled{color:#a1a1aa;background:#f4f4f5}.store-account-page-sep{color:#a1a1aa}.store-account-page-size-wrap{display:inline-flex;align-items:center;gap:6px;margin-left:8px;color:#52525b}.store-account-page-size{height:28px;padding:0 8px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;cursor:pointer;outline:none}.store-account-page-size:focus{border-color:#52525b}.modal-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.28)}.modal{width:min(720px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;padding:20px;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 18px 50px rgba(24,24,27,.18)}.modal header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.modal h2{margin:0;font-size:16px}.modal header button{border:0;background:transparent;color:#71717a;cursor:pointer}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}.modal label{display:flex;flex-direction:column;gap:6px;color:#52525b;font-size:12px}.modal input,.modal select{height:36px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#27272a;font-size:13px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.hint{margin:0 0 16px;color:#71717a;font-size:12px}@media(max-width:760px){.store-account-toolbar{align-items:flex-start;flex-direction:column;padding:16px}.store-account-filters{flex-wrap:wrap;padding:12px 16px}.store-account-batch{flex-wrap:wrap;padding:8px 16px}.store-account-table-wrap{padding:0 16px}.store-account-pagination{bottom:12px;max-width:calc(100% - 24px);gap:6px;padding:7px 10px;overflow-x:auto}.store-account-table-wrap{padding:0 16px}.form-grid{grid-template-columns:1fr}} +.store-account-page{display:flex;flex:1;min-height:0;flex-direction:column;background:#fafafa;color:#27272a}.store-account-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:20px 28px 16px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-toolbar h1{margin:0;font-size:20px;font-weight:650}.store-account-toolbar p{margin:7px 0 0;color:#71717a;font-size:12px}.store-account-actions{display:flex;align-items:center;gap:8px;color:#71717a;font-size:12px}.store-account-actions button,.store-account-filters button,.store-account-pagination button,.store-account-batch button,.modal-actions button{display:inline-flex;height:34px;align-items:center;justify-content:center;gap:5px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;cursor:pointer}.store-account-actions .primary,.primary{border-color:#27272a!important;background:#27272a!important;color:#fff!important}.store-account-filters{display:flex;align-items:center;gap:8px;padding:12px 28px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-filters input,.store-account-filters select,.store-account-batch input,.store-account-batch select{height:34px;min-width:130px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#3f3f46;font-size:12px}.store-account-filters input:first-child{width:250px}.icon-button{width:34px;padding:0!important}.store-account-batch{display:flex;align-items:center;gap:8px;padding:8px 28px;border-bottom:1px solid #e4e4e7;background:#fff;color:#52525b;font-size:12px}.store-account-batch input{width:240px}.store-account-batch button:disabled{opacity:.45;cursor:not-allowed}.store-account-table-wrap{flex:1;min-height:0;overflow:auto;padding:0 28px}.store-account-table-wrap table{width:100%;min-width:1320px;border-collapse:collapse;background:#fff;font-size:12px}.store-account-table-wrap th{position:sticky;top:0;height:42px;padding:0 12px;border-bottom:1px solid #d4d4d8;background:#f4f4f5;color:#52525b;text-align:left;white-space:nowrap}.store-account-table-wrap td{height:46px;padding:0 12px;border-bottom:1px solid #f0f0f1;color:#3f3f46;white-space:nowrap}.store-account-table-wrap tr:hover td{background:#fafafa}.select-column{width:38px;padding:0 8px!important;text-align:center} +.owner-departed{color:#b91c1c;font-weight:600}.row-actions{display:flex;gap:10px}.row-actions button,.password-cell button{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#52525b;cursor:pointer;font-size:12px}.password-cell{display:inline-flex;align-items:center;gap:5px}.password-cell button{padding:2px}.empty{text-align:center;height:180px!important;color:#a1a1aa!important}.store-account-pagination{position:absolute;bottom:24px;left:50%;z-index:30;display:flex;align-items:center;gap:10px;padding:8px 16px;transform:translateX(-50%);border:1px solid #e4e4e7;border-radius:999px;background:rgba(255,255,255,.92);box-shadow:0 4px 12px rgba(0,0,0,.06),0 2px 4px rgba(0,0,0,.04);color:#52525b;font-size:13px;white-space:nowrap;backdrop-filter:blur(16px)}.store-account-page-total{margin-right:8px}.store-account-page-btn{display:inline-flex;align-items:center;gap:4px;height:30px;padding:0 12px!important;border:1px solid #e4e4e7!important;border-radius:4px!important;background:#fff!important;color:#27272a!important;font-size:13px!important;cursor:pointer;transition:all .15s}.store-account-page-btn:hover:not(:disabled){border-color:#52525b!important;background:#f4f4f5!important}.store-account-page-btn:disabled{color:#a1a1aa!important;cursor:not-allowed;opacity:.5}.store-account-pagination > .store-account-page-btn{height:30px!important}.store-account-page-num{display:inline-flex;align-items:center;gap:4px;color:#52525b}.store-account-page-input{width:52px;height:28px;padding:0 6px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;font-weight:600;text-align:center;outline:none}.store-account-page-input:focus{border-color:#52525b;box-shadow:0 0 0 2px rgba(82,82,91,.12)}.store-account-page-input:disabled{color:#a1a1aa;background:#f4f4f5}.store-account-page-sep{color:#a1a1aa}.store-account-page-size-wrap{display:inline-flex;align-items:center;gap:6px;margin-left:8px;color:#52525b}.store-account-page-size{height:28px;padding:0 8px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;cursor:pointer;outline:none}.store-account-page-size:focus{border-color:#52525b}.modal-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.28)}.modal{width:min(720px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;padding:20px;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 18px 50px rgba(24,24,27,.18)}.modal header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.modal h2{margin:0;font-size:16px}.modal header button{border:0;background:transparent;color:#71717a;cursor:pointer}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}.modal label{display:flex;flex-direction:column;gap:6px;color:#52525b;font-size:12px}.modal input,.modal select{height:36px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#27272a;font-size:13px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.hint{margin:0 0 16px;color:#71717a;font-size:12px}@media(max-width:760px){.store-account-toolbar{align-items:flex-start;flex-direction:column;padding:16px}.store-account-filters{flex-wrap:wrap;padding:12px 16px}.store-account-batch{flex-wrap:wrap;padding:8px 16px}.store-account-table-wrap{padding:0 16px}.store-account-pagination{bottom:12px;max-width:calc(100% - 24px);gap:6px;padding:7px 10px;overflow-x:auto}.store-account-table-wrap{padding:0 16px}.form-grid{grid-template-columns:1fr}} /* 标题区已删除:工具条只剩右侧操作组,仍保持靠右 */ .store-account-toolbar { justify-content: flex-end; } .store-account-filters{position:relative;z-index:40;overflow:visible} .store-name-picker{position:relative;width:180px;flex:0 0 180px} +.owner-status-picker{position:relative;width:130px;flex:0 0 130px} +.owner-status-trigger{display:flex;width:100%;height:34px;align-items:center;justify-content:space-between;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#71717a;font-size:12px;cursor:pointer} +.owner-status-trigger:hover,.owner-status-trigger.is-open{border-color:#71717a;color:#27272a} +.owner-status-trigger>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.owner-status-trigger svg{flex:0 0 auto;transition:transform .15s ease} +.owner-status-trigger svg.is-open{transform:rotate(180deg)} +.owner-status-menu{position:absolute;top:calc(100% + 7px);left:0;z-index:80;width:130px;box-sizing:border-box;padding:6px;border:1px solid #d4d4d8;border-radius:6px;background:#fff;box-shadow:0 10px 24px rgba(0,0,0,.12)} +.owner-status-option{display:flex;width:100%;min-height:30px;align-items:center;padding:4px 8px;border:0;border-radius:4px;background:transparent;color:#3f3f46;font-size:13px;text-align:left;cursor:pointer} +.owner-status-option:hover,.owner-status-option.is-selected{background:#f4f4f5} +.owner-status-option.is-selected{color:#18181b;font-weight:550} .store-name-trigger{display:flex;width:100%;height:34px;align-items:center;justify-content:space-between;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#71717a;font-size:12px;cursor:pointer} .store-name-trigger:hover,.store-name-trigger.is-open{border-color:#71717a;color:#27272a} .store-name-trigger>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} diff --git a/furniture-3d-system/src/lib/store-account-management.test.ts b/furniture-3d-system/src/lib/store-account-management.test.ts index 54d5abe7..39233001 100644 --- a/furniture-3d-system/src/lib/store-account-management.test.ts +++ b/furniture-3d-system/src/lib/store-account-management.test.ts @@ -1,4 +1,5 @@ import { + buildDepartedStoreAccountOwnerNames, formatStoreAccountLogValue, formatStoreAccountType, getStoreAccountLogFieldLabel, @@ -18,6 +19,36 @@ describe('store account type presentation', () => { }) }) +describe('departed store account owner names', () => { + it('marks names whose internal accounts are all INACTIVE', () => { + const departed = buildDepartedStoreAccountOwnerNames([ + { name: '玄机', status: 'INACTIVE' }, + { name: '子龙', status: 'ACTIVE' }, + { name: ' 惠然 ', status: 'INACTIVE' }, + ]) + expect(departed.has('玄机')).toBe(true) + expect(departed.has('惠然')).toBe(true) + expect(departed.has('子龙')).toBe(false) + }) + + it('does not mark a name when an ACTIVE account shares it', () => { + const departed = buildDepartedStoreAccountOwnerNames([ + { name: '鲁肃', status: 'ACTIVE' }, + { name: '鲁肃', status: 'INACTIVE' }, + ]) + expect(departed.has('鲁肃')).toBe(false) + }) + + it('does not mark unknown names or non-departure statuses', () => { + const departed = buildDepartedStoreAccountOwnerNames([ + { name: '刘景刚', status: 'ACTIVE' }, + { name: '白泽', status: 'SUSPENDED' }, + { name: ' ', status: 'INACTIVE' }, + ]) + expect(departed.size).toBe(0) + }) +}) + describe('store account profile platform', () => { it('recognizes only the supported 京东- and 京喜- store prefixes', () => { expect(getStoreAccountProfilePlatformForStoreName('京东-旗舰店')).toBe('JD') diff --git a/furniture-3d-system/src/lib/store-account-management.ts b/furniture-3d-system/src/lib/store-account-management.ts index aa1656bd..f78fa670 100644 --- a/furniture-3d-system/src/lib/store-account-management.ts +++ b/furniture-3d-system/src/lib/store-account-management.ts @@ -126,6 +126,7 @@ export const STORE_ACCOUNT_FILTER_KEYS = [ "accountType", "operationOwner", "operationOwnerExact", + "operationOwnerStatus", "operationDepartmentHead", "operationDirector", "profitCenter", @@ -144,6 +145,7 @@ export const EMPTY_STORE_ACCOUNT_FILTERS: StoreAccountFilters = { accountType: "", operationOwner: "", operationOwnerExact: "", + operationOwnerStatus: "", operationDepartmentHead: "", operationDirector: "", profitCenter: "", @@ -154,6 +156,12 @@ export const EMPTY_STORE_ACCOUNT_FILTERS: StoreAccountFilters = { createdAtValue: "", }; +// 负责人状态筛选:已离职=内部账号查得到且全为离职;在职=内部账号存在在职账号;查无此人(不在内部)两档都不算。 +export const STORE_ACCOUNT_OWNER_STATUS_FILTER_OPTIONS = [ + { value: "DEPARTED", label: "已离职" }, + { value: "ACTIVE", label: "在职" }, +] as const; + export type StoreAccountFieldAccess = Record< StoreAccountFieldKey, StoreAccountFieldVisibility @@ -200,6 +208,8 @@ export type StoreAccountRow = { id: string; storeName: string; operationOwner: string; + /** 店铺运营负责人已离职(内部账号存在但状态=离职)时为 true,列表用红色提示需要换人 */ + operationOwnerDeparted?: boolean; operationDepartmentHead: string; operationDirector: string; profitCenter: string; @@ -365,6 +375,39 @@ export type StoreAccountAuthorizationNoticeResult = { }; }; +export type StoreAccountOwnerEmploymentUser = { + name: string; + status: string; +}; + +// 同名存在在职账号时不算离职;只有查到账号且全为离职(INACTIVE)才判为离职。查无此人不判离职(另属"不在内部"口径)。 +export function resolveStoreAccountOwnerEmployment( + users: readonly StoreAccountOwnerEmploymentUser[], +) { + const byName = new Map(); + for (const user of users) { + const name = user.name.trim(); + if (!name) continue; + const entry = byName.get(name) ?? { active: false, inactive: false }; + if (user.status === "ACTIVE") entry.active = true; + if (user.status === "INACTIVE") entry.inactive = true; + byName.set(name, entry); + } + const departedNames = new Set(); + const employedNames = new Set(); + for (const [name, entry] of byName) { + if (entry.inactive && !entry.active) departedNames.add(name); + if (entry.active) employedNames.add(name); + } + return { departedNames, employedNames }; +} + +export function buildDepartedStoreAccountOwnerNames( + users: readonly StoreAccountOwnerEmploymentUser[], +) { + return resolveStoreAccountOwnerEmployment(users).departedNames; +} + export function getStoreAccountLogOperationLabel(operation: string) { return STORE_ACCOUNT_LOG_OPERATION_LABELS[operation] ?? "其他操作"; } diff --git a/furniture-3d-system/src/server/services/store-account-management.service.ts b/furniture-3d-system/src/server/services/store-account-management.service.ts index 97dd5adf..42dcf300 100644 --- a/furniture-3d-system/src/server/services/store-account-management.service.ts +++ b/furniture-3d-system/src/server/services/store-account-management.service.ts @@ -3,6 +3,8 @@ import { AppError, ErrorCode } from "@/server/core/errors"; import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS, + buildDepartedStoreAccountOwnerNames, + resolveStoreAccountOwnerEmployment, getStoreAccountProfilePlatformForStoreName, STORE_ACCOUNT_PROFIT_CENTERS, STORE_ACCOUNT_PROFILE_PLATFORMS, @@ -23,6 +25,7 @@ import { type StoreAccountFilters, type StoreAccountImportProgress, type StoreAccountPage, + type StoreAccountRow, type StoreAccountOwnerOption, type StoreAccountType, type StoreAccountProfilePlatform, @@ -47,6 +50,7 @@ import { } from "@/server/repositories/store-account-management.repository"; import { permissionRoleRepository } from "@/server/repositories/permission-role.repository"; import { storeAccountNotificationService } from "@/server/services/store-account-notification.service"; +import { storeAccountNotificationRepository } from "@/server/repositories/store-account-notification.repository"; import { canRevealStoreAccountPassword, storeAccountPasswordAccessService, @@ -155,6 +159,12 @@ type StoreAccountOwnerOptionReader = { listOwnerOptions(): Promise<{ items: StoreAccountOwnerOption[] }>; }; +// 列表标注“店铺运营负责人已离职”用:按姓名批量查内部账号在职状态。 +type StoreAccountOwnerUserReader = Pick< + typeof storeAccountNotificationRepository, + "findUsersByNames" +>; + export class StoreAccountManagementService { constructor( private readonly repository: StoreAccountManagementRepository = storeAccountManagementRepository, @@ -162,6 +172,7 @@ export class StoreAccountManagementService { private readonly passwordAccessReader: StoreAccountPasswordAccessReader = storeAccountPasswordAccessService, private readonly authorizationReader: StoreAccountAuthorizationReader = storeAccountAuthorizationService, private readonly ownerOptionReader: StoreAccountOwnerOptionReader = supplyChainManagementOwnerConfigService, + private readonly ownerUserReader: StoreAccountOwnerUserReader = storeAccountNotificationRepository, ) {} private async getPasswordViewContext( @@ -175,6 +186,13 @@ export class StoreAccountManagementService { return { scope, rowAccess }; } + // 负责人状态筛选用:候选名字取自筛选选项里的运营负责人清单,再批量查内部账号在职状态。 + private async resolveOwnerEmployment(candidateOwners: readonly string[]) { + const names = [...new Set(candidateOwners.map((name) => name.trim()).filter(Boolean))]; + const users = names.length > 0 ? await this.ownerUserReader.findUsersByNames(names) : []; + return resolveStoreAccountOwnerEmployment(users); + } + async getFieldAccess(user: AuthUser) { const target = await supplyChainPermissionSettingsService.getUserAccess( user, @@ -263,8 +281,13 @@ export class StoreAccountManagementService { ); const authorizationFilter = splitFilterValues(filters.authorizationStatus); const hasAuthorizationFilter = authorizationFilter.length > 0; + const ownerStatusFilter = filters.operationOwnerStatus.trim(); + const hasOwnerStatusFilter = + ownerStatusFilter === "DEPARTED" || ownerStatusFilter === "ACTIVE"; + // 授权状态与负责人状态都不是店铺账号表的本列值,需要拉全量后在内存过滤。 + const needsFullScan = hasAuthorizationFilter || hasOwnerStatusFilter; const [result, storeNames, filterOptions, passwordView] = await Promise.all([ - hasAuthorizationFilter + needsFullScan ? this.repository.listAll({ filters }).then((rows) => ({ rows, total: rows.length })) : this.repository.listPage({ skip: (page - 1) * pageSize, @@ -278,24 +301,55 @@ export class StoreAccountManagementService { const authorizationStatusByStoreName = await this.authorizationReader.getStatusMap( result.rows.map((record) => record.storeName), ); - const filteredRows = hasAuthorizationFilter + const ownerEmployment = hasOwnerStatusFilter + ? await this.resolveOwnerEmployment(filterOptions.operationOwner) + : null; + const filteredRows = needsFullScan ? result.rows.filter((record) => { - const status = authorizationStatusByStoreName.get(record.storeName.trim()); - return STORE_ACCOUNT_AUTHORIZATION_STATUSES.has(status ?? "") - && authorizationFilter.includes(status as string); + if (hasAuthorizationFilter) { + const status = authorizationStatusByStoreName.get(record.storeName.trim()); + if ( + !STORE_ACCOUNT_AUTHORIZATION_STATUSES.has(status ?? "") + || !authorizationFilter.includes(status as string) + ) return false; + } + if (hasOwnerStatusFilter && ownerEmployment) { + const ownerName = record.operationOwner.trim(); + if (ownerStatusFilter === "DEPARTED" && !ownerEmployment.departedNames.has(ownerName)) { + return false; + } + if (ownerStatusFilter === "ACTIVE" && !ownerEmployment.employedNames.has(ownerName)) { + return false; + } + } + return true; }) : result.rows; - const pageRows = hasAuthorizationFilter + const pageRows = needsFullScan ? filteredRows.slice((page - 1) * pageSize, page * pageSize) : filteredRows; - const rows = pageRows.map((record) => - this.toRow( + const ownerNames = [ + ...new Set( + pageRows.map((record) => record.operationOwner.trim()).filter(Boolean), + ), + ]; + const ownerUsers = + ownerNames.length > 0 + ? await this.ownerUserReader.findUsersByNames(ownerNames) + : []; + const departedOwnerNames = buildDepartedStoreAccountOwnerNames(ownerUsers); + const rows = pageRows.map((record) => { + const row = this.toRow( record, passwordView.rowAccess, passwordView.scope, authorizationStatusByStoreName.get(record.storeName.trim()) ?? null, - ), - ); + ); + if (departedOwnerNames.has(record.operationOwner.trim())) { + row.operationOwnerDeparted = true; + } + return row; + }); return { rows, storeNames, @@ -308,10 +362,10 @@ export class StoreAccountManagementService { pageInfo: { page, pageSize, - total: hasAuthorizationFilter ? filteredRows.length : result.total, + total: needsFullScan ? filteredRows.length : result.total, totalPages: Math.max( 1, - Math.ceil((hasAuthorizationFilter ? filteredRows.length : result.total) / pageSize), + Math.ceil((needsFullScan ? filteredRows.length : result.total) / pageSize), ), }, fieldAccess: passwordView.rowAccess, @@ -1037,7 +1091,7 @@ export class StoreAccountManagementService { access: StoreAccountFieldAccess, passwordScope: StoreAccountPasswordScope = "DEFAULT", authorizationStatus: StoreAccountAuthorizationStatus | null = null, - ) { + ): StoreAccountRow { const value = (key: StoreAccountFieldKey, fallback = "") => access[key] === "HIDDEN" ? "" : fallback; const canSeePassword = From b7dd8ac490a86d829db13acf89f8cb56893dfa4f Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 8 Oct 2026 14:01:43 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(supply-chain):=20=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E4=B8=89=E7=BA=A7=E8=B4=9F=E8=B4=A3=E4=BA=BA?= =?UTF-8?q?=E7=A6=BB=E8=81=8C=E6=94=B9=E4=B8=BA=E6=96=87=E5=AD=97=E5=90=8E?= =?UTF-8?q?=E7=BC=80=EF=BC=88=E5=B7=B2=E7=A6=BB=E8=81=8C=EF=BC=89=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BB=BB=E4=B8=80=E5=88=97=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StoreAccountManagementView.tsx | 29 +++++++++++---- .../src/lib/store-account-management.ts | 6 +++- .../store-account-management.service.ts | 36 ++++++++++++++----- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx index 8302a6bd..a104b649 100644 --- a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx @@ -155,6 +155,12 @@ function formatTime(value: string) { }).format(date); } +// 离职的负责人在人名后直接加(已离职)后缀,例如:同叔(已离职) +function formatOwnerCell(value: string, departed?: boolean) { + if (!value.trim()) return "-"; + return departed ? `${value}(已离职)` : value; +} + function rowToDraft(row: StoreAccountRow): Draft { return { id: row.id, @@ -776,11 +782,21 @@ export function StoreAccountManagementView({ formatStoreAccountAuthorizationStatus( row.authorizationStatus, ) - ) : field.key === "operationOwner" && - row.operationOwnerDeparted ? ( - - {row.operationOwner || "-"} - + ) : field.key === "operationOwner" ? ( + formatOwnerCell( + row.operationOwner, + row.operationOwnerDeparted, + ) + ) : field.key === "operationDepartmentHead" ? ( + formatOwnerCell( + row.operationDepartmentHead, + row.operationDepartmentHeadDeparted, + ) + ) : field.key === "operationDirector" ? ( + formatOwnerCell( + row.operationDirector, + row.operationDirectorDeparted, + ) ) : field.key === "password" ? ( {row.password || (row.passwordConfigured ? "••••••" : "未设置")} @@ -1197,8 +1213,7 @@ function StoreNameMultiSelect({ const styles = ` .store-account-table-wrap{overscroll-behavior:contain} .account-profile-section{margin-top:20px;padding-top:18px;border-top:1px solid #e4e4e7}.account-profile-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:12px}.account-profile-heading h3{margin:0;color:#27272a;font-size:14px}.account-profile-heading p{margin:4px 0 0;color:#71717a;font-size:11px}.account-profile-heading>span{color:#a1a1aa;font-size:11px}.account-profile-message{margin:10px 0 0;color:#b45309;font-size:11px} -.store-account-page{display:flex;flex:1;min-height:0;flex-direction:column;background:#fafafa;color:#27272a}.store-account-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:20px 28px 16px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-toolbar h1{margin:0;font-size:20px;font-weight:650}.store-account-toolbar p{margin:7px 0 0;color:#71717a;font-size:12px}.store-account-actions{display:flex;align-items:center;gap:8px;color:#71717a;font-size:12px}.store-account-actions button,.store-account-filters button,.store-account-pagination button,.store-account-batch button,.modal-actions button{display:inline-flex;height:34px;align-items:center;justify-content:center;gap:5px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;cursor:pointer}.store-account-actions .primary,.primary{border-color:#27272a!important;background:#27272a!important;color:#fff!important}.store-account-filters{display:flex;align-items:center;gap:8px;padding:12px 28px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-filters input,.store-account-filters select,.store-account-batch input,.store-account-batch select{height:34px;min-width:130px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#3f3f46;font-size:12px}.store-account-filters input:first-child{width:250px}.icon-button{width:34px;padding:0!important}.store-account-batch{display:flex;align-items:center;gap:8px;padding:8px 28px;border-bottom:1px solid #e4e4e7;background:#fff;color:#52525b;font-size:12px}.store-account-batch input{width:240px}.store-account-batch button:disabled{opacity:.45;cursor:not-allowed}.store-account-table-wrap{flex:1;min-height:0;overflow:auto;padding:0 28px}.store-account-table-wrap table{width:100%;min-width:1320px;border-collapse:collapse;background:#fff;font-size:12px}.store-account-table-wrap th{position:sticky;top:0;height:42px;padding:0 12px;border-bottom:1px solid #d4d4d8;background:#f4f4f5;color:#52525b;text-align:left;white-space:nowrap}.store-account-table-wrap td{height:46px;padding:0 12px;border-bottom:1px solid #f0f0f1;color:#3f3f46;white-space:nowrap}.store-account-table-wrap tr:hover td{background:#fafafa}.select-column{width:38px;padding:0 8px!important;text-align:center} -.owner-departed{color:#b91c1c;font-weight:600}.row-actions{display:flex;gap:10px}.row-actions button,.password-cell button{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#52525b;cursor:pointer;font-size:12px}.password-cell{display:inline-flex;align-items:center;gap:5px}.password-cell button{padding:2px}.empty{text-align:center;height:180px!important;color:#a1a1aa!important}.store-account-pagination{position:absolute;bottom:24px;left:50%;z-index:30;display:flex;align-items:center;gap:10px;padding:8px 16px;transform:translateX(-50%);border:1px solid #e4e4e7;border-radius:999px;background:rgba(255,255,255,.92);box-shadow:0 4px 12px rgba(0,0,0,.06),0 2px 4px rgba(0,0,0,.04);color:#52525b;font-size:13px;white-space:nowrap;backdrop-filter:blur(16px)}.store-account-page-total{margin-right:8px}.store-account-page-btn{display:inline-flex;align-items:center;gap:4px;height:30px;padding:0 12px!important;border:1px solid #e4e4e7!important;border-radius:4px!important;background:#fff!important;color:#27272a!important;font-size:13px!important;cursor:pointer;transition:all .15s}.store-account-page-btn:hover:not(:disabled){border-color:#52525b!important;background:#f4f4f5!important}.store-account-page-btn:disabled{color:#a1a1aa!important;cursor:not-allowed;opacity:.5}.store-account-pagination > .store-account-page-btn{height:30px!important}.store-account-page-num{display:inline-flex;align-items:center;gap:4px;color:#52525b}.store-account-page-input{width:52px;height:28px;padding:0 6px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;font-weight:600;text-align:center;outline:none}.store-account-page-input:focus{border-color:#52525b;box-shadow:0 0 0 2px rgba(82,82,91,.12)}.store-account-page-input:disabled{color:#a1a1aa;background:#f4f4f5}.store-account-page-sep{color:#a1a1aa}.store-account-page-size-wrap{display:inline-flex;align-items:center;gap:6px;margin-left:8px;color:#52525b}.store-account-page-size{height:28px;padding:0 8px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;cursor:pointer;outline:none}.store-account-page-size:focus{border-color:#52525b}.modal-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.28)}.modal{width:min(720px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;padding:20px;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 18px 50px rgba(24,24,27,.18)}.modal header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.modal h2{margin:0;font-size:16px}.modal header button{border:0;background:transparent;color:#71717a;cursor:pointer}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}.modal label{display:flex;flex-direction:column;gap:6px;color:#52525b;font-size:12px}.modal input,.modal select{height:36px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#27272a;font-size:13px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.hint{margin:0 0 16px;color:#71717a;font-size:12px}@media(max-width:760px){.store-account-toolbar{align-items:flex-start;flex-direction:column;padding:16px}.store-account-filters{flex-wrap:wrap;padding:12px 16px}.store-account-batch{flex-wrap:wrap;padding:8px 16px}.store-account-table-wrap{padding:0 16px}.store-account-pagination{bottom:12px;max-width:calc(100% - 24px);gap:6px;padding:7px 10px;overflow-x:auto}.store-account-table-wrap{padding:0 16px}.form-grid{grid-template-columns:1fr}} +.store-account-page{display:flex;flex:1;min-height:0;flex-direction:column;background:#fafafa;color:#27272a}.store-account-toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding:20px 28px 16px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-toolbar h1{margin:0;font-size:20px;font-weight:650}.store-account-toolbar p{margin:7px 0 0;color:#71717a;font-size:12px}.store-account-actions{display:flex;align-items:center;gap:8px;color:#71717a;font-size:12px}.store-account-actions button,.store-account-filters button,.store-account-pagination button,.store-account-batch button,.modal-actions button{display:inline-flex;height:34px;align-items:center;justify-content:center;gap:5px;padding:0 11px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;color:#3f3f46;font-size:12px;cursor:pointer}.store-account-actions .primary,.primary{border-color:#27272a!important;background:#27272a!important;color:#fff!important}.store-account-filters{display:flex;align-items:center;gap:8px;padding:12px 28px;border-bottom:1px solid #e4e4e7;background:#fff}.store-account-filters input,.store-account-filters select,.store-account-batch input,.store-account-batch select{height:34px;min-width:130px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#3f3f46;font-size:12px}.store-account-filters input:first-child{width:250px}.icon-button{width:34px;padding:0!important}.store-account-batch{display:flex;align-items:center;gap:8px;padding:8px 28px;border-bottom:1px solid #e4e4e7;background:#fff;color:#52525b;font-size:12px}.store-account-batch input{width:240px}.store-account-batch button:disabled{opacity:.45;cursor:not-allowed}.store-account-table-wrap{flex:1;min-height:0;overflow:auto;padding:0 28px}.store-account-table-wrap table{width:100%;min-width:1320px;border-collapse:collapse;background:#fff;font-size:12px}.store-account-table-wrap th{position:sticky;top:0;height:42px;padding:0 12px;border-bottom:1px solid #d4d4d8;background:#f4f4f5;color:#52525b;text-align:left;white-space:nowrap}.store-account-table-wrap td{height:46px;padding:0 12px;border-bottom:1px solid #f0f0f1;color:#3f3f46;white-space:nowrap}.store-account-table-wrap tr:hover td{background:#fafafa}.select-column{width:38px;padding:0 8px!important;text-align:center}.row-actions{display:flex;gap:10px}.row-actions button,.password-cell button{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#52525b;cursor:pointer;font-size:12px}.password-cell{display:inline-flex;align-items:center;gap:5px}.password-cell button{padding:2px}.empty{text-align:center;height:180px!important;color:#a1a1aa!important}.store-account-pagination{position:absolute;bottom:24px;left:50%;z-index:30;display:flex;align-items:center;gap:10px;padding:8px 16px;transform:translateX(-50%);border:1px solid #e4e4e7;border-radius:999px;background:rgba(255,255,255,.92);box-shadow:0 4px 12px rgba(0,0,0,.06),0 2px 4px rgba(0,0,0,.04);color:#52525b;font-size:13px;white-space:nowrap;backdrop-filter:blur(16px)}.store-account-page-total{margin-right:8px}.store-account-page-btn{display:inline-flex;align-items:center;gap:4px;height:30px;padding:0 12px!important;border:1px solid #e4e4e7!important;border-radius:4px!important;background:#fff!important;color:#27272a!important;font-size:13px!important;cursor:pointer;transition:all .15s}.store-account-page-btn:hover:not(:disabled){border-color:#52525b!important;background:#f4f4f5!important}.store-account-page-btn:disabled{color:#a1a1aa!important;cursor:not-allowed;opacity:.5}.store-account-pagination > .store-account-page-btn{height:30px!important}.store-account-page-num{display:inline-flex;align-items:center;gap:4px;color:#52525b}.store-account-page-input{width:52px;height:28px;padding:0 6px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;font-weight:600;text-align:center;outline:none}.store-account-page-input:focus{border-color:#52525b;box-shadow:0 0 0 2px rgba(82,82,91,.12)}.store-account-page-input:disabled{color:#a1a1aa;background:#f4f4f5}.store-account-page-sep{color:#a1a1aa}.store-account-page-size-wrap{display:inline-flex;align-items:center;gap:6px;margin-left:8px;color:#52525b}.store-account-page-size{height:28px;padding:0 8px;background:#fff;border:1px solid #e4e4e7;border-radius:4px;color:#27272a;font-size:13px;cursor:pointer;outline:none}.store-account-page-size:focus{border-color:#52525b}.modal-backdrop{position:fixed;inset:0;z-index:100;display:flex;align-items:center;justify-content:center;background:rgba(24,24,27,.28)}.modal{width:min(720px,calc(100vw - 32px));max-height:calc(100vh - 48px);overflow:auto;padding:20px;border:1px solid #e4e4e7;border-radius:8px;background:#fff;box-shadow:0 18px 50px rgba(24,24,27,.18)}.modal header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.modal h2{margin:0;font-size:16px}.modal header button{border:0;background:transparent;color:#71717a;cursor:pointer}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}.modal label{display:flex;flex-direction:column;gap:6px;color:#52525b;font-size:12px}.modal input,.modal select{height:36px;padding:0 9px;border:1px solid #d4d4d8;border-radius:5px;background:#fff;outline:0;color:#27272a;font-size:13px}.modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:22px}.hint{margin:0 0 16px;color:#71717a;font-size:12px}@media(max-width:760px){.store-account-toolbar{align-items:flex-start;flex-direction:column;padding:16px}.store-account-filters{flex-wrap:wrap;padding:12px 16px}.store-account-batch{flex-wrap:wrap;padding:8px 16px}.store-account-table-wrap{padding:0 16px}.store-account-pagination{bottom:12px;max-width:calc(100% - 24px);gap:6px;padding:7px 10px;overflow-x:auto}.store-account-table-wrap{padding:0 16px}.form-grid{grid-template-columns:1fr}} /* 标题区已删除:工具条只剩右侧操作组,仍保持靠右 */ .store-account-toolbar { justify-content: flex-end; } .store-account-filters{position:relative;z-index:40;overflow:visible} diff --git a/furniture-3d-system/src/lib/store-account-management.ts b/furniture-3d-system/src/lib/store-account-management.ts index f78fa670..8f93c553 100644 --- a/furniture-3d-system/src/lib/store-account-management.ts +++ b/furniture-3d-system/src/lib/store-account-management.ts @@ -208,10 +208,14 @@ export type StoreAccountRow = { id: string; storeName: string; operationOwner: string; - /** 店铺运营负责人已离职(内部账号存在但状态=离职)时为 true,列表用红色提示需要换人 */ + /** 店铺运营负责人已离职(内部账号存在但状态=离职)时为 true,列表显示为 名字(已离职) */ operationOwnerDeparted?: boolean; operationDepartmentHead: string; + /** 店铺运营部门负责人已离职时为 true */ + operationDepartmentHeadDeparted?: boolean; operationDirector: string; + /** 店铺运营总监已离职时为 true */ + operationDirectorDeparted?: boolean; profitCenter: string; subject: string; accountType: StoreAccountType; diff --git a/furniture-3d-system/src/server/services/store-account-management.service.ts b/furniture-3d-system/src/server/services/store-account-management.service.ts index 42dcf300..d4cc5f17 100644 --- a/furniture-3d-system/src/server/services/store-account-management.service.ts +++ b/furniture-3d-system/src/server/services/store-account-management.service.ts @@ -3,7 +3,6 @@ import { AppError, ErrorCode } from "@/server/core/errors"; import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS, - buildDepartedStoreAccountOwnerNames, resolveStoreAccountOwnerEmployment, getStoreAccountProfilePlatformForStoreName, STORE_ACCOUNT_PROFIT_CENTERS, @@ -302,7 +301,11 @@ export class StoreAccountManagementService { result.rows.map((record) => record.storeName), ); const ownerEmployment = hasOwnerStatusFilter - ? await this.resolveOwnerEmployment(filterOptions.operationOwner) + ? await this.resolveOwnerEmployment([ + ...filterOptions.operationOwner, + ...filterOptions.operationDepartmentHead, + ...filterOptions.operationDirector, + ]) : null; const filteredRows = needsFullScan ? result.rows.filter((record) => { @@ -314,11 +317,16 @@ export class StoreAccountManagementService { ) return false; } if (hasOwnerStatusFilter && ownerEmployment) { - const ownerName = record.operationOwner.trim(); - if (ownerStatusFilter === "DEPARTED" && !ownerEmployment.departedNames.has(ownerName)) { - return false; + if (ownerStatusFilter === "DEPARTED") { + // 三级负责人任意一列是离职的人名即命中。 + const hit = [record.operationOwner, record.operationDepartmentHead, record.operationDirector] + .some((name) => ownerEmployment.departedNames.has(name.trim())); + if (!hit) return false; } - if (ownerStatusFilter === "ACTIVE" && !ownerEmployment.employedNames.has(ownerName)) { + if ( + ownerStatusFilter === "ACTIVE" + && !ownerEmployment.employedNames.has(record.operationOwner.trim()) + ) { return false; } } @@ -330,14 +338,18 @@ export class StoreAccountManagementService { : filteredRows; const ownerNames = [ ...new Set( - pageRows.map((record) => record.operationOwner.trim()).filter(Boolean), + pageRows.flatMap((record) => [ + record.operationOwner.trim(), + record.operationDepartmentHead.trim(), + record.operationDirector.trim(), + ]).filter(Boolean), ), ]; const ownerUsers = ownerNames.length > 0 ? await this.ownerUserReader.findUsersByNames(ownerNames) : []; - const departedOwnerNames = buildDepartedStoreAccountOwnerNames(ownerUsers); + const ownerEmploymentForRows = resolveStoreAccountOwnerEmployment(ownerUsers); const rows = pageRows.map((record) => { const row = this.toRow( record, @@ -345,9 +357,15 @@ export class StoreAccountManagementService { passwordView.scope, authorizationStatusByStoreName.get(record.storeName.trim()) ?? null, ); - if (departedOwnerNames.has(record.operationOwner.trim())) { + if (ownerEmploymentForRows.departedNames.has(record.operationOwner.trim())) { row.operationOwnerDeparted = true; } + if (ownerEmploymentForRows.departedNames.has(record.operationDepartmentHead.trim())) { + row.operationDepartmentHeadDeparted = true; + } + if (ownerEmploymentForRows.departedNames.has(record.operationDirector.trim())) { + row.operationDirectorDeparted = true; + } return row; }); return { From f13ed3d3fb920e41cc0f0b0f62464155ce4ca5ef Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 8 Oct 2026 14:23:02 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(supply-chain):=20=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=88=97=E7=AD=9B=E9=80=89=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=85=A8=E9=80=89=E5=90=8E=E6=8E=92=E9=99=A4=E4=B8=AA=E5=88=AB?= =?UTF-8?q?=E9=A1=B9=EF=BC=88exclude=20=E6=8D=A2=E7=AE=97=E6=AD=A3?= =?UTF-8?q?=E9=9D=A2=E6=B8=85=E5=8D=95+=E7=AD=9B=E9=80=89=E5=80=BC?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E4=B8=8A=E9=99=90=E6=8F=90=E5=8D=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StoreAccountManagementView.tsx | 10 +++++++++- .../database/store-account-management/page.tsx | 4 ++-- .../src/lib/store-account-management.ts | 3 +++ .../services/store-account-management.service.ts | 3 ++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx index a104b649..1fdc640a 100644 --- a/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/StoreAccountManagementView.tsx @@ -113,7 +113,15 @@ function fromStoreAccountHeaderFilterSelection( selection: ColumnHeaderFilterSelection | null, options: readonly StoreAccountHeaderFilterOption[], ) { - if (!selection || selection.mode !== "include") return ""; + if (!selection) return ""; + // 组件默认全选时是 exclude 模型(values=未勾选项):换算成正面清单,否则"全选后取消个别项"会被静默丢弃。 + if (selection.mode === "exclude") { + const excluded = new Set(selection.values); + return options + .filter((option) => !excluded.has(option.label)) + .map((option) => option.value) + .join(","); + } const selectedLabels = new Set(selection.values); return options .filter((option) => selectedLabels.has(option.label)) diff --git a/furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx b/furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx index ac4ecfa5..d4773ad8 100644 --- a/furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/store-account-management/page.tsx @@ -3,7 +3,7 @@ import { requireSession } from '@/lib/server-session' import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' import { storeAccountManagementService } from '@/server/services/store-account-management.service' import { storeAccountManagementAccessService } from '@/server/services/store-account-management-access.service' -import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS, type StoreAccountFilters } from '@/lib/store-account-management' +import { EMPTY_STORE_ACCOUNT_FILTERS, STORE_ACCOUNT_FILTER_KEYS, STORE_ACCOUNT_FILTER_VALUE_MAX_LENGTH, type StoreAccountFilters } from '@/lib/store-account-management' import { StoreAccountManagementView } from './StoreAccountManagementView' export const dynamic = 'force-dynamic' @@ -14,7 +14,7 @@ export default async function StoreAccountManagementPage({ searchParams }: { sea const access = await supplyChainPermissionSettingsService.getUserAccess(authUser, 'storeAccountManagement') if (!access.view) redirect('/supply-chain') const params = await searchParams - const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, first(params[key]).slice(0, 120)])) as StoreAccountFilters + const filters = Object.fromEntries(STORE_ACCOUNT_FILTER_KEYS.map((key) => [key, first(params[key]).slice(0, STORE_ACCOUNT_FILTER_VALUE_MAX_LENGTH)])) as StoreAccountFilters const page = Math.max(1, Number(first(params.page)) || 1) const pageSize = [50, 100, 200].includes(Number(first(params.pageSize))) ? Number(first(params.pageSize)) : 50 const [data, capabilities] = await Promise.all([ diff --git a/furniture-3d-system/src/lib/store-account-management.ts b/furniture-3d-system/src/lib/store-account-management.ts index 8f93c553..19d8f60d 100644 --- a/furniture-3d-system/src/lib/store-account-management.ts +++ b/furniture-3d-system/src/lib/store-account-management.ts @@ -139,6 +139,9 @@ export const STORE_ACCOUNT_FILTER_KEYS = [ export type StoreAccountFilterKey = (typeof STORE_ACCOUNT_FILTER_KEYS)[number]; export type StoreAccountFilters = Record; +// 列筛选“全选后排除个别项”会生成全量候选的正面清单(数百人名、数千字符),筛选值上限必须容纳它。 +export const STORE_ACCOUNT_FILTER_VALUE_MAX_LENGTH = 8000; + export const EMPTY_STORE_ACCOUNT_FILTERS: StoreAccountFilters = { search: "", storeName: "", diff --git a/furniture-3d-system/src/server/services/store-account-management.service.ts b/furniture-3d-system/src/server/services/store-account-management.service.ts index d4cc5f17..0f88b17f 100644 --- a/furniture-3d-system/src/server/services/store-account-management.service.ts +++ b/furniture-3d-system/src/server/services/store-account-management.service.ts @@ -11,6 +11,7 @@ import { STORE_ACCOUNT_BATCH_EDITABLE_FIELD_KEYS, STORE_ACCOUNT_EDITABLE_FIELD_KEYS, STORE_ACCOUNT_FIELD_PERMISSION_BUSINESS_TYPES, + STORE_ACCOUNT_FILTER_VALUE_MAX_LENGTH, STORE_ACCOUNT_FIELDS, isStoreAccountFieldPermissionSubjectKey, isStoreAccountFieldVisibility, @@ -114,7 +115,7 @@ function normalizeFilters(input?: Partial) { return Object.fromEntries( Object.keys(EMPTY_STORE_ACCOUNT_FILTERS).map((key) => [ key, - clean(input?.[key as keyof StoreAccountFilters], 120), + clean(input?.[key as keyof StoreAccountFilters], STORE_ACCOUNT_FILTER_VALUE_MAX_LENGTH), ]), ) as StoreAccountFilters; } From 64a820a998337ad0adb0654f69ca8133d8f420aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=85=E5=B1=85?= Date: Thu, 8 Oct 2026 16:20:27 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(supply-chain):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E8=A7=84=E5=88=99=E8=A1=A8=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=EF=BC=88=E6=AD=A3=E5=BC=8F=E7=8E=AF=E5=A2=83=E7=BC=BA=E8=A1=A8?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E5=90=8C=E6=AC=BE=E5=95=86=E5=93=81=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E5=85=A8=E4=B8=BA=E7=A9=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 25 +++++++++++++++++++ furniture-3d-system/prisma/schema.prisma | 18 +++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 furniture-3d-system/prisma/migrations/20261008100000_add_product_archive_replacement_rule/migration.sql diff --git a/furniture-3d-system/prisma/migrations/20261008100000_add_product_archive_replacement_rule/migration.sql b/furniture-3d-system/prisma/migrations/20261008100000_add_product_archive_replacement_rule/migration.sql new file mode 100644 index 00000000..a1460bf5 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20261008100000_add_product_archive_replacement_rule/migration.sql @@ -0,0 +1,25 @@ +-- 简尚「替换款管理」换货规则表(商品SABC打标「同款商品编码」依赖)。 +-- 历史遗留:该表一直没进 migrations(本地用 db push 建过),正式环境用 migrate deploy 时缺失, +-- 导致同款商品编码全部为空。这里补上建表语句,生产/本地都可重复执行。 +CREATE TABLE IF NOT EXISTS "SupplyChainProductArchiveReplacementRule" ( + "id" TEXT NOT NULL, + "sourceId" INTEGER NOT NULL, + "sku" TEXT NOT NULL, + "replaceSku" TEXT NOT NULL DEFAULT '', + "replaceType" INTEGER NOT NULL DEFAULT 0, + "isSwapping" INTEGER NOT NULL DEFAULT 0, + "differentPoints" TEXT NOT NULL DEFAULT '', + "creator" TEXT NOT NULL DEFAULT '', + "sourceCreatedAt" TIMESTAMP(6), + "syncedAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SupplyChainProductArchiveReplacementRule_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "SupplyChainProductArchiveReplacementRule_sourceId_key" + ON "SupplyChainProductArchiveReplacementRule" ("sourceId"); + +CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveReplacementRule_sku_idx" + ON "SupplyChainProductArchiveReplacementRule" ("sku"); + +CREATE INDEX IF NOT EXISTS "SupplyChainProductArchiveReplacementRule_replaceSku_idx" + ON "SupplyChainProductArchiveReplacementRule" ("replaceSku"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 533a9d4e..2e813a12 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -1095,6 +1095,24 @@ model SupplyChainProductArchiveDepartmentSnapshot { @@index([generatedAt]) } +/// 简尚「替换款管理」换货规则:sku = 原编码(老),replaceSku = 替换编码(新)。 +/// 商品SABC打标的「同款商品编码(业绩&费比合并计算)」与业绩合并都依赖这张表。 +model SupplyChainProductArchiveReplacementRule { + id String @id + sourceId Int @unique + sku String + replaceSku String @default("") + replaceType Int @default(0) + isSwapping Int @default(0) + differentPoints String @default("") + creator String @default("") + sourceCreatedAt DateTime? @db.Timestamp(6) + syncedAt DateTime @default(now()) @db.Timestamp(6) + + @@index([sku]) + @@index([replaceSku]) +} + model SupplyChainProductArchiveSource { id String @id @default(cuid()) archiveId String From c23f616003ce7df9316d900f0416348543c4aeba Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 8 Oct 2026 16:26:54 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(supply-chain):=20=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E6=A1=A3=E6=A1=88=E4=BA=A7=E5=93=81=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=AF=8F=E6=97=A5=E8=B7=9F=E9=9A=8FSABC=E6=89=93?= =?UTF-8?q?=E6=A0=87=E7=BB=93=E6=9E=9C=EF=BC=88=E6=9C=89=E5=8F=98=E5=8C=96?= =?UTF-8?q?=E6=89=8D=E6=9B=B4=E6=96=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../install-product-sabc-tag-daily-timer.sh | 66 ++++++++ .../scripts/sync-archive-sabc-tag.ts | 53 ++++++ ...duct-archive-sabc-tag-sync.service.test.ts | 154 ++++++++++++++++++ ...n-product-archive-sabc-tag-sync.service.ts | 93 +++++++++++ 4 files changed, 366 insertions(+) create mode 100644 furniture-3d-system/scripts/install-product-sabc-tag-daily-timer.sh create mode 100644 furniture-3d-system/scripts/sync-archive-sabc-tag.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sabc-tag-sync.service.test.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-product-archive-sabc-tag-sync.service.ts diff --git a/furniture-3d-system/scripts/install-product-sabc-tag-daily-timer.sh b/furniture-3d-system/scripts/install-product-sabc-tag-daily-timer.sh new file mode 100644 index 00000000..9e05688e --- /dev/null +++ b/furniture-3d-system/scripts/install-product-sabc-tag-daily-timer.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${PRODUCT_SABC_TAG_SYNC_SERVICE_NAME:-furniture-product-sabc-tag-daily}" +APP_ROOT="$BASE/current/furniture-3d-system" +SHARED="$BASE/shared" +APP_ENV="$SHARED/.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +SCHEDULE="${PRODUCT_SABC_TAG_SYNC_SCHEDULE:-*-*-* 09:00:00 Asia/Shanghai}" +RANDOMIZED_DELAY_SEC="${PRODUCT_SABC_TAG_SYNC_RANDOMIZED_DELAY_SEC:-10min}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze calendar "$SCHEDULE" >/dev/null +fi + +cat >"$SERVICE_FILE" <"$TIMER_FILE" < argument.startsWith('--expected-database-identity=')) + ?.slice('--expected-database-identity='.length) ?? '' + if (expectedIdentity !== databaseIdentity(databaseUrl)) { + throw new Error( + `Pass --expected-database-identity=${databaseIdentity(databaseUrl)}.`, + ) + } + + const [{ prisma }, { supplyChainProductArchiveSabcTagSyncService }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/supply-chain-product-archive-sabc-tag-sync.service'), + ]) + try { + // 每日对账:档案「产品标签」跟随商品 SABC 打标落库表的最新冻结期间; + // 无差异时整体跳过,不写库。 + const result = await supplyChainProductArchiveSabcTagSyncService.syncAll() + console.log(JSON.stringify(result, null, 2)) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sabc-tag-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sabc-tag-sync.service.test.ts new file mode 100644 index 00000000..58cbf187 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sabc-tag-sync.service.test.ts @@ -0,0 +1,154 @@ +import { prisma } from '@/lib/prisma' +import { SupplyChainProductArchiveSabcTagSyncService } from '../supply-chain-product-archive-sabc-tag-sync.service' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + supplyChainProductArchiveSabcResult: { + findFirst: jest.fn(), + findMany: jest.fn(), + }, + supplyChainProductArchive: { + findMany: jest.fn(), + updateMany: jest.fn(), + }, + }, +})) + +const sabcResultFindFirst = prisma.supplyChainProductArchiveSabcResult.findFirst as jest.Mock +const sabcResultFindMany = prisma.supplyChainProductArchiveSabcResult.findMany as jest.Mock +const archiveFindMany = prisma.supplyChainProductArchive.findMany as jest.Mock +const archiveUpdateMany = prisma.supplyChainProductArchive.updateMany as jest.Mock + +const periodStart = new Date('2026-09-01T00:00:00.000Z') +const periodEnd = new Date('2026-09-30T00:00:00.000Z') + +type Row = { id: string; archiveId?: string; sabcRating: string } + +// 游标感知的分页 mock:与真实 DB 一样按 where.id > cursor 过滤,避免分页死循环。 +function mockCursorFindMany( + mock: jest.Mock, + rows: Row[], + mapRow?: (row: Row) => Record, +) { + mock.mockImplementation(async (input: { take: number; where?: { id?: { gt?: string } } }) => { + const cursor = input.where?.id?.gt + const start = cursor ? rows.findIndex((row) => row.id > cursor) : 0 + const safeStart = start === -1 ? rows.length : start + return rows.slice(safeStart, safeStart + input.take).map((row) => mapRow?.(row) ?? { ...row }) + }) +} + +function resultRows(rows: Array<{ id: string; archiveId: string; sabcRating: string }>) { + return rows.map((row) => ({ ...row, periodStart, periodEnd })) +} + +describe('supply-chain product archive sabc tag sync', () => { + const service = new SupplyChainProductArchiveSabcTagSyncService() + + beforeEach(() => { + jest.resetAllMocks() + sabcResultFindFirst.mockResolvedValue({ periodStart, periodEnd }) + }) + + it('skips without writing when no frozen sabc period exists', async () => { + sabcResultFindFirst.mockResolvedValue(null) + + await expect(service.syncAll()).resolves.toEqual({ + skipped: true, + skipReason: 'no-frozen-period', + compared: 0, + changed: 0, + }) + expect(archiveUpdateMany).not.toHaveBeenCalled() + }) + + it('skips writing when archive tags already match the frozen results', async () => { + mockCursorFindMany( + sabcResultFindMany, + resultRows([ + { id: 'result-1', archiveId: 'archive-1', sabcRating: 'A' }, + { id: 'result-2', archiveId: 'archive-2', sabcRating: '' }, + ]), + ) + mockCursorFindMany(archiveFindMany, [ + { id: 'archive-1', sabcRating: 'A' }, + { id: 'archive-2', sabcRating: '' }, + ]) + + await expect(service.syncAll()).resolves.toEqual({ + skipped: false, + periodStart: '2026-09-01', + periodEnd: '2026-09-30', + compared: 2, + changed: 0, + }) + expect(archiveUpdateMany).not.toHaveBeenCalled() + }) + + it('updates archive tags that drifted from the frozen results', async () => { + mockCursorFindMany( + sabcResultFindMany, + resultRows([ + { id: 'result-1', archiveId: 'archive-1', sabcRating: 'B' }, + { id: 'result-2', archiveId: 'archive-2', sabcRating: '' }, + ]), + ) + mockCursorFindMany(archiveFindMany, [ + { id: 'archive-1', sabcRating: '' }, + { id: 'archive-2', sabcRating: '' }, + { id: 'archive-3', sabcRating: 'A' }, + ]) + archiveUpdateMany.mockResolvedValue({ count: 1 }) + + const result = await service.syncAll() + expect(result).toMatchObject({ skipped: false, compared: 3, changed: 2 }) + // archive-1 恢复被清空的 B;archive-3 不在最新结果里,按 SABC 为准清空;archive-2 一致不动 + expect(archiveUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: { in: ['archive-1'] } }, + data: expect.objectContaining({ sabcRating: 'B' }), + }), + ) + expect(archiveUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: { in: ['archive-3'] } }, + data: expect.objectContaining({ sabcRating: '' }), + }), + ) + expect(archiveUpdateMany).toHaveBeenCalledTimes(2) + }) + + it('follows the latest frozen period only', async () => { + const octoberStart = new Date('2026-10-01T00:00:00.000Z') + const octoberEnd = new Date('2026-10-31T00:00:00.000Z') + sabcResultFindFirst.mockResolvedValue({ periodStart: octoberStart, periodEnd: octoberEnd }) + const september = resultRows([ + { id: 'result-old', archiveId: 'archive-1', sabcRating: 'C' }, + ]) + const october = resultRows([ + { id: 'result-new', archiveId: 'archive-1', sabcRating: 'S' }, + ]) + sabcResultFindMany.mockImplementation(async (input: { + take: number + where?: { periodStart?: Date; periodEnd?: Date; id?: { gt?: string } } + }) => { + const rows = input.where?.periodEnd?.getUTCMonth() === 8 ? september : october + const cursor = input.where?.id?.gt + const start = cursor ? rows.findIndex((row) => row.id > cursor) : 0 + const safeStart = start === -1 ? rows.length : start + return rows.slice(safeStart, safeStart + input.take).map((row) => ({ ...row })) + }) + mockCursorFindMany(archiveFindMany, [{ id: 'archive-1', sabcRating: 'C' }]) + archiveUpdateMany.mockResolvedValue({ count: 1 }) + + const result = await service.syncAll() + expect(result).toMatchObject({ + periodStart: '2026-10-01', + periodEnd: '2026-10-31', + changed: 1, + }) + expect(archiveUpdateMany).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ sabcRating: 'S' }) }), + ) + }) +}) diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-sabc-tag-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-sabc-tag-sync.service.ts new file mode 100644 index 00000000..d7678c80 --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-sabc-tag-sync.service.ts @@ -0,0 +1,93 @@ +import { prisma } from '@/lib/prisma' + +export type ArchiveSabcTagSyncResult = { + skipped: boolean + skipReason?: 'no-frozen-period' + periodStart?: string + periodEnd?: string + compared: number + changed: number +} + +// 产品全量档案「产品标签」(dataTag 计算列)跟随主表 sabcRating;sabcRating 的 +// 唯一事实来源是「商品 SABC 打标」落库的冻结结果表。本服务每天把档案标签与该表 +// 最新已生成期间对账一次:一致跳过,不一致更新(含恢复被聚水潭同步清空的标签、 +// 跟随重新核算的结果、结果表没有的清空)。只读 SABC 打标的数据,不参与其核算。 +export class SupplyChainProductArchiveSabcTagSyncService { + async syncAll(input: { batchSize?: number } = {}): Promise { + const batchSize = Math.min(1000, Math.max(1, Math.trunc(input.batchSize ?? 500))) + const latestPeriod = await prisma.supplyChainProductArchiveSabcResult.findFirst({ + orderBy: [{ periodEnd: 'desc' }, { periodStart: 'desc' }], + select: { periodStart: true, periodEnd: true }, + }) + if (!latestPeriod) { + // 还没有任何已生成的打标结果:没有对账基准,整体跳过。 + return { skipped: true, skipReason: 'no-frozen-period', compared: 0, changed: 0 } + } + + const expectedRatings = new Map() + let cursor: string | undefined + for (;;) { + const rows = await prisma.supplyChainProductArchiveSabcResult.findMany({ + where: { + periodStart: latestPeriod.periodStart, + periodEnd: latestPeriod.periodEnd, + ...(cursor ? { id: { gt: cursor } } : {}), + }, + select: { id: true, archiveId: true, sabcRating: true }, + take: batchSize, + orderBy: { id: 'asc' }, + }) + if (rows.length === 0) break + for (const row of rows) { + expectedRatings.set(row.archiveId, row.sabcRating ?? '') + cursor = row.id + } + } + + const periodStart = latestPeriod.periodStart.toISOString().slice(0, 10) + const periodEnd = latestPeriod.periodEnd.toISOString().slice(0, 10) + + let compared = 0 + const updates = new Map() + let archiveCursor: string | undefined + for (;;) { + const archives = await prisma.supplyChainProductArchive.findMany({ + where: archiveCursor ? { id: { gt: archiveCursor } } : undefined, + select: { id: true, sabcRating: true }, + take: batchSize, + orderBy: { id: 'asc' }, + }) + if (archives.length === 0) break + for (const archive of archives) { + archiveCursor = archive.id + compared += 1 + const expected = expectedRatings.get(archive.id) ?? '' + if ((archive.sabcRating ?? '') === expected) continue + const ids = updates.get(expected) ?? [] + ids.push(archive.id) + updates.set(expected, ids) + } + } + + if (updates.size === 0) { + return { skipped: false, periodStart, periodEnd, compared, changed: 0 } + } + + const syncedAt = new Date() + let changed = 0 + for (const [rating, ids] of updates) { + for (let index = 0; index < ids.length; index += batchSize) { + const result = await prisma.supplyChainProductArchive.updateMany({ + where: { id: { in: ids.slice(index, index + batchSize) } }, + data: { sabcRating: rating, sabcUpdatedAt: syncedAt }, + }) + changed += result.count + } + } + return { skipped: false, periodStart, periodEnd, compared, changed } + } +} + +export const supplyChainProductArchiveSabcTagSyncService = + new SupplyChainProductArchiveSabcTagSyncService() From bb777d488020d4202524167e9c715304d8d348be Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 8 Oct 2026 16:43:37 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(supply-chain):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E6=AC=BE=E8=A7=84=E5=88=99=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=AE=9A=E6=97=B6=E5=99=A8=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SupplyChainProductArchiveReplacementRule 的上游是 rpt.pm_replacementManage, 此前只有 local-sync-archive.mjs 会调用 sync-product-replacement-rules.ts, 正式机没有任何定时任务,表建好后仍然是空的,SABC 的「同款商品编码」也就一直是空。 新增 install-product-replacement-rule-sync-timer.sh,按仓库既有 install-*-timer.sh 同款写法安装 furniture-product-replacement-rule-sync.{service,timer}, 默认每天 07:40(Asia/Shanghai)同步一次,早于 09:00 的 SABC 打标日结。 脚本仅安装定时任务,改动生产数据由 sync-product-replacement-rules.ts 自己完成。 --- ...all-product-replacement-rule-sync-timer.sh | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 furniture-3d-system/scripts/install-product-replacement-rule-sync-timer.sh diff --git a/furniture-3d-system/scripts/install-product-replacement-rule-sync-timer.sh b/furniture-3d-system/scripts/install-product-replacement-rule-sync-timer.sh new file mode 100644 index 00000000..8dd549c9 --- /dev/null +++ b/furniture-3d-system/scripts/install-product-replacement-rule-sync-timer.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Installs the daily sync timer for the JST replacement rules table +# (rpt.pm_replacementManage -> SupplyChainProductArchiveReplacementRule). +# The SABC same-product-code merge depends on this table being fresh, +# so the default schedule runs before the 09:00 SABC tag reconciliation. + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${PRODUCT_REPLACEMENT_RULE_SYNC_SERVICE_NAME:-furniture-product-replacement-rule-sync}" +APP_ROOT="$BASE/current/furniture-3d-system" +SHARED="$BASE/shared" +APP_ENV="$SHARED/.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +SCHEDULE="${PRODUCT_REPLACEMENT_RULE_SYNC_SCHEDULE:-*-*-* 07:40:00 Asia/Shanghai}" +RANDOMIZED_DELAY_SEC="${PRODUCT_REPLACEMENT_RULE_SYNC_RANDOMIZED_DELAY_SEC:-5min}" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze calendar "$SCHEDULE" >/dev/null +fi + +cat >"$SERVICE_FILE" <"$TIMER_FILE" <