feat(customer): 新增ICC客户信息同步功能 - OAuth2密码模式鉴权拉取org_base客户、按icc_code幂等upsert写入并记录icc_sync_log,注册每10分钟定时任务;客户档案管理页新增手动'同步客户'按钮(POST /api/customers/sync/icc),同步后自动刷新列表与统计;customer表新增icc_code字段及唯一索引,同步db_seed与设计文档

This commit is contained in:
wk authored and trae-solo-agent committed 2026-10-23 18:20:25 +08:00
1 parent be91d04431
commit 9d3bb13ecf
24 files changed
+1159 -5

No files matched your search

+1 -1
View File
@@ -114,7 +114,7 @@ export class CustomerService {
});
}
private async generateCustomerNo(retry: number = 0): Promise<string> {
public async generateCustomerNo(retry: number = 0): Promise<string> {
const now = new Date();
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
const dateLocal = new Date(offsetMs);
@@ -0,0 +1,282 @@
import { Provide, App } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import { Context, Application } from "@midwayjs/koa";
import Customer from "../entity/customer.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_ORG_ENDPOINT, ICC_ORG_TYPE_BASE, mapIccOrgRecord, parseOrgPage, IccOrgRawRecord } from "../task/icc_customer_mapper";
import { CustomerService } from "./customer.service";
export interface IccCustomerSyncResult {
/** ICC 拉取到的原始组织条数(含非 org_base) */
pulled: number;
/** 成功落库的客户条数(新增+更新) */
synced: number;
/** 新增的客户条数 */
inserted: number;
/** 更新的客户条数 */
updated: number;
/** 跳过的条数(非 org_base / 缺 iccCode / 缺客户名 / 四字段无变化 / 编号冲突无法解决) */
skipped: number;
}
const SYNC_TYPE = "customer";
/** 分页拉取参数(ICC 组织分页接口约定) */
const PAGE_SIZE = 1000;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** mapIccOrgRecord 的返回类型别名 */
type MappedOrg = ReturnType<typeof mapIccOrgRecord>;
/**
* ICC 客户信息拉取同步:
* 1) 从 .env 读取 ICC_BASE_URL / ICC_VERSION,经 getIccAccessToken 走 OAuth2 密码模式鉴权
* 2) 分页请求 ICC 组织列表(/organization/page,pageNum/pageSize/maxRangeId)
* 3) 仅保留 orgType=org_base 的组织(对应客户),按 iccCode 幂等 upsert
* 4) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*
* 客户编号生成:复用 CustomerService.generateCustomerNo(已由 private 改为 public,
* 保持与手工建客户完全一致的 KH+YYYYMMDD+3位序号 规则),经匿名请求上下文获取实例。
*/
@Provide()
export class IccCustomerSyncService {
@App()
app!: Application;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
async syncFromIcc(ctx?: Context): Promise<IccCustomerSyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccCustomerSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const token = await getIccAccessToken();
const customerService = await this.getCustomerService();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_ORG_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const allRaw: IccOrgRawRecord[] = [];
let pageNum = 1;
let maxRangeId = 0;
let currentPage = 1;
let totalPage = 1;
while (pageNum <= totalPage) {
const params = new URLSearchParams({
pageNum: String(pageNum),
pageSize: String(PAGE_SIZE),
maxRangeId: String(maxRangeId)
});
const res = await iccFetch(`${url}?${params.toString()}`, {
method: "GET",
headers: { Accept: "application/json", Authorization: `bearer ${token}` },
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
});
if (!res.ok) {
throw new Error(`ICC 组织接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseOrgPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
currentPage = page.currentPage;
totalPage = page.totalPage;
// currentPage 达到 totalPage 或返回空列表即停止
if (page.list.length === 0 || currentPage >= totalPage) break;
pageNum++;
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw, customerService);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 从匿名请求上下文获取请求作用域的 CustomerService(参照 job_scheduler.buildRuntime) */
private async getCustomerService(): Promise<CustomerService> {
const ctx = this.app.createAnonymousContext();
return (await ctx.requestContext.getAsync(CustomerService)) as CustomerService;
}
/** 构建行政区划字典:org_province/org_city/org_county 的 编码字段 → orgName(名称)。 */
private buildRegionDict(allRaw: IccOrgRawRecord[]): {
provinceDict: Record<string, string>;
cityDict: Record<string, string>;
countyDict: Record<string, string>;
} {
const provinceDict: Record<string, string> = {};
const cityDict: Record<string, string> = {};
const countyDict: Record<string, string> = {};
for (const raw of allRaw) {
const orgType = String(raw.orgType ?? "");
const orgName = String(raw.orgName ?? "").trim();
if (!orgName) continue;
if (orgType === "org_province" && raw.province) {
provinceDict[String(raw.province).trim()] = orgName;
} else if (orgType === "org_city" && raw.city) {
cityDict[String(raw.city).trim()] = orgName;
} else if (orgType === "org_county" && raw.county) {
countyDict[String(raw.county).trim()] = orgName;
}
}
return { provinceDict, cityDict, countyDict };
}
/** 按 iccCode 幂等 upsert(新增/变更四字段:客户名、省、市、区)。 */
private async batchUpsert(allRaw: IccOrgRawRecord[], customerService: CustomerService): Promise<{ inserted: number; updated: number; skipped: number }> {
let skipped = 0;
// 从全量组织树构建行政区划字典(ICC 的 org_base 记录 province/city/county 为
// 国标行政区划编码,客户表存名称;org_province/org_city/org_county 记录的
// province/city/county 编码字段 + orgName 即为 编码→名称 映射)
const { provinceDict, cityDict, countyDict } = this.buildRegionDict(allRaw);
// 仅保留 org_base 组织(客户);组织类型缺失或非 org_base 均跳过,避免误同步非客户组织
const mappedList: MappedOrg[] = [];
for (const raw of allRaw) {
const mapped = mapIccOrgRecord(raw);
if (mapped.orgType !== ICC_ORG_TYPE_BASE) {
skipped++;
continue;
}
// 行政区划编码 → 名称;字典未命中时保留编码原值
if (mapped.province && provinceDict[mapped.province]) mapped.province = provinceDict[mapped.province];
if (mapped.city && cityDict[mapped.city]) mapped.city = cityDict[mapped.city];
if (mapped.district && countyDict[mapped.district]) mapped.district = countyDict[mapped.district];
mappedList.push(mapped);
}
// 预取已存在客户(按 iccCode),避免 N+1;需四字段现值用于变更比较
const iccCodes = [...new Set(mappedList.map((m) => m.iccCode).filter((c) => c !== ""))];
const existingRows = iccCodes.length
? await this.customerModel.findAll({
where: { iccCode: { [Op.in]: iccCodes } },
attributes: ["id", "iccCode", "customerName", "province", "city", "district"]
})
: [];
const customerByIcc = new Map(existingRows.map((c) => [c.iccCode, c]));
let inserted = 0;
let updated = 0;
for (const mapped of mappedList) {
if (!mapped.iccCode) {
skipped++; // iccCode 缺失,无同步匹配键
continue;
}
if (!mapped.customerName) {
skipped++; // 客户名必填(customer.customer_name NOT NULL),缺失跳过避免脏数据
continue;
}
const existing = customerByIcc.get(mapped.iccCode);
if (existing) {
if (this.hasFieldDiff(existing, mapped)) {
await existing.update({
customerName: mapped.customerName,
province: mapped.province ?? null,
city: mapped.city ?? null,
district: mapped.district ?? null
});
updated++;
} else {
skipped++; // 四字段均无变化,不重复更新
}
} else {
if (await this.createCustomer(mapped, customerService)) {
inserted++;
} else {
skipped++;
}
}
}
return { inserted, updated, skipped };
}
/** 四字段变更比较:字符串 trim 后比较,undefined/null 视为空串。 */
private hasFieldDiff(existing: Customer, mapped: MappedOrg): boolean {
const norm = (v: string | null | undefined): string => (v ?? "").trim();
return (
norm(existing.customerName) !== norm(mapped.customerName) ||
norm(existing.province) !== norm(mapped.province) ||
norm(existing.city) !== norm(mapped.city) ||
norm(existing.district) !== norm(mapped.district)
);
}
/**
* 新增客户:编号走 CustomerService.generateCustomerNo(KH+YYYYMMDD+3位序号)。
* 编号唯一冲突(UniqueConstraintError)时重试一次;仍失败返回 false 由调用方计 skipped。
* 其余异常上抛,由外层 catch 记录同步失败。
*/
private async createCustomer(mapped: MappedOrg, customerService: CustomerService): Promise<boolean> {
const payload: any = {
customerName: mapped.customerName,
province: mapped.province ?? null,
city: mapped.city ?? null,
district: mapped.district ?? null,
iccCode: mapped.iccCode,
status: 1
};
try {
payload.customerNo = await customerService.generateCustomerNo();
await this.customerModel.create(payload);
return true;
} catch (err: any) {
if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "customer_no" || e.path === "uk_customer_no")) {
try {
payload.customerNo = await customerService.generateCustomerNo();
await this.customerModel.create(payload);
return true;
} catch (_e) {
return false;
}
}
throw err;
}
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
@@ -9,6 +9,7 @@ import SysJobLog from "../entity/sys_job_log.entity";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
import { IccLineSyncService } from "./icc_line_sync.service";
import { IccCustomerSyncService } from "./icc_customer_sync.service";
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
/** 任务执行结果(一次尝试) */
@@ -452,6 +453,12 @@ export class JobSchedulerService {
const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService;
const result = await iccService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccCustomers: async () => {
const ctx = this.app.createAnonymousContext();
const iccCustomerService = (await ctx.requestContext.getAsync(IccCustomerSyncService)) as IccCustomerSyncService;
const result = await iccCustomerService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
}
};
}