feat(supply-chain): 店铺状态落库(StoreAccount.shopState 列 + 每日同步任务)
This commit is contained in:
parent
291485dbda
commit
a93159dddc
@ -0,0 +1,3 @@
|
||||
-- 店铺状态(数字码:0=正常、6=已关闭、3=已出售;来源公司报表库 rpt.fys_shopInfo,每日定时同步)
|
||||
-- NULL 表示尚未同步或在公司报表库中未匹配到对应店铺
|
||||
ALTER TABLE "StoreAccount" ADD COLUMN "shopState" INTEGER;
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
42
furniture-3d-system/scripts/sync-store-account-shop-state.ts
Normal file
42
furniture-3d-system/scripts/sync-store-account-shop-state.ts
Normal 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
|
||||
})
|
||||
@ -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 },
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user