|
|
import { getEnv } from "@/lib/env";
|
|
|
import { getCcOwnerUserId, getCcProfileId } from "@/lib/cc-owner";
|
|
|
import { logger } from "@/lib/logger";
|
|
|
import { prisma } from "@/services/db";
|
|
|
import { ccAuth } from "@/services/cc/auth";
|
|
|
import { CcHttpClient, CcHttpError } from "@/services/cc/http";
|
|
|
import { normalizeCcCode } from "@/services/cc/code";
|
|
|
import {
|
|
|
mockContainerExists,
|
|
|
mockRegisterContainer,
|
|
|
} from "@/services/cc/mock-state";
|
|
|
import { isCcMock } from "@/services/cc/settings-config";
|
|
|
import { toShanghaiDateString } from "@/utils/dates";
|
|
|
import {
|
|
|
classifyCcFailure,
|
|
|
conflictCheckOperatorMessage,
|
|
|
formatCcApiFailure,
|
|
|
formatCcCause,
|
|
|
} from "@/constants/error-copy";
|
|
|
import { runWithListHttpGate } from "@/services/cc/list-http-gate";
|
|
|
|
|
|
const LIST_PATH = "/Container/GetContainerList";
|
|
|
const ccHttp = new CcHttpClient(ccAuth);
|
|
|
|
|
|
/** V1.73:GetContainerList 1 分钟 1 次;成功结果缓存,避免转仓闸门把限流当成失败 */
|
|
|
const LIST_CACHE_TTL_MS = 70_000;
|
|
|
const listCache = new Map<
|
|
|
string,
|
|
|
{ at: number; result: { ok: true; items: CcContainerRow[] } }
|
|
|
>();
|
|
|
const listInflight = new Map<
|
|
|
string,
|
|
|
Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }>
|
|
|
>();
|
|
|
|
|
|
function listProfileKey(): string {
|
|
|
const profileId = getCcProfileId();
|
|
|
if (profileId != null) return `profile:${profileId}`;
|
|
|
const ownerUserId = getCcOwnerUserId();
|
|
|
if (ownerUserId != null) return `owner:${ownerUserId}`;
|
|
|
return "global";
|
|
|
}
|
|
|
|
|
|
function listCacheKey(profileKey: string, containerNo: string): string {
|
|
|
return `${profileKey}:${containerNo}`;
|
|
|
}
|
|
|
|
|
|
export function isCcRateLimitedMessage(msg: string): boolean {
|
|
|
return classifyCcFailure(msg) === "rate_limit";
|
|
|
}
|
|
|
|
|
|
export type ConflictCheckResult =
|
|
|
| { ok: true }
|
|
|
| {
|
|
|
ok: false;
|
|
|
code: "CONFLICT_CONTAINER";
|
|
|
message: string;
|
|
|
details?: Record<string, unknown>;
|
|
|
}
|
|
|
| {
|
|
|
ok: false;
|
|
|
code: "CONFLICT_CHECK_FAILED";
|
|
|
message: string;
|
|
|
details?: Record<string, unknown>;
|
|
|
};
|
|
|
|
|
|
export interface ConflictCheckInput {
|
|
|
mailId: bigint;
|
|
|
containerNo: string;
|
|
|
forceSkipConflict?: boolean;
|
|
|
actor?: string;
|
|
|
}
|
|
|
|
|
|
interface CcContainerRow {
|
|
|
F_ContainerNo?: string;
|
|
|
F_Id?: string;
|
|
|
F_ContainerTaskId?: string;
|
|
|
F_CreateDate?: string;
|
|
|
/** DO 文件夹 id */
|
|
|
F_DOFile?: string | null;
|
|
|
}
|
|
|
|
|
|
function listDateRange(): { startTime: string; endTime: string } {
|
|
|
const end = new Date();
|
|
|
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
|
return {
|
|
|
startTime: toShanghaiDateString(start) || "",
|
|
|
endTime: toShanghaiDateString(end) || "",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export async function getContainerListFromCc(
|
|
|
containerNo: string,
|
|
|
): Promise<{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }> {
|
|
|
const normalized = containerNo.toUpperCase();
|
|
|
const profileKey = listProfileKey();
|
|
|
const cacheKey = listCacheKey(profileKey, normalized);
|
|
|
|
|
|
if (await isCcMock()) {
|
|
|
if (mockContainerExists(normalized)) {
|
|
|
return {
|
|
|
ok: true,
|
|
|
items: [{ F_ContainerNo: normalized, F_Id: "mock-existing-id" }],
|
|
|
};
|
|
|
}
|
|
|
return { ok: true, items: [] };
|
|
|
}
|
|
|
|
|
|
const cached = listCache.get(cacheKey);
|
|
|
if (cached && Date.now() - cached.at < LIST_CACHE_TTL_MS) {
|
|
|
return cached.result;
|
|
|
}
|
|
|
|
|
|
const pending = listInflight.get(cacheKey);
|
|
|
if (pending) return pending;
|
|
|
|
|
|
const { startTime, endTime } = listDateRange();
|
|
|
// V1.72:data 内 queryJson 必须是 JSON **字符串**(非对象);时间在 queryJson 内
|
|
|
const queryPayload = {
|
|
|
pagination: {
|
|
|
rows: 50,
|
|
|
page: 1,
|
|
|
sidx: "F_CreateDate",
|
|
|
sord: "desc",
|
|
|
},
|
|
|
queryJson: JSON.stringify({
|
|
|
F_ContainerNo: normalized,
|
|
|
F_CabinetType: "",
|
|
|
F_TransMode: "",
|
|
|
F_Status: "",
|
|
|
F_FBAID: "",
|
|
|
F_ShipmentId: "",
|
|
|
StartTime: startTime,
|
|
|
EndTime: endTime,
|
|
|
F_FBACode: "",
|
|
|
}),
|
|
|
};
|
|
|
|
|
|
const run = async (): Promise<
|
|
|
{ ok: true; items: CcContainerRow[] } | { ok: false; error: string }
|
|
|
> => {
|
|
|
try {
|
|
|
const env = getEnv();
|
|
|
const response = await runWithListHttpGate(profileKey, () =>
|
|
|
ccHttp.request(LIST_PATH, queryPayload, {
|
|
|
timeoutMs: env.CC_READ_TIMEOUT_MS,
|
|
|
}),
|
|
|
);
|
|
|
const code = normalizeCcCode(response.code);
|
|
|
if (code !== 200) {
|
|
|
const error = formatCcApiFailure({
|
|
|
api: "GetContainerList",
|
|
|
code,
|
|
|
info: response.info,
|
|
|
fallback: `GetContainerList failed: ${code}`,
|
|
|
});
|
|
|
if (isCcRateLimitedMessage(String(response.info || error)) && cached?.result.ok) {
|
|
|
return cached.result;
|
|
|
}
|
|
|
return { ok: false, error };
|
|
|
}
|
|
|
const items = parseContainerListRows(response.data);
|
|
|
const okResult = { ok: true as const, items };
|
|
|
listCache.set(cacheKey, { at: Date.now(), result: okResult });
|
|
|
return okResult;
|
|
|
} catch (err) {
|
|
|
const msg =
|
|
|
err instanceof CcHttpError
|
|
|
? err.timeout
|
|
|
? "GetContainerList timeout"
|
|
|
: err.message
|
|
|
: "GetContainerList failed";
|
|
|
logger.error({ err, containerNo: normalized }, "cc.list_error");
|
|
|
if (isCcRateLimitedMessage(msg) && cached?.result.ok) {
|
|
|
return cached.result;
|
|
|
}
|
|
|
return { ok: false, error: formatCcCause(msg) };
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const promise = run().finally(() => {
|
|
|
listInflight.delete(cacheKey);
|
|
|
});
|
|
|
listInflight.set(cacheKey, promise);
|
|
|
return promise;
|
|
|
}
|
|
|
|
|
|
function parseContainerListRows(data: unknown): CcContainerRow[] {
|
|
|
let parsed: unknown = data;
|
|
|
if (typeof data === "string") {
|
|
|
try {
|
|
|
parsed = JSON.parse(data);
|
|
|
} catch {
|
|
|
return [];
|
|
|
}
|
|
|
}
|
|
|
if (Array.isArray(parsed)) return parsed as CcContainerRow[];
|
|
|
if (typeof parsed === "object" && parsed) {
|
|
|
const obj = parsed as Record<string, unknown>;
|
|
|
if (Array.isArray(obj.rows)) return obj.rows as CcContainerRow[];
|
|
|
if (Array.isArray(obj.list)) return obj.list as CcContainerRow[];
|
|
|
}
|
|
|
return [];
|
|
|
}
|
|
|
|
|
|
async function checkLocalConflict(
|
|
|
mailId: bigint,
|
|
|
containerNo: string,
|
|
|
): Promise<ConflictCheckResult | null> {
|
|
|
const normalized = containerNo.toUpperCase();
|
|
|
|
|
|
const activeLock = await prisma.containerActiveLock.findUnique({
|
|
|
where: { containerNo: normalized },
|
|
|
});
|
|
|
if (activeLock && activeLock.mailId !== mailId) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
code: "CONFLICT_CONTAINER",
|
|
|
message: "该柜号正在被其他邮件导入",
|
|
|
details: { mailId: activeLock.mailId.toString() },
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const otherSuccess = await prisma.containerImport.findFirst({
|
|
|
where: {
|
|
|
containerNo: normalized,
|
|
|
status: "SUCCESS",
|
|
|
mailId: { not: mailId },
|
|
|
},
|
|
|
include: { mail: { select: { id: true, subject: true, messageId: true } } },
|
|
|
});
|
|
|
if (otherSuccess) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
code: "CONFLICT_CONTAINER",
|
|
|
message: "该柜号已在其他邮件中导入成功",
|
|
|
details: {
|
|
|
mailId: otherSuccess.mailId.toString(),
|
|
|
subject: otherSuccess.mail.subject,
|
|
|
messageId: otherSuccess.mail.messageId,
|
|
|
},
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const sameMailSuccess = await prisma.containerImport.findFirst({
|
|
|
where: {
|
|
|
mailId,
|
|
|
containerNo: normalized,
|
|
|
status: "SUCCESS",
|
|
|
},
|
|
|
});
|
|
|
if (sameMailSuccess) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
code: "CONFLICT_CONTAINER",
|
|
|
message: "该柜已在本邮件中导入成功(幂等跳过)",
|
|
|
details: { importId: sameMailSuccess.id.toString() },
|
|
|
};
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
export async function checkContainerConflict(
|
|
|
input: ConflictCheckInput,
|
|
|
): Promise<ConflictCheckResult> {
|
|
|
const env = getEnv();
|
|
|
const normalized = input.containerNo.toUpperCase();
|
|
|
|
|
|
const local = await checkLocalConflict(input.mailId, normalized);
|
|
|
if (local) return local;
|
|
|
|
|
|
if (input.forceSkipConflict && env.ENABLE_FORCE_IMPORT) {
|
|
|
logger.warn(
|
|
|
{ mailId: input.mailId.toString(), containerNo: normalized, actor: input.actor },
|
|
|
"cc.conflict_force_skip",
|
|
|
);
|
|
|
return { ok: true };
|
|
|
}
|
|
|
|
|
|
const listResult = await getContainerListFromCc(normalized);
|
|
|
if (!listResult.ok) {
|
|
|
if (env.ENABLE_FORCE_IMPORT && input.forceSkipConflict) {
|
|
|
return { ok: true };
|
|
|
}
|
|
|
const kind = classifyCcFailure(listResult.error);
|
|
|
return {
|
|
|
ok: false,
|
|
|
code: "CONFLICT_CHECK_FAILED",
|
|
|
message: conflictCheckOperatorMessage(kind, listResult.error),
|
|
|
details: { kind, error: listResult.error },
|
|
|
};
|
|
|
}
|
|
|
|
|
|
if (listResult.items.length > 0) {
|
|
|
return {
|
|
|
ok: false,
|
|
|
code: "CONFLICT_CONTAINER",
|
|
|
message: "该柜已在 CC 中存在,请确认",
|
|
|
details: {
|
|
|
items: listResult.items.map((i) => ({
|
|
|
F_ContainerNo: i.F_ContainerNo,
|
|
|
F_Id: i.F_Id,
|
|
|
})),
|
|
|
},
|
|
|
};
|
|
|
}
|
|
|
|
|
|
return { ok: true };
|
|
|
}
|
|
|
|
|
|
export async function acquireContainerActiveLock(input: {
|
|
|
containerNo: string;
|
|
|
mailId: bigint;
|
|
|
importId: bigint;
|
|
|
}): Promise<void> {
|
|
|
const normalized = input.containerNo.toUpperCase();
|
|
|
await prisma.containerActiveLock.upsert({
|
|
|
where: { containerNo: normalized },
|
|
|
create: {
|
|
|
containerNo: normalized,
|
|
|
mailId: input.mailId,
|
|
|
importId: input.importId,
|
|
|
lockedAt: new Date(),
|
|
|
},
|
|
|
update: {
|
|
|
mailId: input.mailId,
|
|
|
importId: input.importId,
|
|
|
lockedAt: new Date(),
|
|
|
},
|
|
|
});
|
|
|
}
|
|
|
|
|
|
export async function releaseContainerActiveLock(containerNo: string): Promise<void> {
|
|
|
const normalized = containerNo.toUpperCase();
|
|
|
await prisma.containerActiveLock
|
|
|
.delete({ where: { containerNo: normalized } })
|
|
|
.catch(() => undefined);
|
|
|
}
|
|
|
|
|
|
/** Mock helper: register container after successful save in tests */
|
|
|
export async function mockMarkContainerExisting(containerNo: string): Promise<void> {
|
|
|
if (await isCcMock()) {
|
|
|
mockRegisterContainer(containerNo);
|
|
|
}
|
|
|
}
|