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:
1 parent
be91d04431
commit
9d3bb13ecf
24 files changed
+1159
-5
No files matched your search
@@ -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}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user