feat(supply-chain): 店铺状态落库(StoreAccount.shopState 列 + 每日同步任务)

This commit is contained in:
WuYongJie 2026-09-23 14:03:13 +08:00
parent 291485dbda
commit a93159dddc
7 changed files with 202 additions and 28 deletions

View File

@ -0,0 +1,3 @@
-- 店铺状态(数字码:0=正常、6=已关闭、3=已出售;来源公司报表库 rpt.fys_shopInfo,每日定时同步)
-- NULL 表示尚未同步或在公司报表库中未匹配到对应店铺
ALTER TABLE "StoreAccount" ADD COLUMN "shopState" INTEGER;

View File

@ -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?

View File

@ -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" <<EOF
[Unit]
Description=Furniture store account shop state daily sync from JSReportReplica
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=$APP_ROOT
Environment=NODE_ENV=production
EnvironmentFile=$APP_ENV
ExecStart=/bin/bash -lc 'set -euo pipefail; cd "$APP_ROOT"; ./node_modules/.bin/tsx scripts/sync-store-account-shop-state.ts --apply'
StandardOutput=journal
StandardError=journal
EOF
cat >"$TIMER_FILE" <<EOF
[Unit]
Description=Run Furniture store account shop state sync daily
[Timer]
OnCalendar=$SCHEDULE
Persistent=true
RandomizedDelaySec=120
Unit=$SERVICE_NAME.service
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME.timer"
systemctl list-timers --all "$SERVICE_NAME.timer" --no-pager

View File

@ -0,0 +1,42 @@
import { config as loadDotenv } from 'dotenv'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
loadDotenv({ path: '.env.local', override: false })
loadDotenv()
function parseMode(argv: string[]) {
if (argv.length === 0) return { apply: false }
if (argv.length === 1 && argv[0] === '--apply') return { apply: true }
if (argv.includes('--help') || argv.includes('-h')) {
console.log('Usage: tsx scripts/sync-store-account-shop-state.ts [--apply]')
console.log(' (default) dry-run: 只统计差异,不写库')
console.log(' --apply 把差异写回 "StoreAccount"."shopState"')
process.exit(0)
}
throw new Error(`Unknown arguments: ${argv.join(' ')}`)
}
async function ensureServerOnlyStub() {
const stubDir = path.join(process.cwd(), 'node_modules', 'server-only')
await mkdir(stubDir, { recursive: true })
await writeFile(path.join(stubDir, 'index.js'), 'module.exports = {}\n', 'utf8')
}
async function main() {
const { apply } = parseMode(process.argv.slice(2))
await ensureServerOnlyStub()
const { storeAccountShopStateSyncService } = await import(
'../src/server/services/store-account-shop-state.service'
)
const result = await storeAccountShopStateSyncService.syncToDatabase({ apply })
console.log(JSON.stringify(result, null, 2))
if (!apply) {
console.log('[dry-run] 未写库;确认差异后加 --apply 执行写入。')
}
}
main().catch((error) => {
console.error(`店铺状态同步失败:${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
})

View File

@ -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<StoreAccountFilte
return { gte: start, lt: nextStart }
}
function buildWhere(filters: StoreAccountFilters, shopStateStoreNames?: string[]) {
function buildWhere(filters: StoreAccountFilters) {
const and: Array<Record<string, unknown>> = []
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 },

View File

@ -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(),
};

View File

@ -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<T extends Record<string, unknown> = Record<string, unknown>>(
@ -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<ShopStateSyncResult> {
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,
};
},
};