|
|
/**
|
|
|
* 韧性设计 A-* 场景矩阵(docx/异常场景与系统韧性设计 §1–§10 / §12)
|
|
|
* 不连真实 IMAP/CC;覆盖纯逻辑 + 解析/门禁/限流/鉴权规则。
|
|
|
*/
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
|
import ExcelJS from "exceljs";
|
|
|
import AdmZip from "adm-zip";
|
|
|
import { classifyMail } from "@/services/parse/classify";
|
|
|
import { parsePackingListBuffer } from "@/services/parse/packing-list";
|
|
|
import { extractSpreadsheetFromZip } from "@/services/parse/zip-extract";
|
|
|
import { resolveNoSpreadsheetOutcome } from "@/services/parse/pipeline";
|
|
|
import { mapChannel } from "@/services/parse/channel-map";
|
|
|
import { canEnterForecastConfirm } from "@/services/cc/forecast-confirm-gate";
|
|
|
import {
|
|
|
assertTransition,
|
|
|
canTransition,
|
|
|
resolveAfterParse,
|
|
|
} from "@/services/state-machine";
|
|
|
import { isValidContainerNo, normalizeContainerNo } from "@/utils/iso6346";
|
|
|
import { parseFlexibleDate, round4, toShanghaiDateString } from "@/utils/dates";
|
|
|
import {
|
|
|
extractMailMeta,
|
|
|
sanitizeFilename,
|
|
|
truncateMessageId,
|
|
|
} from "@/services/imap/snapshot";
|
|
|
import { extractLoginToken } from "@/services/cc/auth";
|
|
|
import { normalizeCcCode } from "@/services/cc/code";
|
|
|
import { isRecentlyCreated } from "@/services/import/compensation";
|
|
|
import {
|
|
|
isImapSyncAlert,
|
|
|
nextPollDelayMs,
|
|
|
} from "@/services/imap/runtime-status";
|
|
|
import {
|
|
|
checkSlidingWindow,
|
|
|
resetRateLimitBuckets,
|
|
|
} from "@/lib/rate-limit";
|
|
|
import { verifyCredentials } from "@/lib/session";
|
|
|
import { isAdmin, parseBigIntId } from "@/lib/api";
|
|
|
import { ConfirmImportError } from "@/services/import/confirm";
|
|
|
import { resetEnvCache } from "@/lib/env";
|
|
|
import { computeRawHash, computeShipmentsHash, sha256 } from "@/utils/hash";
|
|
|
|
|
|
async function xlsx(rows: {
|
|
|
headers: string[];
|
|
|
data: unknown[][];
|
|
|
sheet?: string;
|
|
|
}): Promise<Buffer> {
|
|
|
const wb = new ExcelJS.Workbook();
|
|
|
const ws = wb.addWorksheet(rows.sheet ?? "卡派资料");
|
|
|
ws.addRow(rows.headers);
|
|
|
for (const r of rows.data) ws.addRow(r);
|
|
|
return Buffer.from(await wb.xlsx.writeBuffer());
|
|
|
}
|
|
|
|
|
|
describe("A-1 输入层", () => {
|
|
|
it("A-1.1 空主题 → (无主题) + 仍可分类", () => {
|
|
|
const meta = extractMailMeta({ subject: " " } as never);
|
|
|
expect(meta.subject).toBe("(无主题)");
|
|
|
expect(meta.subjectMissing).toBe(true);
|
|
|
const r = classifyMail({ subject: "", body: "请查收卡派", filenames: ["a.xlsx"] });
|
|
|
expect(r.mail_type).toBeTruthy();
|
|
|
});
|
|
|
|
|
|
it("A-1.2 / A-1.3 无正文无附件 → REJECTED;NEW 无清单 → NO_SHIPMENT_ROWS", () => {
|
|
|
expect(
|
|
|
resolveNoSpreadsheetOutcome({
|
|
|
mailType: "NEW_CONTAINER",
|
|
|
hasAttachments: false,
|
|
|
}),
|
|
|
).toEqual({
|
|
|
status: "REJECTED_VALIDATION",
|
|
|
lastError: "NO_SHIPMENT_ROWS",
|
|
|
});
|
|
|
expect(
|
|
|
resolveAfterParse({
|
|
|
mailType: "NEW_CONTAINER",
|
|
|
validCount: 0,
|
|
|
errorCode: "NO_SHIPMENT_ROWS",
|
|
|
}),
|
|
|
).toBe("REJECTED_VALIDATION");
|
|
|
});
|
|
|
|
|
|
it("A-1.4 有附件无表格 → PARSE_FAILED / NO_SUPPORTED_SPREADSHEET", () => {
|
|
|
expect(
|
|
|
resolveNoSpreadsheetOutcome({
|
|
|
mailType: "NEW_CONTAINER",
|
|
|
hasAttachments: true,
|
|
|
}),
|
|
|
).toEqual({
|
|
|
status: "PARSE_FAILED",
|
|
|
lastError: "NO_SUPPORTED_SPREADSHEET",
|
|
|
});
|
|
|
});
|
|
|
|
|
|
it("A-1.5 缺关键列 → PARSE_FAILED + missing_columns", async () => {
|
|
|
const buf = await xlsx({
|
|
|
headers: ["备注", "随便"],
|
|
|
data: [["x", "y"]],
|
|
|
});
|
|
|
const parsed = await parsePackingListBuffer(buf);
|
|
|
expect(parsed.errorCode).toBe("PARSE_FAILED");
|
|
|
expect(parsed.lineage.missing_columns).toEqual(
|
|
|
expect.arrayContaining(["F_FBACode", "F_Transporter", "F_CTNS"]),
|
|
|
);
|
|
|
});
|
|
|
|
|
|
it("A-1.6 非法柜号 ISO 失败", () => {
|
|
|
expect(isValidContainerNo("BAD")).toBe(false);
|
|
|
expect(isValidContainerNo("ABCD1234567", true)).toBe(false);
|
|
|
expect(normalizeContainerNo(" matu2745683 ")).toBe("MATU2745683");
|
|
|
});
|
|
|
|
|
|
it("A-1.7 半空行 INVALID,其它 VALID 不阻断", async () => {
|
|
|
const buf = await xlsx({
|
|
|
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重", "柜号"],
|
|
|
data: [
|
|
|
["ABQ2", "卡派", 1, 0.5, 10, "MATU2745683"],
|
|
|
["", "", 0, "", "", ""],
|
|
|
["FTW1", "卡派", 2, 1.2, 20, "MATU2745683"],
|
|
|
],
|
|
|
});
|
|
|
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
|
|
|
expect(parsed.errorCode).toBeUndefined();
|
|
|
const valid = parsed.shipments.filter((s) => s.row_status === "VALID");
|
|
|
const invalid = parsed.shipments.filter((s) => s.row_status === "INVALID");
|
|
|
expect(valid.length).toBeGreaterThanOrEqual(2);
|
|
|
expect(invalid.length).toBeGreaterThanOrEqual(1);
|
|
|
const ratio = invalid.length / parsed.shipments.length;
|
|
|
expect(ratio).toBeLessThanOrEqual(1);
|
|
|
});
|
|
|
|
|
|
it("A-1.9 未映射渠道仍可为 VALID + CHANNEL_UNMAPPED", async () => {
|
|
|
const mapped = mapChannel("神秘渠道XYZ");
|
|
|
expect(mapped.unmapped).toBe(true);
|
|
|
expect(mapped.transporter.length).toBeLessThanOrEqual(30);
|
|
|
|
|
|
const buf = await xlsx({
|
|
|
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
|
|
|
data: [["ABQ2", "神秘渠道XYZ", 3, 0.8, 12]],
|
|
|
});
|
|
|
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
|
|
|
expect(parsed.shipments[0].warnings).toContain("CHANNEL_UNMAPPED");
|
|
|
expect(parsed.shipments[0].row_status).toBe("VALID");
|
|
|
});
|
|
|
|
|
|
it("A-1.10 体积/重量带单位可解析;乱码 → null 行仍 VALID", async () => {
|
|
|
const buf = await xlsx({
|
|
|
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
|
|
|
data: [["ABQ2", "卡派", 1, "1.25 cbm", "12.5kg"]],
|
|
|
});
|
|
|
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
|
|
|
expect(parsed.shipments[0].row_status).toBe("VALID");
|
|
|
expect(parsed.shipments[0].F_CBM).toBe(1.25);
|
|
|
expect(parsed.shipments[0].F_Weight).toBe(12.5);
|
|
|
|
|
|
const bad = await xlsx({
|
|
|
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
|
|
|
data: [["ABQ2", "卡派", 1, "约莫", "N/A"]],
|
|
|
});
|
|
|
const parsedBad = await parsePackingListBuffer(bad, { enableIsoCheck: false });
|
|
|
// 实现:体积/重量解析失败 → null;packing-fill 现把缺失当 INVALID
|
|
|
// (韧性 §1.10 写「仍 VALID」,与填写规范「体积重量必填」不一致,见审查结论)
|
|
|
expect(parsedBad.shipments[0].F_CBM).toBeNull();
|
|
|
expect(parsedBad.shipments[0].F_Weight).toBeNull();
|
|
|
expect(parsedBad.shipments[0].row_status).toBe("INVALID");
|
|
|
expect(parsedBad.shipments[0].invalid_reasons).toEqual(
|
|
|
expect.arrayContaining(["总体积缺失", "毛重缺失"]),
|
|
|
);
|
|
|
});
|
|
|
|
|
|
it("A-1.11 坏日期 → null;地址截断 500", async () => {
|
|
|
expect(parseFlexibleDate("not-a-date")).toBeNull();
|
|
|
expect(toShanghaiDateString("2026.05.15")).toBe("2026-05-15");
|
|
|
const longAddr = "A".repeat(600);
|
|
|
const buf = await xlsx({
|
|
|
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重", "派送地址", "最早送仓"],
|
|
|
data: [["ABQ2", "卡派", 1, 0.4, 8, longAddr, "坏日期"]],
|
|
|
});
|
|
|
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
|
|
|
expect(parsed.shipments[0].F_Address?.length).toBe(500);
|
|
|
expect(parsed.shipments[0].F_Expected_DeliveryDateB).toBeNull();
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("A-2 / A-9 确认门禁与幂等", () => {
|
|
|
it("A-2.1 / A-2.2 非 PENDING 不可导入;IMPORTING/SUCCESS 不可重解析", () => {
|
|
|
expect(
|
|
|
canEnterForecastConfirm({
|
|
|
status: "PENDING_CONFIRM",
|
|
|
validShipmentCount: 1,
|
|
|
}),
|
|
|
).toBe(true);
|
|
|
expect(
|
|
|
canEnterForecastConfirm({
|
|
|
status: "IMPORTING",
|
|
|
validShipmentCount: 1,
|
|
|
}),
|
|
|
).toBe(false);
|
|
|
expect(canTransition("PENDING_CONFIRM", "IMPORTING")).toBe(true);
|
|
|
expect(canTransition("IMPORTING", "PARSED")).toBe(false);
|
|
|
expect(canTransition("SUCCESS", "PARSING")).toBe(false);
|
|
|
expect(canTransition("PARSE_FAILED", "PARSING")).toBe(true);
|
|
|
});
|
|
|
|
|
|
it("A-2.4 预报确认不看邮件主类型,只看状态与柜/货件数据", () => {
|
|
|
expect(
|
|
|
canEnterForecastConfirm({
|
|
|
status: "PENDING_CONFIRM",
|
|
|
validShipmentCount: 2,
|
|
|
}),
|
|
|
).toBe(true);
|
|
|
});
|
|
|
|
|
|
it("A-9.1 未映射未 ack → ACK_UNMAPPED_REQUIRED", () => {
|
|
|
const err = new ConfirmImportError(
|
|
|
"ACK_UNMAPPED_REQUIRED",
|
|
|
"存在未映射渠道,请确认后提交",
|
|
|
);
|
|
|
expect(err.status).toBe(400);
|
|
|
expect(err.code).toBe("ACK_UNMAPPED_REQUIRED");
|
|
|
});
|
|
|
|
|
|
it("A-9.5 accepted_count / shipments_hash 稳定(勾选顺序无关)", () => {
|
|
|
expect(computeShipmentsHash("MATU2745683", [3, 1, 2])).toBe(
|
|
|
computeShipmentsHash("MATU2745683", [1, 2, 3]),
|
|
|
);
|
|
|
});
|
|
|
|
|
|
it("A-2.5 VERSION_CONFLICT 409", () => {
|
|
|
const err = new ConfirmImportError("VERSION_CONFLICT", "版本冲突");
|
|
|
expect(err.status).toBe(409);
|
|
|
});
|
|
|
|
|
|
it("A-2.1 ALREADY_IMPORTING 409", () => {
|
|
|
const err = new ConfirmImportError("ALREADY_IMPORTING", "正在导入");
|
|
|
expect(err.status).toBe(409);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("A-3 IMAP / A-4 CC 容错规则", () => {
|
|
|
it("A-3.1 连续失败≥3 告警;≥5 指数退避上限 5min", () => {
|
|
|
expect(
|
|
|
isImapSyncAlert({
|
|
|
consecutiveFail: 2,
|
|
|
lastError: null,
|
|
|
lastSuccessAt: null,
|
|
|
lastAttemptAt: null,
|
|
|
lastProcessed: 0,
|
|
|
updatedAt: "",
|
|
|
}),
|
|
|
).toBe(false);
|
|
|
expect(
|
|
|
isImapSyncAlert({
|
|
|
consecutiveFail: 3,
|
|
|
lastError: "timeout",
|
|
|
lastSuccessAt: null,
|
|
|
lastAttemptAt: null,
|
|
|
lastProcessed: 0,
|
|
|
updatedAt: "",
|
|
|
}),
|
|
|
).toBe(true);
|
|
|
expect(nextPollDelayMs(4, 60_000)).toBe(60_000);
|
|
|
expect(nextPollDelayMs(20, 60_000)).toBe(5 * 60_000);
|
|
|
});
|
|
|
|
|
|
it("A-3.2 / A-3.3 近 10 分钟新建视为模糊成功可收敛", () => {
|
|
|
const now = Date.now();
|
|
|
expect(isRecentlyCreated(new Date(now - 60_000).toISOString(), now)).toBe(
|
|
|
true,
|
|
|
);
|
|
|
expect(
|
|
|
isRecentlyCreated(new Date(now - 11 * 60_000).toISOString(), now),
|
|
|
).toBe(false);
|
|
|
});
|
|
|
|
|
|
it("A-4.1 / A-4.5 登录 token 宽松解析;无法解析 → 空", () => {
|
|
|
expect(extractLoginToken("abc-token")).toBe("abc-token");
|
|
|
expect(extractLoginToken({ token: "t1" })).toBe("t1");
|
|
|
expect(extractLoginToken({ baseinfo: { token: "t2" } })).toBe("t2");
|
|
|
expect(extractLoginToken({ foo: 1 })).toBe("");
|
|
|
expect(extractLoginToken(null)).toBe("");
|
|
|
});
|
|
|
|
|
|
it("A-4.x CC code 字符串可归一", () => {
|
|
|
expect(normalizeCcCode("200")).toBe(200);
|
|
|
expect(normalizeCcCode(410)).toBe(410);
|
|
|
expect(normalizeCcCode(undefined)).toBe(0);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("A-5 解析容错", () => {
|
|
|
it("A-5.2 zip 一层可解;嵌套 zip 忽略", () => {
|
|
|
const ok = new AdmZip();
|
|
|
ok.addFile("卡派.xlsx", Buffer.from("x"));
|
|
|
const okRes = extractSpreadsheetFromZip(ok.toBuffer());
|
|
|
expect(okRes.files[0]?.filename).toBe("卡派.xlsx");
|
|
|
|
|
|
const inner = new AdmZip();
|
|
|
inner.addFile("a.xlsx", Buffer.from("x"));
|
|
|
const outer = new AdmZip();
|
|
|
outer.addFile("nested.zip", inner.toBuffer());
|
|
|
const nested = extractSpreadsheetFromZip(outer.toBuffer());
|
|
|
expect(nested.warnings).toContain("zip.nested_ignored");
|
|
|
expect(nested.files).toHaveLength(0);
|
|
|
});
|
|
|
|
|
|
it("A-5.2 路径穿越条目跳过或只留 basename", () => {
|
|
|
const zip = new AdmZip();
|
|
|
zip.addFile("folder/../../evil.xlsx", Buffer.from("x"));
|
|
|
const res = extractSpreadsheetFromZip(zip.toBuffer());
|
|
|
const skipped = res.warnings.includes("path_traversal_skipped");
|
|
|
const onlyBase = res.files.every((f) => !f.filename.includes(".."));
|
|
|
expect(skipped || onlyBase).toBe(true);
|
|
|
});
|
|
|
|
|
|
it("A-5.3 无「卡派」sheet → fallback 第一 sheet", async () => {
|
|
|
const buf = await xlsx({
|
|
|
sheet: "Sheet1",
|
|
|
headers: ["仓库ID", "渠道", "件数", "总体积", "毛重"],
|
|
|
data: [["ABQ2", "卡派", 1, 0.3, 5]],
|
|
|
});
|
|
|
const parsed = await parsePackingListBuffer(buf, { enableIsoCheck: false });
|
|
|
expect(parsed.lineage.sheetFallback).toBe(true);
|
|
|
expect(parsed.shipments[0].row_status).toBe("VALID");
|
|
|
});
|
|
|
|
|
|
it("A-5.5 硬限制 >hard → REJECTED_VALIDATION;软限仅标记", async () => {
|
|
|
const buf = await xlsx({
|
|
|
headers: ["仓库ID", "渠道", "件数"],
|
|
|
data: Array.from({ length: 8 }, (_, i) => [`W${i}`, "卡派", 1]),
|
|
|
});
|
|
|
const hard = await parsePackingListBuffer(buf, {
|
|
|
enableIsoCheck: false,
|
|
|
hardLimit: 5,
|
|
|
});
|
|
|
expect(hard.errorCode).toBe("REJECTED_VALIDATION");
|
|
|
expect(hard.shipments).toHaveLength(0);
|
|
|
|
|
|
const soft = await parsePackingListBuffer(buf, {
|
|
|
enableIsoCheck: false,
|
|
|
softLimit: 3,
|
|
|
hardLimit: 100,
|
|
|
});
|
|
|
expect(soft.errorCode).toBeUndefined();
|
|
|
expect(soft.lineage.soft_limit_exceeded).toBe(true);
|
|
|
expect(soft.shipments.length).toBe(8);
|
|
|
});
|
|
|
|
|
|
it("A-5.6 正文柜号 ≠ 附件柜号 → 冲突规则", () => {
|
|
|
const body = normalizeContainerNo("TCLU1234567");
|
|
|
const att = normalizeContainerNo("MATU2745683");
|
|
|
expect(body).not.toBe(att);
|
|
|
});
|
|
|
|
|
|
it("A-5.8 重新解析允许 PARSE_FAILED/REJECTED/PARSED;禁止 SUCCESS/IMPORTING", () => {
|
|
|
expect(canTransition("PARSE_FAILED", "PARSING")).toBe(true);
|
|
|
expect(canTransition("REJECTED_VALIDATION", "PARSING")).toBe(true);
|
|
|
expect(canTransition("PARSED", "PARSING")).toBe(true);
|
|
|
expect(canTransition("FAILED", "PARSING")).toBe(true);
|
|
|
expect(canTransition("SUCCESS", "PARSING")).toBe(false);
|
|
|
expect(canTransition("PARTIAL_SUCCESS", "PARSING")).toBe(false);
|
|
|
expect(canTransition("IMPORTING", "PARSING")).toBe(false);
|
|
|
});
|
|
|
|
|
|
it("A-1.8 / A-5.x 附件名 sanitize 防穿越", () => {
|
|
|
expect(sanitizeFilename("../../etc/passwd")).toBe("passwd");
|
|
|
expect(sanitizeFilename("a<>b.xlsx")).toBe("a__b.xlsx");
|
|
|
expect(sanitizeFilename("")).toBe("attachment");
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("A-6 状态机 / A-7 幂等", () => {
|
|
|
it("A-6.2 PARSING → FETCHED(stale reaper)", () => {
|
|
|
expect(canTransition("PARSING", "FETCHED")).toBe(true);
|
|
|
});
|
|
|
|
|
|
it("A-6.3 IMPORTING → FAILED(超时回收)", () => {
|
|
|
expect(canTransition("IMPORTING", "FAILED")).toBe(true);
|
|
|
expect(() => assertTransition("IMPORTING", "PENDING_CONFIRM")).toThrow();
|
|
|
});
|
|
|
|
|
|
it("A-6.6 非法迁移拒绝", () => {
|
|
|
expect(() => assertTransition("SUCCESS", "PENDING_CONFIRM")).toThrow();
|
|
|
expect(canTransition("IGNORED", "PARSING")).toBe(false);
|
|
|
});
|
|
|
|
|
|
it("A-7.3 hash 碰撞分叉:加盐后不等于 base", () => {
|
|
|
const base = computeRawHash({
|
|
|
date: "2026-01-01T00:00:00.000Z",
|
|
|
from: "a@b.com",
|
|
|
subject: "x",
|
|
|
filenames: ["a.xlsx"],
|
|
|
});
|
|
|
expect(sha256(`${base}|acc|INBOX|99`)).not.toBe(base);
|
|
|
});
|
|
|
|
|
|
it("A-7.4 Message-ID 超长截断 191(UNIQUE 列)", () => {
|
|
|
const long = `<${"x".repeat(300)}@ex.com>`;
|
|
|
expect(truncateMessageId(long)).toHaveLength(191);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe("A-9 计算 / A-10 权限", () => {
|
|
|
beforeEach(() => {
|
|
|
process.env.DATABASE_URL =
|
|
|
process.env.DATABASE_URL ||
|
|
|
"mysql://app:app@localhost:7023/email_forecast";
|
|
|
process.env.SESSION_SECRET =
|
|
|
process.env.SESSION_SECRET ||
|
|
|
"unit-test-session-secret-at-least-32-chars!!";
|
|
|
resetEnvCache();
|
|
|
resetRateLimitBuckets();
|
|
|
});
|
|
|
|
|
|
afterEach(() => {
|
|
|
resetRateLimitBuckets();
|
|
|
});
|
|
|
|
|
|
it("A-9.2 浮点 round4", () => {
|
|
|
expect(round4(0.12345)).toBe(0.1235);
|
|
|
});
|
|
|
|
|
|
it("A-9.3 上海时区跨日", () => {
|
|
|
expect(toShanghaiDateString(new Date("2026-07-12T16:30:00.000Z"))).toBe(
|
|
|
"2026-07-13",
|
|
|
);
|
|
|
});
|
|
|
|
|
|
it("A-10.1 错误口令拒绝;空 session 非 admin", () => {
|
|
|
expect(verifyCredentials("admin", "wrong-password")).toBeNull();
|
|
|
expect(isAdmin({})).toBe(false);
|
|
|
expect(isAdmin({ username: "ops", role: "ops" })).toBe(false);
|
|
|
expect(isAdmin({ username: "admin", role: "admin" })).toBe(true);
|
|
|
});
|
|
|
|
|
|
it("A-10.5 导入限流 10/分钟 → 第 11 次拒绝", () => {
|
|
|
const key = "import:ops";
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
expect(checkSlidingWindow(key, 10, 60_000)).toBe(true);
|
|
|
}
|
|
|
expect(checkSlidingWindow(key, 10, 60_000)).toBe(false);
|
|
|
});
|
|
|
|
|
|
it("A-10.x 伪造 mail_id 非法 → parseBigIntId null", () => {
|
|
|
expect(parseBigIntId("abc")).toBeNull();
|
|
|
expect(parseBigIntId("-1")).toBeNull();
|
|
|
expect(parseBigIntId("12")).toBe(12n);
|
|
|
});
|
|
|
});
|