diff --git a/furniture-3d-system/prisma/migrations/20260923120000_add_store_account_shop_state/migration.sql b/furniture-3d-system/prisma/migrations/20260923120000_add_store_account_shop_state/migration.sql new file mode 100644 index 00000000..f60d3f49 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260923120000_add_store_account_shop_state/migration.sql @@ -0,0 +1,3 @@ +-- 店铺状态(数字码:0=正常、6=已关闭、3=已出售;来源公司报表库 rpt.fys_shopInfo,每日定时同步) +-- NULL 表示尚未同步或在公司报表库中未匹配到对应店铺 +ALTER TABLE "StoreAccount" ADD COLUMN "shopState" INTEGER; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 0c7aea6d..8a151215 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -3743,6 +3743,7 @@ model StoreAccount { verificationContact String @default("") jstOrderService String @default("") accountGroupName String @default("") + shopState Int? subject String @default("") boundBankAccountNumberCiphertext String @default("") @db.Text createdById String? diff --git a/furniture-3d-system/scripts/install-store-account-shop-state-sync-timer.sh b/furniture-3d-system/scripts/install-store-account-shop-state-sync-timer.sh new file mode 100644 index 00000000..ef3fb3aa --- /dev/null +++ b/furniture-3d-system/scripts/install-store-account-shop-state-sync-timer.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${STORE_ACCOUNT_SHOP_STATE_SYNC_SERVICE_NAME:-furniture-store-account-shop-state-sync}" +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" +SCHEDULE="${STORE_ACCOUNT_SHOP_STATE_SYNC_SCHEDULE:-*-*-* 03:10: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_PLANNING_SQLSERVER_HOST=.+$' "$APP_ENV" || ! grep -Eq '^JST_PLANNING_SQLSERVER_PASSWORD=.+$' "$APP_ENV"; then + echo "JST_PLANNING_SQLSERVER_HOST and JST_PLANNING_SQLSERVER_PASSWORD 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" < { + console.error(`店铺状态同步失败:${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 +}) diff --git a/furniture-3d-system/src/server/repositories/store-account-management.repository.ts b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts index 041efadd..12c6e7c0 100644 --- a/furniture-3d-system/src/server/repositories/store-account-management.repository.ts +++ b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts @@ -16,6 +16,7 @@ export type StoreAccountRecord = { verificationContact: string jstOrderService: string accountGroupName: string + shopState: number | null subject: string boundBankAccountNumberCiphertext: string createdAt: Date @@ -65,6 +66,7 @@ const ACCOUNT_SELECT = { verificationContact: true, jstOrderService: true, accountGroupName: true, + shopState: true, subject: true, boundBankAccountNumberCiphertext: true, createdAt: true, @@ -136,7 +138,7 @@ export function buildStoreAccountCreatedAtFilter(filters: Pick> = [] if (filters.search) { const searchTokens = [...new Set(filters.search.split(/[\s,,;;]+/).map((value) => value.trim()).filter(Boolean))].slice(0, 100) @@ -164,15 +166,18 @@ function buildWhere(filters: StoreAccountFilters, shopStateStoreNames?: string[] const value = filters[key as StoreAccountFilterKey] if (value) and.push({ [field]: contains(value) }) } - if (shopStateStoreNames) and.push({ storeName: { in: shopStateStoreNames } }) + const shopStateCode = Number(filters.shopState) + if (filters.shopState) { + and.push(Number.isFinite(shopStateCode) ? { shopState: { equals: shopStateCode } } : { id: { in: [] } }) + } const createdAt = buildStoreAccountCreatedAtFilter(filters) if (createdAt) and.push({ createdAt }) return and.length ? { AND: and } : undefined } export class StoreAccountManagementRepository { - async listPage(params: { skip: number; take: number; filters: StoreAccountFilters; shopStateStoreNames?: string[] }) { - const where = buildWhere(params.filters, params.shopStateStoreNames) + async listPage(params: { skip: number; take: number; filters: StoreAccountFilters }) { + const where = buildWhere(params.filters) const [rows, total] = await Promise.all([ prisma.storeAccount.findMany({ where, select: ACCOUNT_SELECT, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: params.skip, take: params.take }), prisma.storeAccount.count({ where }), @@ -237,6 +242,23 @@ export class StoreAccountManagementRepository { }) } + async listForShopStateSync() { + return prisma.storeAccount.findMany({ + select: { id: true, storeName: true, shopState: true }, + orderBy: { id: 'asc' }, + }) + } + + async applyShopStateUpdates(updates: Array<{ id: string; shopState: number | null }>) { + if (updates.length === 0) return 0 + const results = await prisma.$transaction( + updates.map((item) => + prisma.storeAccount.update({ where: { id: item.id }, data: { shopState: item.shopState }, select: { id: true } }), + ), + ) + return results.length + } + async listProfiles(storeName: string) { return prisma.storeAccountStoreProfile.findMany({ where: { 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 015273ea..1eaaf7d8 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 @@ -37,10 +37,6 @@ import { } from "@/lib/supply-chain-permission-settings"; import { supplyChainPermissionSettingsService } from "./supply-chain-permission-settings.service"; import { storeAccountManagementAccessService } from "./store-account-management-access.service"; -import { - storeAccountShopStateService, - type StoreAccountShopStateReader, -} from "./store-account-shop-state.service"; import { storeAccountManagementRepository, type StoreAccountManagementRepository, @@ -142,7 +138,6 @@ export class StoreAccountManagementService { private readonly repository: StoreAccountManagementRepository = storeAccountManagementRepository, private readonly permissionRoleReader: StoreAccountPermissionRoleReader = permissionRoleRepository, private readonly passwordAccessReader: StoreAccountPasswordAccessReader = storeAccountPasswordAccessService, - private readonly shopStateReader: StoreAccountShopStateReader = storeAccountShopStateService, ) {} private async getPasswordViewContext( @@ -242,33 +237,17 @@ export class StoreAccountManagementService { ErrorCode.AUTH_PERMISSION_DENIED, "当前账号没有店铺账号管理权限", ); - const shopStates = await this.shopStateReader.getShopStateMap(); - let shopStateStoreNames: string[] | undefined; - if (filters.shopState) { - const code = Number(filters.shopState); - shopStateStoreNames = Number.isFinite(code) - ? Array.from(shopStates.entries()) - .filter(([, state]) => state === code) - .map(([name]) => name) - : []; - } const [result, storeNames, passwordView] = await Promise.all([ this.repository.listPage({ skip: (page - 1) * pageSize, take: pageSize, filters, - shopStateStoreNames, }), this.repository.listStoreNames(), this.getPasswordViewContext(params.user, access), ]); const rows = result.rows.map((record) => - this.toRow( - record, - passwordView.rowAccess, - passwordView.scope, - shopStates.get(record.storeName.trim()) ?? null, - ), + this.toRow(record, passwordView.rowAccess, passwordView.scope), ); return { rows, @@ -930,7 +909,6 @@ export class StoreAccountManagementService { record: StoreAccountRecord, access: StoreAccountFieldAccess, passwordScope: StoreAccountPasswordScope = "DEFAULT", - shopState: number | null = null, ) { const value = (key: StoreAccountFieldKey, fallback = "") => access[key] === "HIDDEN" ? "" : fallback; @@ -974,7 +952,7 @@ export class StoreAccountManagementService { ), jstOrderService: value("jstOrderService", record.jstOrderService), accountGroupName: value("accountGroupName", record.accountGroupName), - shopState, + shopState: record.shopState, createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), }; diff --git a/furniture-3d-system/src/server/services/store-account-shop-state.service.ts b/furniture-3d-system/src/server/services/store-account-shop-state.service.ts index 14e85148..29f2b8db 100644 --- a/furniture-3d-system/src/server/services/store-account-shop-state.service.ts +++ b/furniture-3d-system/src/server/services/store-account-shop-state.service.ts @@ -1,8 +1,20 @@ +import { storeAccountManagementRepository } from "@/server/repositories/store-account-management.repository"; + const CACHE_TTL_MS = 5 * 60 * 1000; const FAILURE_CACHE_TTL_MS = 60 * 1000; const DEFAULT_SOURCE_SCHEMA = "rpt"; const SHOP_INFO_TABLE = "fys_shopInfo"; +export type ShopStateSyncResult = { + apply: boolean; + sourceStores: number; + localRows: number; + matchedLocal: number; + changed: number; + applied: number; + cleared: number; +}; + type MssqlConnectionPool = { request(): { query = Record>( @@ -185,3 +197,51 @@ export type StoreAccountShopStateReader = Pick< typeof storeAccountShopStateService, "getShopStateMap" >; + +export const storeAccountShopStateSyncService = { + /** + * 从公司报表库拉取 erpShopName → shopState 映射并写回本地 "StoreAccount"."shopState" 列。 + * 供每日定时任务与手动同步使用;apply=false 时只统计差异不写库。 + * 与页面用的 getShopStateMap 不同:这里源库失败必须抛错,让定时任务显式失败。 + */ + async syncToDatabase(options: { apply: boolean }): Promise { + const connectionConfig = createShopStateConnectionConfig(); + if (!connectionConfig) { + throw new Error( + "JST_PLANNING_SQLSERVER_* is not configured; cannot sync shop states", + ); + } + const shopStateByName = await fetchShopStateMap(connectionConfig); + const rows = await storeAccountManagementRepository.listForShopStateSync(); + + const updates: Array<{ id: string; shopState: number | null }> = []; + let matchedLocal = 0; + let cleared = 0; + for (const row of rows) { + const name = normalizeStoreNameKey(row.storeName); + const exists = shopStateByName.has(name); + const next = exists ? (shopStateByName.get(name) as number) : null; + if (exists) matchedLocal += 1; + if (next === row.shopState) continue; + if (next === null) cleared += 1; + updates.push({ id: row.id, shopState: next }); + } + + let applied = 0; + if (options.apply && updates.length > 0) { + applied = await storeAccountManagementRepository.applyShopStateUpdates( + updates, + ); + } + + return { + apply: options.apply, + sourceStores: shopStateByName.size, + localRows: rows.length, + matchedLocal, + changed: updates.length, + applied, + cleared, + }; + }, +};