feat(store-account): display exact source platforms and filter before pagination

This commit is contained in:
WuYongJie 2026-10-12 10:22:51 +08:00
parent c4f3063e88
commit 520706df0d
9 changed files with 687 additions and 202 deletions

View File

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

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),

View File

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