feat(store-account): display exact source platforms and filter before pagination
This commit is contained in:
parent
c4f3063e88
commit
520706df0d
@ -45,6 +45,8 @@ import {
|
||||
STORE_ACCOUNT_OWNER_STATUS_FILTER_OPTIONS,
|
||||
STORE_ACCOUNT_PROFILE_FIELDS,
|
||||
STORE_ACCOUNT_SHOP_STATE_COLUMN,
|
||||
STORE_ACCOUNT_PLATFORM_COLUMN,
|
||||
STORE_ACCOUNT_EMPTY_PLATFORM_FILTER,
|
||||
STORE_ACCOUNT_SHOP_STATE_FILTER_OPTIONS,
|
||||
} from "@/lib/store-account-management";
|
||||
import { ColumnHeaderFilter } from "@/components/common/ColumnHeaderFilter";
|
||||
@ -61,6 +63,15 @@ import {
|
||||
type StoreAccountProfileDraft,
|
||||
} from "./StoreAccountEditorDialog";
|
||||
import { useStoreAccountImport } from "./useStoreAccountImport";
|
||||
import {
|
||||
STORE_ACCOUNT_HEADER_FILTER_KEYS,
|
||||
fromStoreAccountHeaderFilterSelection,
|
||||
getStoreAccountHeaderFilterStateKey,
|
||||
toStoreAccountHeaderFilterSelection,
|
||||
type StoreAccountHeaderFilterKey,
|
||||
type StoreAccountHeaderFilterOption,
|
||||
type StoreAccountTableColumn,
|
||||
} from "./storeAccountTableColumns";
|
||||
|
||||
type Props = {
|
||||
initialData: StoreAccountPage;
|
||||
@ -71,81 +82,6 @@ type Props = {
|
||||
};
|
||||
type Draft = StoreAccountDraft;
|
||||
type ProfileDraft = StoreAccountProfileDraft;
|
||||
type StoreAccountTableColumn =
|
||||
| (typeof STORE_ACCOUNT_FIELDS)[number]
|
||||
| typeof STORE_ACCOUNT_SHOP_STATE_COLUMN
|
||||
| typeof STORE_ACCOUNT_AUTHORIZATION_COLUMN;
|
||||
|
||||
type StoreAccountHeaderFilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type StoreAccountHeaderFilterKey =
|
||||
| "storeName"
|
||||
| "operationOwner"
|
||||
| "operationDepartmentHead"
|
||||
| "operationDirector"
|
||||
| "profitCenter"
|
||||
| "accountType"
|
||||
| "shopState"
|
||||
| "authorizationStatus";
|
||||
|
||||
function toStoreAccountHeaderFilterSelection(
|
||||
value: string,
|
||||
options: readonly StoreAccountHeaderFilterOption[],
|
||||
): ColumnHeaderFilterSelection {
|
||||
const selectedValues = new Set(
|
||||
value
|
||||
.split(/[,,;;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
return {
|
||||
mode: "include",
|
||||
values: options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => option.label),
|
||||
};
|
||||
}
|
||||
|
||||
function fromStoreAccountHeaderFilterSelection(
|
||||
selection: ColumnHeaderFilterSelection | null,
|
||||
options: readonly StoreAccountHeaderFilterOption[],
|
||||
) {
|
||||
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))
|
||||
.map((option) => option.value)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
const STORE_ACCOUNT_HEADER_FILTER_KEYS = new Set<StoreAccountHeaderFilterKey>([
|
||||
"storeName",
|
||||
"operationOwner",
|
||||
"operationDepartmentHead",
|
||||
"operationDirector",
|
||||
"profitCenter",
|
||||
"accountType",
|
||||
"shopState",
|
||||
"authorizationStatus",
|
||||
]);
|
||||
|
||||
function getStoreAccountHeaderFilterStateKey(
|
||||
key: StoreAccountHeaderFilterKey,
|
||||
): "operationOwnerExact" | Exclude<StoreAccountHeaderFilterKey, "operationOwner"> {
|
||||
return key === "operationOwner" ? "operationOwnerExact" : key;
|
||||
}
|
||||
|
||||
async function readResponse(response: Response) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload?.success)
|
||||
@ -222,7 +158,9 @@ export function StoreAccountManagementView({
|
||||
const renderColumns = useMemo<StoreAccountTableColumn[]>(
|
||||
() =>
|
||||
visibleFields.flatMap((field): StoreAccountTableColumn[] =>
|
||||
field.key === "accountType"
|
||||
field.key === "profitCenter"
|
||||
? [field, STORE_ACCOUNT_PLATFORM_COLUMN]
|
||||
: field.key === "accountType"
|
||||
? [
|
||||
field,
|
||||
STORE_ACCOUNT_SHOP_STATE_COLUMN,
|
||||
@ -318,9 +256,12 @@ export function StoreAccountManagementView({
|
||||
options: readonly StoreAccountHeaderFilterOption[],
|
||||
) => {
|
||||
const stateKey = getStoreAccountHeaderFilterStateKey(key);
|
||||
const value = fromStoreAccountHeaderFilterSelection(selection, options);
|
||||
const nextFilters = {
|
||||
...filters,
|
||||
[stateKey]: fromStoreAccountHeaderFilterSelection(selection, options),
|
||||
[stateKey]: key === "platform" && selection && !value
|
||||
? STORE_ACCOUNT_EMPTY_PLATFORM_FILTER
|
||||
: value,
|
||||
};
|
||||
setFilters(nextFilters);
|
||||
void load(nextFilters, 1, data.pageInfo.pageSize);
|
||||
@ -715,7 +656,9 @@ export function StoreAccountManagementView({
|
||||
field.key as StoreAccountHeaderFilterKey,
|
||||
).map((option) => option.label)
|
||||
}
|
||||
selection={toStoreAccountHeaderFilterSelection(
|
||||
selection={field.key === "platform" && !filters.platform
|
||||
? null
|
||||
: toStoreAccountHeaderFilterSelection(
|
||||
filters[
|
||||
getStoreAccountHeaderFilterStateKey(
|
||||
field.key as StoreAccountHeaderFilterKey,
|
||||
@ -790,6 +733,8 @@ export function StoreAccountManagementView({
|
||||
formatStoreAccountAuthorizationStatus(
|
||||
row.authorizationStatus,
|
||||
)
|
||||
) : field.key === "platform" ? (
|
||||
row.platform || "-"
|
||||
) : field.key === "operationOwner" ? (
|
||||
formatOwnerCell(
|
||||
row.operationOwner,
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
import type { ColumnHeaderFilterSelection } from "@/lib/column-header-filters";
|
||||
import {
|
||||
STORE_ACCOUNT_FIELDS,
|
||||
STORE_ACCOUNT_SHOP_STATE_COLUMN,
|
||||
STORE_ACCOUNT_AUTHORIZATION_COLUMN,
|
||||
STORE_ACCOUNT_PLATFORM_COLUMN,
|
||||
} from "@/lib/store-account-management";
|
||||
|
||||
export type StoreAccountTableColumn =
|
||||
| (typeof STORE_ACCOUNT_FIELDS)[number]
|
||||
| typeof STORE_ACCOUNT_SHOP_STATE_COLUMN
|
||||
| typeof STORE_ACCOUNT_AUTHORIZATION_COLUMN
|
||||
| typeof STORE_ACCOUNT_PLATFORM_COLUMN;
|
||||
|
||||
export type StoreAccountHeaderFilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type StoreAccountHeaderFilterKey =
|
||||
| "storeName"
|
||||
| "operationOwner"
|
||||
| "operationDepartmentHead"
|
||||
| "operationDirector"
|
||||
| "profitCenter"
|
||||
| "platform"
|
||||
| "accountType"
|
||||
| "shopState"
|
||||
| "authorizationStatus";
|
||||
|
||||
export function toStoreAccountHeaderFilterSelection(
|
||||
value: string,
|
||||
options: readonly StoreAccountHeaderFilterOption[],
|
||||
): ColumnHeaderFilterSelection {
|
||||
const selectedValues = new Set(
|
||||
value
|
||||
.split(/[,,;;]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
return {
|
||||
mode: "include",
|
||||
values: options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => option.label),
|
||||
};
|
||||
}
|
||||
|
||||
export function fromStoreAccountHeaderFilterSelection(
|
||||
selection: ColumnHeaderFilterSelection | null,
|
||||
options: readonly StoreAccountHeaderFilterOption[],
|
||||
) {
|
||||
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))
|
||||
.map((option) => option.value)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export const STORE_ACCOUNT_HEADER_FILTER_KEYS = new Set<StoreAccountHeaderFilterKey>([
|
||||
"storeName",
|
||||
"operationOwner",
|
||||
"operationDepartmentHead",
|
||||
"operationDirector",
|
||||
"profitCenter",
|
||||
"platform",
|
||||
"accountType",
|
||||
"shopState",
|
||||
"authorizationStatus",
|
||||
]);
|
||||
|
||||
export function getStoreAccountHeaderFilterStateKey(
|
||||
key: StoreAccountHeaderFilterKey,
|
||||
): "operationOwnerExact" | Exclude<StoreAccountHeaderFilterKey, "operationOwner"> {
|
||||
return key === "operationOwner" ? "operationOwnerExact" : key;
|
||||
}
|
||||
@ -130,6 +130,7 @@ export const STORE_ACCOUNT_FILTER_KEYS = [
|
||||
"operationDepartmentHead",
|
||||
"operationDirector",
|
||||
"profitCenter",
|
||||
"platform",
|
||||
"accountGroupName",
|
||||
"shopState",
|
||||
"authorizationStatus",
|
||||
@ -152,6 +153,7 @@ export const EMPTY_STORE_ACCOUNT_FILTERS: StoreAccountFilters = {
|
||||
operationDepartmentHead: "",
|
||||
operationDirector: "",
|
||||
profitCenter: "",
|
||||
platform: "",
|
||||
accountGroupName: "",
|
||||
shopState: "",
|
||||
authorizationStatus: "",
|
||||
@ -220,6 +222,7 @@ export type StoreAccountRow = {
|
||||
/** 店铺运营总监已离职时为 true */
|
||||
operationDirectorDeparted?: boolean;
|
||||
profitCenter: string;
|
||||
platform: string;
|
||||
subject: string;
|
||||
accountType: StoreAccountType;
|
||||
account: string;
|
||||
@ -259,6 +262,7 @@ export type StoreAccountPage = {
|
||||
operationDepartmentHead: string[];
|
||||
operationDirector: string[];
|
||||
profitCenter: string[];
|
||||
platform: string[];
|
||||
accountType: string[];
|
||||
shopState: string[];
|
||||
authorizationStatus: string[];
|
||||
@ -314,6 +318,13 @@ export const STORE_ACCOUNT_SHOP_STATE_COLUMN = {
|
||||
label: "店铺状态",
|
||||
} as const;
|
||||
|
||||
export const STORE_ACCOUNT_PLATFORM_COLUMN = {
|
||||
key: "platform",
|
||||
label: "平台",
|
||||
} as const;
|
||||
|
||||
export const STORE_ACCOUNT_EMPTY_PLATFORM_FILTER = "__NO_PLATFORM_SELECTED__";
|
||||
|
||||
export type StoreAccountAuthorizationStatus =
|
||||
| "AUTHORIZED"
|
||||
| "NOT_REQUIRED"
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
import {
|
||||
buildShopPlatformQuery,
|
||||
createShopPlatformConnectionConfig,
|
||||
} from "../store-account-platform.repository";
|
||||
import { buildPlatformMap } from "@/server/services/store-account-platform.service";
|
||||
|
||||
describe("shop platform matching", () => {
|
||||
it("trims names while preserving distinct Chinese platform values", () => {
|
||||
const result = buildPlatformMap([
|
||||
{ erpShopName: " 店铺一 ", platform: " 京东 " },
|
||||
{ erpShopName: "店铺一", platform: "京东" },
|
||||
{ erpShopName: "店铺二", platform: "京东供销" },
|
||||
{ erpShopName: "店铺三", platform: "唯品会MP" },
|
||||
{ erpShopName: "店铺四", platform: "唯品会" },
|
||||
]);
|
||||
expect([...result]).toEqual([
|
||||
["店铺一", "京东"],
|
||||
["店铺二", "京东供销"],
|
||||
["店铺三", "唯品会MP"],
|
||||
["店铺四", "唯品会"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves conflicting, empty and unnamed shops unmatched", () => {
|
||||
const result = buildPlatformMap([
|
||||
{ erpShopName: "同名店", platform: "京东" },
|
||||
{ erpShopName: "同名店", platform: "京东供销" },
|
||||
{ erpShopName: "空平台", platform: null },
|
||||
{ erpShopName: " ", platform: "天猫" },
|
||||
]);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("reads only the shop name and platform and rejects unsafe schemas", () => {
|
||||
expect(buildShopPlatformQuery()).toContain("FROM [rpt].[fys_shopInfo]");
|
||||
expect(buildShopPlatformQuery()).toContain("AS platform");
|
||||
expect(() => buildShopPlatformQuery("rpt];DROP")).toThrow("Unsafe");
|
||||
});
|
||||
|
||||
it("uses the complete personal report connection before legacy planning credentials", () => {
|
||||
const previousEnv = process.env;
|
||||
process.env = {
|
||||
...previousEnv,
|
||||
JST_REPORT_DB_HOST: "personal-source.invalid",
|
||||
JST_REPORT_DB_NAME: "personal-report",
|
||||
JST_REPORT_DB_USER: "personal-reader",
|
||||
JST_REPORT_DB_PASSWORD: "personal-test-password",
|
||||
JST_PLANNING_SQLSERVER_HOST: "legacy-source.invalid",
|
||||
JST_PLANNING_SQLSERVER_DATABASE: "legacy-report",
|
||||
JST_PLANNING_SQLSERVER_USER: "legacy-reader",
|
||||
JST_PLANNING_SQLSERVER_PASSWORD: "legacy-test-password",
|
||||
};
|
||||
try {
|
||||
expect(createShopPlatformConnectionConfig()).toMatchObject({
|
||||
server: "personal-source.invalid",
|
||||
database: "personal-report",
|
||||
user: "personal-reader",
|
||||
password: "personal-test-password",
|
||||
});
|
||||
delete process.env.JST_REPORT_DB_PASSWORD;
|
||||
expect(() => createShopPlatformConnectionConfig()).toThrow("not configured");
|
||||
} finally {
|
||||
process.env = previousEnv;
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,133 @@
|
||||
const DEFAULT_SOURCE_SCHEMA = "rpt";
|
||||
const SHOP_INFO_TABLE = "fys_shopInfo";
|
||||
|
||||
type MssqlConnectionPool = {
|
||||
connect(): Promise<void>;
|
||||
request(): {
|
||||
query<T extends Record<string, unknown> = Record<string, unknown>>(
|
||||
queryText: string,
|
||||
): Promise<{ recordset?: T[] }>;
|
||||
};
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type MssqlModule = {
|
||||
ConnectionPool: new (config: Record<string, unknown>) => MssqlConnectionPool;
|
||||
};
|
||||
|
||||
export type PlatformSourceRow = {
|
||||
erpShopName: string;
|
||||
platform: string | null;
|
||||
};
|
||||
|
||||
function readFirstEnv(names: string[]) {
|
||||
return names.map((name) => process.env[name]?.trim() ?? "").find(Boolean) ?? "";
|
||||
}
|
||||
|
||||
function quoteSqlServerIdentifier(value: string) {
|
||||
if (!/^[A-Za-z0-9_]+$/.test(value)) {
|
||||
throw new Error(`Unsafe SQL Server identifier: ${value}`);
|
||||
}
|
||||
return `[${value}]`;
|
||||
}
|
||||
|
||||
export function createShopPlatformConnectionConfig(): Record<string, unknown> | null {
|
||||
const useReportConnection = Boolean(readFirstEnv(["JST_REPORT_DB_HOST"]));
|
||||
const host = readFirstEnv(useReportConnection ? ["JST_REPORT_DB_HOST"] : [
|
||||
"JST_PLANNING_SQLSERVER_HOST",
|
||||
"JST_SQLSERVER_HOST",
|
||||
]);
|
||||
const database = readFirstEnv(useReportConnection ? ["JST_REPORT_DB_NAME"] : [
|
||||
"JST_PLANNING_SQLSERVER_DATABASE",
|
||||
"JST_SQLSERVER_DATABASE",
|
||||
]);
|
||||
const user = readFirstEnv(useReportConnection ? ["JST_REPORT_DB_USER"] : [
|
||||
"JST_PLANNING_SQLSERVER_USER",
|
||||
"JST_PLANNING_SQLSERVER_USERNAME",
|
||||
"JST_SQLSERVER_USER",
|
||||
"JST_SQLSERVER_USERNAME",
|
||||
]);
|
||||
const password = readFirstEnv(useReportConnection ? ["JST_REPORT_DB_PASSWORD"] : [
|
||||
"JST_PLANNING_SQLSERVER_PASSWORD",
|
||||
"JST_SQLSERVER_PASSWORD",
|
||||
]);
|
||||
if (![host, database, user, password].some(Boolean)) return null;
|
||||
if (!host || !database || !user || !password) {
|
||||
throw new Error("Store shop-platform SQL Server database is not configured");
|
||||
}
|
||||
return {
|
||||
server: host,
|
||||
port: Number(
|
||||
readFirstEnv(useReportConnection ? ["JST_REPORT_DB_PORT"] : ["JST_PLANNING_SQLSERVER_PORT", "JST_SQLSERVER_PORT"]) ||
|
||||
"1433",
|
||||
),
|
||||
database,
|
||||
user,
|
||||
password,
|
||||
options: {
|
||||
encrypt:
|
||||
(readFirstEnv(useReportConnection ? ["JST_REPORT_DB_ENCRYPT"] : [
|
||||
"JST_PLANNING_SQLSERVER_ENCRYPT",
|
||||
"JST_SQLSERVER_ENCRYPT",
|
||||
]) || (useReportConnection ? "true" : "false")) === "true",
|
||||
trustServerCertificate:
|
||||
readFirstEnv(useReportConnection ? ["JST_REPORT_DB_TRUST_SERVER_CERTIFICATE"] : [
|
||||
"JST_PLANNING_SQLSERVER_TRUST_SERVER_CERTIFICATE",
|
||||
"JST_SQLSERVER_TRUST_SERVER_CERTIFICATE",
|
||||
]) !== "false",
|
||||
},
|
||||
connectionTimeout: Number(
|
||||
readFirstEnv(useReportConnection ? ["JST_REPORT_DB_CONNECTION_TIMEOUT_MS"] : [
|
||||
"JST_PLANNING_SQLSERVER_CONNECTION_TIMEOUT_MS",
|
||||
"JST_SQLSERVER_CONNECTION_TIMEOUT_MS",
|
||||
]) || "10000",
|
||||
),
|
||||
requestTimeout: Number(
|
||||
readFirstEnv(useReportConnection ? ["JST_REPORT_DB_REQUEST_TIMEOUT_MS"] : [
|
||||
"JST_PLANNING_SQLSERVER_REQUEST_TIMEOUT_MS",
|
||||
"JST_SQLSERVER_REQUEST_TIMEOUT_MS",
|
||||
]) || "15000",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildShopPlatformQuery(sourceSchema = DEFAULT_SOURCE_SCHEMA) {
|
||||
const tableSql = `${quoteSqlServerIdentifier(sourceSchema)}.${quoteSqlServerIdentifier(SHOP_INFO_TABLE)}`;
|
||||
return `
|
||||
SELECT LTRIM(RTRIM(ISNULL(erpShopName, N''))) AS erpShopName,
|
||||
LTRIM(RTRIM(ISNULL(platform, N''))) AS platform
|
||||
FROM ${tableSql}
|
||||
WHERE LTRIM(RTRIM(ISNULL(erpShopName, N''))) <> N''
|
||||
`.trim();
|
||||
}
|
||||
|
||||
async function loadMssql(): Promise<MssqlModule> {
|
||||
const moduleName = "mssql";
|
||||
const imported = (await import(moduleName)) as MssqlModule & {
|
||||
default?: MssqlModule;
|
||||
};
|
||||
return imported.default ?? imported;
|
||||
}
|
||||
|
||||
export const storeAccountPlatformRepository = {
|
||||
async listPlatforms(): Promise<PlatformSourceRow[]> {
|
||||
const config = createShopPlatformConnectionConfig();
|
||||
if (!config) return [];
|
||||
const sql = await loadMssql();
|
||||
const pool = new sql.ConnectionPool(config);
|
||||
try {
|
||||
await pool.connect();
|
||||
const schema =
|
||||
readFirstEnv(process.env.JST_REPORT_DB_HOST?.trim()
|
||||
? ["JST_REPORT_DB_SCHEMA"]
|
||||
: ["JST_PLANNING_SQLSERVER_SCHEMA", "JST_SQLSERVER_SCHEMA"]) ||
|
||||
DEFAULT_SOURCE_SCHEMA;
|
||||
const result = await pool.request().query<PlatformSourceRow>(
|
||||
buildShopPlatformQuery(schema),
|
||||
);
|
||||
return result.recordset ?? [];
|
||||
} finally {
|
||||
await pool.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,126 @@
|
||||
import { StoreAccountManagementService } from "../store-account-management.service";
|
||||
import type {
|
||||
StoreAccountManagementRepository,
|
||||
StoreAccountRecord,
|
||||
} from "@/server/repositories/store-account-management.repository";
|
||||
import {
|
||||
STORE_ACCOUNT_FIELDS,
|
||||
STORE_ACCOUNT_EMPTY_PLATFORM_FILTER,
|
||||
type StoreAccountFieldAccess,
|
||||
} from "@/lib/store-account-management";
|
||||
|
||||
const user = {
|
||||
id: "admin-1",
|
||||
email: "admin@example.com",
|
||||
name: "管理员",
|
||||
role: "ADMIN" as const,
|
||||
status: "ACTIVE" as const,
|
||||
};
|
||||
|
||||
function record(id: string, storeName: string, owner = "负责人"): StoreAccountRecord {
|
||||
return {
|
||||
id, storeName, operationOwner: owner,
|
||||
operationDepartmentHead: "", operationDirector: "", profitCenter: "",
|
||||
accountType: "MAIN", account: "", passwordCiphertext: "",
|
||||
boundPhone: "", verificationContact: "", jstOrderService: "",
|
||||
accountGroupName: "", shopState: 0, subject: "",
|
||||
boundBankAccountNumberCiphertext: "",
|
||||
createdAt: new Date("2026-10-09T00:00:00Z"),
|
||||
updatedAt: new Date("2026-10-09T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const allRows = [
|
||||
record("1", " 店铺一 "),
|
||||
record("2", "店铺二"),
|
||||
record("3", "店铺三", "离职负责人"),
|
||||
record("4", "无匹配店"),
|
||||
];
|
||||
const repository = {
|
||||
listAll: jest.fn().mockResolvedValue(allRows),
|
||||
listPage: jest.fn().mockResolvedValue({ rows: allRows.slice(0, 2), total: 4 }),
|
||||
listStoreNames: jest.fn().mockResolvedValue(allRows.map((row) => row.storeName)),
|
||||
listFilterOptions: jest.fn().mockResolvedValue({
|
||||
storeName: allRows.map((row) => row.storeName),
|
||||
operationOwner: ["负责人", "离职负责人"],
|
||||
operationDepartmentHead: [], operationDirector: [],
|
||||
profitCenter: [], accountType: ["MAIN"], shopState: ["0"],
|
||||
}),
|
||||
};
|
||||
const service = new StoreAccountManagementService(
|
||||
repository as unknown as StoreAccountManagementRepository,
|
||||
undefined,
|
||||
{ resolveScope: jest.fn().mockResolvedValue("DEFAULT") },
|
||||
{ getStatusMap: jest.fn().mockResolvedValue(new Map([
|
||||
["店铺一", "AUTHORIZED"], ["店铺二", "AUTHORIZED"],
|
||||
["店铺三", "UNAUTHORIZED"],
|
||||
])) },
|
||||
undefined,
|
||||
{ findUsersByNames: jest.fn().mockResolvedValue([
|
||||
{ id: "1", name: "负责人", email: "", status: "ACTIVE", businessRoles: [], dingtalkUserId: null },
|
||||
{ id: "2", name: "离职负责人", email: "", status: "INACTIVE", businessRoles: [], dingtalkUserId: null },
|
||||
]) },
|
||||
{ getPlatformMap: jest.fn().mockResolvedValue(new Map([
|
||||
["店铺一", "京东"], ["店铺二", "京东供销"], ["店铺三", "京东"],
|
||||
["源库独有店", "抖音小店"],
|
||||
])) },
|
||||
);
|
||||
const access = Object.fromEntries(
|
||||
STORE_ACCOUNT_FIELDS.map((field) => [field.key, "READONLY"]),
|
||||
) as StoreAccountFieldAccess;
|
||||
jest.spyOn(service, "getFieldAccess").mockResolvedValue({
|
||||
target: { view: true, edit: false, download: false }, access,
|
||||
});
|
||||
return { service, repository };
|
||||
}
|
||||
|
||||
describe("store account derived platform listing", () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it("returns no rows when the platform selection is empty", async () => {
|
||||
const { service } = setup();
|
||||
const page = await service.listPage({
|
||||
user, filters: { platform: STORE_ACCOUNT_EMPTY_PLATFORM_FILTER },
|
||||
});
|
||||
expect(page.rows).toEqual([]);
|
||||
expect(page.pageInfo.total).toBe(0);
|
||||
});
|
||||
|
||||
it("filters exact platforms before pagination and counts all matching rows", async () => {
|
||||
const { service, repository } = setup();
|
||||
const page = await service.listPage({ user, page: 2, pageSize: 1, filters: { platform: "京东" } });
|
||||
expect(page.rows.map((row) => row.id)).toEqual(["3"]);
|
||||
expect(page.rows[0].platform).toBe("京东");
|
||||
expect(page.pageInfo).toEqual({ page: 2, pageSize: 1, total: 2, totalPages: 2 });
|
||||
expect(repository.listPage).not.toHaveBeenCalled();
|
||||
expect(page.filterOptions.platform).toEqual(["京东", "京东供销"]);
|
||||
});
|
||||
|
||||
it("combines platform multiselect and authorization filters", async () => {
|
||||
const { service } = setup();
|
||||
const page = await service.listPage({
|
||||
user, filters: { platform: "京东,京东供销", authorizationStatus: "AUTHORIZED" },
|
||||
});
|
||||
expect(page.rows.map((row) => row.id)).toEqual(["1", "2"]);
|
||||
expect(page.pageInfo.total).toBe(2);
|
||||
expect(page.rows.map((row) => row.authorizationStatus)).toEqual(["AUTHORIZED", "AUTHORIZED"]);
|
||||
});
|
||||
|
||||
it("preserves departure filtering and row flags together with platform", async () => {
|
||||
const { service } = setup();
|
||||
const page = await service.listPage({
|
||||
user, filters: { platform: "京东", operationOwnerStatus: "DEPARTED" },
|
||||
});
|
||||
expect(page.rows.map((row) => row.id)).toEqual(["3"]);
|
||||
expect(page.rows[0].operationOwnerDeparted).toBe(true);
|
||||
});
|
||||
|
||||
it("uses database pagination when no derived filter is selected", async () => {
|
||||
const { service, repository } = setup();
|
||||
const page = await service.listPage({ user, pageSize: 2 });
|
||||
expect(repository.listAll).not.toHaveBeenCalled();
|
||||
expect(page.rows.map((row) => row.platform)).toEqual(["京东", "京东供销"]);
|
||||
expect(page.pageInfo.total).toBe(4);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,157 @@
|
||||
import {
|
||||
STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS,
|
||||
resolveStoreAccountOwnerEmployment,
|
||||
type StoreAccountAuthorizationStatus,
|
||||
type StoreAccountFieldAccess,
|
||||
type StoreAccountFilters,
|
||||
type StoreAccountPage,
|
||||
type StoreAccountRow,
|
||||
} from "@/lib/store-account-management";
|
||||
import type {
|
||||
StoreAccountManagementRepository,
|
||||
StoreAccountRecord,
|
||||
} from "@/server/repositories/store-account-management.repository";
|
||||
import type { storeAccountNotificationRepository } from "@/server/repositories/store-account-notification.repository";
|
||||
import type { StoreAccountPlatformReader } from "./store-account-platform.service";
|
||||
import type { StoreAccountAuthorizationReader } from "./store-account-authorization.service";
|
||||
|
||||
type ListDependencies = {
|
||||
repository: StoreAccountManagementRepository;
|
||||
authorizationReader: StoreAccountAuthorizationReader;
|
||||
platformReader: StoreAccountPlatformReader;
|
||||
ownerUserReader: Pick<typeof storeAccountNotificationRepository, "findUsersByNames">;
|
||||
};
|
||||
|
||||
type ListParams = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
filters: StoreAccountFilters;
|
||||
fieldAccess: StoreAccountFieldAccess;
|
||||
toRow(record: StoreAccountRecord, status: StoreAccountAuthorizationStatus | null): StoreAccountRow;
|
||||
};
|
||||
|
||||
function splitFilterValues(value: string) {
|
||||
return [...new Set(value.split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
export async function listStoreAccountPage(
|
||||
{ repository, authorizationReader, platformReader, ownerUserReader }: ListDependencies,
|
||||
{ page, pageSize, filters, fieldAccess, toRow }: ListParams,
|
||||
): Promise<StoreAccountPage> {
|
||||
const authorizationFilter = splitFilterValues(filters.authorizationStatus);
|
||||
const platformFilter = splitFilterValues(filters.platform);
|
||||
const hasAuthorizationFilter = authorizationFilter.length > 0;
|
||||
const hasPlatformFilter = platformFilter.length > 0;
|
||||
const ownerStatusFilter = filters.operationOwnerStatus.trim();
|
||||
const hasOwnerStatusFilter =
|
||||
ownerStatusFilter === "DEPARTED" || ownerStatusFilter === "ACTIVE";
|
||||
// Derived filters must run before pagination to keep totals and pages accurate.
|
||||
const needsFullScan = hasAuthorizationFilter || hasOwnerStatusFilter || hasPlatformFilter;
|
||||
const [result, storeNames, filterOptions, platformByStoreName] = await Promise.all([
|
||||
needsFullScan
|
||||
? repository.listAll({ filters }).then((rows) => ({ rows, total: rows.length }))
|
||||
: repository.listPage({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
filters,
|
||||
}),
|
||||
repository.listStoreNames(),
|
||||
repository.listFilterOptions(),
|
||||
platformReader.getPlatformMap(),
|
||||
]);
|
||||
const authorizationStatusByStoreName = await authorizationReader.getStatusMap(
|
||||
result.rows.map((record) => record.storeName),
|
||||
);
|
||||
const candidateOwners = [
|
||||
...filterOptions.operationOwner,
|
||||
...filterOptions.operationDepartmentHead,
|
||||
...filterOptions.operationDirector,
|
||||
];
|
||||
const candidateNames = [...new Set(candidateOwners.map((name) => name.trim()).filter(Boolean))];
|
||||
const ownerEmployment = hasOwnerStatusFilter
|
||||
? resolveStoreAccountOwnerEmployment(
|
||||
candidateNames.length > 0 ? await ownerUserReader.findUsersByNames(candidateNames) : [],
|
||||
)
|
||||
: null;
|
||||
const filteredRows = needsFullScan
|
||||
? result.rows.filter((record) => {
|
||||
if (
|
||||
hasPlatformFilter &&
|
||||
!platformFilter.includes(platformByStoreName.get(record.storeName.trim()) ?? "")
|
||||
) return false;
|
||||
if (hasAuthorizationFilter) {
|
||||
const status = authorizationStatusByStoreName.get(record.storeName.trim());
|
||||
if (!status || !authorizationFilter.includes(status)) return false;
|
||||
}
|
||||
if (hasOwnerStatusFilter && ownerEmployment) {
|
||||
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(record.operationOwner.trim())
|
||||
) return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
: result.rows;
|
||||
const pageRows = needsFullScan
|
||||
? filteredRows.slice((page - 1) * pageSize, page * pageSize)
|
||||
: filteredRows;
|
||||
const ownerNames = [
|
||||
...new Set(
|
||||
pageRows.flatMap((record) => [
|
||||
record.operationOwner.trim(),
|
||||
record.operationDepartmentHead.trim(),
|
||||
record.operationDirector.trim(),
|
||||
]).filter(Boolean),
|
||||
),
|
||||
];
|
||||
const ownerUsers = ownerNames.length > 0
|
||||
? await ownerUserReader.findUsersByNames(ownerNames)
|
||||
: [];
|
||||
const ownerEmploymentForRows = resolveStoreAccountOwnerEmployment(ownerUsers);
|
||||
const rows = pageRows.map((record) => {
|
||||
const row = toRow(
|
||||
record,
|
||||
authorizationStatusByStoreName.get(record.storeName.trim()) ?? null,
|
||||
);
|
||||
row.platform = fieldAccess.profitCenter === "HIDDEN"
|
||||
? ""
|
||||
: platformByStoreName.get(record.storeName.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;
|
||||
});
|
||||
const platforms = [...new Set(
|
||||
storeNames.map((name) => platformByStoreName.get(name.trim()) ?? "").filter(Boolean),
|
||||
)].sort((left, right) => left.localeCompare(right, "zh-CN"));
|
||||
const total = needsFullScan ? filteredRows.length : result.total;
|
||||
return {
|
||||
rows,
|
||||
storeNames,
|
||||
filterOptions: {
|
||||
...filterOptions,
|
||||
platform: fieldAccess.profitCenter === "HIDDEN" ? [] : platforms,
|
||||
authorizationStatus: STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS.map(
|
||||
(option) => option.value,
|
||||
),
|
||||
},
|
||||
pageInfo: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
fieldAccess,
|
||||
};
|
||||
}
|
||||
@ -2,8 +2,6 @@ import type { AuthUser } from "@/server/core/types";
|
||||
import { AppError, ErrorCode } from "@/server/core/errors";
|
||||
import {
|
||||
EMPTY_STORE_ACCOUNT_FILTERS,
|
||||
STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS,
|
||||
resolveStoreAccountOwnerEmployment,
|
||||
getStoreAccountProfilePlatformForStoreName,
|
||||
STORE_ACCOUNT_PROFIT_CENTERS,
|
||||
STORE_ACCOUNT_PROFILE_PLATFORMS,
|
||||
@ -62,13 +60,13 @@ import {
|
||||
type StoreAccountAuthorizationReader,
|
||||
} from "@/server/services/store-account-authorization.service";
|
||||
import { supplyChainManagementOwnerConfigService } from "@/server/services/supply-chain-management-owner-config.service";
|
||||
import {
|
||||
storeAccountPlatformService,
|
||||
type StoreAccountPlatformReader,
|
||||
} from "./store-account-platform.service";
|
||||
import { listStoreAccountPage } from "./store-account-list.service";
|
||||
|
||||
const DEFAULT_MAX_PAGE_SIZE = 200;
|
||||
const STORE_ACCOUNT_AUTHORIZATION_STATUSES = new Set([
|
||||
"AUTHORIZED",
|
||||
"NOT_REQUIRED",
|
||||
"UNAUTHORIZED",
|
||||
]);
|
||||
|
||||
function clean(value: unknown, max = 240) {
|
||||
return String(value ?? "")
|
||||
@ -120,10 +118,6 @@ function normalizeFilters(input?: Partial<StoreAccountFilters>) {
|
||||
) as StoreAccountFilters;
|
||||
}
|
||||
|
||||
function splitFilterValues(value: string) {
|
||||
return [...new Set(value.split(/[,,;;]+/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function defaultFieldAccess(canEdit: boolean): StoreAccountFieldAccess {
|
||||
return Object.fromEntries(
|
||||
STORE_ACCOUNT_FIELDS.map((field) => [
|
||||
@ -173,6 +167,7 @@ export class StoreAccountManagementService {
|
||||
private readonly authorizationReader: StoreAccountAuthorizationReader = storeAccountAuthorizationService,
|
||||
private readonly ownerOptionReader: StoreAccountOwnerOptionReader = supplyChainManagementOwnerConfigService,
|
||||
private readonly ownerUserReader: StoreAccountOwnerUserReader = storeAccountNotificationRepository,
|
||||
private readonly platformReader: StoreAccountPlatformReader = storeAccountPlatformService,
|
||||
) {}
|
||||
|
||||
private async getPasswordViewContext(
|
||||
@ -186,13 +181,6 @@ 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,
|
||||
@ -279,116 +267,23 @@ export class StoreAccountManagementService {
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
"当前账号没有店铺账号管理权限",
|
||||
);
|
||||
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([
|
||||
needsFullScan
|
||||
? this.repository.listAll({ filters }).then((rows) => ({ rows, total: rows.length }))
|
||||
: this.repository.listPage({
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
filters,
|
||||
}),
|
||||
this.repository.listStoreNames(),
|
||||
this.repository.listFilterOptions(),
|
||||
this.getPasswordViewContext(params.user, access),
|
||||
]);
|
||||
const authorizationStatusByStoreName = await this.authorizationReader.getStatusMap(
|
||||
result.rows.map((record) => record.storeName),
|
||||
);
|
||||
const ownerEmployment = hasOwnerStatusFilter
|
||||
? await this.resolveOwnerEmployment([
|
||||
...filterOptions.operationOwner,
|
||||
...filterOptions.operationDepartmentHead,
|
||||
...filterOptions.operationDirector,
|
||||
])
|
||||
: null;
|
||||
const filteredRows = needsFullScan
|
||||
? result.rows.filter((record) => {
|
||||
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) {
|
||||
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(record.operationOwner.trim())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
: result.rows;
|
||||
const pageRows = needsFullScan
|
||||
? filteredRows.slice((page - 1) * pageSize, page * pageSize)
|
||||
: filteredRows;
|
||||
const ownerNames = [
|
||||
...new Set(
|
||||
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 ownerEmploymentForRows = resolveStoreAccountOwnerEmployment(ownerUsers);
|
||||
const rows = pageRows.map((record) => {
|
||||
const row = this.toRow(
|
||||
record,
|
||||
passwordView.rowAccess,
|
||||
passwordView.scope,
|
||||
authorizationStatusByStoreName.get(record.storeName.trim()) ?? null,
|
||||
);
|
||||
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 {
|
||||
rows,
|
||||
storeNames,
|
||||
filterOptions: {
|
||||
...filterOptions,
|
||||
authorizationStatus: STORE_ACCOUNT_AUTHORIZATION_FILTER_OPTIONS.map(
|
||||
(option) => option.value,
|
||||
),
|
||||
const passwordView = await this.getPasswordViewContext(params.user, access);
|
||||
return listStoreAccountPage(
|
||||
{
|
||||
repository: this.repository,
|
||||
authorizationReader: this.authorizationReader,
|
||||
platformReader: this.platformReader,
|
||||
ownerUserReader: this.ownerUserReader,
|
||||
},
|
||||
pageInfo: {
|
||||
{
|
||||
page,
|
||||
pageSize,
|
||||
total: needsFullScan ? filteredRows.length : result.total,
|
||||
totalPages: Math.max(
|
||||
1,
|
||||
Math.ceil((needsFullScan ? filteredRows.length : result.total) / pageSize),
|
||||
),
|
||||
filters,
|
||||
fieldAccess: passwordView.rowAccess,
|
||||
toRow: (record, status) =>
|
||||
this.toRow(record, passwordView.rowAccess, passwordView.scope, status),
|
||||
},
|
||||
fieldAccess: passwordView.rowAccess,
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
async getOne(id: string, user: AuthUser) {
|
||||
@ -1133,6 +1028,7 @@ export class StoreAccountManagementService {
|
||||
),
|
||||
operationDirector: value("operationDirector", record.operationDirector),
|
||||
profitCenter: value("profitCenter", record.profitCenter),
|
||||
platform: "",
|
||||
subject: value("subject", record.subject),
|
||||
accountType: value("accountType", record.accountType) as StoreAccountType,
|
||||
account: value("account", record.account),
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
import {
|
||||
storeAccountPlatformRepository,
|
||||
type PlatformSourceRow,
|
||||
} from "@/server/repositories/store-account-platform.repository";
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const FAILURE_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
let cachedPlatforms: {
|
||||
expiresAt: number;
|
||||
value: Map<string, string>;
|
||||
} | null = null;
|
||||
|
||||
let pendingPlatforms: Promise<Map<string, string>> | null = null;
|
||||
|
||||
function normalizeStoreName(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
/** Keeps only unambiguous non-empty platform values for each ERP shop name. */
|
||||
export function buildPlatformMap(rows: readonly PlatformSourceRow[]) {
|
||||
const valuesByName = new Map<string, Set<string>>();
|
||||
for (const row of rows) {
|
||||
const name = normalizeStoreName(row.erpShopName);
|
||||
const platform = normalizeStoreName(row.platform);
|
||||
if (!name || !platform) continue;
|
||||
const values = valuesByName.get(name) ?? new Set<string>();
|
||||
values.add(platform);
|
||||
valuesByName.set(name, values);
|
||||
}
|
||||
return new Map(
|
||||
[...valuesByName].flatMap(([name, values]) =>
|
||||
values.size === 1 ? [[name, [...values][0]] as const] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type StoreAccountPlatformReader = {
|
||||
getPlatformMap(): Promise<Map<string, string>>;
|
||||
};
|
||||
|
||||
export const storeAccountPlatformService: StoreAccountPlatformReader = {
|
||||
async getPlatformMap() {
|
||||
if (cachedPlatforms && cachedPlatforms.expiresAt > Date.now()) {
|
||||
return cachedPlatforms.value;
|
||||
}
|
||||
if (pendingPlatforms) return pendingPlatforms;
|
||||
pendingPlatforms = (async () => {
|
||||
let value = new Map<string, string>();
|
||||
let ttl = CACHE_TTL_MS;
|
||||
try {
|
||||
value = buildPlatformMap(await storeAccountPlatformRepository.listPlatforms());
|
||||
} catch {
|
||||
console.warn("[store-account-platform] 报表库查询失败,平台暂为空值");
|
||||
ttl = FAILURE_CACHE_TTL_MS;
|
||||
}
|
||||
cachedPlatforms = { expiresAt: Date.now() + ttl, value };
|
||||
return value;
|
||||
})();
|
||||
try {
|
||||
return await pendingPlatforms;
|
||||
} finally {
|
||||
pendingPlatforms = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user