@chenchaolong/plugin-trade-compliance-workbench 1.0.48 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/lib/entities/settings.entity.d.ts +2 -0
  2. package/dist/lib/entities/settings.entity.d.ts.map +1 -1
  3. package/dist/lib/entities/settings.entity.js +8 -0
  4. package/dist/lib/entities/settings.entity.js.map +1 -1
  5. package/dist/lib/remote-components/trade-compliance-workbench/app.css +1 -1
  6. package/dist/lib/remote-components/trade-compliance-workbench/app.js +4 -4
  7. package/dist/lib/remote-ui/analytics-contracts.d.ts +121 -145
  8. package/dist/lib/remote-ui/analytics-contracts.d.ts.map +1 -1
  9. package/dist/lib/remote-ui/analytics-contracts.js +8 -3
  10. package/dist/lib/remote-ui/analytics-contracts.js.map +1 -1
  11. package/dist/lib/remote-ui/app-source.js +130 -38
  12. package/dist/lib/remote-ui/app-source.js.map +1 -1
  13. package/dist/lib/remote-ui/model.d.ts.map +1 -1
  14. package/dist/lib/remote-ui/model.js +7 -43
  15. package/dist/lib/remote-ui/model.js.map +1 -1
  16. package/dist/lib/remote-ui/pages/analytics-charts.d.ts +12 -0
  17. package/dist/lib/remote-ui/pages/analytics-charts.d.ts.map +1 -0
  18. package/dist/lib/remote-ui/pages/analytics-charts.js +41 -0
  19. package/dist/lib/remote-ui/pages/analytics-charts.js.map +1 -0
  20. package/dist/lib/remote-ui/pages/analytics-dashboard.d.ts +11 -0
  21. package/dist/lib/remote-ui/pages/analytics-dashboard.d.ts.map +1 -0
  22. package/dist/lib/remote-ui/pages/analytics-dashboard.js +59 -0
  23. package/dist/lib/remote-ui/pages/analytics-dashboard.js.map +1 -0
  24. package/dist/lib/remote-ui/pages/analytics-detail-list.d.ts +13 -0
  25. package/dist/lib/remote-ui/pages/analytics-detail-list.d.ts.map +1 -0
  26. package/dist/lib/remote-ui/pages/analytics-detail-list.js +33 -0
  27. package/dist/lib/remote-ui/pages/analytics-detail-list.js.map +1 -0
  28. package/dist/lib/remote-ui/pages/analytics-format.d.ts +7 -0
  29. package/dist/lib/remote-ui/pages/analytics-format.d.ts.map +1 -0
  30. package/dist/lib/remote-ui/pages/analytics-format.js +31 -0
  31. package/dist/lib/remote-ui/pages/analytics-format.js.map +1 -0
  32. package/dist/lib/remote-ui/pages/analytics.d.ts +6 -9
  33. package/dist/lib/remote-ui/pages/analytics.d.ts.map +1 -1
  34. package/dist/lib/remote-ui/pages/analytics.js +77 -372
  35. package/dist/lib/remote-ui/pages/analytics.js.map +1 -1
  36. package/dist/lib/remote-ui/styles.css +158 -247
  37. package/dist/lib/runtime-validation.d.ts.map +1 -1
  38. package/dist/lib/runtime-validation.js +6 -35
  39. package/dist/lib/runtime-validation.js.map +1 -1
  40. package/dist/lib/sales-file.service.js +1 -1
  41. package/dist/lib/sales-file.service.js.map +1 -1
  42. package/dist/lib/schema-maintenance.d.ts +10 -0
  43. package/dist/lib/schema-maintenance.d.ts.map +1 -0
  44. package/dist/lib/schema-maintenance.js +34 -0
  45. package/dist/lib/schema-maintenance.js.map +1 -0
  46. package/dist/lib/types.d.ts +72 -63
  47. package/dist/lib/types.d.ts.map +1 -1
  48. package/dist/lib/types.js +2 -3
  49. package/dist/lib/types.js.map +1 -1
  50. package/dist/lib/workbench-view.provider.d.ts +12 -62
  51. package/dist/lib/workbench-view.provider.d.ts.map +1 -1
  52. package/dist/lib/workbench-view.provider.js +20 -4
  53. package/dist/lib/workbench-view.provider.js.map +1 -1
  54. package/dist/lib/workbench.middleware.d.ts +8 -8
  55. package/dist/lib/workbench.plugin.d.ts +3 -3
  56. package/dist/lib/workbench.plugin.d.ts.map +1 -1
  57. package/dist/lib/workbench.plugin.js +6 -6
  58. package/dist/lib/workbench.plugin.js.map +1 -1
  59. package/dist/lib/workbench.service.d.ts +4 -33
  60. package/dist/lib/workbench.service.d.ts.map +1 -1
  61. package/dist/lib/workbench.service.js +236 -265
  62. package/dist/lib/workbench.service.js.map +1 -1
  63. package/package.json +1 -1
  64. package/dist/lib/legacy-schema-cleanup.d.ts +0 -9
  65. package/dist/lib/legacy-schema-cleanup.d.ts.map +0 -1
  66. package/dist/lib/legacy-schema-cleanup.js +0 -28
  67. package/dist/lib/legacy-schema-cleanup.js.map +0 -1
  68. package/dist/lib/remote-ui/pages/analytics-prototype.css +0 -307
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
  import { finished } from 'node:stream/promises';
12
12
  import { randomUUID } from 'node:crypto';
13
13
  import { BankAccountSettings, CatalogReviewBlock, CompanySanctionMatch, CompanySettings, ControlledCatalogBatch, ControlledCatalogImportIssue, ControlledGoodsRecord, Customer, CustomerContract, CustomerContractAnalysisRun, CustomerContractClause, CustomerContractClauseAnalysis, DerivedSalesOrderManualData, GeneratedSalesFile, ImportTask, ManagedAsset, MonthlySafeExchangeRate, ProductComplianceMatch, ProfitRuleSettings, PurchaseOrder, PurchaseOrderLine, SanctionCatalogBatch, SanctionCatalogImportIssue, SanctionedCompanyAlias, SanctionedCompanyRecord, SettingsHistory, Supplier } from './entities/index.js';
14
- import { CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
14
+ import { AnalyticsComplianceCategoryKeys, CatalogTypes, ClauseTypes, CustomerProfitRateStatuses, DomainError, DomainErrorCodes, RefundValidationMethods, SalesPaymentStatuses, SanctionHitStatuses } from './types.js';
15
15
  import { deriveSalesLine, assertReceivedAmount } from './domain/finance.js';
16
16
  import { parseCustomerContractNo, parsePurchaseContractNo } from './domain/contract-number.js';
17
17
  import { isProductNameMatch, normalizeExactName, normalizeForProductMatch, normalizeHsCode, normalizeSanctionSource } from './domain/normalization.js';
@@ -28,7 +28,6 @@ import { catalogTerminalOutcome } from './catalog-import/outcome.js';
28
28
  const DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS = 50_000;
29
29
  const HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS = 100_000;
30
30
  const CUSTOMER_REANALYSIS_TIMEOUT_MS = 20 * 60 * 1000;
31
- const DEFAULT_ANALYTICS_EXPORT_MAX_ROWS = 100_000;
32
31
  const CONTROLLED_CATALOG_TRANSLATION_CONCURRENCY = 4;
33
32
  const CONTROLLED_CATALOG_TRANSLATION_TIMEOUT_MS = 60_000;
34
33
  const CONTROLLED_CATALOG_PROCESSING_CLAIM_LEASE_MS = 90_000;
@@ -3006,53 +3005,41 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3006
3005
  }
3007
3006
  async analytics(scope, query, source = this.dataSource) {
3008
3007
  const currentYear = shanghaiCurrentYear();
3009
- const statement = analyticsAggregationStatement(scope, query, currentYear, 'PAGE');
3010
- const rawRows = await source.query(statement.sql, statement.parameters);
3011
- const total = Number(rawRows[0]?.total ?? rawRows.length);
3012
- const maxRows = analyticsExportMaxRows();
3013
- if (total > maxRows)
3014
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析分组最多支持 ${maxRows} 条`, { total });
3015
- const pageCount = Math.max(1, Math.ceil(total / query.pageSize));
3016
- const page = Math.min(query.page, pageCount);
3017
- const pageStart = (page - 1) * query.pageSize + 1;
3018
- const pageEnd = pageStart + query.pageSize - 1;
3019
- const rankedRows = rawRows.map((row, index) => ({ row, rank: Number(row.rank ?? index + 1) }));
3020
- const details = rankedRows.filter(item => item.rank >= pageStart && item.rank <= pageEnd).map(item => analyticsDetailFromRaw(item.row, query));
3021
- const top5 = rankedRows.filter(item => item.rank <= 5).map(item => ({ key: String(item.row.key), label: String(item.row.label), amountCny: moneyString(item.row.amountCny) }));
3022
- const metrics = analyticsMetricsFromRaw(rawRows[0], query);
3023
- const dashboard = analyticsDashboardFromRaw(rawRows[0], metrics);
3024
- return { mode: query.mode, currentYear, dimension: query.dimension, metrics, top5, details: { items: details, page, pageSize: query.pageSize, total, pageCount }, dashboard };
3008
+ const statement = analyticsDashboardStatement(scope, query, currentYear);
3009
+ const rows = await source.query(statement.sql, statement.parameters);
3010
+ return { currentYear, dashboard: analyticsDashboardFromRaw(rows[0]) };
3011
+ }
3012
+ async analyticsDetails(scope, query, source = this.dataSource) {
3013
+ const statement = analyticsDetailStatement(scope, query, shanghaiCurrentYear());
3014
+ const rows = await source.query(statement.sql, statement.parameters);
3015
+ const total = numberValue(rows[0]?.total);
3016
+ return {
3017
+ items: rows.filter(row => row.id != null).map(analyticsBusinessDetailFromRaw),
3018
+ total,
3019
+ page: query.page,
3020
+ pageSize: query.pageSize,
3021
+ pageCount: Math.max(1, Math.ceil(total / query.pageSize))
3022
+ };
3025
3023
  }
3026
3024
  async exportAnalytics(scope, query) {
3027
- const title = query.mode === 'PURCHASE' ? '采购分析' : '销售分析';
3028
- const subject = query.dimension === 'SUPPLIER' ? '供应商' : query.dimension === 'CUSTOMER' ? '客户' : '商品名称';
3029
- const headers = query.dimension === 'PRODUCT'
3030
- ? [subject, '订单数', '商品数量', '商品明细金额 CNY']
3031
- : query.mode === 'PURCHASE'
3032
- ? [subject, '订单数', '订单总额 CNY', '已付款 CNY', '未付款 CNY', '已退税 CNY', '未退税 CNY']
3033
- : [subject, '订单数', '订单总额 CNY', '已收款 CNY', '未收款 CNY'];
3025
+ const title = '数据分析驾驶舱';
3034
3026
  const directory = await mkdtemp(join(tmpdir(), 'tcw-analytics-'));
3035
3027
  const filePath = join(directory, `${title}.xlsx`);
3036
3028
  try {
3029
+ const { dashboard } = await this.analytics(scope, query);
3037
3030
  await writeWorkbookFile(filePath, async (workbook) => {
3038
- const sheet = workbook.addWorksheet(title);
3039
- sheet.columns = headers.map(header => ({ header, width: 20 }));
3040
- sheet.getRow(1).font = { bold: true };
3041
- sheet.getRow(1).commit();
3042
- const statement = analyticsAggregationStatement(scope, query, shanghaiCurrentYear(), 'EXPORT');
3043
- const maxRows = analyticsExportMaxRows();
3044
- let count = 0;
3045
- await this.withAnalyticsSnapshot(statement, async (raw) => {
3046
- count += 1;
3047
- if (count > maxRows)
3048
- throw new DomainError(DomainErrorCodes.INVALID_INPUT, `分析导出最多支持 ${maxRows} 条明细`, { total: count });
3049
- const row = analyticsDetailFromRaw(raw, query);
3050
- sheet.addRow(query.dimension === 'PRODUCT'
3051
- ? [row.label, row.orderCount, row.productQuantity, row.amountCny]
3052
- : query.mode === 'PURCHASE'
3053
- ? [row.label, row.orderCount, row.amountCny, row.paidCny, row.unpaidCny, row.refundedCny, row.unrefundedCny]
3054
- : [row.label, row.orderCount, row.amountCny, row.receivedCny, row.unreceivedCny]).commit();
3055
- });
3031
+ const summary = workbook.addWorksheet('业务汇总');
3032
+ summary.columns = [{ header: '指标', width: 28 }, { header: '数值', width: 24 }];
3033
+ summary.getRow(1).font = { bold: true };
3034
+ summary.getRow(1).commit();
3035
+ for (const [label, value] of analyticsKpiExportRows(dashboard))
3036
+ summary.addRow([label, value]).commit();
3037
+ const compliance = workbook.addWorksheet('合规分类');
3038
+ compliance.columns = [{ header: '分类', width: 28 }, { header: '数量', width: 16 }];
3039
+ compliance.getRow(1).font = { bold: true };
3040
+ compliance.getRow(1).commit();
3041
+ for (const item of dashboard.riskDistribution)
3042
+ compliance.addRow([item.label, item.count]).commit();
3056
3043
  });
3057
3044
  return { fileName: `${title}.xlsx`, buffer: await readFile(filePath) };
3058
3045
  }
@@ -3060,32 +3047,6 @@ let TradeComplianceWorkbenchService = class TradeComplianceWorkbenchService {
3060
3047
  await rm(directory, { recursive: true, force: true });
3061
3048
  }
3062
3049
  }
3063
- async withAnalyticsSnapshot(statement, consume) {
3064
- if (typeof this.dataSource.createQueryRunner === 'function') {
3065
- const runner = this.dataSource.createQueryRunner();
3066
- await runner.connect();
3067
- await runner.startTransaction('REPEATABLE READ');
3068
- try {
3069
- const stream = await runner.stream(statement.sql, statement.parameters);
3070
- for await (const row of stream)
3071
- await consume(row);
3072
- await runner.commitTransaction();
3073
- }
3074
- catch (error) {
3075
- await runner.rollbackTransaction();
3076
- throw error;
3077
- }
3078
- finally {
3079
- await runner.release();
3080
- }
3081
- return;
3082
- }
3083
- await this.dataSource.transaction('REPEATABLE READ', async (manager) => {
3084
- const rows = await manager.query(statement.sql, statement.parameters);
3085
- for (const row of rows)
3086
- await consume(row);
3087
- });
3088
- }
3089
3050
  async recalculateProductRisks(scope, reason, controlledBatchId) {
3090
3051
  return this.dataSource.transaction(async (manager) => {
3091
3052
  const matchRepo = manager.getRepository(ProductComplianceMatch);
@@ -3644,7 +3605,6 @@ function reanalysisClauseInput(value) {
3644
3605
  return { clauseType: clause.clauseType, extractionStatus: clause.extractionStatus, sourceText: optionalText('sourceText'), reviewedText: optionalText('reviewedText'), sourceLocation: optionalText('sourceLocation'), sourceContent: optionalText('sourceContent') };
3645
3606
  }
3646
3607
  function importIssueExportLimit() { const configured = Number(process.env.TRADE_COMPLIANCE_IMPORT_ISSUE_EXPORT_MAX_ROWS); return Math.min(HARD_IMPORT_ISSUE_EXPORT_MAX_ROWS, Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : DEFAULT_IMPORT_ISSUE_EXPORT_MAX_ROWS); }
3647
- function analyticsExportMaxRows() { const configured = Number(process.env.TRADE_COMPLIANCE_ANALYTICS_EXPORT_MAX_ROWS); return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_ANALYTICS_EXPORT_MAX_ROWS; }
3648
3608
  function hasQueryValue(value) { return value !== undefined && value !== null && String(value).trim() !== ''; }
3649
3609
  function generatedFileCustomerName(snapshot) {
3650
3610
  const customer = snapshot?.customer;
@@ -3950,9 +3910,9 @@ function shanghaiCurrentYear(now = new Date()) {
3950
3910
  function currentYearDateRange(currentYear, requested) {
3951
3911
  const yearFrom = `${currentYear}-01-01`;
3952
3912
  const yearTo = `${currentYear}-12-31`;
3953
- return { from: requested?.from && requested.from > yearFrom ? requested.from : yearFrom, to: requested?.to && requested.to < yearTo ? requested.to : yearTo };
3913
+ return { from: requested?.from ?? yearFrom, to: requested?.to ?? yearTo };
3954
3914
  }
3955
- function analyticsAggregationStatement(scope, query, currentYear, purpose) {
3915
+ function analyticsDashboardStatement(scope, query, currentYear) {
3956
3916
  const parameters = [];
3957
3917
  const parameter = (value) => { parameters.push(value); return `$${parameters.length}`; };
3958
3918
  const tenant = parameter(scope.tenantId);
@@ -3960,114 +3920,17 @@ function analyticsAggregationStatement(scope, query, currentYear, purpose) {
3960
3920
  const range = currentYearDateRange(currentYear, query.filters.orderDate);
3961
3921
  const from = parameter(range.from);
3962
3922
  const to = parameter(range.to);
3963
- const fuzzy = (expression, value) => value ? `${expression} ILIKE ${parameter(`%${value.trim()}%`)}` : null;
3964
- const lineConditions = (alias) => [
3965
- `${alias}."tenantId" = ${tenant}`,
3966
- `${alias}."organizationId" = ${organization}`,
3967
- `${alias}."deletedAt" IS NULL`,
3968
- fuzzy(`${alias}."productName"`, query.filters.productName),
3969
- fuzzy(`${alias}."hsCode"`, query.filters.hsCode)
3970
- ].filter((condition) => Boolean(condition));
3971
- let sourceSql;
3972
- if (query.mode === 'PURCHASE') {
3973
- const expectedRefund = `(SELECT COALESCE(SUM(refund_line."lineAmountTaxIncluded" / (1 + COALESCE(refund_line."vatRate", 0)) * COALESCE(refund_line."refundRate", 0)), 0) FROM plugin_trade_compliance_purchase_order_line refund_line WHERE refund_line."purchaseOrderId" = purchase.id AND refund_line."tenantId" = purchase."tenantId" AND refund_line."organizationId" = purchase."organizationId" AND refund_line."deletedAt" IS NULL)`;
3974
- const orderConditions = [
3975
- `purchase."tenantId" = ${tenant}`,
3976
- `purchase."organizationId" = ${organization}`,
3977
- 'purchase."deletedAt" IS NULL',
3978
- `purchase."orderDate" BETWEEN ${from} AND ${to}`,
3979
- fuzzy('supplier.name', query.filters.supplier),
3980
- query.filters.unpaidOnly ? 'GREATEST(purchase."contractAmountCny" - purchase."paidAmountCny", 0) > 0' : null,
3981
- query.filters.unrefundedOnly ? `GREATEST(${expectedRefund} - purchase."refundedAmountCny", 0) > 0` : null,
3982
- `EXISTS (SELECT 1 FROM plugin_trade_compliance_purchase_order_line matched_line WHERE matched_line."purchaseOrderId" = purchase.id AND ${lineConditions('matched_line').join(' AND ')})`
3983
- ].filter((condition) => Boolean(condition));
3984
- const orders = `purchase_orders AS (
3985
- SELECT purchase.id, purchase."supplierId", supplier.name AS "subjectLabel",
3986
- purchase."contractAmountCny" AS "amountCny", purchase."paidAmountCny" AS "paidCny",
3987
- GREATEST(purchase."contractAmountCny" - purchase."paidAmountCny", 0) AS "unpaidCny",
3988
- purchase."refundedAmountCny" AS "refundedCny", GREATEST(${expectedRefund} - purchase."refundedAmountCny", 0) AS "unrefundedCny"
3989
- FROM plugin_trade_compliance_purchase_order purchase
3990
- INNER JOIN plugin_trade_compliance_supplier supplier ON supplier.id = purchase."supplierId" AND supplier."tenantId" = purchase."tenantId" AND supplier."organizationId" = purchase."organizationId" AND supplier."deletedAt" IS NULL
3991
- WHERE ${orderConditions.join(' AND ')}
3992
- )`;
3993
- sourceSql = query.dimension === 'PRODUCT'
3994
- ? `${orders}, grouped AS (
3995
- SELECT LOWER(BTRIM(line."productName")) AS key, MIN(BTRIM(line."productName")) AS label,
3996
- COUNT(DISTINCT orders.id)::bigint AS "orderCount", SUM(line.quantity) AS "productQuantity", SUM(line."lineAmountTaxIncluded") AS "amountCny"
3997
- FROM purchase_orders orders
3998
- INNER JOIN plugin_trade_compliance_purchase_order_line line ON line."purchaseOrderId" = orders.id AND ${lineConditions('line').join(' AND ')}
3999
- GROUP BY LOWER(BTRIM(line."productName"))
4000
- )`
4001
- : `${orders}, grouped AS (
4002
- SELECT orders."supplierId"::text AS key, MIN(orders."subjectLabel") AS label, COUNT(DISTINCT orders.id)::bigint AS "orderCount",
4003
- SUM(orders."amountCny") AS "amountCny", SUM(orders."paidCny") AS "paidCny", SUM(orders."unpaidCny") AS "unpaidCny",
4004
- SUM(orders."refundedCny") AS "refundedCny", SUM(orders."unrefundedCny") AS "unrefundedCny"
4005
- FROM purchase_orders orders GROUP BY orders."supplierId"
4006
- )`;
4007
- }
4008
- else {
4009
- const effectiveRate = 'COALESCE(manual."pricingProfitRateOverride", customer."defaultPricingProfitRate")';
4010
- const lineAmount = (alias) => `ROUND(ROUND(${alias}."unitPriceTaxIncluded" * (1 + ${effectiveRate}), 0) * ${alias}.quantity, 0)`;
4011
- const orderAmount = `(SELECT COALESCE(SUM(${lineAmount('sales_line')}), 0) FROM plugin_trade_compliance_purchase_order_line sales_line WHERE sales_line."purchaseOrderId" = purchase.id AND sales_line."tenantId" = purchase."tenantId" AND sales_line."organizationId" = purchase."organizationId" AND sales_line."deletedAt" IS NULL)`;
4012
- const orderConditions = [
4013
- `contract."tenantId" = ${tenant}`,
4014
- `contract."organizationId" = ${organization}`,
4015
- 'contract."deletedAt" IS NULL',
4016
- `purchase."orderDate" BETWEEN ${from} AND ${to}`,
4017
- `(manual."pricingProfitRateOverride" IS NOT NULL OR (customer."pricingProfitRateStatus" = 'CONFIGURED' AND customer."defaultPricingProfitRate" IS NOT NULL))`,
4018
- fuzzy('customer.name', query.filters.customer),
4019
- query.filters.unreceivedOnly ? `GREATEST(${orderAmount} - COALESCE(manual."receivedAmountCny", 0), 0) > 0` : null,
4020
- `EXISTS (SELECT 1 FROM plugin_trade_compliance_purchase_order_line matched_line WHERE matched_line."purchaseOrderId" = purchase.id AND ${lineConditions('matched_line').join(' AND ')})`
4021
- ].filter((condition) => Boolean(condition));
4022
- const orders = `sales_orders AS (
4023
- SELECT contract.id AS "customerContractId", purchase.id AS "purchaseOrderId", customer.id AS "customerId", customer.name AS "subjectLabel", ${effectiveRate} AS "effectiveRate",
4024
- ${orderAmount} AS "amountCny", COALESCE(manual."receivedAmountCny", 0) AS "receivedCny",
4025
- GREATEST(${orderAmount} - COALESCE(manual."receivedAmountCny", 0), 0) AS "unreceivedCny"
4026
- FROM plugin_trade_compliance_customer_contract contract
4027
- INNER JOIN plugin_trade_compliance_customer customer ON customer.id = contract."customerId" AND customer."tenantId" = contract."tenantId" AND customer."organizationId" = contract."organizationId" AND customer."deletedAt" IS NULL
4028
- INNER JOIN plugin_trade_compliance_purchase_order purchase ON purchase."tenantId" = contract."tenantId" AND purchase."organizationId" = contract."organizationId" AND purchase."deletedAt" IS NULL AND contract."normalizedContractNo" = regexp_replace(purchase."normalizedContractNo", '(-[0-9]+)?C$', '') || 'X'
4029
- LEFT JOIN plugin_trade_compliance_derived_sales_manual manual ON manual."customerContractId" = contract.id AND manual."purchaseOrderId" = purchase.id AND manual."tenantId" = contract."tenantId" AND manual."organizationId" = contract."organizationId" AND manual."deletedAt" IS NULL
4030
- WHERE ${orderConditions.join(' AND ')}
4031
- )`;
4032
- sourceSql = query.dimension === 'PRODUCT'
4033
- ? `${orders}, grouped AS (
4034
- SELECT LOWER(BTRIM(line."productName")) AS key, MIN(BTRIM(line."productName")) AS label,
4035
- COUNT(DISTINCT orders."customerContractId"::text || ':' || orders."purchaseOrderId"::text)::bigint AS "orderCount",
4036
- SUM(line.quantity) AS "productQuantity", SUM(ROUND(ROUND(line."unitPriceTaxIncluded" * (1 + orders."effectiveRate"), 0) * line.quantity, 0)) AS "amountCny"
4037
- FROM sales_orders orders
4038
- INNER JOIN plugin_trade_compliance_purchase_order_line line ON line."purchaseOrderId" = orders."purchaseOrderId" AND ${lineConditions('line').join(' AND ')}
4039
- GROUP BY LOWER(BTRIM(line."productName"))
4040
- )`
4041
- : `${orders}, grouped AS (
4042
- SELECT orders."customerId"::text AS key, MIN(orders."subjectLabel") AS label,
4043
- COUNT(DISTINCT orders."customerContractId"::text || ':' || orders."purchaseOrderId"::text)::bigint AS "orderCount",
4044
- SUM(orders."amountCny") AS "amountCny", SUM(orders."receivedCny") AS "receivedCny", SUM(orders."unreceivedCny") AS "unreceivedCny"
4045
- FROM sales_orders orders GROUP BY orders."customerId"
4046
- )`;
4047
- }
4048
- const bounded = `${sourceSql}, bounded AS (SELECT * FROM grouped ORDER BY "amountCny" DESC, key ASC LIMIT ${analyticsExportMaxRows() + 1})`;
4049
- if (purpose === 'EXPORT')
4050
- return { sql: `WITH ${bounded} SELECT * FROM bounded ORDER BY "amountCny" DESC, key ASC`, parameters };
4051
- const metricColumns = query.dimension === 'PRODUCT'
4052
- ? 'SUM("amountCny") OVER () AS "metricAmountCny"'
4053
- : query.mode === 'PURCHASE'
4054
- ? 'SUM("amountCny") OVER () AS "metricAmountCny", SUM("paidCny") OVER () AS "metricPaidCny", SUM("unpaidCny") OVER () AS "metricUnpaidCny", SUM("refundedCny") OVER () AS "metricRefundedCny", SUM("unrefundedCny") OVER () AS "metricUnrefundedCny"'
4055
- : 'SUM("amountCny") OVER () AS "metricAmountCny", SUM("receivedCny") OVER () AS "metricReceivedCny", SUM("unreceivedCny") OVER () AS "metricUnreceivedCny"';
4056
- const dashboardJson = analyticsDashboardJsonSql(query, tenant, organization, from, to);
4057
- const start = (query.page - 1) * query.pageSize + 1;
4058
- const end = start + query.pageSize - 1;
4059
- return {
4060
- sql: `WITH ${bounded}, ranked AS (SELECT bounded.*, ROW_NUMBER() OVER (ORDER BY "amountCny" DESC, key ASC) AS rank, COUNT(*) OVER () AS total, ${metricColumns} FROM bounded) SELECT ranked.*, ${dashboardJson} AS "dashboardJson" FROM ranked WHERE rank <= 5 OR rank BETWEEN ${parameter(start)} AND ${parameter(end)} ORDER BY rank`,
4061
- parameters
4062
- };
3923
+ return { sql: `SELECT ${analyticsDashboardJsonSql(tenant, organization, from, to)} AS "dashboardJson"`, parameters };
4063
3924
  }
4064
- function analyticsDashboardJsonSql(query, tenant, organization, from, to) {
4065
- void query;
4066
- const customerContractReviewCount = `(SELECT COUNT(*) FROM plugin_trade_compliance_import_task task WHERE task."tenantId" = ${tenant} AND task."organizationId" = ${organization} AND task."deletedAt" IS NULL AND task."documentType" = 'CUSTOMER_CONTRACT' AND task.status = 'AWAITING_REVIEW')`;
4067
- const purchaseContractReviewCount = `(SELECT COUNT(*) FROM plugin_trade_compliance_import_task task WHERE task."tenantId" = ${tenant} AND task."organizationId" = ${organization} AND task."deletedAt" IS NULL AND task."documentType" = 'PURCHASE_CONTRACT' AND task.status = 'AWAITING_REVIEW')`;
4068
- const productRiskCount = `(SELECT COUNT(DISTINCT match."purchaseOrderLineId") FROM plugin_trade_compliance_product_match match INNER JOIN plugin_trade_compliance_purchase_order_line line ON line.id = match."purchaseOrderLineId" AND line."tenantId" = match."tenantId" AND line."organizationId" = match."organizationId" AND line."deletedAt" IS NULL INNER JOIN plugin_trade_compliance_purchase_order purchase ON purchase.id = line."purchaseOrderId" AND purchase."tenantId" = line."tenantId" AND purchase."organizationId" = line."organizationId" AND purchase."deletedAt" IS NULL AND purchase."orderDate" BETWEEN ${from} AND ${to} WHERE match."tenantId" = ${tenant} AND match."organizationId" = ${organization} AND match."deletedAt" IS NULL)`;
4069
- const sanctionHitCount = `(SELECT COUNT(*) FROM plugin_trade_compliance_company_sanction_match match WHERE match."tenantId" = ${tenant} AND match."organizationId" = ${organization} AND match."deletedAt" IS NULL)`;
4070
- const salesFileReviewCount = `(SELECT COUNT(*) FROM plugin_trade_compliance_generated_sales_file file WHERE file."tenantId" = ${tenant} AND file."organizationId" = ${organization} AND file."deletedAt" IS NULL AND file."lastEditedAt" IS NULL)`;
3925
+ function analyticsDashboardJsonSql(tenant, organization, from, to) {
3926
+ const complianceDetails = analyticsComplianceDetailSql(tenant, organization, from, to);
3927
+ const categoryCount = (key) => `(SELECT COUNT(*) FROM compliance_details WHERE "categoryKey" = '${key}')`;
3928
+ const categoryAmount = (key) => `(SELECT COALESCE(SUM("amountCny"), 0) FROM compliance_details WHERE "categoryKey" = '${key}')`;
3929
+ const customerContractReviewCount = categoryCount('customer-contract-review');
3930
+ const purchaseContractReviewCount = categoryCount('purchase-contract-review');
3931
+ const productRiskCount = categoryCount('product-risk-review');
3932
+ const sanctionHitCount = categoryCount('sanction-review');
3933
+ const salesFileReviewCount = categoryCount('sales-file-review');
4071
3934
  const complianceTodoCount = `(${customerContractReviewCount} + ${purchaseContractReviewCount} + ${productRiskCount} + ${sanctionHitCount} + ${salesFileReviewCount})`;
4072
3935
  const monthlyTrend = `(SELECT COALESCE(jsonb_agg(jsonb_build_object(
4073
3936
  'month', monthly.month_no,
@@ -4102,7 +3965,7 @@ function analyticsDashboardJsonSql(query, tenant, organization, from, to) {
4102
3965
  GROUP BY EXTRACT(MONTH FROM purchase."orderDate")::int
4103
3966
  ) sales_month USING (month_no)
4104
3967
  ) monthly)`;
4105
- return `(WITH purchase_scope AS (
3968
+ return `(WITH compliance_details AS (${complianceDetails}), purchase_scope AS (
4106
3969
  SELECT purchase.id, purchase."supplierId", supplier.name AS "supplierName", purchase."contractNo", purchase."orderDate",
4107
3970
  purchase."contractAmountCny" AS "purchaseTotalCny", purchase."paidAmountCny" AS "paidCny",
4108
3971
  GREATEST(purchase."contractAmountCny" - purchase."paidAmountCny", 0) AS "unpaidCny",
@@ -4132,16 +3995,24 @@ function analyticsDashboardJsonSql(query, tenant, organization, from, to) {
4132
3995
  ), global_sales AS (
4133
3996
  SELECT COALESCE(SUM("salesTotalCny"), 0) AS "salesTotalCny", COALESCE(SUM("receivedCny"), 0) AS "receivedCny", COALESCE(SUM("unreceivedCny"), 0) AS "unreceivedCny", COALESCE(SUM("estimatedGrossProfitCny"), 0) AS "estimatedGrossProfitCny"
4134
3997
  FROM sales_scope
4135
- ), customer_top AS (
3998
+ ), customer_rollup AS (
4136
3999
  SELECT "customerId", MIN("customerName") AS "customerName", SUM("salesTotalCny") AS "salesAmountCny", SUM("estimatedGrossProfitCny") AS "estimatedGrossProfitCny", SUM("unreceivedCny") AS "unreceivedCny", COUNT(*)::bigint AS "orderCount", MAX("orderDate") AS "latestOrderDate"
4137
- FROM sales_scope GROUP BY "customerId" ORDER BY SUM("salesTotalCny") DESC LIMIT 5
4000
+ FROM sales_scope GROUP BY "customerId"
4001
+ ), customer_value_top AS (
4002
+ SELECT * FROM customer_rollup ORDER BY "salesAmountCny" DESC LIMIT 5
4003
+ ), receivable_top AS (
4004
+ SELECT * FROM customer_rollup WHERE "unreceivedCny" > 0 ORDER BY "unreceivedCny" DESC LIMIT 5
4138
4005
  ), supplier_top AS (
4139
- SELECT purchase_scope."supplierId", MIN(purchase_scope."supplierName") AS "supplierName", SUM(purchase_scope."unpaidCny") AS "unpaidCny", COUNT(*)::bigint AS "purchaseOrderCount",
4140
- COUNT(DISTINCT product_match.id)::bigint AS "productRiskHitCount", COUNT(DISTINCT sanction_match.id)::bigint AS "sanctionHitCount"
4006
+ SELECT purchase_scope."supplierId", MIN(purchase_scope."supplierName") AS "supplierName", SUM(purchase_scope."unpaidCny") AS "unpaidCny", COUNT(DISTINCT purchase_scope.id)::bigint AS "purchaseOrderCount",
4007
+ (SELECT COUNT(DISTINCT product_match.id) FROM plugin_trade_compliance_purchase_order risk_purchase
4008
+ INNER JOIN plugin_trade_compliance_purchase_order_line risk_line ON risk_line."purchaseOrderId" = risk_purchase.id AND risk_line."tenantId" = risk_purchase."tenantId" AND risk_line."organizationId" = risk_purchase."organizationId" AND risk_line."deletedAt" IS NULL
4009
+ INNER JOIN plugin_trade_compliance_product_match product_match ON product_match."purchaseOrderLineId" = risk_line.id AND product_match."tenantId" = risk_line."tenantId" AND product_match."organizationId" = risk_line."organizationId" AND product_match."deletedAt" IS NULL
4010
+ INNER JOIN plugin_trade_compliance_controlled_goods goods ON goods.id = product_match."controlledGoodsId" AND goods."tenantId" = product_match."tenantId" AND goods."organizationId" = product_match."organizationId" AND goods."deletedAt" IS NULL
4011
+ WHERE risk_purchase."tenantId" = ${tenant} AND risk_purchase."organizationId" = ${organization} AND risk_purchase."deletedAt" IS NULL AND risk_purchase."supplierId" = purchase_scope."supplierId" AND risk_purchase."orderDate" BETWEEN ${from} AND ${to})::bigint AS "productRiskHitCount",
4012
+ (SELECT COUNT(DISTINCT sanction_match.id) FROM plugin_trade_compliance_company_sanction_match sanction_match
4013
+ INNER JOIN plugin_trade_compliance_sanctioned_company company ON company.id = sanction_match."sanctionedCompanyId" AND company."tenantId" = sanction_match."tenantId" AND company."organizationId" = sanction_match."organizationId" AND company."deletedAt" IS NULL
4014
+ WHERE sanction_match."tenantId" = ${tenant} AND sanction_match."organizationId" = ${organization} AND sanction_match."deletedAt" IS NULL AND sanction_match."subjectType" = 'SUPPLIER' AND sanction_match."supplierId" = purchase_scope."supplierId")::bigint AS "sanctionHitCount"
4141
4015
  FROM purchase_scope
4142
- LEFT JOIN plugin_trade_compliance_purchase_order_line line ON line."purchaseOrderId" = purchase_scope.id AND line."tenantId" = ${tenant} AND line."organizationId" = ${organization} AND line."deletedAt" IS NULL
4143
- LEFT JOIN plugin_trade_compliance_product_match product_match ON product_match."purchaseOrderLineId" = line.id AND product_match."tenantId" = ${tenant} AND product_match."organizationId" = ${organization} AND product_match."deletedAt" IS NULL
4144
- LEFT JOIN plugin_trade_compliance_company_sanction_match sanction_match ON sanction_match."supplierId" = purchase_scope."supplierId" AND sanction_match."tenantId" = ${tenant} AND sanction_match."organizationId" = ${organization} AND sanction_match."deletedAt" IS NULL
4145
4016
  GROUP BY purchase_scope."supplierId" ORDER BY SUM(purchase_scope."unpaidCny") DESC LIMIT 5
4146
4017
  )
4147
4018
  SELECT jsonb_build_object(
@@ -4152,28 +4023,27 @@ function analyticsDashboardJsonSql(query, tenant, organization, from, to) {
4152
4023
  'estimatedGrossProfitCny', (SELECT "estimatedGrossProfitCny" FROM global_sales),
4153
4024
  'estimatedGrossProfitRate', CASE WHEN (SELECT "salesTotalCny" FROM global_sales) > 0 THEN (SELECT "estimatedGrossProfitCny" FROM global_sales) / (SELECT "salesTotalCny" FROM global_sales) ELSE 0 END,
4154
4025
  'complianceTodoCount', ${complianceTodoCount},
4155
- 'riskExposureCny', ((SELECT "unreceivedCny" FROM global_sales) + (SELECT "unpaidCny" FROM global_purchase) + (SELECT "unrefundedCny" FROM global_purchase))
4026
+ 'riskExposureCny', (SELECT COALESCE(SUM("amountCny"), 0) FROM compliance_details)
4156
4027
  ),
4157
4028
  'monthlyTrend', ${monthlyTrend},
4158
4029
  'todos', jsonb_build_array(
4159
- jsonb_build_object('key', 'customer-contract-review', 'label', '客户合同待审核', 'count', ${customerContractReviewCount}, 'priority', 'high', 'description', 'AI 解析完成但未人工确认的客户合同'),
4160
- jsonb_build_object('key', 'purchase-contract-review', 'label', '采购合同待审核', 'count', ${purchaseContractReviewCount}, 'priority', 'high', 'description', '上传完成但未审核入库的采购合同'),
4161
- jsonb_build_object('key', 'product-risk-review', 'label', '商品风险待确认', 'count', ${productRiskCount}, 'priority', 'medium', 'description', '命中管控商品目录的商品明细'),
4162
- jsonb_build_object('key', 'sanction-review', 'label', '制裁公司待复核', 'count', ${sanctionHitCount}, 'priority', 'high', 'description', '客户或供应商命中公司制裁目录'),
4163
- jsonb_build_object('key', 'sales-file-review', 'label', '销售文件待复核', 'count', ${salesFileReviewCount}, 'priority', 'medium', 'description', '生成后尚未在线审核确认的销售文件')
4030
+ jsonb_build_object('key', 'customer-contract-review', 'label', '客户合同待审核', 'count', ${customerContractReviewCount}, 'amountCny', ${categoryAmount('customer-contract-review')}, 'priority', 'high', 'description', 'AI 解析完成但未人工确认的客户合同'),
4031
+ jsonb_build_object('key', 'purchase-contract-review', 'label', '采购合同待审核', 'count', ${purchaseContractReviewCount}, 'amountCny', ${categoryAmount('purchase-contract-review')}, 'priority', 'high', 'description', '上传完成但未审核入库的采购合同'),
4032
+ jsonb_build_object('key', 'product-risk-review', 'label', '商品风险待确认', 'count', ${productRiskCount}, 'amountCny', ${categoryAmount('product-risk-review')}, 'priority', 'medium', 'description', '命中管控商品目录的商品明细'),
4033
+ jsonb_build_object('key', 'sanction-review', 'label', '制裁公司待复核', 'count', ${sanctionHitCount}, 'amountCny', ${categoryAmount('sanction-review')}, 'priority', 'high', 'description', '客户或供应商命中公司制裁目录'),
4034
+ jsonb_build_object('key', 'sales-file-review', 'label', '销售文件待复核', 'count', ${salesFileReviewCount}, 'amountCny', ${categoryAmount('sales-file-review')}, 'priority', 'medium', 'description', '生成时记录了真实业务异常的销售文件')
4164
4035
  ),
4165
4036
  'riskDistribution', jsonb_build_array(
4166
- jsonb_build_object('key', 'product-risk', 'label', '管控商品命中', 'count', ${productRiskCount}),
4167
- jsonb_build_object('key', 'sanction-hit', 'label', '制裁公司命中', 'count', ${sanctionHitCount}),
4168
- jsonb_build_object('key', 'customer-contract-review', 'label', '客户合同待审核', 'count', ${customerContractReviewCount}),
4169
- jsonb_build_object('key', 'purchase-contract-review', 'label', '采购合同待审核', 'count', ${purchaseContractReviewCount}),
4170
- jsonb_build_object('key', 'sales-file-review', 'label', '销售文件待复核', 'count', ${salesFileReviewCount}),
4171
- jsonb_build_object('key', 'capital-risk', 'label', '未收/未付资金风险', 'count', (SELECT COUNT(*) FROM sales_scope WHERE "unreceivedCny" > 0) + (SELECT COUNT(*) FROM purchase_scope WHERE "unpaidCny" > 0), 'amountCny', ((SELECT "unreceivedCny" FROM global_sales) + (SELECT "unpaidCny" FROM global_purchase)))
4037
+ jsonb_build_object('key', 'customer-contract-review', 'label', '客户合同待审核', 'count', ${customerContractReviewCount}, 'amountCny', ${categoryAmount('customer-contract-review')}),
4038
+ jsonb_build_object('key', 'purchase-contract-review', 'label', '采购合同待审核', 'count', ${purchaseContractReviewCount}, 'amountCny', ${categoryAmount('purchase-contract-review')}),
4039
+ jsonb_build_object('key', 'product-risk-review', 'label', '商品风险待确认', 'count', ${productRiskCount}, 'amountCny', ${categoryAmount('product-risk-review')}),
4040
+ jsonb_build_object('key', 'sanction-review', 'label', '制裁公司待复核', 'count', ${sanctionHitCount}, 'amountCny', ${categoryAmount('sanction-review')}),
4041
+ jsonb_build_object('key', 'sales-file-review', 'label', '销售文件待复核', 'count', ${salesFileReviewCount}, 'amountCny', ${categoryAmount('sales-file-review')})
4172
4042
  ),
4173
- 'customerValueTop5', (SELECT COALESCE(jsonb_agg(jsonb_build_object('customerId', customer_top."customerId", 'customerName', customer_top."customerName", 'salesAmountCny', customer_top."salesAmountCny", 'estimatedGrossProfitCny', customer_top."estimatedGrossProfitCny", 'estimatedGrossProfitRate', CASE WHEN customer_top."salesAmountCny" > 0 THEN customer_top."estimatedGrossProfitCny" / customer_top."salesAmountCny" ELSE 0 END, 'unreceivedCny', customer_top."unreceivedCny", 'riskStatus', CASE WHEN customer_top."unreceivedCny" >= 100000 THEN 'HIGH' WHEN customer_top."unreceivedCny" > 0 THEN 'MEDIUM' ELSE 'LOW' END) ORDER BY customer_top."salesAmountCny" DESC), '[]'::jsonb) FROM customer_top),
4174
- 'receivableRiskTop5', (SELECT COALESCE(jsonb_agg(jsonb_build_object('customerId', customer_top."customerId", 'customerName', customer_top."customerName", 'unreceivedCny', customer_top."unreceivedCny", 'orderCount', customer_top."orderCount", 'latestOrderDate', customer_top."latestOrderDate") ORDER BY customer_top."unreceivedCny" DESC), '[]'::jsonb) FROM customer_top WHERE customer_top."unreceivedCny" > 0),
4043
+ 'customerValueTop5', (SELECT COALESCE(jsonb_agg(jsonb_build_object('customerId', customer_value_top."customerId", 'customerName', customer_value_top."customerName", 'salesAmountCny', customer_value_top."salesAmountCny", 'estimatedGrossProfitCny', customer_value_top."estimatedGrossProfitCny", 'estimatedGrossProfitRate', CASE WHEN customer_value_top."salesAmountCny" > 0 THEN customer_value_top."estimatedGrossProfitCny" / customer_value_top."salesAmountCny" ELSE 0 END, 'unreceivedCny', customer_value_top."unreceivedCny", 'riskStatus', CASE WHEN customer_value_top."unreceivedCny" >= 100000 THEN 'HIGH' WHEN customer_value_top."unreceivedCny" > 0 THEN 'MEDIUM' ELSE 'LOW' END) ORDER BY customer_value_top."salesAmountCny" DESC), '[]'::jsonb) FROM customer_value_top),
4044
+ 'receivableRiskTop5', (SELECT COALESCE(jsonb_agg(jsonb_build_object('customerId', receivable_top."customerId", 'customerName', receivable_top."customerName", 'unreceivedCny', receivable_top."unreceivedCny", 'orderCount', receivable_top."orderCount", 'latestOrderDate', receivable_top."latestOrderDate") ORDER BY receivable_top."unreceivedCny" DESC), '[]'::jsonb) FROM receivable_top),
4175
4045
  'supplierRiskTop5', (SELECT COALESCE(jsonb_agg(jsonb_build_object('supplierId', supplier_top."supplierId", 'supplierName', supplier_top."supplierName", 'unpaidCny', supplier_top."unpaidCny", 'sanctionHitCount', supplier_top."sanctionHitCount", 'productRiskHitCount', supplier_top."productRiskHitCount", 'purchaseOrderCount', supplier_top."purchaseOrderCount") ORDER BY supplier_top."unpaidCny" DESC), '[]'::jsonb) FROM supplier_top WHERE supplier_top."unpaidCny" > 0 OR supplier_top."sanctionHitCount" > 0 OR supplier_top."productRiskHitCount" > 0),
4176
- 'salesFileReviews', (SELECT COALESCE(jsonb_agg(jsonb_build_object('fileId', file.id, 'fileName', file."fileName", 'fileType', file."fileType", 'salesOrderNo', file."salesOrderNoSnapshot", 'reason', '生成后尚未在线审核确认', 'generatedAt', file."generatedAt") ORDER BY file."generatedAt" DESC), '[]'::jsonb) FROM (SELECT * FROM plugin_trade_compliance_generated_sales_file file WHERE file."tenantId" = ${tenant} AND file."organizationId" = ${organization} AND file."deletedAt" IS NULL AND file."lastEditedAt" IS NULL ORDER BY file."generatedAt" DESC LIMIT 5) file),
4046
+ 'salesFileReviews', (SELECT COALESCE(jsonb_agg(jsonb_build_object('fileId', file.id, 'fileName', file."fileName", 'fileType', file."fileType", 'salesOrderNo', file."salesOrderNoSnapshot", 'reason', file.reason, 'generatedAt', file."generatedAt") ORDER BY file."generatedAt" DESC), '[]'::jsonb) FROM (SELECT source.*, (SELECT COALESCE(NULLIF(warning->>'message', ''), NULLIF(warning->>'reason', ''), NULLIF(warning->>'code', '')) FROM jsonb_array_elements(source."generationWarnings") warning WHERE COALESCE(NULLIF(warning->>'message', ''), NULLIF(warning->>'reason', ''), NULLIF(warning->>'code', '')) IS NOT NULL LIMIT 1) AS reason FROM plugin_trade_compliance_generated_sales_file source WHERE source."tenantId" = ${tenant} AND source."organizationId" = ${organization} AND source."deletedAt" IS NULL AND source."reviewedAt" IS NULL AND source."generatedAt"::date BETWEEN ${from} AND ${to} AND jsonb_typeof(source."generationWarnings") = 'array' AND jsonb_array_length(source."generationWarnings") > 0 AND EXISTS (SELECT 1 FROM jsonb_array_elements(source."generationWarnings") warning WHERE COALESCE(NULLIF(warning->>'message', ''), NULLIF(warning->>'reason', ''), NULLIF(warning->>'code', '')) IS NOT NULL) ORDER BY source."generatedAt" DESC LIMIT 5) file),
4177
4047
  'riskOrders', (SELECT COALESCE(jsonb_agg(risk_item.item), '[]'::jsonb) FROM (SELECT jsonb_build_object('orderKey', 'sales:' || sales_scope."purchaseOrderId"::text, 'orderNo', sales_scope."salesOrderNo", 'partyName', sales_scope."customerName", 'riskType', '未收款风险', 'riskAmountCny', sales_scope."unreceivedCny", 'status', '待跟进') AS item, sales_scope."unreceivedCny" AS amount FROM sales_scope WHERE sales_scope."unreceivedCny" > 0 UNION ALL SELECT jsonb_build_object('orderKey', 'purchase:' || purchase_scope.id::text, 'orderNo', purchase_scope."contractNo", 'partyName', purchase_scope."supplierName", 'riskType', '未付款风险', 'riskAmountCny', purchase_scope."unpaidCny", 'status', '待跟进') AS item, purchase_scope."unpaidCny" AS amount FROM purchase_scope WHERE purchase_scope."unpaidCny" > 0 ORDER BY amount DESC LIMIT 5) risk_item),
4178
4048
  'dataSources', jsonb_build_array(
4179
4049
  jsonb_build_object('label', '采购订单', 'description', '采购金额、付款记录、退税记录'),
@@ -4191,45 +4061,156 @@ function analyticsDashboardJsonSql(query, tenant, organization, from, to) {
4191
4061
  )
4192
4062
  ))`;
4193
4063
  }
4194
- function analyticsDetailFromRaw(row, query) {
4195
- const base = { key: String(row.key), label: String(row.label), orderCount: Number(row.orderCount), amountCny: moneyString(row.amountCny) };
4196
- if (query.dimension === 'PRODUCT')
4197
- return { ...base, productQuantity: decimalString(row.productQuantity) };
4198
- if (query.mode === 'PURCHASE')
4199
- return { ...base, paidCny: moneyString(row.paidCny), unpaidCny: moneyString(row.unpaidCny), refundedCny: moneyString(row.refundedCny), unrefundedCny: moneyString(row.unrefundedCny) };
4200
- return { ...base, receivedCny: moneyString(row.receivedCny), unreceivedCny: moneyString(row.unreceivedCny) };
4064
+ function analyticsDetailStatement(scope, query, currentYear) {
4065
+ const parameters = [];
4066
+ const parameter = (value) => { parameters.push(value); return `$${parameters.length}`; };
4067
+ const tenant = parameter(scope.tenantId);
4068
+ const organization = parameter(scope.organizationId);
4069
+ const range = currentYearDateRange(currentYear, query.filters.orderDate);
4070
+ const from = parameter(range.from);
4071
+ const to = parameter(range.to);
4072
+ const source = query.kind === 'COMPLIANCE_TODO' || query.kind === 'COMPLIANCE_RISK'
4073
+ ? analyticsComplianceDetailSql(tenant, organization, from, to)
4074
+ : query.kind === 'SALES_FILE_REVIEW'
4075
+ ? analyticsSalesFileReviewDetailSql(tenant, organization, from, to)
4076
+ : analyticsRiskOrderDetailSql(tenant, organization, from, to);
4077
+ const category = query.categoryKey ? `WHERE details."categoryKey" = ${parameter(query.categoryKey)}` : '';
4078
+ const offset = parameter((query.page - 1) * query.pageSize);
4079
+ const limit = parameter(query.pageSize);
4080
+ return {
4081
+ sql: `WITH details AS (${source}), filtered AS (SELECT * FROM details ${category}), counted AS (SELECT COUNT(*) AS total FROM filtered), paged AS (SELECT * FROM filtered ORDER BY "businessDate" DESC NULLS LAST, id DESC OFFSET ${offset} LIMIT ${limit}) SELECT paged.*, counted.total FROM counted LEFT JOIN paged ON TRUE ORDER BY paged."businessDate" DESC NULLS LAST, paged.id DESC`,
4082
+ parameters
4083
+ };
4201
4084
  }
4202
- function analyticsMetricsFromRaw(row, query) {
4203
- if (query.mode === 'PURCHASE')
4204
- return {
4205
- orderTotalCny: moneyString(row?.metricAmountCny ?? row?.amountCny),
4206
- paidCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricPaidCny ?? row?.paidCny),
4207
- unpaidCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricUnpaidCny ?? row?.unpaidCny),
4208
- refundedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricRefundedCny ?? row?.refundedCny),
4209
- unrefundedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricUnrefundedCny ?? row?.unrefundedCny)
4210
- };
4085
+ function analyticsComplianceDetailSql(tenant, organization, from, to) {
4086
+ const salesFileRows = analyticsSalesFileReviewDetailSql(tenant, organization, from, to);
4087
+ return `
4088
+ SELECT task.id::text AS id, 'customer-contract-review' AS "categoryKey", '客户合同待审核' AS "categoryLabel",
4089
+ task."sourceFileName" AS "referenceNo", task."sourceFileName" AS "subjectName", task."createdAt"::text AS "businessDate",
4090
+ NULL::text AS reason, NULL::numeric AS "amountCny", '待审核' AS status,
4091
+ jsonb_build_object('kind', 'CUSTOMER_CONTRACT_REVIEW', 'label', '审核', 'id', task.id, 'importTaskId', task.id) AS action
4092
+ FROM plugin_trade_compliance_import_task task
4093
+ WHERE task."tenantId" = ${tenant} AND task."organizationId" = ${organization} AND task."deletedAt" IS NULL AND task."documentType" = 'CUSTOMER_CONTRACT' AND task.status = 'AWAITING_REVIEW' AND task."createdAt"::date BETWEEN ${from} AND ${to}
4094
+ UNION ALL
4095
+ SELECT task.id::text, 'purchase-contract-review', '采购合同待审核', task."sourceFileName", task."sourceFileName", task."createdAt"::text,
4096
+ NULL::text, NULL::numeric, '待审核', jsonb_build_object('kind', 'PURCHASE_CONTRACT_REVIEW', 'label', '审核', 'id', task.id, 'importTaskId', task.id)
4097
+ FROM plugin_trade_compliance_import_task task
4098
+ WHERE task."tenantId" = ${tenant} AND task."organizationId" = ${organization} AND task."deletedAt" IS NULL AND task."documentType" = 'PURCHASE_CONTRACT' AND task.status = 'AWAITING_REVIEW' AND task."createdAt"::date BETWEEN ${from} AND ${to}
4099
+ UNION ALL
4100
+ SELECT match.id::text, 'product-risk-review', '商品风险待确认', purchase."contractNo", line."productName", purchase."orderDate"::text,
4101
+ COALESCE(goods."controlDescription", goods."productName"), line."lineAmountTaxIncluded", '待确认',
4102
+ jsonb_build_object('kind', 'PURCHASE_ORDER_VIEW', 'label', '查看', 'id', match.id, 'purchaseOrderId', purchase.id, 'supplierId', purchase."supplierId")
4103
+ FROM plugin_trade_compliance_product_match match
4104
+ INNER JOIN plugin_trade_compliance_purchase_order_line line ON line.id = match."purchaseOrderLineId" AND line."tenantId" = match."tenantId" AND line."organizationId" = match."organizationId" AND line."deletedAt" IS NULL
4105
+ INNER JOIN plugin_trade_compliance_purchase_order purchase ON purchase.id = line."purchaseOrderId" AND purchase."tenantId" = match."tenantId" AND purchase."organizationId" = match."organizationId" AND purchase."deletedAt" IS NULL
4106
+ INNER JOIN plugin_trade_compliance_controlled_goods goods ON goods.id = match."controlledGoodsId" AND goods."tenantId" = match."tenantId" AND goods."organizationId" = match."organizationId" AND goods."deletedAt" IS NULL
4107
+ WHERE match."tenantId" = ${tenant} AND match."organizationId" = ${organization} AND match."deletedAt" IS NULL AND purchase."orderDate" BETWEEN ${from} AND ${to}
4108
+ UNION ALL
4109
+ SELECT ('supplier:' || match.id::text || ':' || purchase.id::text), 'sanction-review', '制裁公司待复核', purchase."contractNo", supplier.name, purchase."orderDate"::text,
4110
+ company."sanctionReason", purchase."contractAmountCny", '待复核', jsonb_build_object('kind', 'PURCHASE_ORDER_VIEW', 'label', '查看', 'id', match.id, 'purchaseOrderId', purchase.id, 'supplierId', supplier.id)
4111
+ FROM plugin_trade_compliance_company_sanction_match match
4112
+ INNER JOIN plugin_trade_compliance_supplier supplier ON supplier.id = match."supplierId" AND supplier."tenantId" = match."tenantId" AND supplier."organizationId" = match."organizationId" AND supplier."deletedAt" IS NULL
4113
+ INNER JOIN plugin_trade_compliance_purchase_order purchase ON purchase."supplierId" = supplier.id AND purchase."tenantId" = match."tenantId" AND purchase."organizationId" = match."organizationId" AND purchase."deletedAt" IS NULL AND purchase."orderDate" BETWEEN ${from} AND ${to}
4114
+ INNER JOIN plugin_trade_compliance_sanctioned_company company ON company.id = match."sanctionedCompanyId" AND company."tenantId" = match."tenantId" AND company."organizationId" = match."organizationId" AND company."deletedAt" IS NULL
4115
+ WHERE match."tenantId" = ${tenant} AND match."organizationId" = ${organization} AND match."deletedAt" IS NULL AND match."subjectType" = 'SUPPLIER'
4116
+ UNION ALL
4117
+ SELECT ('customer:' || match.id::text || ':' || purchase.id::text), 'sanction-review', '制裁公司待复核', COALESCE(file."salesOrderNoSnapshot", contract."contractNo"), customer.name, purchase."orderDate"::text,
4118
+ company."sanctionReason", contract."contractAmountReviewCny", '待复核', jsonb_build_object('kind', 'SALES_ORDER_VIEW', 'label', '查看', 'id', match.id, 'customerId', customer.id, 'customerContractId', contract.id, 'purchaseOrderId', purchase.id)
4119
+ FROM plugin_trade_compliance_company_sanction_match match
4120
+ INNER JOIN plugin_trade_compliance_customer customer ON customer.id = match."customerId" AND customer."tenantId" = match."tenantId" AND customer."organizationId" = match."organizationId" AND customer."deletedAt" IS NULL
4121
+ INNER JOIN plugin_trade_compliance_customer_contract contract ON contract."customerId" = customer.id AND contract."tenantId" = match."tenantId" AND contract."organizationId" = match."organizationId" AND contract."deletedAt" IS NULL
4122
+ INNER JOIN plugin_trade_compliance_purchase_order purchase ON purchase."tenantId" = contract."tenantId" AND purchase."organizationId" = contract."organizationId" AND purchase."deletedAt" IS NULL AND contract."normalizedContractNo" = regexp_replace(purchase."normalizedContractNo", '(-[0-9]+)?C$', '') || 'X' AND purchase."orderDate" BETWEEN ${from} AND ${to}
4123
+ INNER JOIN plugin_trade_compliance_sanctioned_company company ON company.id = match."sanctionedCompanyId" AND company."tenantId" = match."tenantId" AND company."organizationId" = match."organizationId" AND company."deletedAt" IS NULL
4124
+ LEFT JOIN LATERAL (SELECT generated."salesOrderNoSnapshot" FROM plugin_trade_compliance_generated_sales_file generated WHERE generated."customerContractId" = contract.id AND generated."purchaseOrderId" = purchase.id AND generated."tenantId" = contract."tenantId" AND generated."organizationId" = contract."organizationId" AND generated."deletedAt" IS NULL ORDER BY generated."generatedAt" DESC LIMIT 1) file ON TRUE
4125
+ WHERE match."tenantId" = ${tenant} AND match."organizationId" = ${organization} AND match."deletedAt" IS NULL AND match."subjectType" = 'CUSTOMER'
4126
+ UNION ALL ${salesFileRows}`;
4127
+ }
4128
+ function analyticsSalesFileReviewDetailSql(tenant, organization, from, to) {
4129
+ return `SELECT file.id::text AS id, 'sales-file-review' AS "categoryKey", '销售文件待复核' AS "categoryLabel", file."salesOrderNoSnapshot" AS "referenceNo",
4130
+ file."fileName" AS "subjectName", file."generatedAt"::text AS "businessDate",
4131
+ (SELECT COALESCE(NULLIF(warning->>'message', ''), NULLIF(warning->>'reason', ''), NULLIF(warning->>'code', '')) FROM jsonb_array_elements(file."generationWarnings") warning WHERE COALESCE(NULLIF(warning->>'message', ''), NULLIF(warning->>'reason', ''), NULLIF(warning->>'code', '')) IS NOT NULL LIMIT 1) AS reason,
4132
+ NULL::numeric AS "amountCny", '待复核' AS status,
4133
+ jsonb_build_object('kind', 'SALES_FILE_REVIEW', 'label', '复核', 'id', file.id, 'customerContractId', file."customerContractId", 'purchaseOrderId', file."purchaseOrderId", 'salesOrderNoSnapshot', file."salesOrderNoSnapshot", 'fileType', file."fileType", 'fileName', file."fileName", 'storageFileId', file."storageFileId", 'editVersion', file."editVersion", 'generatedAt', file."generatedAt") AS action
4134
+ FROM plugin_trade_compliance_generated_sales_file file
4135
+ WHERE file."tenantId" = ${tenant} AND file."organizationId" = ${organization} AND file."deletedAt" IS NULL AND file."reviewedAt" IS NULL AND file."generatedAt"::date BETWEEN ${from} AND ${to}
4136
+ AND jsonb_typeof(file."generationWarnings") = 'array' AND jsonb_array_length(file."generationWarnings") > 0
4137
+ AND EXISTS (SELECT 1 FROM jsonb_array_elements(file."generationWarnings") warning WHERE COALESCE(NULLIF(warning->>'message', ''), NULLIF(warning->>'reason', ''), NULLIF(warning->>'code', '')) IS NOT NULL)`;
4138
+ }
4139
+ function analyticsRiskOrderDetailSql(tenant, organization, from, to) {
4140
+ return `SELECT ('purchase:' || purchase.id::text) AS id, 'risk-order' AS "categoryKey", '未付款订单' AS "categoryLabel", purchase."contractNo" AS "referenceNo", supplier.name AS "subjectName", purchase."orderDate"::text AS "businessDate", '未付款' AS reason,
4141
+ GREATEST(purchase."contractAmountCny" - purchase."paidAmountCny", 0) AS "amountCny", '待跟进' AS status,
4142
+ jsonb_build_object('kind', 'PURCHASE_ORDER_VIEW', 'label', '跟进', 'id', purchase.id, 'purchaseOrderId', purchase.id, 'supplierId', supplier.id) AS action
4143
+ FROM plugin_trade_compliance_purchase_order purchase
4144
+ INNER JOIN plugin_trade_compliance_supplier supplier ON supplier.id = purchase."supplierId" AND supplier."tenantId" = purchase."tenantId" AND supplier."organizationId" = purchase."organizationId" AND supplier."deletedAt" IS NULL
4145
+ WHERE purchase."tenantId" = ${tenant} AND purchase."organizationId" = ${organization} AND purchase."deletedAt" IS NULL AND purchase."orderDate" BETWEEN ${from} AND ${to} AND GREATEST(purchase."contractAmountCny" - purchase."paidAmountCny", 0) > 0
4146
+ UNION ALL
4147
+ SELECT ('sales:' || contract.id::text || ':' || purchase.id::text), 'risk-order', '未收款订单', COALESCE(file."salesOrderNoSnapshot", contract."contractNo"), customer.name, purchase."orderDate"::text, '未收款',
4148
+ GREATEST(sales_amount."salesTotalCny" - COALESCE(manual."receivedAmountCny", 0), 0) AS "amountCny", '待跟进',
4149
+ jsonb_build_object('kind', 'SALES_ORDER_VIEW', 'label', '跟进', 'id', contract.id::text || ':' || purchase.id::text, 'customerId', customer.id, 'customerContractId', contract.id, 'purchaseOrderId', purchase.id)
4150
+ FROM plugin_trade_compliance_customer_contract contract
4151
+ INNER JOIN plugin_trade_compliance_customer customer ON customer.id = contract."customerId" AND customer."tenantId" = contract."tenantId" AND customer."organizationId" = contract."organizationId" AND customer."deletedAt" IS NULL
4152
+ INNER JOIN plugin_trade_compliance_purchase_order purchase ON purchase."tenantId" = contract."tenantId" AND purchase."organizationId" = contract."organizationId" AND purchase."deletedAt" IS NULL AND contract."normalizedContractNo" = regexp_replace(purchase."normalizedContractNo", '(-[0-9]+)?C$', '') || 'X'
4153
+ LEFT JOIN plugin_trade_compliance_derived_sales_manual manual ON manual."customerContractId" = contract.id AND manual."purchaseOrderId" = purchase.id AND manual."tenantId" = contract."tenantId" AND manual."organizationId" = contract."organizationId" AND manual."deletedAt" IS NULL
4154
+ CROSS JOIN LATERAL (SELECT COALESCE(SUM(ROUND(ROUND(line."unitPriceTaxIncluded" * (1 + COALESCE(manual."pricingProfitRateOverride", customer."defaultPricingProfitRate")), 0) * line.quantity, 0)), 0) AS "salesTotalCny" FROM plugin_trade_compliance_purchase_order_line line WHERE line."purchaseOrderId" = purchase.id AND line."tenantId" = purchase."tenantId" AND line."organizationId" = purchase."organizationId" AND line."deletedAt" IS NULL) sales_amount
4155
+ LEFT JOIN LATERAL (SELECT generated."salesOrderNoSnapshot" FROM plugin_trade_compliance_generated_sales_file generated WHERE generated."customerContractId" = contract.id AND generated."purchaseOrderId" = purchase.id AND generated."tenantId" = contract."tenantId" AND generated."organizationId" = contract."organizationId" AND generated."deletedAt" IS NULL ORDER BY generated."generatedAt" DESC LIMIT 1) file ON TRUE
4156
+ WHERE contract."tenantId" = ${tenant} AND contract."organizationId" = ${organization} AND contract."deletedAt" IS NULL AND purchase."orderDate" BETWEEN ${from} AND ${to}
4157
+ AND (manual."pricingProfitRateOverride" IS NOT NULL OR (customer."pricingProfitRateStatus" = 'CONFIGURED' AND customer."defaultPricingProfitRate" IS NOT NULL))
4158
+ AND GREATEST(sales_amount."salesTotalCny" - COALESCE(manual."receivedAmountCny", 0), 0) > 0`;
4159
+ }
4160
+ function analyticsBusinessDetailFromRaw(row) {
4161
+ const action = typeof row.action === 'string' ? safeJsonParse(row.action) : row.action;
4211
4162
  return {
4212
- orderTotalCny: moneyString(row?.metricAmountCny ?? row?.amountCny),
4213
- receivedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricReceivedCny ?? row?.receivedCny),
4214
- unreceivedCny: query.dimension === 'PRODUCT' ? '0.00' : moneyString(row?.metricUnreceivedCny ?? row?.unreceivedCny)
4163
+ id: stringValue(row.id), categoryKey: analyticsBusinessCategoryKeyValue(row.categoryKey), categoryLabel: stringValue(row.categoryLabel), referenceNo: stringValue(row.referenceNo),
4164
+ ...(row.subjectName == null ? {} : { subjectName: stringValue(row.subjectName) }),
4165
+ ...(row.businessDate == null ? {} : { businessDate: stringValue(row.businessDate) }),
4166
+ ...(row.reason == null ? {} : { reason: stringValue(row.reason) }),
4167
+ ...(row.amountCny == null ? {} : { amountCny: moneyString(row.amountCny) }),
4168
+ status: stringValue(row.status), action: analyticsDetailActionValue(action)
4215
4169
  };
4216
4170
  }
4217
- function analyticsDashboardFromRaw(row, metrics) {
4171
+ function analyticsDetailActionValue(value) {
4172
+ if (!value || typeof value !== 'object' || Array.isArray(value))
4173
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '分析明细操作无效');
4174
+ const action = value;
4175
+ const id = requiredId(action.id, '分析明细操作标识');
4176
+ if (action.kind === 'CUSTOMER_CONTRACT_REVIEW')
4177
+ return { kind: action.kind, label: '审核', id, importTaskId: requiredId(action.importTaskId, '客户合同任务标识') };
4178
+ if (action.kind === 'PURCHASE_CONTRACT_REVIEW')
4179
+ return { kind: action.kind, label: '审核', id, importTaskId: requiredId(action.importTaskId, '采购合同任务标识') };
4180
+ if (action.kind === 'PURCHASE_ORDER_VIEW')
4181
+ return { kind: action.kind, label: analyticsDetailActionLabel(action.label), id, purchaseOrderId: requiredId(action.purchaseOrderId, '采购订单标识'), supplierId: requiredId(action.supplierId, '供应商标识') };
4182
+ if (action.kind === 'SALES_ORDER_VIEW')
4183
+ return { kind: action.kind, label: analyticsDetailActionLabel(action.label), id, customerId: requiredId(action.customerId, '客户标识'), customerContractId: requiredId(action.customerContractId, '客户合同标识'), purchaseOrderId: requiredId(action.purchaseOrderId, '采购订单标识') };
4184
+ if (action.kind === 'SALES_FILE_REVIEW') {
4185
+ const fileType = ['PI', 'CI', 'PL', 'CUSTOMS_DECLARATION'].find(item => item === action.fileType);
4186
+ const editVersion = Number(action.editVersion);
4187
+ if (!fileType || !Number.isInteger(editVersion) || editVersion < 0)
4188
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '销售文件复核操作无效');
4189
+ return { kind: action.kind, label: '复核', id, customerContractId: requiredId(action.customerContractId, '客户合同标识'), purchaseOrderId: requiredId(action.purchaseOrderId, '采购订单标识'), salesOrderNoSnapshot: requiredId(action.salesOrderNoSnapshot, '销售订单编号'), fileType, fileName: requiredId(action.fileName, '文件名'), storageFileId: requiredId(action.storageFileId, '存储文件标识'), editVersion, generatedAt: requiredId(action.generatedAt, '生成时间') };
4190
+ }
4191
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '分析明细操作类型无效', { kind: action.kind });
4192
+ }
4193
+ function analyticsDetailActionLabel(value) {
4194
+ if (value === '查看' || value === '跟进')
4195
+ return value;
4196
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '分析明细操作名称无效', { value });
4197
+ }
4198
+ function analyticsKpiExportRows(dashboard) {
4199
+ return [
4200
+ ['采购总额 CNY', dashboard.kpis.purchaseTotalCny], ['销售总额 CNY', dashboard.kpis.salesTotalCny], ['已收款 CNY', dashboard.kpis.receivedCny],
4201
+ ['未收款 CNY', dashboard.kpis.unreceivedCny], ['已付款 CNY', dashboard.kpis.paidCny], ['未付款 CNY', dashboard.kpis.unpaidCny],
4202
+ ['已退税 CNY', dashboard.kpis.refundedCny], ['未退税 CNY', dashboard.kpis.unrefundedCny], ['预计毛利 CNY', dashboard.kpis.estimatedGrossProfitCny],
4203
+ ['合规待办', dashboard.kpis.complianceTodoCount], ['风险敞口 CNY', dashboard.kpis.riskExposureCny]
4204
+ ];
4205
+ }
4206
+ function analyticsDashboardFromRaw(row) {
4218
4207
  const parsed = parseAnalyticsDashboard(row?.dashboardJson);
4219
4208
  if (parsed)
4220
4209
  return parsed;
4221
- const purchaseTotalCny = metrics.paidCny != null ? metrics.orderTotalCny ?? '0.00' : '0.00';
4222
- const salesTotalCny = metrics.receivedCny != null ? metrics.orderTotalCny ?? '0.00' : '0.00';
4223
4210
  return {
4224
4211
  kpis: {
4225
- purchaseTotalCny,
4226
- salesTotalCny,
4227
- receivedCny: metrics.receivedCny ?? '0.00',
4228
- unreceivedCny: metrics.unreceivedCny ?? '0.00',
4229
- paidCny: metrics.paidCny ?? '0.00',
4230
- unpaidCny: metrics.unpaidCny ?? '0.00',
4231
- refundedCny: metrics.refundedCny ?? '0.00',
4232
- unrefundedCny: metrics.unrefundedCny ?? '0.00',
4212
+ purchaseTotalCny: '0.00', salesTotalCny: '0.00', receivedCny: '0.00', unreceivedCny: '0.00',
4213
+ paidCny: '0.00', unpaidCny: '0.00', refundedCny: '0.00', unrefundedCny: '0.00',
4233
4214
  estimatedGrossProfitCny: '0.00',
4234
4215
  estimatedGrossProfitRate: '0.00000000',
4235
4216
  complianceTodoCount: 0,
@@ -4243,7 +4224,7 @@ function analyticsDashboardFromRaw(row, metrics) {
4243
4224
  supplierRiskTop5: [],
4244
4225
  salesFileReviews: [],
4245
4226
  riskOrders: [],
4246
- dataSources: defaultAnalyticsDataSources()
4227
+ dataSources: []
4247
4228
  };
4248
4229
  }
4249
4230
  function parseAnalyticsDashboard(value) {
@@ -4253,6 +4234,15 @@ function parseAnalyticsDashboard(value) {
4253
4234
  const dashboard = source;
4254
4235
  if (!dashboard.kpis || typeof dashboard.kpis !== 'object')
4255
4236
  return null;
4237
+ const rawTodos = arrayOf(dashboard.todos);
4238
+ const riskDistribution = arrayOf(dashboard.riskDistribution).flatMap(item => {
4239
+ const key = complianceCategoryKeyValue(item.key);
4240
+ return key ? [{ key, label: stringValue(item.label), count: numberValue(item.count), amountCny: item.amountCny == null ? undefined : moneyString(item.amountCny) }] : [];
4241
+ });
4242
+ const todos = riskDistribution.map(risk => {
4243
+ const todo = rawTodos.find(item => item.key === risk.key);
4244
+ return { ...risk, priority: priorityValue(todo?.priority), description: stringValue(todo?.description) };
4245
+ });
4256
4246
  return {
4257
4247
  kpis: {
4258
4248
  purchaseTotalCny: moneyString(dashboard.kpis.purchaseTotalCny),
@@ -4265,7 +4255,7 @@ function parseAnalyticsDashboard(value) {
4265
4255
  unrefundedCny: moneyString(dashboard.kpis.unrefundedCny),
4266
4256
  estimatedGrossProfitCny: moneyString(dashboard.kpis.estimatedGrossProfitCny),
4267
4257
  estimatedGrossProfitRate: decimalString(dashboard.kpis.estimatedGrossProfitRate),
4268
- complianceTodoCount: numberValue(dashboard.kpis.complianceTodoCount),
4258
+ complianceTodoCount: riskDistribution.reduce((sum, item) => sum + item.count, 0),
4269
4259
  riskExposureCny: moneyString(dashboard.kpis.riskExposureCny)
4270
4260
  },
4271
4261
  monthlyTrend: arrayOf(dashboard.monthlyTrend).map(item => ({
@@ -4275,20 +4265,8 @@ function parseAnalyticsDashboard(value) {
4275
4265
  estimatedGrossProfitCny: moneyString(item.estimatedGrossProfitCny),
4276
4266
  receivedCny: moneyString(item.receivedCny)
4277
4267
  })),
4278
- todos: arrayOf(dashboard.todos).map(item => ({
4279
- key: stringValue(item.key),
4280
- label: stringValue(item.label),
4281
- count: numberValue(item.count),
4282
- amountCny: item.amountCny == null ? undefined : moneyString(item.amountCny),
4283
- priority: priorityValue(item.priority),
4284
- description: stringValue(item.description)
4285
- })),
4286
- riskDistribution: arrayOf(dashboard.riskDistribution).map(item => ({
4287
- key: stringValue(item.key),
4288
- label: stringValue(item.label),
4289
- count: numberValue(item.count),
4290
- amountCny: item.amountCny == null ? undefined : moneyString(item.amountCny)
4291
- })),
4268
+ todos,
4269
+ riskDistribution,
4292
4270
  customerValueTop5: arrayOf(dashboard.customerValueTop5).map(item => ({
4293
4271
  customerId: stringValue(item.customerId),
4294
4272
  customerName: stringValue(item.customerName),
@@ -4329,25 +4307,9 @@ function parseAnalyticsDashboard(value) {
4329
4307
  riskAmountCny: moneyString(item.riskAmountCny),
4330
4308
  status: stringValue(item.status)
4331
4309
  })),
4332
- dataSources: arrayOf(dashboard.dataSources).length ? arrayOf(dashboard.dataSources).map(item => ({ label: stringValue(item.label), description: stringValue(item.description) })) : defaultAnalyticsDataSources()
4310
+ dataSources: arrayOf(dashboard.dataSources).map(item => ({ label: stringValue(item.label), description: stringValue(item.description) }))
4333
4311
  };
4334
4312
  }
4335
- function defaultAnalyticsDataSources() {
4336
- return [
4337
- { label: '采购订单', description: '采购金额、付款记录、退税记录' },
4338
- { label: '采购商品明细', description: '采购商品金额、商品风险命中' },
4339
- { label: '客户合同审核结果', description: '客户合同风险、待审核合同' },
4340
- { label: '公司制裁目录匹配结果', description: '客户/供应商制裁命中' },
4341
- { label: '付款记录', description: '已付款、未付款金额' },
4342
- { label: '销售订单', description: '销售金额、收款记录、客户合同关联' },
4343
- { label: '销售商品明细', description: '销售商品金额、管控商品命中' },
4344
- { label: '管控商品目录匹配结果', description: '商品风险命中与处理状态' },
4345
- { label: '收款记录', description: '已收款、未收款金额' },
4346
- { label: '退税记录', description: '已退税、未退税金额' },
4347
- { label: '销售文件生成与审核记录', description: '销售单据生成、复核状态与异常原因' },
4348
- { label: '系统设置中的币种与汇率规则', description: '币种、月度汇率与金额折算规则' }
4349
- ];
4350
- }
4351
4313
  function safeJsonParse(value) { try {
4352
4314
  return JSON.parse(value);
4353
4315
  }
@@ -4355,6 +4317,15 @@ catch {
4355
4317
  return null;
4356
4318
  } }
4357
4319
  function arrayOf(value) { return Array.isArray(value) ? value.filter((item) => Boolean(item) && typeof item === 'object') : []; }
4320
+ function complianceCategoryKeyValue(value) { return AnalyticsComplianceCategoryKeys.find(key => key === value); }
4321
+ function analyticsBusinessCategoryKeyValue(value) {
4322
+ const complianceKey = complianceCategoryKeyValue(value);
4323
+ if (complianceKey)
4324
+ return complianceKey;
4325
+ if (value === 'risk-order')
4326
+ return value;
4327
+ throw new DomainError(DomainErrorCodes.INVALID_INPUT, '分析明细业务分类无效', { value });
4328
+ }
4358
4329
  function stringValue(value) { return value == null ? '' : String(value); }
4359
4330
  function numberValue(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; }
4360
4331
  function priorityValue(value) { return value === 'high' || value === 'medium' || value === 'low' ? value : 'medium'; }