const endpoints = {
orders: "/api/orders",
risks: "/api/risks",
flow: "/api/flow",
nodeTimes: "/api/node-times",
cutoffs: "/api/order-cutoffs",
exceptionMap: "/api/exception-map",
};
const labels = {
orderNo: "订单号",
customerName: "客户",
status: "状态",
currentNodeName: "当前节点",
ownerName: "责任人",
riskName: "风险",
exceptionName: "异常",
affectedNodeName: "影响节点",
sequence: "顺序",
nodeName: "节点",
nextNodeName: "下一节点",
deadlineName: "关键时限",
plannedTime: "计划时间",
cutoffTime: "截止时间",
actualTime: "实际完成",
isCompleted: "完成",
isDelayed: "超时",
customsCutoffTime: "截关",
terminalCutoffTime: "截港",
vgmCutoffTime: "VGM截止",
estimatedSailingTime: "预计开船",
};
let currentRunId = null;
let operationsLoaded = false;
let marketOptions = null;
let mockCatalog = null;
let activeCatalog = "routes";
document.querySelectorAll(".view-tab").forEach(button => {
button.addEventListener("click", () => switchView(button.dataset.view));
});
document.getElementById("inquiryForm").addEventListener("submit", submitInquiry);
document.getElementById("recommendBtn").addEventListener("click", submitInquiry);
document.getElementById("refreshBtn").addEventListener("click", loadOperations);
document.getElementById("auditBtn").addEventListener("click", openAudit);
document.getElementById("generateMockBtn").addEventListener("click", generateMockBatch);
document.getElementById("resetMockBtn").addEventListener("click", resetMockData);
document.getElementById("origin").addEventListener("change", () => syncRouteControls(true));
document.getElementById("destination").addEventListener("change", () => applyRouteDefaults(true));
document.querySelectorAll(".catalog-tab").forEach(button => {
button.addEventListener("click", () => selectCatalog(button.dataset.catalog));
});
document.getElementById("closeAuditBtn").addEventListener("click", () => {
document.getElementById("auditDialog").close();
});
initialize();
async function initialize() {
try {
const [health, options] = await Promise.all([
fetchJson("/health"),
fetchJson("/api/market/form-options"),
]);
document.getElementById("systemStatus").textContent =
`本地知识图谱 · ${numberFormat(health.triple_count)} triples`;
marketOptions = options;
populateInquiryForm(options);
await requestRecommendation(options.defaults);
} catch (error) {
renderMarketError(error.message);
}
}
function switchView(view) {
document.querySelectorAll(".view-tab").forEach(button => {
button.classList.toggle("is-active", button.dataset.view === view);
});
document.querySelectorAll(".app-view").forEach(section => {
section.classList.toggle("is-active", section.id === `${view}View`);
});
if (view === "operations" && !operationsLoaded) loadOperations();
if (view === "mock" && !mockCatalog) loadMockCatalog();
}
function populateInquiryForm(options) {
populateSelect("customer", options.customers, "id", "name", options.defaults.customer);
populateSelect("origin", options.origins, null, null, options.defaults.origin);
syncRouteControls(false, options.defaults.destination);
const defaultRoute = options.routes.find(route =>
route.origin === options.defaults.origin && route.destination === options.defaults.destination
);
if (defaultRoute) {
populateSelect(
"routeType",
defaultRoute.route_types,
null,
null,
options.defaults.preferred_route_type,
);
}
document.getElementById("cargoName").value = options.defaults.cargo_name;
document.getElementById("cargoVolume").value = options.defaults.cargo_volume;
document.getElementById("evaluationDate").value = options.defaults.evaluation_date;
document.getElementById("departureDate").value = options.defaults.requested_departure_date;
document.getElementById("maxTransitDays").value = options.defaults.max_transit_days;
document.getElementById("targetAmount").value = options.defaults.target_amount;
document.getElementById("dangerousGoods").checked = options.defaults.dangerous_goods;
populateMockRouteSelect();
}
function syncRouteControls(updateDefaults, selectedDestination) {
if (!marketOptions) return;
const origin = document.getElementById("origin").value;
const destinations = marketOptions.routes
.filter(route => route.origin === origin)
.map(route => route.destination);
const preferredDestination = selectedDestination && destinations.includes(selectedDestination)
? selectedDestination
: destinations[0];
populateSelect("destination", destinations, null, null, preferredDestination);
applyRouteDefaults(updateDefaults);
}
function applyRouteDefaults(updateValues) {
if (!marketOptions) return;
const origin = document.getElementById("origin").value;
const destination = document.getElementById("destination").value;
const route = marketOptions.routes.find(item => item.origin === origin && item.destination === destination);
if (!route) return;
const currentRouteType = document.getElementById("routeType").value;
const preferredRouteType = route.route_types.includes(currentRouteType)
? currentRouteType
: route.route_types[0];
populateSelect("routeType", route.route_types, null, null, preferredRouteType);
if (updateValues) {
document.getElementById("targetAmount").value = route.target_amount;
document.getElementById("departureDate").value = route.requested_departure_date;
document.getElementById("maxTransitDays").value = Math.ceil(route.max_transit_days);
}
}
function populateMockRouteSelect() {
if (!marketOptions) return;
const allRoutes = marketOptions.routes.map((route, index) => ({
id: String(index),
name: `${route.origin} → ${route.destination}`,
}));
const indiaRoutes = allRoutes.filter(route => route.name.includes("印度"));
const routes = indiaRoutes.length ? indiaRoutes : allRoutes;
populateSelect("mockRoute", routes, "id", "name", routes[0]?.id || "");
}
function populateSelect(id, values, valueKey, labelKey, selectedValue) {
const select = document.getElementById(id);
select.innerHTML = values.map(item => {
const value = valueKey ? item[valueKey] : item;
const label = labelKey ? item[labelKey] : item;
return ``;
}).join("");
}
async function submitInquiry(event) {
event.preventDefault();
const form = new FormData(document.getElementById("inquiryForm"));
const inquiry = {
customer: form.get("customer"),
origin: form.get("origin"),
destination: form.get("destination"),
cargo_name: form.get("cargo_name"),
cargo_volume: Number(form.get("cargo_volume")),
evaluation_date: form.get("evaluation_date"),
requested_departure_date: form.get("requested_departure_date"),
max_transit_days: Number(form.get("max_transit_days")),
target_amount: Number(form.get("target_amount")),
preferred_route_type: form.get("preferred_route_type"),
dangerous_goods: document.getElementById("dangerousGoods").checked,
salesperson_adjustment: 0,
};
await requestRecommendation(inquiry);
}
async function requestRecommendation(inquiry) {
setRecommendationLoading(true);
try {
const result = await fetchJson("/api/market/recommend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(inquiry),
});
currentRunId = result.run_id;
renderMarketResult(result);
} catch (error) {
currentRunId = null;
renderMarketError(error.message);
} finally {
setRecommendationLoading(false);
}
}
function setRecommendationLoading(loading) {
const button = document.getElementById("recommendBtn");
button.disabled = loading;
button.querySelector("span:first-child").textContent = loading ? "正在计算" : "生成市场方案";
document.getElementById("inquiryState").textContent = loading ? "决策计算中" : "已标准化";
if (loading) {
document.getElementById("recommendationBand").className = "recommendation-band is-loading";
document.getElementById("recommendationBand").innerHTML = `
`;
}
}
function renderMarketResult(result) {
const inquiry = result.normalized_inquiry;
const recommendation = result.recommendation;
document.getElementById("decisionTitle").textContent = `${inquiry.customer_name} · ${inquiry.route}`;
document.getElementById("inquiryState").textContent = `任务 ${result.run_id}`;
document.getElementById("auditBtn").disabled = false;
renderPipeline(result.pipeline);
renderRecommendation(result);
renderCandidates(result.candidates);
renderRejected(result.rejected_candidates);
}
function renderPipeline(pipeline) {
const steps = [
["DISCOVER", pipeline.discovered, "发现候选", ""],
["REJECT", pipeline.rejected, "硬条件淘汰", "is-rejected"],
["FEASIBLE", pipeline.feasible, "通过可执行校验", "is-accent"],
["SOLUTIONS", pipeline.generated_solutions, "生成市场方案", "is-accent"],
];
document.getElementById("pipeline").innerHTML = steps.map(([code, value, label, className]) => `
${code}${escapeHtml(value)}${label}
`).join("");
}
function renderRecommendation(result) {
const recommendation = result.recommendation;
const top = result.candidates.find(item => item.id === recommendation.candidate_id);
const pricing = top.pricing;
const context = result.market_context;
const confidence = Math.round(recommendation.confidence_score * 100);
const band = document.getElementById("recommendationBand");
band.className = "recommendation-band";
band.innerHTML = `
AI MARKET RECOMMENDATION
${escapeHtml(recommendation.solution_type)} · ${escapeHtml(recommendation.supplier_name)}
${escapeHtml(recommendation.summary)}
${escapeHtml(top.route_type)}
${escapeHtml(top.transit_days)} 天
与 ${escapeHtml(top.supplier_name)} ${escapeHtml(top.relationship.tier)}
${escapeHtml(top.spot_space.summary)}
低价机会 ${percent(top.relationship.low_price_access_probability)}
${escapeHtml(context.strategy_name)}
客户最终建议报价
${money(pricing.final_quote_amount)}${escapeHtml(pricing.currency)}
授权区间 ${money(pricing.allowed_quote_lower_amount)} – ${money(pricing.allowed_quote_upper_amount)}
`;
}
function renderCandidates(candidates) {
document.getElementById("candidateMeta").textContent = `${candidates.length} 套可执行方案 · 方案、供应商合作与现舱优先`;
document.getElementById("candidateGrid").innerHTML = candidates.map((candidate, index) => {
const typeClass = candidate.solution_type === "关系优选"
? "type-relationship"
: candidate.solution_type === "价格参考"
? "type-price"
: candidate.solution_type === "时效参考" ? "type-speed" : "type-primary";
const riskClass = candidate.pricing.cost_risk_status.includes("底线")
? "is-danger"
: candidate.pricing.cost_risk_status.includes("安全") ? "is-safe" : "";
return `
${escapeHtml(candidate.solution_type)}
RANK ${candidate.rank}
${escapeHtml(candidate.product_name)}
${escapeHtml(candidate.supplier_name)} · ${escapeHtml(candidate.offering_channel)}
预计时效${escapeHtml(candidate.transit_days)} 天
方案保障${candidate.solution_assurance_score}
供应商合作等级${escapeHtml(candidate.relationship.tier)}
现舱${escapeHtml(candidate.spot_space.status)}${candidate.spot_space.teu ? ` · ${escapeHtml(candidate.spot_space.teu)} TEU` : ""}
${scoreRow("方案保障", candidate.solution_assurance_score)}
${scoreRow("关系优势", candidate.relationship_advantage_score)}
${scoreRow("现舱保障", candidate.spot_space_score)}
${scoreRow("供应履约", candidate.supplier_execution_score)}
${scoreRow("客户适配", candidate.customer_fit_score)}
${scoreRow("运输时效", candidate.transit_score)}
${scoreRow("价格参考", candidate.price_score)}
我方与供应商 ${escapeHtml(candidate.relationship.supplier_name)}(${escapeHtml(candidate.relationship.supplier_channel)})的合作关系${escapeHtml(candidate.relationship.tier)} · 供应商合作亲密度 ${percent(candidate.relationship.strength)}
低价获取概率${percent(candidate.relationship.low_price_access_probability)}
优先舱位概率${percent(candidate.relationship.priority_space_probability)}
现舱状态${escapeHtml(candidate.spot_space.status)}
${escapeHtml(candidate.relationship.evidence)}
${candidate.reasons.slice(0, 4).map(escapeHtml).join(";")}
建议客户报价${money(candidate.pricing.final_quote_amount)}
${escapeHtml(candidate.pricing.cost_risk_status)}
`;
}).join("");
}
function scoreRow(label, value) {
const width = Math.max(0, Math.min(100, Number(value)));
return ``;
}
function renderRejected(rejected) {
document.getElementById("rejectedMeta").textContent = `${rejected.length} 个淘汰候选`;
document.getElementById("rejectedList").innerHTML = rejected.length
? rejected.map(item => `
${escapeHtml(item.product_name)}
${escapeHtml(item.supplier_name)} · ${escapeHtml(item.quote_no)}
${item.reasons.map(reason => `${escapeHtml(reason)}`).join("")}
`).join("")
: `本次没有候选被硬条件淘汰
`;
}
function renderMarketError(message) {
document.getElementById("decisionTitle").textContent = "当前询价无法生成可执行方案";
document.getElementById("auditBtn").disabled = true;
document.getElementById("pipeline").innerHTML = "";
const band = document.getElementById("recommendationBand");
band.className = "recommendation-band";
band.innerHTML = `DECISION STOPPED
${escapeHtml(message)}
`;
document.getElementById("candidateGrid").innerHTML = "";
document.getElementById("candidateMeta").textContent = "0 套可执行方案";
}
async function openAudit() {
if (!currentRunId) return;
const dialog = document.getElementById("auditDialog");
const target = document.getElementById("auditContent");
target.innerHTML = `正在读取审计记录
`;
dialog.showModal();
try {
const audit = await fetchJson(`/api/market/recommendations/${encodeURIComponent(currentRunId)}/audit`);
const explanation = audit.business_explanation;
target.innerHTML = `
本次怎么选
${escapeHtml(explanation.decision)}
为什么优先推荐
${explanation.why_recommended.map(reason => `- ${escapeHtml(reason)}
`).join("")}
其他方案为什么保留
${explanation.alternatives.map(item => `
${escapeHtml(item.title)}${escapeHtml(item.detail)}
`).join("") || "
本次没有其他可执行备选方案。
"}
没有采用的方案
${explanation.rejected.map(reason => `- ${escapeHtml(reason)}
`).join("") || "- 本次没有方案因硬条件被淘汰。
"}
判断口径${escapeHtml(explanation.selection_basis)}
`;
} catch (error) {
target.innerHTML = `${escapeHtml(error.message)}
`;
}
}
function auditItem(label, value) {
return `${escapeHtml(label)}${escapeHtml(value)}
`;
}
async function loadMockCatalog() {
const target = document.getElementById("mockCatalogTable");
target.innerHTML = `正在读取本地 RDF 数据
`;
try {
mockCatalog = await fetchJson("/api/mock/catalog");
renderMockCatalog();
} catch (error) {
target.innerHTML = `${escapeHtml(error.message)}
`;
}
}
function renderMockCatalog() {
if (!mockCatalog) return;
const summary = mockCatalog.summary;
const stats = [
["RDF 三元组", summary.triple_count, summary.in_memory_triple_count ? `+${summary.in_memory_triple_count} 内存` : "基础图", summary.in_memory_triple_count > 0],
["客户", summary.customer_count, "Customer", false],
["供应商", summary.supplier_count, "Supplier", false],
["产品", summary.product_count, "ServiceProduct", false],
["报价", summary.quote_count, "SupplierQuote", false],
["路线", summary.route_count, "Route", false],
["启用策略", summary.strategy_count, "Strategy", false],
];
document.getElementById("mockSummary").innerHTML = stats.map(([label, value, note, hasMemory]) => `
${escapeHtml(label)}${numberFormat(value)}${escapeHtml(note)}
`).join("");
document.getElementById("mockSources").innerHTML = mockCatalog.loaded_files
.map(source => `${escapeHtml(source)}`).join("");
document.getElementById("systemStatus").textContent = summary.in_memory_triple_count
? `本地知识图谱 · ${numberFormat(summary.triple_count)} triples · +${summary.in_memory_triple_count} 内存`
: `本地知识图谱 · ${numberFormat(summary.triple_count)} triples`;
renderActiveCatalogTable();
}
function selectCatalog(catalog) {
activeCatalog = catalog;
document.querySelectorAll(".catalog-tab").forEach(button => {
button.classList.toggle("is-active", button.dataset.catalog === catalog);
});
renderActiveCatalogTable();
}
function renderActiveCatalogTable() {
if (!mockCatalog) return;
const definitions = {
routes: {
columns: [
["origin", "起运地"], ["destination", "目的地"], ["transport_modes", "运输方式"],
["product_count", "产品"], ["supplier_count", "供应商"], ["quote_count", "全部报价"],
["current_quote_count", "当前有效"],
],
rows: mockCatalog.routes,
},
customers: {
columns: [
["name", "客户"], ["industry", "行业"], ["preferred_route", "主要路线"],
["repeat_purchase_count", "复购"], ["price_sensitivity", "价格敏感"],
["time_sensitivity", "时效敏感"], ["reliability_sensitivity", "可靠性敏感"],
],
rows: mockCatalog.customers,
},
quotes: {
columns: [
["quote_no", "报价编号"], ["route", "路线"], ["transport_mode", "方式"],
["product_name", "产品"], ["supplier_name", "供应商"],
["relationship_tier", "供应商合作等级"], ["relationship_strength", "供应商合作亲密度"],
["low_price_access_probability", "低价获取"], ["priority_space_probability", "优先舱位"],
["spot_space_status", "现舱状态"], ["spot_space_teu", "现舱 TEU"],
["relationship_evidence", "合作依据"], ["supplier_cost_amount", "采购金额"],
["valid_to", "有效至"], ["status", "状态"],
],
rows: mockCatalog.quotes,
},
strategies: {
columns: [
["name", "策略"], ["route", "适用路线"], ["objective", "目标"],
["adjustment_rate", "价格调整"], ["valid_to", "有效至"],
],
rows: mockCatalog.strategies,
},
};
const definition = definitions[activeCatalog];
document.getElementById("mockCatalogTable").innerHTML = renderCatalogTable(
definition.rows,
definition.columns,
);
}
function renderCatalogTable(rows, columns) {
if (!rows.length) return `当前分类没有数据
`;
const head = columns.map(([, label]) => `${escapeHtml(label)} | `).join("");
const body = rows.map(row => `
${columns.map(([key]) => `| ${formatCatalogCell(key, row[key], row)} | `).join("")}
`
).join("");
return ``;
}
function formatCatalogCell(key, value, row) {
if ([
"price_sensitivity", "time_sensitivity", "reliability_sensitivity",
"relationship_strength", "low_price_access_probability", "priority_space_probability",
].includes(key)) {
const width = Math.round(Number(value || 0) * 100);
return ``;
}
if (key === "supplier_cost_amount") {
return `${money(value)} ${escapeHtml(row.currency || "CNY")}${row.runtime_mock ? ` 内存` : ""}`;
}
if (key === "spot_space_teu") return value ? `${Number(value)} TEU` : "待确认";
if (key === "status") {
const className = value === "当前有效" ? "status-current" : value === "已过期" ? "status-expired" : "";
return `${escapeHtml(value)}`;
}
if (key === "adjustment_rate") return percent(value);
return escapeHtml(value ?? "");
}
async function generateMockBatch() {
if (!marketOptions?.routes?.length) return;
const button = document.getElementById("generateMockBtn");
const route = marketOptions.routes[Number(document.getElementById("mockRoute").value)];
if (!route) return;
button.disabled = true;
button.querySelector("span:first-child").textContent = "正在写入内存图";
document.getElementById("mockStatus").textContent = "正在生成供应商、产品、班期、报价和客户画像";
try {
const result = await fetchJson("/api/mock/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
origin: route.origin,
destination: route.destination,
supplier_count: Number(document.getElementById("mockSupplierCount").value),
customer_count: Number(document.getElementById("mockCustomerCount").value),
evaluation_date: document.getElementById("evaluationDate").value || "2026-07-20",
}),
});
mockCatalog = result.catalog;
document.getElementById("mockStatus").textContent =
`批次 ${result.batch_id} 已增加 ${result.added_triples} 条内存三元组`;
await refreshMarketOptions();
renderMockCatalog();
} catch (error) {
document.getElementById("mockStatus").textContent = error.message;
} finally {
button.disabled = false;
button.querySelector("span:first-child").textContent = "添加一批 Mock 数据";
}
}
async function resetMockData() {
const button = document.getElementById("resetMockBtn");
button.disabled = true;
try {
mockCatalog = await fetchJson("/api/mock/reset", { method: "POST" });
document.getElementById("mockStatus").textContent = "动态数据已清空,已恢复本地 TTL 基础图";
await refreshMarketOptions();
await requestRecommendation(marketOptions.defaults);
renderMockCatalog();
} catch (error) {
document.getElementById("mockStatus").textContent = error.message;
} finally {
button.disabled = false;
}
}
async function refreshMarketOptions() {
marketOptions = await fetchJson("/api/market/form-options");
populateInquiryForm(marketOptions);
}
async function loadOperations() {
await Promise.all([
loadTable("orders", endpoints.orders, ["orderNo", "customerName", "status", "currentNodeName", "ownerName"]),
loadTable("risks", endpoints.risks, ["orderNo", "riskName", "exceptionName", "affectedNodeName"]),
loadFlow(),
loadTable("nodeTimes", endpoints.nodeTimes, ["sequence", "nodeName", "deadlineName", "plannedTime", "cutoffTime", "actualTime", "isCompleted", "isDelayed"]),
loadTable("cutoffs", endpoints.cutoffs, ["orderNo", "customsCutoffTime", "terminalCutoffTime", "vgmCutoffTime", "estimatedSailingTime"]),
loadTable("exceptionMap", endpoints.exceptionMap, ["exceptionName", "affectedNodeName", "riskName"]),
]);
operationsLoaded = true;
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
let detail = `请求失败:HTTP ${response.status}`;
try {
const payload = await response.json();
detail = payload.detail || detail;
} catch (_) {
// Preserve the HTTP message when the response is not JSON.
}
throw new Error(detail);
}
return response.json();
}
async function fetchRows(url) {
const data = await fetchJson(url);
return data.rows || [];
}
async function loadTable(id, url, columns) {
const target = document.getElementById(id);
const counter = document.getElementById(`${id}Count`);
try {
const rows = await fetchRows(url);
if (counter) counter.textContent = rows.length;
target.innerHTML = renderTable(rows, columns);
} catch (error) {
target.innerHTML = `本地RDF数据读取失败
`;
}
}
async function loadFlow() {
const target = document.getElementById("flow");
try {
const rows = await fetchRows(endpoints.flow);
if (!rows.length) {
target.innerHTML = `暂无流程数据
`;
return;
}
target.innerHTML = rows.map(row => `
${escapeHtml(row.sequence)}. ${escapeHtml(row.nodeName)}
${row.deadlineName ? `时限:${escapeHtml(row.deadlineName)}` : "无关键时限"}
${row.nextNodeName ? `下一步:${escapeHtml(row.nextNodeName)}` : "流程结束"}
`).join("");
} catch (error) {
target.innerHTML = `流程读取失败
`;
}
}
function renderTable(rows, columns) {
if (!rows.length) return `暂无数据
`;
const head = columns.map(column => `${labels[column] || column} | `).join("");
const body = rows.map(row => `
${columns.map(column => `| ${formatCell(column, row[column])} | `).join("")}
`).join("");
return ``;
}
function formatCell(column, value) {
if (!value) return "";
if (column === "isDelayed" && value === "true") return `是`;
if (column === "isDelayed" && value === "false") return "否";
if (column === "isCompleted" && value === "true") return "是";
if (column === "isCompleted" && value === "false") return `否`;
if (column.endsWith("Time")) return escapeHtml(value.replace("T", " ").replace("+08:00", ""));
return escapeHtml(value);
}
function money(value) {
return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value));
}
function numberFormat(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value));
}
function percent(value) {
return `${Math.round(Number(value) * 100)}%`;
}
function shortDate(value) {
return value ? value.slice(5).replace("-", "/") : "待确认";
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}