7501 lines
272 KiB
JavaScript
7501 lines
272 KiB
JavaScript
/**
|
|
* 閮ㄩ棬鍒嗘瀽API
|
|
* 鎻愪緵鍚勯儴闂ㄧ淮搴︾殑鏁版嵁鎺ュ彛
|
|
*/
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const dayjs = require('dayjs');
|
|
const dataSource = require('../config/database');
|
|
const redis = require('../config/redis');
|
|
const cloudDb = require('../config/cloudDatabase');
|
|
const planningDatabase = require('../services/planningDatabase');
|
|
const authMiddleware = require('../middleware/auth');
|
|
const dataPageCache = require('../services/dataPageCache');
|
|
const {
|
|
BRAND_OPTIONS,
|
|
getBrandAnalysisDetails,
|
|
getBrandAnalysisList,
|
|
} = require('../services/brandAnalysis');
|
|
const {
|
|
getPddNewProductSelectionCategories,
|
|
getPddNewProductSelectionLibraryFilterOptions,
|
|
getPddNewProductSelectionLibraryList,
|
|
getPddNewProductSelectionLogDetail,
|
|
getPddNewProductSelectionLogs,
|
|
searchPddNewProductSelection,
|
|
} = require('../services/aiImageSearch');
|
|
const {
|
|
queryLogisticsPickupBoard,
|
|
normalizeSnapshotData,
|
|
readLogisticsPickupBoardSnapshot,
|
|
} = require('../services/logisticsPickupBoard');
|
|
const {
|
|
queryLogisticsDeliveryDurationBoard,
|
|
queryLogisticsDeliveryDurationDetails,
|
|
} = require('../services/logisticsDeliveryDurationBoard');
|
|
const registerPddSkuDimensionBoard = require('./pddSkuDimensionBoard');
|
|
const { ensurePlanningStockSelectionSchema } = require('../services/planningSupportSchema');
|
|
|
|
const localDb = dataSource.local;
|
|
const stockSelectionDb = planningDatabase;
|
|
const IDENTIFIER_REGEXP = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
const PDD_DATA_GOODS_SCHEMA = 'dbo';
|
|
const PDD_DATA_GOODS_TABLE = 'pdd_DataGoods';
|
|
const PDD_SPREAD_GOODS_TABLE = 'pdd_ReportGoodsSpread';
|
|
const PDD_EVALUATION_TABLE = 'pdd_Evaluation';
|
|
const PDD_JST_SKU_TABLE_FULL_NAME = 'dbo.jst_sku';
|
|
const PDD_STOCK_SELECTION_BASE_TABLE = 'op_BarcodeChannelDecisionBase';
|
|
const PDD_STOCK_SELECTION_RESULT_TABLE = 'op_BarcodeChannelDecisionResult';
|
|
const PDD_TARGET_TABLE_FULL_NAME = 'JSDataMiddlePlatform.dbo.op_TargetManagement';
|
|
const PDD_EMPLOYEE_REGISTER_TABLE_FULL_NAME = 'JSDataMiddlePlatform.dbo.hr_employee_register';
|
|
const PDD_DATA_GOODS_FIELDS = [
|
|
{ key: 'shopName', label: '店铺名称' },
|
|
{ key: 'createTime', label: '发生时间' },
|
|
{ key: 'goodsId', label: '商品id' },
|
|
{ key: 'goodsName', label: '商品名称' },
|
|
{ key: 'hdThumbUrl', label: '商品图' },
|
|
{ key: 'goodsUv', label: '商品访客数' },
|
|
{ key: 'goodsPv', label: '商品浏览量' },
|
|
{ key: 'payOrdrAmt', label: '成交金额' },
|
|
{ key: 'payOrdrGoodsQty', label: '成交件数' },
|
|
{ key: 'payOrdrCnt', label: '成交订单数' },
|
|
{ key: 'payOrdrUsrCnt', label: '成交买家数' },
|
|
{ key: 'goodsVcr', label: '成交转化率' },
|
|
{ key: 'goodsFavCnt', label: '商品收藏用户数' },
|
|
{ key: 'ordrVstrRto', label: '下单率' },
|
|
{ key: 'payOrdrRto', label: '成交率' },
|
|
{ key: 'imprUsrCnt', label: '流量损失指数' },
|
|
{ key: 'goodsPtHelpRate', label: '商品平台求助率' },
|
|
];
|
|
const PDD_SPREAD_GOODS_FIELDS = [
|
|
{ key: 'goodsName', label: '商品名称' },
|
|
{ key: 'goodsId', label: '商品id' },
|
|
{ key: 'minGroupPrice', label: '拼单价(厘)' },
|
|
{ key: 'targetRoi', label: '目标投产比' },
|
|
{ key: 'spend', label: '总花费' },
|
|
{ key: 'gmv', label: '交易额' },
|
|
{ key: 'roi', label: '实际投产比' },
|
|
{ key: 'orderNum', label: '成交笔数' },
|
|
{ key: 'transactionCost', label: '每笔成交花费' },
|
|
{ key: 'avgPayAmount', label: '每笔成交金额' },
|
|
{ key: 'directGmv', label: '直接交易额' },
|
|
{ key: 'indirectGmv', label: '间接交易额' },
|
|
{ key: 'directOrderNum', label: '直接成交笔数' },
|
|
{ key: 'indirectOrderNum', label: '间接成交笔数' },
|
|
{ key: 'avgDirectPayAmount', label: '每笔直接成交金额' },
|
|
{ key: 'avgIndirectPayAmount', label: '每笔间接成交金额' },
|
|
{ key: 'globalTakeRate', label: '全站推广费用占比' },
|
|
{ key: 'impression', label: '曝光量' },
|
|
{ key: 'click', label: '点击量' },
|
|
{ key: 'ctr', label: '点击率' },
|
|
{ key: 'cvr', label: '点击转化率' },
|
|
{ key: 'shopName', label: '店铺名称' },
|
|
{ key: 'createTime', label: '抓取时间' },
|
|
{ key: 'orderSpend', label: '成交花费' },
|
|
{ key: 'netGmv', label: '净交易额' },
|
|
{ key: 'netRoi', label: '净实际投产比' },
|
|
{ key: 'netOrderNum', label: '净成交笔数' },
|
|
{ key: 'netAvgPayAmount', label: '每笔净成交花费' },
|
|
{ key: 'netRate', label: '净交易额占比' },
|
|
{ key: 'settlementGmv', label: '结算金额' },
|
|
{ key: 'settlementRoi', label: '结算投产比' },
|
|
{ key: 'settlementOrder', label: '结算笔数' },
|
|
{ key: 'refundGmvRate30d', label: '退款率' },
|
|
{ key: 'refundOrderRate30d', label: '退单率' },
|
|
{ key: 'exemptRefundGmvRate30d', label: '退款豁免率' },
|
|
{ key: 'exemptRefundOrderRate30d', label: '退单豁免率' },
|
|
{ key: 'settlementGmvRate', label: '交易额结算率' },
|
|
{ key: 'settlementOrderRate', label: '订单结算率' },
|
|
{ key: 'costPerSettlementOrderForDeal', label: '结算订单成本' },
|
|
{ key: 'inquirySpend', label: '询单花费' },
|
|
{ key: 'multiGoalInquiryNum', label: '询单量' },
|
|
{ key: 'multiGoalCostPerInquiry', label: '平均询单成本' },
|
|
{ key: 'goodsFavSpend', label: '收藏花费' },
|
|
{ key: 'multiGoalGoodsFavNum', label: '收藏量' },
|
|
{ key: 'multiGoalCostPerGoodsFav', label: '平均收藏成本' },
|
|
{ key: 'mallFavSpend', label: '关注花费' },
|
|
{ key: 'multiGoalMallFavNum', label: '关注量' },
|
|
{ key: 'multiGoalCostPerMallFav', label: '平均关注成本' },
|
|
{ key: 'billingImpression', label: '推广曝光量' },
|
|
{ key: 'settlementOrderSpend', label: '实际成交花费' },
|
|
{ key: 'targetBid', label: '净成交出价' },
|
|
];
|
|
const PDD_EVALUATION_FIELDS = [
|
|
{ key: 'descScore', label: '评价分' },
|
|
{ key: 'comment', label: '评价内容' },
|
|
{ key: 'created', label: '评价时间' },
|
|
{ key: 'orderSn', label: '订单号' },
|
|
{ key: 'name', label: '买家昵称' },
|
|
{ key: 'goodsName', label: '商品名称' },
|
|
{ key: 'goodsId', label: '商品id' },
|
|
{ key: 'thumbUrl', label: '商品图' },
|
|
{ key: 'specs', label: '购买明细' },
|
|
{ key: 'keywords', label: '标签' },
|
|
{ key: 'shopName', label: '店铺名称' },
|
|
{ key: 'createTime', label: '发生时间' },
|
|
];
|
|
const PDD_JOIN_EXTRA_FIELDS = [
|
|
{ key: 'idOwner', label: 'id负责人', expr: 'tm.[ider]' },
|
|
{ key: 'ownerDept', label: '所属部门', expr: 'tm.[dept]' },
|
|
];
|
|
const PDD_SPREAD_EXTRA_FIELDS = [
|
|
{ key: 'hdThumbUrl', label: '商品图', expr: 'dg.[hdThumbUrl]' },
|
|
...PDD_JOIN_EXTRA_FIELDS,
|
|
];
|
|
const PDD_DATA_GOODS_DERIVED_FIELDS = [
|
|
{
|
|
key: 'orderUserCount',
|
|
label: '下单人数',
|
|
expr: 'CAST(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]) * TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]) AS DECIMAL(38, 6))',
|
|
},
|
|
{
|
|
key: 'unpaidUserCount',
|
|
label: '未支付人数',
|
|
expr: 'CAST((TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]) * TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto])) - TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]) AS DECIMAL(38, 6))',
|
|
},
|
|
{
|
|
key: 'customerUnitPrice',
|
|
label: '客单价',
|
|
expr: 'CAST(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]) / NULLIF(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0) AS DECIMAL(38, 6))',
|
|
},
|
|
{
|
|
key: 'pieceUnitPrice',
|
|
label: '件单价',
|
|
expr: 'CAST(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]) / NULLIF(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0) AS DECIMAL(38, 6))',
|
|
},
|
|
];
|
|
const PDD_DEPARTMENT_FIELD_CANDIDATES = [
|
|
'departmentName',
|
|
'department',
|
|
'dept',
|
|
'belongDept',
|
|
'belongDepartment',
|
|
'cater',
|
|
'currCater',
|
|
];
|
|
const PDD_SHOP_FIELD_CANDIDATES = [
|
|
'shopName',
|
|
'shop_name',
|
|
'storeName',
|
|
'store_name',
|
|
'mallName',
|
|
'mall_name',
|
|
];
|
|
const SQL_TEXT_TYPES = new Set([
|
|
'char',
|
|
'nchar',
|
|
'varchar',
|
|
'nvarchar',
|
|
'text',
|
|
'ntext',
|
|
]);
|
|
const SQL_DATE_TYPES = new Set([
|
|
'date',
|
|
'datetime',
|
|
'datetime2',
|
|
'smalldatetime',
|
|
'datetimeoffset',
|
|
'time',
|
|
]);
|
|
const SQL_NUMBER_TYPES = new Set([
|
|
'bigint',
|
|
'int',
|
|
'smallint',
|
|
'tinyint',
|
|
'decimal',
|
|
'numeric',
|
|
'float',
|
|
'real',
|
|
'money',
|
|
'smallmoney',
|
|
]);
|
|
const PDD_SPREAD_DIVIDE_BY_1000_FIELDS = new Set([
|
|
'minGroupPrice',
|
|
'spend',
|
|
'gmv',
|
|
'transactionCost',
|
|
'avgPayAmount',
|
|
'directGmv',
|
|
'indirectGmv',
|
|
'avgDirectPayAmount',
|
|
'avgIndirectPayAmount',
|
|
'orderSpend',
|
|
'netGmv',
|
|
'netAvgPayAmount',
|
|
'settlementGmv',
|
|
'costPerSettlementOrderForDeal',
|
|
'inquirySpend',
|
|
'multiGoalCostPerInquiry',
|
|
'goodsFavSpend',
|
|
'multiGoalCostPerGoodsFav',
|
|
'mallFavSpend',
|
|
'multiGoalCostPerMallFav',
|
|
'settlementOrderSpend',
|
|
'targetBid',
|
|
]);
|
|
const PDD_SPREAD_DIVIDE_BY_10000_FIELDS = new Set([
|
|
'targetRoi',
|
|
]);
|
|
const PDD_META_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
const PDD_COUNT_CACHE_TTL_MS = 60 * 1000;
|
|
const PDD_DASHBOARD_BIZ_DATE_CACHE_TTL_SECONDS = 180;
|
|
const PDD_PRODUCT_BOARD_CACHE_VERSION = 'v1';
|
|
const ANALYSIS_HOT_CACHE_VERSION = 'v1';
|
|
const ANALYSIS_HOT_MEMORY_TTL_SECONDS = 10 * 60;
|
|
const ANALYSIS_HOT_REDIS_TTL_SECONDS = 30 * 60;
|
|
const PDD_LATEST_DATE_CACHE_TTL_MS = 60 * 1000;
|
|
const PDD_DASHBOARD_RESULT_CACHE_TTL_MS = 60 * 1000;
|
|
const PDD_PRICE_LIMIT_INDEX_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
const PDD_PRICE_LIMIT_STATE_CACHE_TTL_MS = 60 * 1000;
|
|
const PDD_PRICE_LIMIT_TABLE_SPECS = [
|
|
{
|
|
tableName: 'Bulk_Price_Limit',
|
|
keyFields: [ 'product_code', 'style_code', 'spu' ],
|
|
ordinaryFields: [ 'price_limit', 'min_price', 'alimama_limit_price', 'jingxi_limit_price_1', 'jingxi_limit_price_2', 'jingxi_limit_price' ],
|
|
spuFields: [ 'price_limit', 'min_price', 'alimama_limit_price', 'jingxi_limit_price_1', 'jingxi_limit_price_2', 'jingxi_limit_price' ],
|
|
orderField: 'process_end_date',
|
|
},
|
|
{
|
|
tableName: 'Brand_Price_Limit',
|
|
keyFields: [ 'spu', 'product_no', 'sku_id', 'style_code' ],
|
|
ordinaryFields: [ 'non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ],
|
|
spuFields: [ 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ],
|
|
fallbackFields: [ 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ],
|
|
managerField: 'spu_manager',
|
|
orderField: 'update_date',
|
|
},
|
|
{
|
|
tableName: 'Combo_Product_Price_Limit',
|
|
keyFields: [ 'combo_sku_id', 'child_sku_id', 'style_code' ],
|
|
ordinaryFields: [ 'final_combo_product_limit_price', 'combo_product_limit_price_aux', 'price_limit', 'pdd_al_limit_price', 'jingxi_limit_price', 'combo_product_jingxi_limit_price' ],
|
|
spuFields: [ 'final_combo_product_limit_price', 'combo_product_limit_price_aux', 'price_limit', 'pdd_al_limit_price', 'jingxi_limit_price', 'combo_product_jingxi_limit_price' ],
|
|
orderField: 'modified_time',
|
|
},
|
|
{
|
|
tableName: 'Brand_Combo_Code_Price_Limit',
|
|
keyFields: [ 'combo_sku_id', 'child_sku_id', 'spu', 'product_no', 'style_code' ],
|
|
ordinaryFields: [ 'non_spu_manager_limit_price_new', 'non_spu_manager_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ],
|
|
spuFields: [ 'spu_manager_limit_price_combo_2', 'spu_manager_limit_price_combo', 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ],
|
|
fallbackFields: [ 'expected_daily_limit_price', 'main_sales_pdd_subsidy_limit_price', 'pdd_limit_price_new' ],
|
|
managerField: 'spu_manager',
|
|
orderField: 'update_date',
|
|
},
|
|
];
|
|
|
|
function getDefaultAnalysisDateRange() {
|
|
return {
|
|
startDate: dayjs().startOf('month').format('YYYY-MM-DD'),
|
|
endDate: dayjs().format('YYYY-MM-DD'),
|
|
};
|
|
}
|
|
|
|
async function getCachedAnalysisPayload({
|
|
scope,
|
|
payload,
|
|
label,
|
|
memoryTtlSeconds = ANALYSIS_HOT_MEMORY_TTL_SECONDS,
|
|
redisTtlSeconds = ANALYSIS_HOT_REDIS_TTL_SECONDS,
|
|
loader,
|
|
}) {
|
|
const cacheKey = dataPageCache.buildHashedCacheKey(
|
|
'analysis:hot',
|
|
{
|
|
scope,
|
|
...(payload || {}),
|
|
},
|
|
ANALYSIS_HOT_CACHE_VERSION
|
|
);
|
|
|
|
return dataPageCache.getOrSetCachedPayload({
|
|
key: cacheKey,
|
|
memoryTtlSeconds,
|
|
redisTtlSeconds,
|
|
label,
|
|
loader,
|
|
});
|
|
}
|
|
|
|
async function queryWarehouseKpi(start, end) {
|
|
const result = await localDb.query(`
|
|
SELECT
|
|
COALESCE(SUM(ship_count), 0) as shipCount,
|
|
COALESCE(SUM(refund_count), 0) as refundCount,
|
|
ROUND(COALESCE(SUM(refund_count), 0) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
COUNT(DISTINCT warehouse_name) as warehouseCount
|
|
FROM summary_daily_warehouse
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
`, [start, end]);
|
|
|
|
return result[0] || {};
|
|
}
|
|
|
|
async function queryWarehouseRanking(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
warehouse_name as name,
|
|
SUM(ship_count) as shipCount,
|
|
SUM(refund_count) as refundCount,
|
|
ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
ROUND(AVG(NULLIF(timely_rate, 0)), 1) as timelyRate
|
|
FROM summary_daily_warehouse
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY warehouse_name
|
|
ORDER BY refundRate ASC
|
|
LIMIT ?
|
|
`, [start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function queryLogisticsKpi(start, end) {
|
|
const result = await localDb.query(`
|
|
SELECT
|
|
COALESCE(SUM(ship_count), 0) as shipCount,
|
|
COALESCE(SUM(refund_count), 0) as refundCount,
|
|
ROUND(COALESCE(SUM(refund_count), 0) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
ROUND(AVG(avg_delivery_hours), 1) as avgDeliveryHours,
|
|
ROUND(AVG(pickup_timely_rate), 2) as pickupTimelyRate,
|
|
COALESCE(SUM(damage_count), 0) as damageCount,
|
|
COALESCE(SUM(damage_base_count), 0) as damageBaseCount,
|
|
ROUND(COALESCE(SUM(damage_count), 0) * 100.0 / NULLIF(SUM(damage_base_count), 0), 2) as damageRate,
|
|
ROUND(AVG(bad_review_rate), 2) as badReviewRate,
|
|
COUNT(DISTINCT courier_name) as courierCount
|
|
FROM summary_daily_courier
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
`, [start, end]);
|
|
|
|
return result[0] || {};
|
|
}
|
|
|
|
async function queryLogisticsRanking(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
c.courier_name as name,
|
|
SUM(c.ship_count) as shipCount,
|
|
SUM(c.refund_count) as refundCount,
|
|
ROUND(SUM(c.refund_count) * 100.0 / NULLIF(SUM(c.ship_count), 0), 2) as refundRate,
|
|
ROUND(AVG(NULLIF(c.avg_delivery_hours, 0)), 1) as avgDeliveryHours,
|
|
ROUND(
|
|
(SUM(COALESCE(p.total_order_count, 0)) - SUM(COALESCE(p.timeout_count, 0))) * 100.0
|
|
/ NULLIF(SUM(COALESCE(p.total_order_count, 0)), 0),
|
|
2
|
|
) as pickupRate,
|
|
SUM(COALESCE(p.total_order_count, 0)) as pickupBaseCount,
|
|
ROUND(AVG(c.bad_review_rate), 2) as badReviewRate,
|
|
SUM(COALESCE(c.damage_count, 0)) as damageCount,
|
|
SUM(COALESCE(c.damage_base_count, 0)) as damageBaseCount,
|
|
ROUND(SUM(COALESCE(c.damage_count, 0)) * 100.0 / NULLIF(SUM(COALESCE(c.damage_base_count, 0)), 0), 2) as damageRate
|
|
FROM summary_daily_courier c
|
|
LEFT JOIN summary_daily_pickup_courier p
|
|
ON c.stat_date = p.stat_date AND c.courier_name = p.courier_name
|
|
WHERE c.stat_date BETWEEN ? AND ?
|
|
GROUP BY c.courier_name
|
|
ORDER BY refundRate ASC
|
|
LIMIT ?
|
|
`, [start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function queryLogisticsAbnormalTypes(start, end) {
|
|
const sql = `
|
|
WITH abnormal_base AS (
|
|
SELECT
|
|
CASE
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%涓欢%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%涓㈠け%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓欢%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓㈠け%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓欢%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓㈠け%'
|
|
THEN N'涓欢'
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%鐮存崯%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%鎹熷潖%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%鐮存崯%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎹熷潖%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%鐮存崯%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎹熷潖%'
|
|
THEN N'鐮存崯'
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%寤惰%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%瓒呮椂%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%婊炵暀%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%寤惰%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%瓒呮椂%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%婊炵暀%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%寤惰%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%瓒呮椂%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%婊炵暀%'
|
|
THEN N'寤惰'
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%鎷掓敹%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%绛炬敹寮傚父%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%閫€鍥?'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎷掓敹%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%绛炬敹寮傚父%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%閫€鍥?'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎷掓敹%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%绛炬敹寮傚父%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%閫€鍥?'
|
|
THEN N'绛炬敹寮傚父'
|
|
ELSE N'鍏朵粬寮傚父'
|
|
END AS abnormalType,
|
|
COUNT(DISTINCT e.so_id) AS orderCount
|
|
FROM jst_order_express_details e
|
|
WHERE CONVERT(date, e.send_time) BETWEEN @startDate AND @endDate
|
|
AND (
|
|
ISNULL(e.first_question_type, N'') <> N''
|
|
OR ISNULL(e.logistic_status_name, N'') <> N''
|
|
OR ISNULL(e.last_logistic_info, N'') <> N''
|
|
)
|
|
GROUP BY
|
|
CASE
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%涓欢%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%涓㈠け%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓欢%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%涓㈠け%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓欢%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%涓㈠け%'
|
|
THEN N'涓欢'
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%鐮存崯%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%鎹熷潖%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%鐮存崯%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎹熷潖%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%鐮存崯%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎹熷潖%'
|
|
THEN N'鐮存崯'
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%寤惰%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%瓒呮椂%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%婊炵暀%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%寤惰%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%瓒呮椂%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%婊炵暀%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%寤惰%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%瓒呮椂%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%婊炵暀%'
|
|
THEN N'寤惰'
|
|
WHEN ISNULL(e.first_question_type, N'') LIKE N'%鎷掓敹%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%绛炬敹寮傚父%'
|
|
OR ISNULL(e.first_question_type, N'') LIKE N'%閫€鍥?'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%鎷掓敹%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%绛炬敹寮傚父%'
|
|
OR ISNULL(e.logistic_status_name, N'') LIKE N'%閫€鍥?'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%鎷掓敹%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%绛炬敹寮傚父%'
|
|
OR ISNULL(e.last_logistic_info, N'') LIKE N'%閫€鍥?'
|
|
THEN N'绛炬敹寮傚父'
|
|
ELSE N'鍏朵粬寮傚父'
|
|
END
|
|
)
|
|
SELECT
|
|
abnormalType AS name,
|
|
orderCount AS value,
|
|
CAST(orderCount * 100.0 / NULLIF(SUM(orderCount) OVER (), 0) AS DECIMAL(10,2)) AS pct
|
|
FROM abnormal_base
|
|
ORDER BY orderCount DESC
|
|
`;
|
|
|
|
return cloudDb.query(sql, { startDate: start, endDate: end });
|
|
}
|
|
|
|
async function queryLogisticsTrend(start, end, dimension) {
|
|
let groupExpr = "DATE_FORMAT(stat_date, '%m-%d')";
|
|
if (dimension === 'weekly') {
|
|
groupExpr = "CONCAT(DATE_FORMAT(MIN(stat_date), '%m-%d'), '~', DATE_FORMAT(MAX(stat_date), '%m-%d'))";
|
|
} else if (dimension === 'monthly') {
|
|
groupExpr = "DATE_FORMAT(stat_date, '%Y-%m')";
|
|
}
|
|
|
|
return localDb.query(`
|
|
SELECT
|
|
${groupExpr} as date,
|
|
ROUND(SUM(total_delivery_hours) / NULLIF(SUM(total_order_count), 0), 2) as avgTime,
|
|
SUM(total_order_count) as orderCount
|
|
FROM summary_daily_delivery
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY ${dimension === 'daily' ? 'stat_date' : dimension === 'weekly' ? 'YEARWEEK(stat_date, 1)' : "DATE_FORMAT(stat_date, '%Y-%m')"}
|
|
ORDER BY MIN(stat_date) ASC
|
|
`, [start, end]);
|
|
}
|
|
|
|
async function queryRegionKpi(start, end) {
|
|
const result = await localDb.query(`
|
|
SELECT
|
|
COALESCE(SUM(ship_count), 0) as shipCount,
|
|
COALESCE(SUM(refund_count), 0) as refundCount,
|
|
ROUND(COALESCE(SUM(refund_count), 0) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
ROUND(AVG(avg_delivery_hours), 1) as avgDeliveryHours,
|
|
COUNT(DISTINCT province) as provinceCount
|
|
FROM summary_daily_province
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
`, [start, end]);
|
|
|
|
return result[0] || {};
|
|
}
|
|
|
|
async function queryRegionRanking(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
province as name,
|
|
SUM(ship_count) as shipCount,
|
|
SUM(refund_count) as refundCount,
|
|
ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
ROUND(AVG(avg_delivery_hours), 1) as avgDeliveryHours
|
|
FROM summary_daily_province
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY province
|
|
ORDER BY shipCount DESC
|
|
LIMIT ?
|
|
`, [start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function querySupplierRanking(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
supplier_name as name,
|
|
SUM(ship_count) as shipCount,
|
|
SUM(refund_count) as refundCount,
|
|
ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate
|
|
FROM summary_daily_supplier
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY supplier_name
|
|
ORDER BY refundRate ASC
|
|
LIMIT ?
|
|
`, [start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function queryCsRanking(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
cs_name as name,
|
|
SUM(ship_count) as shipCount,
|
|
SUM(refund_count) as refundCount,
|
|
ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
SUM(compensation_count) as compensationCount,
|
|
SUM(chat_issue_count) as chatIssueCount
|
|
FROM summary_daily_cs
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY cs_name
|
|
ORDER BY refundRate ASC
|
|
LIMIT ?
|
|
`, [start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function queryProcurementRanking(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
purchaser_name as name,
|
|
SUM(ship_count) as shipCount,
|
|
SUM(refund_count) as refundCount,
|
|
ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
SUM(under10_count) as under10Count
|
|
FROM summary_daily_purchaser
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY purchaser_name
|
|
ORDER BY refundRate ASC
|
|
LIMIT ?
|
|
`, [start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function queryDesignRanking(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
designer_name as name,
|
|
SUM(ship_count) as shipCount,
|
|
SUM(refund_count) as refundCount,
|
|
ROUND(SUM(refund_count) * 100.0 / NULLIF(SUM(ship_count), 0), 2) as refundRate,
|
|
SUM(problem_spu_count) as problemSpuCount
|
|
FROM summary_daily_designer
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY designer_name
|
|
ORDER BY refundRate ASC
|
|
LIMIT ?
|
|
`, [start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function queryChatIssues(start, end, limit) {
|
|
return localDb.query(`
|
|
SELECT
|
|
main_reason as mainReason,
|
|
second_reason as secondReason,
|
|
SUM(issue_count) as count,
|
|
ROUND(SUM(issue_count) * 100.0 /
|
|
(SELECT SUM(issue_count) FROM summary_daily_chat_issue
|
|
WHERE stat_date BETWEEN ? AND ?), 2) as percent
|
|
FROM summary_daily_chat_issue
|
|
WHERE stat_date BETWEEN ? AND ?
|
|
GROUP BY main_reason, second_reason
|
|
ORDER BY count DESC
|
|
LIMIT ?
|
|
`, [start, end, start, end, parseInt(limit, 10)]);
|
|
}
|
|
|
|
async function prewarmHotAnalysisPages() {
|
|
const { startDate, endDate } = getDefaultAnalysisDateRange();
|
|
const warmTasks = [
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'warehouse:kpi',
|
|
payload: { startDate, endDate },
|
|
label: 'analysis:warehouse:kpi',
|
|
loader: () => queryWarehouseKpi(startDate, endDate),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'warehouse:ranking',
|
|
payload: { startDate, endDate, limit: 20 },
|
|
label: 'analysis:warehouse:ranking',
|
|
loader: () => queryWarehouseRanking(startDate, endDate, 20),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'logistics:kpi',
|
|
payload: { startDate, endDate },
|
|
label: 'analysis:logistics:kpi',
|
|
loader: () => queryLogisticsKpi(startDate, endDate),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'logistics:ranking',
|
|
payload: { startDate, endDate, limit: 20 },
|
|
label: 'analysis:logistics:ranking',
|
|
loader: () => queryLogisticsRanking(startDate, endDate, 20),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'logistics:abnormal-types',
|
|
payload: { startDate, endDate },
|
|
label: 'analysis:logistics:abnormal-types',
|
|
loader: () => queryLogisticsAbnormalTypes(startDate, endDate),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'logistics:trend',
|
|
payload: { startDate, endDate, dimension: 'daily' },
|
|
label: 'analysis:logistics:trend',
|
|
loader: () => queryLogisticsTrend(startDate, endDate, 'daily'),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'region:kpi',
|
|
payload: { startDate, endDate },
|
|
label: 'analysis:region:kpi',
|
|
loader: () => queryRegionKpi(startDate, endDate),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'region:ranking',
|
|
payload: { startDate, endDate, limit: 30 },
|
|
label: 'analysis:region:ranking',
|
|
loader: () => queryRegionRanking(startDate, endDate, 30),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'supplier:ranking',
|
|
payload: { startDate, endDate, limit: 20 },
|
|
label: 'analysis:supplier:ranking',
|
|
loader: () => querySupplierRanking(startDate, endDate, 20),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'cs:ranking',
|
|
payload: { startDate, endDate, limit: 20 },
|
|
label: 'analysis:cs:ranking',
|
|
loader: () => queryCsRanking(startDate, endDate, 20),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'procurement:ranking',
|
|
payload: { startDate, endDate, limit: 20 },
|
|
label: 'analysis:procurement:ranking',
|
|
loader: () => queryProcurementRanking(startDate, endDate, 20),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'design:ranking',
|
|
payload: { startDate, endDate, limit: 20 },
|
|
label: 'analysis:design:ranking',
|
|
loader: () => queryDesignRanking(startDate, endDate, 20),
|
|
}),
|
|
() => getCachedAnalysisPayload({
|
|
scope: 'chat-issues',
|
|
payload: { startDate, endDate, limit: 20 },
|
|
label: 'analysis:chat-issues',
|
|
loader: () => queryChatIssues(startDate, endDate, 20),
|
|
}),
|
|
];
|
|
|
|
for (const run of warmTasks) {
|
|
await run();
|
|
}
|
|
|
|
console.log(`[AnalysisCache] 宸查鐑垎鏋愰〉榛樿鏌ヨ锛屾椂闂磋寖鍥?${startDate} ~ ${endDate}锛屼换鍔℃暟 ${warmTasks.length}`);
|
|
return { startDate, endDate, taskCount: warmTasks.length };
|
|
}
|
|
const PDD_PRODUCT_BOARD_LIST_MEMORY_TTL_SECONDS = 1800;
|
|
const PDD_PRODUCT_BOARD_LIST_REDIS_TTL_SECONDS = 3600;
|
|
const pddMetaCache = new Map();
|
|
const pddCountCache = new Map();
|
|
const pddLatestDateCache = new Map();
|
|
const pddDashboardResultCache = new Map();
|
|
const PDD_ACCESS_DEPARTMENT_MAP = {
|
|
'杩愯惀涓績A': '杩愯惀涓績A',
|
|
'杩愯惀涓績B': '杩愯惀涓績B',
|
|
'杩愯惀涓績C': '杩愯惀涓績C',
|
|
'杩愯惀涓績G': '杩愯惀涓績G',
|
|
'鍝佺墝涓績': '榫欐櫒鍝佺墝涓績',
|
|
'榫欐櫒鍝佺墝涓績': '榫欐櫒鍝佺墝涓績',
|
|
};
|
|
|
|
function getMemoryCache(map, key) {
|
|
const cached = map.get(key);
|
|
if (!cached) return null;
|
|
if (cached.expiresAt <= Date.now()) {
|
|
map.delete(key);
|
|
return null;
|
|
}
|
|
return cached.value;
|
|
}
|
|
|
|
function setMemoryCache(map, key, value, ttlMs) {
|
|
map.set(key, {
|
|
value,
|
|
expiresAt: Date.now() + ttlMs,
|
|
});
|
|
}
|
|
|
|
function normalizePddDepartments(...departmentValues) {
|
|
const result = new Set();
|
|
|
|
departmentValues
|
|
.filter(Boolean)
|
|
.flatMap((value) => String(value).split(/[\/,锛屻€亅]/))
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.forEach((value) => {
|
|
const mapped = PDD_ACCESS_DEPARTMENT_MAP[value];
|
|
if (mapped) {
|
|
result.add(mapped);
|
|
}
|
|
});
|
|
|
|
return Array.from(result);
|
|
}
|
|
|
|
function normalizeMultiValueQueryParam(value) {
|
|
const rawValues = Array.isArray(value) ? value : [ value ];
|
|
|
|
return rawValues
|
|
.flatMap((item) => String(item || '').split(/[,\uff0c]/))
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function getLatestStockSelectionStatDate() {
|
|
await ensurePlanningStockSelectionSchema();
|
|
const rows = await stockSelectionDb.query(
|
|
`
|
|
SELECT MAX(statDate) AS latestStatDate
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\`
|
|
`
|
|
);
|
|
|
|
return rows?.[0]?.latestStatDate || null;
|
|
}
|
|
|
|
async function getLatestPddDataGoodsDate() {
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
MAX(TRY_CONVERT(date, [createTime])) AS latestBizDate
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}]
|
|
WHERE TRY_CONVERT(date, [createTime]) IS NOT NULL
|
|
`
|
|
);
|
|
|
|
return rows?.[0]?.latestBizDate || null;
|
|
}
|
|
|
|
async function getLatestPddTableDate(tableName) {
|
|
const cacheKey = `latest-date:${tableName}`;
|
|
const cachedValue = getMemoryCache(pddLatestDateCache, cacheKey);
|
|
if (cachedValue !== null) {
|
|
return cachedValue;
|
|
}
|
|
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
MAX(TRY_CONVERT(date, [createTime])) AS latestBizDate
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${tableName}]
|
|
WHERE TRY_CONVERT(date, [createTime]) IS NOT NULL
|
|
`
|
|
);
|
|
|
|
const latestBizDate = rows?.[0]?.latestBizDate || null;
|
|
setMemoryCache(pddLatestDateCache, cacheKey, latestBizDate, PDD_LATEST_DATE_CACHE_TTL_MS);
|
|
return latestBizDate;
|
|
}
|
|
|
|
async function getLatestPddDashboardBizDate(tableNames = []) {
|
|
const uniqueTableNames = Array.from(new Set((tableNames || []).filter(Boolean)));
|
|
if (uniqueTableNames.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const cacheKey = dataPageCache.buildHashedCacheKey(
|
|
'pdd:dashboard:latest-biz-date',
|
|
{ tableNames: uniqueTableNames.sort() },
|
|
PDD_PRODUCT_BOARD_CACHE_VERSION
|
|
);
|
|
|
|
const cached = await dataPageCache.getOrSetCachedPayload({
|
|
key: cacheKey,
|
|
memoryTtlSeconds: PDD_DASHBOARD_BIZ_DATE_CACHE_TTL_SECONDS,
|
|
redisTtlSeconds: PDD_DASHBOARD_BIZ_DATE_CACHE_TTL_SECONDS,
|
|
loader: async () => {
|
|
const dateValues = [];
|
|
for (const tableName of uniqueTableNames) {
|
|
const latestDate = await getLatestPddTableDate(tableName);
|
|
if (latestDate) {
|
|
dateValues.push(dayjs(latestDate).format('YYYY-MM-DD'));
|
|
}
|
|
}
|
|
|
|
if (dateValues.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return dateValues.reduce((minDate, currentDate) => (
|
|
dayjs(currentDate).isBefore(dayjs(minDate)) ? currentDate : minDate
|
|
));
|
|
},
|
|
});
|
|
|
|
return cached.value;
|
|
}
|
|
|
|
async function getPddProductDashboardRequestContext(req, tableNames = []) {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
const error = new Error('user not found');
|
|
error.statusCode = 401;
|
|
throw error;
|
|
}
|
|
|
|
const latestBizDate = await getLatestPddDashboardBizDate(tableNames);
|
|
const endDate = latestBizDate ? dayjs(latestBizDate).format('YYYY-MM-DD') : null;
|
|
const startDate = endDate ? dayjs(endDate).subtract(6, 'day').format('YYYY-MM-DD') : null;
|
|
const prevStartDate = endDate ? dayjs(endDate).subtract(13, 'day').format('YYYY-MM-DD') : null;
|
|
const prevEndDate = endDate ? dayjs(endDate).subtract(7, 'day').format('YYYY-MM-DD') : null;
|
|
|
|
return {
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
prevStartDate,
|
|
prevEndDate,
|
|
};
|
|
}
|
|
|
|
function getPddProductDashboardCommonFilters(req) {
|
|
return {
|
|
shopName: String(req.query.shopName || '').trim(),
|
|
goodsId: String(req.query.goodsId || '').trim(),
|
|
skuCode: String(req.query.skuCode || '').trim(),
|
|
department: String(req.query.department || '').trim(),
|
|
idOwners: normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner),
|
|
};
|
|
}
|
|
|
|
function shouldUsePddTargetJoinForDashboardFilters(req, accessScope) {
|
|
const { department, idOwners } = getPddProductDashboardCommonFilters(req);
|
|
return !accessScope.isAllData || Boolean(department) || idOwners.length > 0;
|
|
}
|
|
|
|
function appendPddCreateTimeRangeFilters({
|
|
whereParts,
|
|
params,
|
|
fieldExpr = 'g.[createTime]',
|
|
startDate,
|
|
endDate,
|
|
startParamName = 'startDateTime',
|
|
endParamName = 'endDateExclusive',
|
|
} = {}) {
|
|
if (startDate) {
|
|
whereParts.push(`${fieldExpr} >= @${startParamName}`);
|
|
params[startParamName] = `${dayjs(startDate).format('YYYY-MM-DD')} 00:00:00`;
|
|
}
|
|
|
|
if (endDate) {
|
|
whereParts.push(`${fieldExpr} < @${endParamName}`);
|
|
params[endParamName] = dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00');
|
|
}
|
|
}
|
|
|
|
function appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
shopNameExpr = "TRY_CONVERT(NVARCHAR(255), g.[shopName])",
|
|
goodsIdExpr = "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))",
|
|
skuCodeExpr = null,
|
|
departmentExpr = 'tm.[dept]',
|
|
idOwnerExpr = 'tm.[ider]',
|
|
scopeParamPrefix = 'scopeDept',
|
|
shopNameParamName = 'shopName',
|
|
goodsIdParamName = 'goodsId',
|
|
skuCodeParamName = 'skuCode',
|
|
departmentParamName = 'department',
|
|
idOwnerParamPrefix = 'idOwner',
|
|
} = {}) {
|
|
const { shopName, goodsId, skuCode, department, idOwners } = getPddProductDashboardCommonFilters(req);
|
|
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0) {
|
|
return false;
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, departmentExpr, scopeParamPrefix);
|
|
}
|
|
|
|
if (shopName) {
|
|
whereParts.push(`${shopNameExpr} = @${shopNameParamName}`);
|
|
params[shopNameParamName] = shopName;
|
|
}
|
|
|
|
if (goodsId) {
|
|
whereParts.push(`${goodsIdExpr} = @${goodsIdParamName}`);
|
|
params[goodsIdParamName] = goodsId;
|
|
}
|
|
|
|
if (skuCode && skuCodeExpr) {
|
|
whereParts.push(`${skuCodeExpr} = @${skuCodeParamName}`);
|
|
params[skuCodeParamName] = skuCode;
|
|
}
|
|
|
|
if (department && departmentExpr) {
|
|
whereParts.push(`${departmentExpr} = @${departmentParamName}`);
|
|
params[departmentParamName] = department;
|
|
}
|
|
|
|
if (idOwners.length > 0 && idOwnerExpr) {
|
|
appendNamedInCondition(whereParts, params, idOwners, idOwnerExpr, idOwnerParamPrefix);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function getPddProductDashboardBaseColumns(extraColumns = []) {
|
|
return [
|
|
{ key: 'goodsId', title: '商品id' },
|
|
{ key: 'goodsName', title: '商品名称' },
|
|
{ key: 'hdThumbUrl', title: '商品主图' },
|
|
{ key: 'idOwner', title: 'id负责人' },
|
|
{ key: 'ownerDept', title: '所属部门' },
|
|
{ key: 'shopName', title: '店铺名称' },
|
|
...extraColumns,
|
|
];
|
|
}
|
|
|
|
function buildPddDashboardAccessScopeCacheFragment(accessScope) {
|
|
if (!accessScope || accessScope.isAllData) {
|
|
return {
|
|
scopeType: 'all',
|
|
departments: [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
scopeType: 'department',
|
|
departments: normalizePddDepartments(...(accessScope.allowedDepartments || []))
|
|
.map((item) => String(item).trim().toLowerCase())
|
|
.sort(),
|
|
};
|
|
}
|
|
|
|
function buildPddProductDashboardResponse({ page, pageSize, total, startDate, endDate, columns, rows, extra = {} }) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
rows: rows || [],
|
|
...extra,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns, extra = {} }) {
|
|
return buildPddProductDashboardResponse({
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
rows: [],
|
|
extra,
|
|
});
|
|
}
|
|
|
|
async function getCachedPddProductDashboardPayload({
|
|
boardKey,
|
|
reqQuery,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
extraCachePayload = {},
|
|
extraResponseData = {},
|
|
loader,
|
|
}) {
|
|
const cacheKey = dataPageCache.buildHashedCacheKey(
|
|
'pdd:product-dashboard:list',
|
|
{
|
|
boardKey,
|
|
query: reqQuery || {},
|
|
page,
|
|
pageSize,
|
|
startDate,
|
|
endDate,
|
|
accessScope: buildPddDashboardAccessScopeCacheFragment(accessScope),
|
|
...extraCachePayload,
|
|
},
|
|
PDD_PRODUCT_BOARD_CACHE_VERSION
|
|
);
|
|
|
|
const cached = await dataPageCache.getOrSetCachedPayload({
|
|
key: cacheKey,
|
|
memoryTtlSeconds: PDD_PRODUCT_BOARD_LIST_MEMORY_TTL_SECONDS,
|
|
redisTtlSeconds: PDD_PRODUCT_BOARD_LIST_REDIS_TTL_SECONDS,
|
|
label: `pdd:${boardKey}:list`,
|
|
loader: async () => {
|
|
const result = await loader();
|
|
return buildPddProductDashboardResponse({
|
|
page,
|
|
pageSize,
|
|
total: result.total,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
rows: result.rows,
|
|
extra: extraResponseData,
|
|
});
|
|
},
|
|
});
|
|
|
|
return cached.value;
|
|
}
|
|
|
|
function buildPddDashboardScopeJoinSql({
|
|
accessScope,
|
|
params,
|
|
whereParts,
|
|
goodsIdExpr,
|
|
alias = 'tm',
|
|
}) {
|
|
if (accessScope.isAllData) {
|
|
return '';
|
|
}
|
|
|
|
const joinSql = aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: false,
|
|
goodsIdExpr,
|
|
}), alias);
|
|
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, `${alias}.[dept]`, `${alias}ScopeDept`);
|
|
return joinSql;
|
|
}
|
|
|
|
function aliasPddTargetJoinSql(joinSql, alias) {
|
|
return String(joinSql || '')
|
|
.replace(/\btm\./g, `${alias}.`)
|
|
.replace(/\)\s*tm\b/g, `) ${alias}`);
|
|
}
|
|
|
|
function buildStockSelectionWhereClause({
|
|
statDate,
|
|
poolType,
|
|
ruleCode,
|
|
barcode,
|
|
}) {
|
|
const whereParts = [ 'r.`statDate` = ?' ];
|
|
const params = [ statDate ];
|
|
|
|
if (poolType) {
|
|
whereParts.push('r.`poolType` = ?');
|
|
params.push(poolType);
|
|
}
|
|
if (ruleCode) {
|
|
whereParts.push('r.`ruleCode` = ?');
|
|
params.push(ruleCode);
|
|
}
|
|
if (barcode) {
|
|
whereParts.push('CAST(r.`barcode` AS CHAR) LIKE ?');
|
|
params.push(`%${barcode}%`);
|
|
}
|
|
|
|
return {
|
|
whereSql: `WHERE ${whereParts.join(' AND ')}`,
|
|
params,
|
|
};
|
|
}
|
|
|
|
function appendNamedInCondition(whereParts, params, values, fieldExpr, paramPrefix) {
|
|
if (!Array.isArray(values) || values.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const placeholders = values.map((value, index) => {
|
|
const key = `${paramPrefix}${index}`;
|
|
params[key] = value;
|
|
return `@${key}`;
|
|
});
|
|
|
|
whereParts.push(`${fieldExpr} IN (${placeholders.join(', ')})`);
|
|
}
|
|
|
|
async function getPddDataAccessScope(userId) {
|
|
const rows = await localDb.query(
|
|
`SELECT
|
|
u.id,
|
|
u.username,
|
|
u.role,
|
|
u.department_names,
|
|
d.name AS department_name
|
|
FROM users u
|
|
LEFT JOIN departments d ON u.department_id = d.id
|
|
WHERE u.id = ?
|
|
LIMIT 1`,
|
|
[userId]
|
|
);
|
|
|
|
const user = rows[0];
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
if (user.role === 'admin') {
|
|
return {
|
|
isAllData: true,
|
|
allowedDepartments: [],
|
|
user,
|
|
};
|
|
}
|
|
|
|
const allowedDepartments = normalizePddDepartments(user.department_names, user.department_name);
|
|
return {
|
|
isAllData: false,
|
|
allowedDepartments,
|
|
user,
|
|
};
|
|
}
|
|
|
|
function safeIdentifier(name, fieldName) {
|
|
if (!name || !IDENTIFIER_REGEXP.test(name)) {
|
|
const error = new Error(`Invalid ${fieldName}`);
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function safePositiveInt(value, defaultValue, minValue, maxValue) {
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isFinite(parsed)) return defaultValue;
|
|
return Math.min(maxValue, Math.max(minValue, parsed));
|
|
}
|
|
|
|
// ==================== 浠撳簱鍒嗘瀽 ====================
|
|
|
|
/**
|
|
* 浠撳簱KPI
|
|
*/
|
|
function getPddSpreadFieldTitle(fieldDef) {
|
|
if (fieldDef.key === 'minGroupPrice') {
|
|
return '拼单价';
|
|
}
|
|
return fieldDef.label;
|
|
}
|
|
|
|
function getPddSpreadFieldExpr(fieldKey) {
|
|
if (PDD_SPREAD_DIVIDE_BY_1000_FIELDS.has(fieldKey)) {
|
|
return `CAST(TRY_CONVERT(DECIMAL(38, 6), g.[${fieldKey}]) / 1000.0 AS DECIMAL(38, 6))`;
|
|
}
|
|
if (PDD_SPREAD_DIVIDE_BY_10000_FIELDS.has(fieldKey)) {
|
|
return `CAST(TRY_CONVERT(DECIMAL(38, 6), g.[${fieldKey}]) / 10000.0 AS DECIMAL(38, 6))`;
|
|
}
|
|
return `g.[${fieldKey}]`;
|
|
}
|
|
|
|
async function getPddTableColumns(schemaName, tableName) {
|
|
const metaCacheKey = `${schemaName}.${tableName}.columns`;
|
|
let allColumns = getMemoryCache(pddMetaCache, metaCacheKey);
|
|
if (!allColumns) {
|
|
allColumns = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
COLUMN_NAME AS columnName,
|
|
DATA_TYPE AS dataType
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = @schemaName
|
|
AND TABLE_NAME = @tableName
|
|
ORDER BY ORDINAL_POSITION
|
|
`,
|
|
{ schemaName, tableName }
|
|
);
|
|
setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS);
|
|
}
|
|
return allColumns || [];
|
|
}
|
|
|
|
function buildPddTargetJoinSql({ includeIdOwner = true } = {}) {
|
|
return buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
});
|
|
}
|
|
|
|
function getPddGoodsIdJoinExpr(tableAlias = 'g') {
|
|
return `LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), ${tableAlias}.[goodsId])))`;
|
|
}
|
|
|
|
function buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner = true,
|
|
goodsIdExpr,
|
|
} = {}) {
|
|
const selectFields = includeIdOwner
|
|
? `
|
|
MAX(src.[ider]) AS [ider],
|
|
MAX(src.[dept]) AS [dept]`
|
|
: `
|
|
MAX(src.[dept]) AS [dept]`;
|
|
|
|
return `
|
|
OUTER APPLY (
|
|
SELECT
|
|
${selectFields}
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
WHERE src.[shopproid] = ${goodsIdExpr}
|
|
) tm
|
|
`;
|
|
}
|
|
|
|
function buildPddTargetApplySql({
|
|
includeIdOwner = true,
|
|
goodsIdExpr,
|
|
} = {}) {
|
|
const selectFields = includeIdOwner
|
|
? `
|
|
MAX(src.[ider]) AS [ider],
|
|
MAX(src.[dept]) AS [dept]
|
|
`
|
|
: `
|
|
MAX(src.[dept]) AS [dept]
|
|
`;
|
|
|
|
return `
|
|
OUTER APPLY (
|
|
SELECT
|
|
${selectFields}
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
WHERE src.[shopproid] = ${goodsIdExpr}
|
|
) tm
|
|
`;
|
|
}
|
|
|
|
function buildPddTargetExtendedJoinSqlByExpr({
|
|
goodsIdExpr,
|
|
fields = [ 'ider', 'dept', 'oper', 'spu' ],
|
|
} = {}) {
|
|
const validFields = Array.from(new Set((fields || []).filter(Boolean)));
|
|
const selectFields = validFields
|
|
.map((field) => `MAX(src.[${field}]) AS [${field}]`)
|
|
.join(',\n ');
|
|
|
|
return `
|
|
OUTER APPLY (
|
|
SELECT
|
|
${selectFields}
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
WHERE src.[shopproid] = ${goodsIdExpr}
|
|
) tm
|
|
`;
|
|
}
|
|
|
|
function buildPddDataGoodsImageApplySql({
|
|
goodsIdExpr,
|
|
} = {}) {
|
|
return `
|
|
OUTER APPLY (
|
|
SELECT TOP (1)
|
|
TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]) AS [hdThumbUrl]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] src
|
|
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId]))) = ${goodsIdExpr}
|
|
AND TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]) IS NOT NULL
|
|
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]))) <> ''
|
|
ORDER BY TRY_CONVERT(DATETIME2, src.[createTime]) DESC, TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl]) DESC
|
|
) dg
|
|
`;
|
|
}
|
|
|
|
function buildPddPagedBaseSubquery({
|
|
schemaName,
|
|
tableName,
|
|
whereSql,
|
|
orderExpr,
|
|
orderDirection = 'DESC',
|
|
fallbackOrderExpr = null,
|
|
}) {
|
|
const orderBySql = !fallbackOrderExpr || fallbackOrderExpr === orderExpr
|
|
? `${orderExpr} ${orderDirection}`
|
|
: `${orderExpr} ${orderDirection}, ${fallbackOrderExpr} DESC`;
|
|
return `
|
|
FROM (
|
|
SELECT *
|
|
FROM [${schemaName}].[${tableName}] g
|
|
${whereSql}
|
|
ORDER BY ${orderBySql}
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
|
|
) g
|
|
`;
|
|
}
|
|
|
|
function buildPagedRowsQuery({
|
|
selectFieldsSql,
|
|
fromSql,
|
|
whereSql,
|
|
orderExpr,
|
|
}) {
|
|
return `
|
|
SELECT *, COUNT(1) OVER() AS __total
|
|
${fromSql}
|
|
${whereSql}
|
|
ORDER BY ${orderExpr} DESC
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
|
|
`.replace('SELECT *', `SELECT ${selectFieldsSql}`);
|
|
}
|
|
|
|
function buildPddDisplayFieldDefs(selectedFieldDefs, options = {}) {
|
|
const {
|
|
extraFields = [],
|
|
extraFieldsAfterKey = null,
|
|
insertFieldGroups = [],
|
|
getTitle = (fieldDef) => fieldDef.label,
|
|
getExpr = (fieldDef) => `g.[${fieldDef.key}]`,
|
|
getSortExpr = (fieldDef) => fieldDef.sortExpr || getExpr(fieldDef),
|
|
} = options;
|
|
|
|
const displayFieldDefs = [];
|
|
const insertFieldGroupMap = new Map();
|
|
|
|
insertFieldGroups.forEach((group) => {
|
|
if (!group?.afterKey || !Array.isArray(group.fields) || group.fields.length === 0) {
|
|
return;
|
|
}
|
|
if (!insertFieldGroupMap.has(group.afterKey)) {
|
|
insertFieldGroupMap.set(group.afterKey, []);
|
|
}
|
|
insertFieldGroupMap.get(group.afterKey).push(...group.fields);
|
|
});
|
|
|
|
selectedFieldDefs.forEach((fieldDef) => {
|
|
displayFieldDefs.push({
|
|
key: fieldDef.key,
|
|
title: getTitle(fieldDef),
|
|
expr: getExpr(fieldDef),
|
|
sortExpr: getSortExpr(fieldDef),
|
|
});
|
|
|
|
if (extraFieldsAfterKey && fieldDef.key === extraFieldsAfterKey) {
|
|
extraFields.forEach((extraField) => {
|
|
displayFieldDefs.push({
|
|
key: extraField.key,
|
|
title: extraField.label,
|
|
expr: extraField.expr,
|
|
sortExpr: extraField.sortExpr || extraField.expr,
|
|
});
|
|
});
|
|
}
|
|
|
|
const groupedFields = insertFieldGroupMap.get(fieldDef.key) || [];
|
|
groupedFields.forEach((extraField) => {
|
|
displayFieldDefs.push({
|
|
key: extraField.key,
|
|
title: extraField.label,
|
|
expr: extraField.expr,
|
|
sortExpr: extraField.sortExpr || extraField.expr,
|
|
});
|
|
});
|
|
});
|
|
|
|
return displayFieldDefs;
|
|
}
|
|
|
|
async function queryPddPagedRows({
|
|
selectFieldsSql,
|
|
fromSql,
|
|
whereSql,
|
|
orderExpr,
|
|
params,
|
|
page,
|
|
pageSize,
|
|
fallbackCountSql = null,
|
|
}) {
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
|
|
const rows = await cloudDb.query(
|
|
buildPagedRowsQuery({
|
|
selectFieldsSql,
|
|
fromSql,
|
|
whereSql,
|
|
orderExpr,
|
|
}),
|
|
pagingParams
|
|
);
|
|
|
|
let total = Number(rows?.[0]?.__total || 0);
|
|
const normalizedRows = (rows || []).map((row) => {
|
|
const nextRow = { ...row };
|
|
delete nextRow.__total;
|
|
return nextRow;
|
|
});
|
|
|
|
if (normalizedRows.length === 0 && page > 1 && fallbackCountSql) {
|
|
const totalRows = await cloudDb.query(fallbackCountSql, params);
|
|
total = Number(totalRows?.[0]?.total || 0);
|
|
}
|
|
|
|
return {
|
|
total,
|
|
rows: normalizedRows,
|
|
};
|
|
}
|
|
|
|
async function queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql,
|
|
orderBySql,
|
|
extraFromSql = '',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
}) {
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
|
|
const rows = await cloudDb.query(
|
|
`
|
|
${baseCteSql}
|
|
SELECT ${selectFieldsSql}, COUNT(1) OVER() AS [__total]
|
|
FROM base
|
|
${extraFromSql}
|
|
ORDER BY ${orderBySql}
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
|
|
`,
|
|
pagingParams
|
|
);
|
|
|
|
const total = Number(rows?.[0]?.__total || 0);
|
|
const normalizedRows = (rows || []).map((row) => {
|
|
const nextRow = { ...row };
|
|
delete nextRow.__total;
|
|
return nextRow;
|
|
});
|
|
|
|
return {
|
|
total,
|
|
rows: normalizedRows,
|
|
};
|
|
}
|
|
|
|
async function hydratePddDashboardRowsWithGoodsDetails(rows, {
|
|
detailTableName = PDD_DATA_GOODS_TABLE,
|
|
startDate = null,
|
|
endDate = null,
|
|
includeIdOwner = true,
|
|
includeImage = true,
|
|
} = {}) {
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
return rows || [];
|
|
}
|
|
|
|
const goodsIds = Array.from(new Set(rows.map((row) => String(row.goodsId || '').trim()).filter(Boolean)));
|
|
if (goodsIds.length === 0) {
|
|
return rows;
|
|
}
|
|
|
|
const numericGoodsIds = goodsIds.every((value) => /^\d+$/.test(value))
|
|
? goodsIds.map((value) => Number(value))
|
|
: null;
|
|
const detailParams = {};
|
|
const detailWhereParts = [];
|
|
const hasNativeImageColumn = detailTableName === PDD_DATA_GOODS_TABLE;
|
|
appendNamedInCondition(
|
|
detailWhereParts,
|
|
detailParams,
|
|
numericGoodsIds || goodsIds,
|
|
numericGoodsIds ? 'src.[goodsId]' : 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId])))',
|
|
'detailGoodsId'
|
|
);
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts: detailWhereParts,
|
|
params: detailParams,
|
|
fieldExpr: 'src.[createTime]',
|
|
startDate,
|
|
endDate,
|
|
startParamName: 'detailStartDateTime',
|
|
endParamName: 'detailEndDateExclusive',
|
|
});
|
|
|
|
const detailRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
CONVERT(NVARCHAR(50), src.[goodsId]) AS [goodsId],
|
|
MAX(TRY_CONVERT(NVARCHAR(500), src.[goodsName])) AS [goodsName],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), src.[shopName])) AS [shopName],
|
|
${includeImage && hasNativeImageColumn ? 'MAX(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl])) AS [hdThumbUrl],' : 'CAST(NULL AS NVARCHAR(1000)) AS [hdThumbUrl],'}
|
|
CAST(NULL AS NVARCHAR(255)) AS [idOwner],
|
|
CAST(NULL AS NVARCHAR(255)) AS [ownerDept]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${detailTableName}] src
|
|
WHERE ${detailWhereParts.join(' AND ')}
|
|
GROUP BY src.[goodsId]
|
|
`,
|
|
detailParams
|
|
);
|
|
|
|
const detailMap = new Map((detailRows || []).map((row) => [ String(row.goodsId || ''), row ]));
|
|
|
|
if (includeIdOwner) {
|
|
const ownerParams = {};
|
|
const ownerWhereParts = [
|
|
'src.[shopproid] IS NOT NULL',
|
|
"LTRIM(RTRIM(src.[shopproid])) <> ''",
|
|
];
|
|
appendNamedInCondition(
|
|
ownerWhereParts,
|
|
ownerParams,
|
|
goodsIds,
|
|
'src.[shopproid]',
|
|
'ownerGoodsId'
|
|
);
|
|
const ownerRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
src.[shopproid] AS [goodsId],
|
|
MAX(src.[ider]) AS [idOwner],
|
|
MAX(src.[dept]) AS [ownerDept]
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
WHERE ${ownerWhereParts.join(' AND ')}
|
|
GROUP BY src.[shopproid]
|
|
`,
|
|
ownerParams
|
|
);
|
|
|
|
for (const ownerRow of ownerRows || []) {
|
|
const key = String(ownerRow.goodsId || '').trim();
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
const detail = detailMap.get(key) || { goodsId: key };
|
|
detail.idOwner = ownerRow.idOwner || detail.idOwner || null;
|
|
detail.ownerDept = ownerRow.ownerDept || detail.ownerDept || null;
|
|
detailMap.set(key, detail);
|
|
}
|
|
}
|
|
|
|
if (includeImage && !hasNativeImageColumn) {
|
|
const imageParams = {};
|
|
const imageWhereParts = [];
|
|
appendNamedInCondition(
|
|
imageWhereParts,
|
|
imageParams,
|
|
numericGoodsIds || goodsIds,
|
|
numericGoodsIds ? 'src.[goodsId]' : 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId])))',
|
|
'imageGoodsId'
|
|
);
|
|
const imageRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
CONVERT(NVARCHAR(50), src.[goodsId]) AS [goodsId],
|
|
MAX(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl])) AS [hdThumbUrl]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] src
|
|
WHERE ${imageWhereParts.join(' AND ')}
|
|
GROUP BY src.[goodsId]
|
|
`,
|
|
imageParams
|
|
);
|
|
|
|
for (const imageRow of imageRows || []) {
|
|
const key = String(imageRow.goodsId || '').trim();
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
const detail = detailMap.get(key) || { goodsId: key };
|
|
detail.hdThumbUrl = imageRow.hdThumbUrl || detail.hdThumbUrl || null;
|
|
detailMap.set(key, detail);
|
|
}
|
|
}
|
|
|
|
return rows.map((row) => {
|
|
const key = String(row.goodsId || '');
|
|
const detail = detailMap.get(key) || {};
|
|
return {
|
|
...row,
|
|
goodsName: detail.goodsName || row.goodsName || null,
|
|
hdThumbUrl: includeImage ? (detail.hdThumbUrl || row.hdThumbUrl || null) : (row.hdThumbUrl || null),
|
|
idOwner: includeIdOwner ? (detail.idOwner || row.idOwner || null) : (row.idOwner || null),
|
|
ownerDept: includeIdOwner ? (detail.ownerDept || row.ownerDept || null) : (row.ownerDept || null),
|
|
shopName: detail.shopName || row.shopName || null,
|
|
};
|
|
});
|
|
}
|
|
|
|
async function hydratePddDashboardRowsWithTargetFields(rows, fields = []) {
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
return rows || [];
|
|
}
|
|
|
|
const validFields = Array.from(new Set((fields || []).filter(Boolean)));
|
|
if (validFields.length === 0) {
|
|
return rows;
|
|
}
|
|
|
|
const goodsIds = Array.from(new Set(rows.map((row) => String(row.goodsId || '').trim()).filter(Boolean)));
|
|
if (goodsIds.length === 0) {
|
|
return rows;
|
|
}
|
|
|
|
const params = {};
|
|
const whereParts = [
|
|
'src.[shopproid] IS NOT NULL',
|
|
"LTRIM(RTRIM(src.[shopproid])) <> ''",
|
|
];
|
|
appendNamedInCondition(whereParts, params, goodsIds, 'src.[shopproid]', 'targetGoodsId');
|
|
|
|
const selectFieldsSql = validFields
|
|
.map((field) => `MAX(src.[${field}]) AS [${field}]`)
|
|
.join(',\n ');
|
|
|
|
const targetRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
src.[shopproid] AS [goodsId],
|
|
${selectFieldsSql}
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
WHERE ${whereParts.join(' AND ')}
|
|
GROUP BY src.[shopproid]
|
|
`,
|
|
params
|
|
);
|
|
|
|
const targetMap = new Map((targetRows || []).map((row) => [ String(row.goodsId || '').trim(), row ]));
|
|
|
|
return rows.map((row) => {
|
|
const key = String(row.goodsId || '').trim();
|
|
const targetRow = targetMap.get(key) || {};
|
|
const nextRow = { ...row };
|
|
for (const field of validFields) {
|
|
nextRow[field] = targetRow[field] || nextRow[field] || null;
|
|
}
|
|
return nextRow;
|
|
});
|
|
}
|
|
|
|
function getPddDashboardCacheKey(scope, payload) {
|
|
return JSON.stringify({ scope, ...payload });
|
|
}
|
|
|
|
async function getOrSetPddDashboardResultCache(cacheKey, loader, ttlMs = PDD_DASHBOARD_RESULT_CACHE_TTL_MS) {
|
|
const cachedValue = getMemoryCache(pddDashboardResultCache, cacheKey);
|
|
if (cachedValue !== null) {
|
|
return cachedValue;
|
|
}
|
|
|
|
const value = await loader();
|
|
setMemoryCache(pddDashboardResultCache, cacheKey, value, ttlMs);
|
|
return value;
|
|
}
|
|
|
|
function normalizePddPriceLimitIdentifier(value) {
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
return String(value).trim().toLowerCase();
|
|
}
|
|
|
|
function toPddPositiveNumber(value, fallback = null) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
|
|
}
|
|
|
|
function pickLowestPddPriceLimitCandidate(row, fields = []) {
|
|
return fields
|
|
.map((field) => ({
|
|
field,
|
|
value: toPddPositiveNumber(row?.[field], null),
|
|
}))
|
|
.filter((item) => item.value !== null)
|
|
.sort((left, right) => left.value - right.value)[0] || null;
|
|
}
|
|
|
|
function getPddPriceLimitSelectFields(spec) {
|
|
return Array.from(new Set([
|
|
...(spec.keyFields || []),
|
|
...(spec.ordinaryFields || []),
|
|
...(spec.spuFields || []),
|
|
...(spec.fallbackFields || []),
|
|
spec.managerField,
|
|
spec.orderField,
|
|
].filter(Boolean)));
|
|
}
|
|
|
|
function getPddPriceLimitSortValue(value) {
|
|
if (!value) {
|
|
return 0;
|
|
}
|
|
const parsed = dayjs(value);
|
|
return parsed.isValid() ? parsed.valueOf() : 0;
|
|
}
|
|
|
|
async function getPddPriceLimitSourceState() {
|
|
const cacheKey = 'pdd:price-limit:source-state';
|
|
return getOrSetPddDashboardResultCache(cacheKey, async () => {
|
|
const params = PDD_PRICE_LIMIT_TABLE_SPECS.reduce((acc, spec, index) => {
|
|
acc[`tableName${index}`] = spec.tableName;
|
|
return acc;
|
|
}, {});
|
|
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
t.name AS [tableName],
|
|
SUM(p.rows) AS [rowCount]
|
|
FROM sys.tables t
|
|
INNER JOIN sys.partitions p
|
|
ON t.object_id = p.object_id
|
|
AND p.index_id IN (0, 1)
|
|
WHERE t.name IN (${PDD_PRICE_LIMIT_TABLE_SPECS.map((_, index) => `@tableName${index}`).join(', ')})
|
|
GROUP BY t.name
|
|
`,
|
|
params
|
|
);
|
|
|
|
const rowMap = new Map((rows || []).map((row) => [ String(row.tableName || ''), Number(row.rowCount || 0) ]));
|
|
const tables = PDD_PRICE_LIMIT_TABLE_SPECS.map((spec) => ({
|
|
tableName: spec.tableName,
|
|
rowCount: rowMap.get(spec.tableName) || 0,
|
|
}));
|
|
return {
|
|
ok: true,
|
|
tables,
|
|
totalRows: tables.reduce((sum, item) => sum + Number(item.rowCount || 0), 0),
|
|
};
|
|
}, PDD_PRICE_LIMIT_STATE_CACHE_TTL_MS);
|
|
}
|
|
|
|
async function loadPddPriceLimitTableIndex(spec) {
|
|
const cacheKey = `pdd:price-limit:index:${spec.tableName}`;
|
|
return getOrSetPddDashboardResultCache(cacheKey, async () => {
|
|
const selectSql = getPddPriceLimitSelectFields(spec)
|
|
.map((field) => `[${field}] AS [${field}]`)
|
|
.join(',\n ');
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
${selectSql}
|
|
FROM dbo.[${spec.tableName}]
|
|
`,
|
|
{},
|
|
{ requestTimeout: 120000 }
|
|
);
|
|
|
|
const keyIndex = new Map();
|
|
for (const row of rows || []) {
|
|
const sortValue = getPddPriceLimitSortValue(row?.[spec.orderField]);
|
|
for (const fieldName of spec.keyFields || []) {
|
|
const identifier = normalizePddPriceLimitIdentifier(row?.[fieldName]);
|
|
if (!identifier) {
|
|
continue;
|
|
}
|
|
const existed = keyIndex.get(identifier);
|
|
if (!existed || sortValue >= existed.__sortValue) {
|
|
keyIndex.set(identifier, {
|
|
...row,
|
|
__sortValue: sortValue,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
tableName: spec.tableName,
|
|
keyIndex,
|
|
};
|
|
}, PDD_PRICE_LIMIT_INDEX_CACHE_TTL_MS);
|
|
}
|
|
|
|
async function loadPddPriceLimitIndexes(activeTableNames = []) {
|
|
const activeSet = new Set((activeTableNames || []).map((item) => String(item)));
|
|
const results = await Promise.all(
|
|
PDD_PRICE_LIMIT_TABLE_SPECS
|
|
.filter((spec) => activeSet.size === 0 || activeSet.has(spec.tableName))
|
|
.map(async (spec) => [ spec.tableName, await loadPddPriceLimitTableIndex(spec) ])
|
|
);
|
|
return new Map(results);
|
|
}
|
|
|
|
function queryPddPriceLimitCandidates({ identifiers = [], ownerOper = '' }, priceLimitIndexes) {
|
|
const normalizedIdentifiers = Array.from(new Set(
|
|
(identifiers || [])
|
|
.map((item) => normalizePddPriceLimitIdentifier(item))
|
|
.filter(Boolean)
|
|
));
|
|
if (normalizedIdentifiers.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const normalizedOwnerOper = normalizePddPriceLimitIdentifier(ownerOper);
|
|
const results = [];
|
|
|
|
for (const spec of PDD_PRICE_LIMIT_TABLE_SPECS) {
|
|
const indexPayload = priceLimitIndexes?.get(spec.tableName);
|
|
if (!indexPayload?.ok || !indexPayload.keyIndex) {
|
|
continue;
|
|
}
|
|
|
|
const matchedRows = [];
|
|
const dedupeKeys = new Set();
|
|
for (const identifier of normalizedIdentifiers) {
|
|
const matchedRow = indexPayload.keyIndex.get(identifier);
|
|
if (!matchedRow) {
|
|
continue;
|
|
}
|
|
const dedupeKey = JSON.stringify(getPddPriceLimitSelectFields(spec).map((field) => matchedRow?.[field] ?? null));
|
|
if (dedupeKeys.has(dedupeKey)) {
|
|
continue;
|
|
}
|
|
dedupeKeys.add(dedupeKey);
|
|
matchedRows.push(matchedRow);
|
|
}
|
|
|
|
let bestCandidate = null;
|
|
for (const row of matchedRows) {
|
|
const normalizedManager = normalizePddPriceLimitIdentifier(spec.managerField ? row?.[spec.managerField] : row?.spu_manager);
|
|
const useSpuBranch = Boolean(normalizedOwnerOper && normalizedManager && normalizedOwnerOper === normalizedManager);
|
|
const directCandidate = pickLowestPddPriceLimitCandidate(row, useSpuBranch ? spec.spuFields : spec.ordinaryFields);
|
|
const fallbackCandidate = directCandidate || pickLowestPddPriceLimitCandidate(row, spec.fallbackFields || []);
|
|
if (!fallbackCandidate) {
|
|
continue;
|
|
}
|
|
|
|
const candidatePayload = {
|
|
tableName: spec.tableName,
|
|
limitFieldUsed: fallbackCandidate.field,
|
|
effectiveLimitPrice: fallbackCandidate.value,
|
|
};
|
|
if (!bestCandidate || candidatePayload.effectiveLimitPrice < bestCandidate.effectiveLimitPrice) {
|
|
bestCandidate = candidatePayload;
|
|
}
|
|
}
|
|
|
|
if (bestCandidate) {
|
|
results.push(bestCandidate);
|
|
}
|
|
}
|
|
|
|
return results.sort((left, right) => left.effectiveLimitPrice - right.effectiveLimitPrice);
|
|
}
|
|
|
|
async function loadPddPriceBreakFlagByGoodsIds({ goodsIds = [], startDate, endDate }) {
|
|
const normalizedGoodsIds = Array.from(new Set(
|
|
(goodsIds || [])
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean)
|
|
));
|
|
if (normalizedGoodsIds.length === 0 || !startDate || !endDate) {
|
|
return new Map();
|
|
}
|
|
|
|
const priceLimitState = await getPddPriceLimitSourceState();
|
|
if (!priceLimitState?.ok || Number(priceLimitState.totalRows || 0) <= 0) {
|
|
return new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ]));
|
|
}
|
|
|
|
const activeTables = (priceLimitState.tables || [])
|
|
.filter((item) => Number(item.rowCount || 0) > 0)
|
|
.map((item) => item.tableName);
|
|
const priceLimitIndexes = await loadPddPriceLimitIndexes(activeTables);
|
|
if (!(priceLimitIndexes instanceof Map) || priceLimitIndexes.size === 0) {
|
|
return new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ]));
|
|
}
|
|
|
|
const params = { startDate, endDate };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendNamedInCondition(
|
|
whereParts,
|
|
params,
|
|
normalizedGoodsIds,
|
|
'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
'priceBreakGoodsId'
|
|
);
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts,
|
|
params,
|
|
fieldExpr: 'g.[createTime]',
|
|
startDate,
|
|
endDate,
|
|
});
|
|
|
|
const baseRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), tm.[oper])) AS [oper],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), tm.[spu])) AS [spu],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payAmt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS [goodsQty]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${buildPddTargetExtendedJoinSqlByExpr({
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
})}
|
|
WHERE ${whereParts.join(' AND ')}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) > 0
|
|
`,
|
|
params,
|
|
{ requestTimeout: 120000 }
|
|
);
|
|
|
|
if (!Array.isArray(baseRows) || baseRows.length === 0) {
|
|
return new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ]));
|
|
}
|
|
|
|
const uniqueSpus = Array.from(new Set(
|
|
baseRows
|
|
.map((row) => String(row.spu || '').trim())
|
|
.filter(Boolean)
|
|
));
|
|
const spuIdentifierMap = new Map();
|
|
if (uniqueSpus.length > 0) {
|
|
const skuParams = {};
|
|
const skuWhereParts = [
|
|
'src.[other_1] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) <> ''",
|
|
];
|
|
appendNamedInCondition(
|
|
skuWhereParts,
|
|
skuParams,
|
|
uniqueSpus,
|
|
'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1])))',
|
|
'priceBreakSpu'
|
|
);
|
|
|
|
const skuRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) AS [spu],
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[sku_id]))) AS [skuId],
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_7]))) AS [productNo],
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[i_id]))) AS [styleCode]
|
|
FROM ${PDD_JST_SKU_TABLE_FULL_NAME} src
|
|
WHERE ${skuWhereParts.join(' AND ')}
|
|
`,
|
|
skuParams,
|
|
{ requestTimeout: 120000 }
|
|
);
|
|
|
|
for (const row of skuRows || []) {
|
|
const spu = String(row.spu || '').trim();
|
|
if (!spu) {
|
|
continue;
|
|
}
|
|
if (!spuIdentifierMap.has(spu)) {
|
|
spuIdentifierMap.set(spu, new Set([ spu ]));
|
|
}
|
|
[ row.skuId, row.productNo, row.styleCode ].forEach((value) => {
|
|
const normalizedValue = String(value || '').trim();
|
|
if (normalizedValue) {
|
|
spuIdentifierMap.get(spu).add(normalizedValue);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const resultMap = new Map(normalizedGoodsIds.map((goodsId) => [ goodsId, false ]));
|
|
for (const row of baseRows) {
|
|
const pieceUnitPrice = Number(row.goodsQty) > 0 ? Number(row.payAmt) / Number(row.goodsQty) : null;
|
|
if (!Number.isFinite(pieceUnitPrice) || pieceUnitPrice <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const spu = String(row.spu || '').trim();
|
|
const identifierSet = new Set([ spu ].filter(Boolean));
|
|
const extraIdentifiers = spuIdentifierMap.get(spu);
|
|
if (extraIdentifiers) {
|
|
extraIdentifiers.forEach((value) => identifierSet.add(value));
|
|
}
|
|
|
|
const candidates = queryPddPriceLimitCandidates({
|
|
identifiers: Array.from(identifierSet),
|
|
ownerOper: row.oper,
|
|
}, priceLimitIndexes);
|
|
const bestCandidate = candidates[0] || null;
|
|
if (!bestCandidate || !Number.isFinite(bestCandidate.effectiveLimitPrice)) {
|
|
continue;
|
|
}
|
|
|
|
if (pieceUnitPrice < Number(bestCandidate.effectiveLimitPrice)) {
|
|
resultMap.set(String(row.goodsId || '').trim(), true);
|
|
}
|
|
}
|
|
|
|
return resultMap;
|
|
}
|
|
|
|
function getPddColumnSortExpr({
|
|
fieldKey,
|
|
fieldExpr,
|
|
availableColumns,
|
|
columnTypeMap,
|
|
tableAlias = 'g',
|
|
}) {
|
|
if (!availableColumns.has(fieldKey)) {
|
|
return fieldExpr;
|
|
}
|
|
|
|
const dataType = columnTypeMap.get(fieldKey);
|
|
if (SQL_NUMBER_TYPES.has(dataType)) {
|
|
return `TRY_CONVERT(DECIMAL(38, 6), ${tableAlias}.[${fieldKey}])`;
|
|
}
|
|
if (SQL_DATE_TYPES.has(dataType)) {
|
|
return `TRY_CONVERT(DATETIME2, ${tableAlias}.[${fieldKey}])`;
|
|
}
|
|
if (SQL_TEXT_TYPES.has(dataType)) {
|
|
return `TRY_CONVERT(NVARCHAR(255), ${tableAlias}.[${fieldKey}])`;
|
|
}
|
|
|
|
return fieldExpr;
|
|
}
|
|
|
|
function createPddShopOptionsHandler({ schemaName, tableName }) {
|
|
return async (req, res) => {
|
|
try {
|
|
const keyword = (req.query.keyword || '').trim();
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
if (!accessScope.isAllData && accessScope.allowedDepartments.length === 0) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
|
|
const allColumns = await getPddTableColumns(schemaName, tableName);
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
const hasGoodsId = availableColumns.has('goodsId');
|
|
const goodsIdDataType = columnTypeMap.get('goodsId');
|
|
const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType);
|
|
if (!shopField) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
if (!accessScope.isAllData && !hasGoodsId) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
|
|
const whereParts = [
|
|
`g.[${shopField}] IS NOT NULL`,
|
|
`LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[${shopField}]))) <> ''`,
|
|
];
|
|
const params = {};
|
|
let joinSql = '';
|
|
|
|
if (!accessScope.isAllData && hasGoodsId) {
|
|
joinSql = buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: false,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
}).replace('LEFT JOIN', 'INNER JOIN');
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
if (keyword) {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) LIKE @keyword`);
|
|
params.keyword = `%${keyword}%`;
|
|
}
|
|
|
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT TOP (1000)
|
|
TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) AS shopName,
|
|
COUNT(1) AS shopCount
|
|
FROM [${schemaName}].[${tableName}] g
|
|
${joinSql}
|
|
${whereSql}
|
|
GROUP BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}])
|
|
ORDER BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) ASC
|
|
`,
|
|
params
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: (rows || []).map((r) => ({
|
|
shopName: r.shopName,
|
|
rowCount: Number(r.shopCount || 0),
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
};
|
|
}
|
|
|
|
function createPddIdOwnerOptionsHandler() {
|
|
return async (req, res) => {
|
|
try {
|
|
const keyword = (req.query.keyword || '').trim();
|
|
const whereParts = [
|
|
"emp.[sys00-name] IS NOT NULL",
|
|
"LTRIM(RTRIM(emp.[sys00-name])) <> ''",
|
|
"LTRIM(RTRIM(emp.[sys00-dept])) IN (N'杩愯惀涓績A', N'杩愯惀涓績B', N'杩愯惀涓績C', N'杩愯惀涓績G', N'榫欐櫒鍝佺墝涓績', N'鍝佺墝涓績')",
|
|
];
|
|
const params = {};
|
|
|
|
if (keyword) {
|
|
whereParts.push('LTRIM(RTRIM(emp.[sys00-name])) LIKE @keyword');
|
|
params.keyword = `%${keyword}%`;
|
|
}
|
|
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT DISTINCT TOP (500)
|
|
LTRIM(RTRIM(emp.[sys00-name])) AS ownerName
|
|
FROM ${PDD_EMPLOYEE_REGISTER_TABLE_FULL_NAME} emp
|
|
WHERE ${whereParts.join(' AND ')}
|
|
ORDER BY ownerName ASC
|
|
`,
|
|
params
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: rows || [],
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
};
|
|
}
|
|
|
|
function createPddListHandler({
|
|
schemaName,
|
|
tableName,
|
|
fieldDefs,
|
|
emptyTableMessage,
|
|
emptyFieldsMessage,
|
|
extraFields = [],
|
|
extraFieldsAfterKey = null,
|
|
insertFieldGroups = [],
|
|
countCacheScope,
|
|
getFieldTitle = (fieldDef) => fieldDef.label,
|
|
getFieldExpr = (fieldDef) => `g.[${fieldDef.key}]`,
|
|
additionalJoinSql = '',
|
|
}) {
|
|
return async (req, res) => {
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const sortField = (req.query.sortField || '').trim();
|
|
const sortOrder = (req.query.sortOrder || '').trim().toLowerCase();
|
|
const startDate = (req.query.startDate || '').trim();
|
|
const endDate = (req.query.endDate || '').trim();
|
|
const shopName = (req.query.shopName || '').trim();
|
|
const goodsId = (req.query.goodsId || '').trim();
|
|
const department = (req.query.department || '').trim();
|
|
const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner);
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const allColumns = await getPddTableColumns(schemaName, tableName);
|
|
if (!allColumns || allColumns.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: emptyTableMessage,
|
|
});
|
|
}
|
|
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const selectedFieldDefs = fieldDefs.filter((fieldDef) => availableColumns.has(fieldDef.key));
|
|
if (selectedFieldDefs.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: emptyFieldsMessage,
|
|
});
|
|
}
|
|
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
const hasGoodsId = availableColumns.has('goodsId');
|
|
const createTimeDataType = columnTypeMap.get('createTime');
|
|
const goodsIdDataType = columnTypeMap.get('goodsId');
|
|
const shopDataType = shopField ? columnTypeMap.get(shopField) : null;
|
|
const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType);
|
|
const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType);
|
|
const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType);
|
|
const joinSql = hasGoodsId
|
|
? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
})
|
|
: '';
|
|
const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, {
|
|
extraFields,
|
|
extraFieldsAfterKey,
|
|
insertFieldGroups,
|
|
getTitle: getFieldTitle,
|
|
getExpr: getFieldExpr,
|
|
getSortExpr: (fieldDef) => getPddColumnSortExpr({
|
|
fieldKey: fieldDef.key,
|
|
fieldExpr: getFieldExpr(fieldDef),
|
|
availableColumns,
|
|
columnTypeMap,
|
|
tableAlias: 'g',
|
|
}),
|
|
});
|
|
|
|
const whereParts = [];
|
|
const params = {};
|
|
|
|
if (startDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] >= @startDate');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate');
|
|
}
|
|
params.startDate = startDate;
|
|
}
|
|
if (endDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate');
|
|
}
|
|
params.endDate = endDate;
|
|
}
|
|
if (shopName && shopField) {
|
|
if (hasTextShopField) {
|
|
whereParts.push(`g.[${shopField}] = @shopName`);
|
|
} else {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`);
|
|
}
|
|
params.shopName = shopName;
|
|
}
|
|
if (goodsId && availableColumns.has('goodsId')) {
|
|
if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧',
|
|
});
|
|
}
|
|
whereParts.push('g.[goodsId] = @goodsId');
|
|
params.goodsId = goodsId;
|
|
}
|
|
if (department && hasGoodsId) {
|
|
whereParts.push('tm.[dept] = @department');
|
|
params.department = department;
|
|
}
|
|
if (idOwners.length > 0 && hasGoodsId) {
|
|
appendNamedInCondition(whereParts, params, idOwners, 'tm.[ider]', 'idOwner');
|
|
}
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0 || !hasGoodsId) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : '';
|
|
const sortFieldDef = displayFieldDefs.find((fieldDef) => fieldDef.key === sortField) || null;
|
|
const defaultOrderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : displayFieldDefs[0].sortExpr;
|
|
const orderExpr = sortFieldDef?.sortExpr || defaultOrderExpr;
|
|
const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC';
|
|
const selectFieldsSql = displayFieldDefs.map((fieldDef) => `${fieldDef.expr} AS [${fieldDef.key}]`).join(', ');
|
|
const needJoinForCount = hasGoodsId && (Boolean(department) || idOwners.length > 0 || !accessScope.isAllData);
|
|
const countFromSql = needJoinForCount
|
|
? `
|
|
FROM [${schemaName}].[${tableName}] g
|
|
${joinSql}
|
|
${additionalJoinSql}
|
|
${whereSql}
|
|
`
|
|
: `
|
|
FROM [${schemaName}].[${tableName}] g
|
|
${additionalJoinSql}
|
|
${whereSql}
|
|
`;
|
|
const countCacheKey = JSON.stringify({
|
|
scope: countCacheScope,
|
|
userId: req.user.sub,
|
|
startDate,
|
|
endDate,
|
|
shopName,
|
|
goodsId,
|
|
department,
|
|
idOwners,
|
|
scopeDepartments: accessScope.allowedDepartments,
|
|
isAllData: accessScope.isAllData,
|
|
});
|
|
let total = getMemoryCache(pddCountCache, countCacheKey);
|
|
if (total === null) {
|
|
const totalRows = await cloudDb.query(
|
|
`
|
|
SELECT COUNT(1) AS total
|
|
${countFromSql}
|
|
`,
|
|
params
|
|
);
|
|
total = Number(totalRows?.[0]?.total || 0);
|
|
setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS);
|
|
}
|
|
const rowsFromSql = needJoinForCount
|
|
? `
|
|
FROM [${schemaName}].[${tableName}] g
|
|
${joinSql}
|
|
${additionalJoinSql}
|
|
`
|
|
: `
|
|
${buildPddPagedBaseSubquery({
|
|
schemaName,
|
|
tableName,
|
|
whereSql,
|
|
orderExpr,
|
|
orderDirection,
|
|
fallbackOrderExpr: defaultOrderExpr,
|
|
})}
|
|
${hasGoodsId ? buildPddTargetApplySql({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
}) : ''}
|
|
${additionalJoinSql}
|
|
`;
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const rows = total > 0
|
|
? await cloudDb.query(
|
|
`
|
|
SELECT ${selectFieldsSql}
|
|
${rowsFromSql}
|
|
${needJoinForCount ? whereSql : ''}
|
|
ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''}
|
|
`,
|
|
pagingParams
|
|
)
|
|
: [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: rows || [],
|
|
sortField: sortFieldDef?.key || '',
|
|
sortOrder: sortFieldDef ? sortOrder || 'descend' : '',
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
};
|
|
}
|
|
|
|
const pddDataCenterListHandler = createPddListHandler({
|
|
schemaName: PDD_DATA_GOODS_SCHEMA,
|
|
tableName: PDD_DATA_GOODS_TABLE,
|
|
fieldDefs: PDD_DATA_GOODS_FIELDS,
|
|
emptyTableMessage: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_DataGoods 表',
|
|
emptyFieldsMessage: 'dbo.pdd_DataGoods 表存在,但未匹配到预期字段',
|
|
extraFields: PDD_JOIN_EXTRA_FIELDS,
|
|
extraFieldsAfterKey: 'hdThumbUrl',
|
|
insertFieldGroups: [
|
|
{
|
|
afterKey: 'goodsVcr',
|
|
fields: PDD_DATA_GOODS_DERIVED_FIELDS,
|
|
},
|
|
],
|
|
countCacheScope: 'pdd-data-center',
|
|
});
|
|
|
|
const pddSpreadShopOptionsHandler = createPddShopOptionsHandler({
|
|
schemaName: PDD_DATA_GOODS_SCHEMA,
|
|
tableName: PDD_SPREAD_GOODS_TABLE,
|
|
});
|
|
const pddIdOwnerOptionsHandler = createPddIdOwnerOptionsHandler();
|
|
|
|
const pddSpreadListHandler = createPddListHandler({
|
|
schemaName: PDD_DATA_GOODS_SCHEMA,
|
|
tableName: PDD_SPREAD_GOODS_TABLE,
|
|
fieldDefs: PDD_SPREAD_GOODS_FIELDS,
|
|
emptyTableMessage: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_ReportGoodsSpread 表',
|
|
emptyFieldsMessage: 'dbo.pdd_ReportGoodsSpread 表存在,但未匹配到预期字段',
|
|
extraFields: PDD_SPREAD_EXTRA_FIELDS,
|
|
extraFieldsAfterKey: 'goodsId',
|
|
countCacheScope: 'pdd-spread-center',
|
|
getFieldTitle: getPddSpreadFieldTitle,
|
|
getFieldExpr: (fieldDef) => getPddSpreadFieldExpr(fieldDef.key),
|
|
additionalJoinSql: buildPddDataGoodsImageApplySql({
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
}),
|
|
});
|
|
|
|
router.get('/warehouse/kpi', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'warehouse:kpi',
|
|
payload: { startDate: start, endDate: end },
|
|
label: 'analysis:warehouse:kpi',
|
|
loader: () => queryWarehouseKpi(start, end),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 浠撳簱鎺掑悕
|
|
*/
|
|
router.get('/warehouse/ranking', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 20 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'warehouse:ranking',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:warehouse:ranking',
|
|
loader: () => queryWarehouseRanking(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 蹇€掑垎鏋?====================
|
|
|
|
/**
|
|
* 蹇€扠PI
|
|
*/
|
|
router.get('/logistics/kpi', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'logistics:kpi',
|
|
payload: { startDate: start, endDate: end },
|
|
label: 'analysis:logistics:kpi',
|
|
loader: () => queryLogisticsKpi(start, end),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 蹇€掑叕鍙告帓鍚?
|
|
*/
|
|
router.get('/logistics/ranking', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 20 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'logistics:ranking',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:logistics:ranking',
|
|
loader: () => queryLogisticsRanking(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 鐗╂祦寮傚父绫诲瀷鍒嗗竷
|
|
*/
|
|
router.get('/logistics/abnormal-types', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'logistics:abnormal-types',
|
|
payload: { startDate: start, endDate: end },
|
|
label: 'analysis:logistics:abnormal-types',
|
|
loader: () => queryLogisticsAbnormalTypes(start, end),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 蹇€掓椂鏁堣秼鍔?
|
|
*/
|
|
router.get('/logistics/trend', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, dimension = 'daily' } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'logistics:trend',
|
|
payload: { startDate: start, endDate: end, dimension },
|
|
label: `analysis:logistics:trend:${dimension}`,
|
|
loader: () => queryLogisticsTrend(start, end, dimension),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 鍦板尯鍒嗘瀽 ====================
|
|
|
|
/**
|
|
* 鍦板尯KPI
|
|
*/
|
|
router.get('/region/kpi', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'region:kpi',
|
|
payload: { startDate: start, endDate: end },
|
|
label: 'analysis:region:kpi',
|
|
loader: () => queryRegionKpi(start, end),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 鐪佷唤鎺掑悕
|
|
*/
|
|
router.get('/region/ranking', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 30 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'region:ranking',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:region:ranking',
|
|
loader: () => queryRegionRanking(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 渚涘簲鍟嗗垎鏋?====================
|
|
|
|
/**
|
|
* 渚涘簲鍟嗘帓鍚?
|
|
*/
|
|
router.get('/supplier/ranking', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 20 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'supplier:ranking',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:supplier:ranking',
|
|
loader: () => querySupplierRanking(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 瀹㈡湇鍒嗘瀽 ====================
|
|
|
|
/**
|
|
* 瀹㈡湇鎺掑悕
|
|
*/
|
|
router.get('/cs/ranking', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 20 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'cs:ranking',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:cs:ranking',
|
|
loader: () => queryCsRanking(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 閲囪喘鍒嗘瀽 ====================
|
|
|
|
/**
|
|
* 閲囪喘鍛樻帓鍚?
|
|
*/
|
|
router.get('/procurement/ranking', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 20 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'procurement:ranking',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:procurement:ranking',
|
|
loader: () => queryProcurementRanking(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 寮€鍙?璁捐鍒嗘瀽 ====================
|
|
|
|
/**
|
|
* 璁捐甯堟帓鍚?
|
|
*/
|
|
router.get('/design/ranking', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 20 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'design:ranking',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:design:ranking',
|
|
loader: () => queryDesignRanking(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 閫氱敤鑱婂ぉ闂鍒嗘瀽 ====================
|
|
|
|
/**
|
|
* 鑱婂ぉ闂鍒嗗竷锛堝悇閮ㄩ棬閫氱敤锛?
|
|
*/
|
|
router.get('/chat-issues', async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, limit = 20 } = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'chat-issues',
|
|
payload: { startDate: start, endDate: end, limit: parseInt(limit, 10) },
|
|
label: 'analysis:chat-issues',
|
|
loader: () => queryChatIssues(start, end, limit),
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 涓氬姟鏁版嵁搴撻€氱敤鏌ヨ ====================
|
|
|
|
router.get('/business-db/tables', async (req, res) => {
|
|
try {
|
|
const schemaName = req.query.schemaName ? safeIdentifier(req.query.schemaName, 'schemaName') : null;
|
|
const sql = `
|
|
SELECT
|
|
TABLE_SCHEMA AS schemaName,
|
|
TABLE_NAME AS tableName
|
|
FROM INFORMATION_SCHEMA.TABLES
|
|
WHERE TABLE_TYPE IN ('BASE TABLE', 'VIEW')
|
|
AND (@schemaName IS NULL OR TABLE_SCHEMA = @schemaName)
|
|
ORDER BY TABLE_SCHEMA ASC, TABLE_NAME ASC
|
|
`;
|
|
const rows = await cloudDb.query(sql, { schemaName });
|
|
res.json({ success: true, data: rows || [] });
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/business-db/preview', async (req, res) => {
|
|
try {
|
|
const schemaName = safeIdentifier(req.query.schemaName || 'dbo', 'schemaName');
|
|
const tableName = safeIdentifier(req.query.tableName, 'tableName');
|
|
const limit = safePositiveInt(req.query.limit, 50, 1, 500);
|
|
|
|
const sql = `SELECT TOP (${limit}) * FROM [${schemaName}].[${tableName}]`;
|
|
const rows = await cloudDb.query(sql);
|
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: { schemaName, tableName, limit, columns, rows },
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 鎷煎澶?鏁版嵁涓績鍟嗗搧鏄庣粏 ====================
|
|
|
|
router.get('/pdd/data-center/tables', async (_req, res) => {
|
|
res.json({
|
|
success: true,
|
|
data: [{
|
|
schemaName: PDD_DATA_GOODS_SCHEMA,
|
|
tableName: PDD_DATA_GOODS_TABLE,
|
|
}],
|
|
});
|
|
});
|
|
|
|
router.get('/pdd/data-center/columns', async (req, res) => {
|
|
try {
|
|
const allColumns = await cloudDb.query(
|
|
`
|
|
SELECT COLUMN_NAME AS columnName, DATA_TYPE AS dataType
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = @schemaName
|
|
AND TABLE_NAME = @tableName
|
|
ORDER BY ORDINAL_POSITION
|
|
`,
|
|
{ schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_DATA_GOODS_TABLE }
|
|
);
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const selectedFieldDefs = PDD_DATA_GOODS_FIELDS.filter((f) => availableColumns.has(f.key));
|
|
const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, {
|
|
extraFields: PDD_JOIN_EXTRA_FIELDS,
|
|
extraFieldsAfterKey: 'hdThumbUrl',
|
|
insertFieldGroups: [
|
|
{
|
|
afterKey: 'goodsVcr',
|
|
fields: PDD_DATA_GOODS_DERIVED_FIELDS,
|
|
},
|
|
],
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
departmentField: 'op_TargetManagement.dept',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get(
|
|
'/pdd/data-center/shop-options',
|
|
authMiddleware,
|
|
createPddShopOptionsHandler({
|
|
schemaName: PDD_DATA_GOODS_SCHEMA,
|
|
tableName: PDD_DATA_GOODS_TABLE,
|
|
})
|
|
);
|
|
|
|
router.get('/pdd/id-owner-options', authMiddleware, async (req, res) => {
|
|
return pddIdOwnerOptionsHandler(req, res);
|
|
});
|
|
|
|
router.get('/pdd/data-center/list', authMiddleware, async (req, res) => {
|
|
return pddDataCenterListHandler(req, res);
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const startDate = (req.query.startDate || '').trim();
|
|
const endDate = (req.query.endDate || '').trim();
|
|
const shopName = (req.query.shopName || '').trim();
|
|
const goodsId = (req.query.goodsId || '').trim();
|
|
const department = (req.query.department || '').trim();
|
|
const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner);
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const metaCacheKey = `${PDD_DATA_GOODS_SCHEMA}.${PDD_DATA_GOODS_TABLE}.columns`;
|
|
let allColumns = getMemoryCache(pddMetaCache, metaCacheKey);
|
|
if (!allColumns) {
|
|
allColumns = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
COLUMN_NAME AS columnName,
|
|
DATA_TYPE AS dataType
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = @schemaName
|
|
AND TABLE_NAME = @tableName
|
|
ORDER BY ORDINAL_POSITION
|
|
`,
|
|
{ schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_DATA_GOODS_TABLE }
|
|
);
|
|
setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS);
|
|
}
|
|
const tableExists = Boolean(allColumns && allColumns.length > 0);
|
|
if (!tableExists) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: `业务数据库[${currentDbName}]中不存在表 dbo.pdd_DataGoods`,
|
|
});
|
|
}
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const selectedFieldDefs = PDD_DATA_GOODS_FIELDS.filter((f) => availableColumns.has(f.key));
|
|
if (selectedFieldDefs.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: `表 dbo.pdd_DataGoods 存在,但未匹配到预期字段,请核对字段名。当前库: ${currentDbName}`,
|
|
});
|
|
}
|
|
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
const hasGoodsId = availableColumns.has('goodsId');
|
|
const createTimeDataType = columnTypeMap.get('createTime');
|
|
const goodsIdDataType = columnTypeMap.get('goodsId');
|
|
const shopDataType = shopField ? columnTypeMap.get(shopField) : null;
|
|
const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType);
|
|
const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType);
|
|
const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType);
|
|
const joinSql = hasGoodsId
|
|
? `
|
|
LEFT JOIN (
|
|
SELECT
|
|
TRY_CONVERT(BIGINT, shopproid) AS shopproidKey,
|
|
MAX(ider) AS ider,
|
|
MAX(dept) AS dept
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME}
|
|
WHERE TRY_CONVERT(BIGINT, shopproid) IS NOT NULL
|
|
GROUP BY TRY_CONVERT(BIGINT, shopproid)
|
|
) tm
|
|
ON g.[goodsId] = tm.shopproidKey
|
|
`
|
|
: '';
|
|
|
|
const displayFieldDefs = [];
|
|
selectedFieldDefs.forEach((f) => {
|
|
displayFieldDefs.push({ key: f.key, title: f.label, expr: `g.[${f.key}]` });
|
|
if (f.key === 'hdThumbUrl') {
|
|
PDD_JOIN_EXTRA_FIELDS.forEach((extra) => displayFieldDefs.push({ key: extra.key, title: extra.label, expr: extra.expr }));
|
|
}
|
|
});
|
|
|
|
const whereParts = [];
|
|
const params = {};
|
|
|
|
if (startDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] >= @startDate');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate');
|
|
}
|
|
params.startDate = startDate;
|
|
}
|
|
if (endDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate');
|
|
}
|
|
params.endDate = endDate;
|
|
}
|
|
if (shopName && shopField) {
|
|
if (hasTextShopField) {
|
|
whereParts.push(`g.[${shopField}] = @shopName`);
|
|
params.shopName = shopName;
|
|
} else {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`);
|
|
params.shopName = shopName;
|
|
}
|
|
}
|
|
if (goodsId && availableColumns.has('goodsId')) {
|
|
if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧',
|
|
});
|
|
}
|
|
whereParts.push('g.[goodsId] = @goodsId');
|
|
params.goodsId = goodsId;
|
|
}
|
|
if (department && hasGoodsId) {
|
|
whereParts.push('tm.[dept] = @department');
|
|
params.department = department;
|
|
}
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })),
|
|
rows: [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : '';
|
|
const orderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : displayFieldDefs[0].expr;
|
|
const selectFieldsSql = displayFieldDefs.map((f) => `${f.expr} AS [${f.key}]`).join(', ');
|
|
const needJoinForCount = hasGoodsId && (Boolean(department) || !accessScope.isAllData);
|
|
const countFromSql = needJoinForCount
|
|
? `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${joinSql}
|
|
${whereSql}
|
|
`
|
|
: `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${whereSql}
|
|
`;
|
|
|
|
const countCacheKey = JSON.stringify({
|
|
userId: req.user.sub,
|
|
startDate,
|
|
endDate,
|
|
shopName,
|
|
goodsId,
|
|
department,
|
|
scopeDepartments: accessScope.allowedDepartments,
|
|
isAllData: accessScope.isAllData,
|
|
});
|
|
let total = getMemoryCache(pddCountCache, countCacheKey);
|
|
if (total === null) {
|
|
const totalRows = await cloudDb.query(
|
|
`
|
|
SELECT COUNT(1) AS total
|
|
${countFromSql}
|
|
`,
|
|
params
|
|
);
|
|
total = Number(totalRows?.[0]?.total || 0);
|
|
setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS);
|
|
}
|
|
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const rows = total > 0
|
|
? await cloudDb.query(
|
|
`
|
|
SELECT ${selectFieldsSql}
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${joinSql}
|
|
${whereSql}
|
|
ORDER BY ${orderExpr} DESC
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
|
|
`,
|
|
pagingParams
|
|
)
|
|
: [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })),
|
|
rows: rows || [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
// ==================== 鎷煎澶?鎺ㄥ箍涓績鍟嗗搧鏄庣粏 ====================
|
|
|
|
router.get('/pdd/spread-center/shop-options', authMiddleware, async (req, res) => {
|
|
return pddSpreadShopOptionsHandler(req, res);
|
|
try {
|
|
const keyword = (req.query.keyword || '').trim();
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
if (!accessScope.isAllData && accessScope.allowedDepartments.length === 0) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
|
|
const metaCacheKey = `${PDD_DATA_GOODS_SCHEMA}.${PDD_SPREAD_GOODS_TABLE}.columns`;
|
|
let allColumns = getMemoryCache(pddMetaCache, metaCacheKey);
|
|
if (!allColumns) {
|
|
allColumns = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
COLUMN_NAME AS columnName,
|
|
DATA_TYPE AS dataType
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = @schemaName
|
|
AND TABLE_NAME = @tableName
|
|
ORDER BY ORDINAL_POSITION
|
|
`,
|
|
{ schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_SPREAD_GOODS_TABLE }
|
|
);
|
|
setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS);
|
|
}
|
|
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
if (!shopField) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
|
|
const whereParts = [
|
|
`g.[${shopField}] IS NOT NULL`,
|
|
`LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[${shopField}]))) <> ''`,
|
|
];
|
|
const params = {};
|
|
let joinSql = '';
|
|
|
|
if (!accessScope.isAllData) {
|
|
joinSql = `
|
|
INNER JOIN (
|
|
SELECT
|
|
TRY_CONVERT(BIGINT, shopproid) AS shopproidKey,
|
|
MAX(dept) AS dept
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME}
|
|
WHERE TRY_CONVERT(BIGINT, shopproid) IS NOT NULL
|
|
GROUP BY TRY_CONVERT(BIGINT, shopproid)
|
|
) tm
|
|
ON g.[goodsId] = tm.shopproidKey
|
|
`;
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
if (keyword) {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) LIKE @keyword`);
|
|
params.keyword = `%${keyword}%`;
|
|
}
|
|
|
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT TOP (1000)
|
|
TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) AS shopName,
|
|
COUNT(1) AS shopCount
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g
|
|
${joinSql}
|
|
${whereSql}
|
|
GROUP BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}])
|
|
ORDER BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) ASC
|
|
`,
|
|
params
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: (rows || []).map((r) => ({
|
|
shopName: r.shopName,
|
|
rowCount: Number(r.shopCount || 0),
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/spread-center/list', authMiddleware, async (req, res) => {
|
|
return pddSpreadListHandler(req, res);
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const startDate = (req.query.startDate || '').trim();
|
|
const endDate = (req.query.endDate || '').trim();
|
|
const shopName = (req.query.shopName || '').trim();
|
|
const goodsId = (req.query.goodsId || '').trim();
|
|
const department = (req.query.department || '').trim();
|
|
const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner);
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const metaCacheKey = `${PDD_DATA_GOODS_SCHEMA}.${PDD_SPREAD_GOODS_TABLE}.columns`;
|
|
let allColumns = getMemoryCache(pddMetaCache, metaCacheKey);
|
|
if (!allColumns) {
|
|
allColumns = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
COLUMN_NAME AS columnName,
|
|
DATA_TYPE AS dataType
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = @schemaName
|
|
AND TABLE_NAME = @tableName
|
|
ORDER BY ORDINAL_POSITION
|
|
`,
|
|
{ schemaName: PDD_DATA_GOODS_SCHEMA, tableName: PDD_SPREAD_GOODS_TABLE }
|
|
);
|
|
setMemoryCache(pddMetaCache, metaCacheKey, allColumns || [], PDD_META_CACHE_TTL_MS);
|
|
}
|
|
|
|
if (!allColumns || allColumns.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_ReportGoodsSpread 表',
|
|
});
|
|
}
|
|
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const selectedFieldDefs = PDD_SPREAD_GOODS_FIELDS.filter((f) => availableColumns.has(f.key));
|
|
if (selectedFieldDefs.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'dbo.pdd_ReportGoodsSpread 表存在,但未匹配到预期字段',
|
|
});
|
|
}
|
|
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
const hasGoodsId = availableColumns.has('goodsId');
|
|
const createTimeDataType = columnTypeMap.get('createTime');
|
|
const goodsIdDataType = columnTypeMap.get('goodsId');
|
|
const shopDataType = shopField ? columnTypeMap.get(shopField) : null;
|
|
const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType);
|
|
const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType);
|
|
const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType);
|
|
const joinSql = hasGoodsId
|
|
? `
|
|
LEFT JOIN (
|
|
SELECT
|
|
TRY_CONVERT(BIGINT, shopproid) AS shopproidKey,
|
|
MAX(ider) AS ider,
|
|
MAX(dept) AS dept
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME}
|
|
WHERE TRY_CONVERT(BIGINT, shopproid) IS NOT NULL
|
|
GROUP BY TRY_CONVERT(BIGINT, shopproid)
|
|
) tm
|
|
ON g.[goodsId] = tm.shopproidKey
|
|
`
|
|
: '';
|
|
|
|
const displayFieldDefs = [];
|
|
selectedFieldDefs.forEach((fieldDef) => {
|
|
displayFieldDefs.push({
|
|
key: fieldDef.key,
|
|
title: getPddSpreadFieldTitle(fieldDef),
|
|
expr: getPddSpreadFieldExpr(fieldDef.key),
|
|
});
|
|
if (fieldDef.key === 'goodsId') {
|
|
PDD_JOIN_EXTRA_FIELDS.forEach((extra) => {
|
|
displayFieldDefs.push({ key: extra.key, title: extra.label, expr: extra.expr });
|
|
});
|
|
}
|
|
});
|
|
|
|
const whereParts = [];
|
|
const params = {};
|
|
|
|
if (startDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] >= @startDate');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate');
|
|
}
|
|
params.startDate = startDate;
|
|
}
|
|
if (endDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate');
|
|
}
|
|
params.endDate = endDate;
|
|
}
|
|
if (shopName && shopField) {
|
|
if (hasTextShopField) {
|
|
whereParts.push(`g.[${shopField}] = @shopName`);
|
|
} else {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`);
|
|
}
|
|
params.shopName = shopName;
|
|
}
|
|
if (goodsId && availableColumns.has('goodsId')) {
|
|
if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧',
|
|
});
|
|
}
|
|
whereParts.push('g.[goodsId] = @goodsId');
|
|
params.goodsId = goodsId;
|
|
}
|
|
if (department && hasGoodsId) {
|
|
whereParts.push('tm.[dept] = @department');
|
|
params.department = department;
|
|
}
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })),
|
|
rows: [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : '';
|
|
const orderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : displayFieldDefs[0].expr;
|
|
const selectFieldsSql = displayFieldDefs.map((f) => `${f.expr} AS [${f.key}]`).join(', ');
|
|
const needJoinForCount = hasGoodsId && (Boolean(department) || !accessScope.isAllData);
|
|
const countFromSql = needJoinForCount
|
|
? `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g
|
|
${joinSql}
|
|
${whereSql}
|
|
`
|
|
: `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g
|
|
${whereSql}
|
|
`;
|
|
|
|
const countCacheKey = JSON.stringify({
|
|
scope: 'pdd-spread-center',
|
|
userId: req.user.sub,
|
|
startDate,
|
|
endDate,
|
|
shopName,
|
|
goodsId,
|
|
department,
|
|
scopeDepartments: accessScope.allowedDepartments,
|
|
isAllData: accessScope.isAllData,
|
|
});
|
|
let total = getMemoryCache(pddCountCache, countCacheKey);
|
|
if (total === null) {
|
|
const totalRows = await cloudDb.query(
|
|
`
|
|
SELECT COUNT(1) AS total
|
|
${countFromSql}
|
|
`,
|
|
params
|
|
);
|
|
total = Number(totalRows?.[0]?.total || 0);
|
|
setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS);
|
|
}
|
|
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const rows = total > 0
|
|
? await cloudDb.query(
|
|
`
|
|
SELECT ${selectFieldsSql}
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g
|
|
${joinSql}
|
|
${whereSql}
|
|
ORDER BY ${orderExpr} DESC
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
|
|
`,
|
|
pagingParams
|
|
)
|
|
: [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
columns: displayFieldDefs.map((f) => ({ key: f.key, title: f.title })),
|
|
rows: rows || [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/goods-detail-summary/list', authMiddleware, async (req, res) => {
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const sortField = (req.query.sortField || '').trim();
|
|
const sortOrder = (req.query.sortOrder || '').trim().toLowerCase();
|
|
const startDate = (req.query.startDate || '').trim();
|
|
const endDate = (req.query.endDate || '').trim();
|
|
const shopName = (req.query.shopName || '').trim();
|
|
const goodsId = (req.query.goodsId || '').trim();
|
|
const department = (req.query.department || '').trim();
|
|
const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner);
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const baseColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_DATA_GOODS_TABLE);
|
|
const spreadColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_SPREAD_GOODS_TABLE);
|
|
if (!baseColumns || baseColumns.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_DataGoods 表',
|
|
});
|
|
}
|
|
if (!spreadColumns || spreadColumns.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_ReportGoodsSpread 表',
|
|
});
|
|
}
|
|
|
|
const baseAvailableColumns = new Set(baseColumns.map((c) => c.columnName));
|
|
const baseColumnTypeMap = new Map(baseColumns.map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const spreadAvailableColumns = new Set(spreadColumns.map((c) => c.columnName));
|
|
const selectedBaseFieldDefs = PDD_DATA_GOODS_FIELDS.filter((fieldDef) => baseAvailableColumns.has(fieldDef.key));
|
|
if (selectedBaseFieldDefs.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'dbo.pdd_DataGoods 表存在,但未匹配到预期字段',
|
|
});
|
|
}
|
|
|
|
const baseDisplayFieldDefs = buildPddDisplayFieldDefs(selectedBaseFieldDefs, {
|
|
extraFields: PDD_JOIN_EXTRA_FIELDS,
|
|
extraFieldsAfterKey: 'hdThumbUrl',
|
|
insertFieldGroups: [
|
|
{
|
|
afterKey: 'goodsVcr',
|
|
fields: PDD_DATA_GOODS_DERIVED_FIELDS,
|
|
},
|
|
],
|
|
});
|
|
const existingFieldKeys = new Set(baseDisplayFieldDefs.map((fieldDef) => fieldDef.key));
|
|
const spreadStartIndex = PDD_SPREAD_GOODS_FIELDS.findIndex((fieldDef) => fieldDef.key === 'minGroupPrice');
|
|
const appendSpreadFieldDefs = PDD_SPREAD_GOODS_FIELDS
|
|
.slice(Math.max(spreadStartIndex, 0))
|
|
.filter((fieldDef) => spreadAvailableColumns.has(fieldDef.key))
|
|
.filter((fieldDef) => !existingFieldKeys.has(fieldDef.key));
|
|
|
|
const summaryDisplayFieldDefs = [
|
|
...baseDisplayFieldDefs,
|
|
...appendSpreadFieldDefs.map((fieldDef) => ({
|
|
key: fieldDef.key,
|
|
title: getPddSpreadFieldTitle(fieldDef),
|
|
expr: `spread.[${fieldDef.key}]`,
|
|
sortExpr: `TRY_CONVERT(DECIMAL(38, 6), spread.[${fieldDef.key}])`,
|
|
})),
|
|
];
|
|
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => baseAvailableColumns.has(field)) || null;
|
|
const createTimeDataType = baseColumnTypeMap.get('createTime');
|
|
const goodsIdDataType = baseColumnTypeMap.get('goodsId');
|
|
const shopDataType = shopField ? baseColumnTypeMap.get(shopField) : null;
|
|
const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType);
|
|
const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType);
|
|
const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType);
|
|
const baseJoinSql = buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
});
|
|
|
|
const spreadSelectSql = appendSpreadFieldDefs.map((fieldDef) => {
|
|
const rawExpr = getPddSpreadFieldExpr(fieldDef.key).replace(/g\.\[/g, 's.[');
|
|
return `${rawExpr} AS [${fieldDef.key}]`;
|
|
}).join(',\n ');
|
|
const spreadApplySql = `
|
|
OUTER APPLY (
|
|
SELECT TOP (1)
|
|
${spreadSelectSql}
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s
|
|
WHERE ${getPddGoodsIdJoinExpr('s')} = ${getPddGoodsIdJoinExpr('g')}
|
|
AND TRY_CONVERT(date, s.[createTime]) = TRY_CONVERT(date, g.[createTime])
|
|
ORDER BY s.[createTime] DESC
|
|
) spread
|
|
`;
|
|
const spreadDateWhereParts = [
|
|
`${getPddGoodsIdJoinExpr('s')} <> ''`,
|
|
'TRY_CONVERT(date, s.[createTime]) IS NOT NULL',
|
|
];
|
|
if (startDate) {
|
|
spreadDateWhereParts.push('TRY_CONVERT(date, s.[createTime]) >= @startDate');
|
|
}
|
|
if (endDate) {
|
|
spreadDateWhereParts.push('TRY_CONVERT(date, s.[createTime]) <= @endDate');
|
|
}
|
|
const spreadSubquerySql = `
|
|
LEFT JOIN (
|
|
SELECT *
|
|
FROM (
|
|
SELECT
|
|
${getPddGoodsIdJoinExpr('s')} AS goodsIdKey,
|
|
TRY_CONVERT(date, s.[createTime]) AS dataDate,
|
|
${spreadSelectSql},
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY ${getPddGoodsIdJoinExpr('s')}, TRY_CONVERT(date, s.[createTime])
|
|
ORDER BY s.[createTime] DESC
|
|
) AS rn
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s
|
|
WHERE ${spreadDateWhereParts.join('\n AND ')}
|
|
) spreadLatest
|
|
WHERE spreadLatest.rn = 1
|
|
) spread
|
|
ON ${getPddGoodsIdJoinExpr('g')} = spread.[goodsIdKey]
|
|
AND TRY_CONVERT(date, g.[createTime]) = spread.[dataDate]
|
|
`;
|
|
|
|
const whereParts = [];
|
|
const params = {};
|
|
|
|
if (startDate && baseAvailableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] >= @startDate');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate');
|
|
}
|
|
params.startDate = startDate;
|
|
}
|
|
if (endDate && baseAvailableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate');
|
|
}
|
|
params.endDate = endDate;
|
|
}
|
|
if (shopName && shopField) {
|
|
if (hasTextShopField) {
|
|
whereParts.push(`g.[${shopField}] = @shopName`);
|
|
} else {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`);
|
|
}
|
|
params.shopName = shopName;
|
|
}
|
|
if (goodsId && baseAvailableColumns.has('goodsId')) {
|
|
if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧',
|
|
});
|
|
}
|
|
whereParts.push('g.[goodsId] = @goodsId');
|
|
params.goodsId = goodsId;
|
|
}
|
|
if (department) {
|
|
whereParts.push('tm.[dept] = @department');
|
|
params.department = department;
|
|
}
|
|
if (idOwners.length > 0) {
|
|
appendNamedInCondition(whereParts, params, idOwners, 'tm.[ider]', 'idOwner');
|
|
}
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
columns: summaryDisplayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : '';
|
|
const sortFieldDef = summaryDisplayFieldDefs.find((fieldDef) => fieldDef.key === sortField) || null;
|
|
const defaultOrderExpr = baseAvailableColumns.has('createTime') ? 'g.[createTime]' : (summaryDisplayFieldDefs[0].sortExpr || summaryDisplayFieldDefs[0].expr);
|
|
const orderExpr = sortFieldDef?.sortExpr || sortFieldDef?.expr || defaultOrderExpr;
|
|
const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC';
|
|
const selectFieldsSql = summaryDisplayFieldDefs.map((fieldDef) => `${fieldDef.expr} AS [${fieldDef.key}]`).join(', ');
|
|
const needJoinForCount = Boolean(department) || idOwners.length > 0 || !accessScope.isAllData;
|
|
const countFromSql = needJoinForCount
|
|
? `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${baseJoinSql}
|
|
${whereSql}
|
|
`
|
|
: `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${whereSql}
|
|
`;
|
|
const fallbackCountSql = `
|
|
SELECT COUNT(1) AS total
|
|
${countFromSql}
|
|
`;
|
|
const countCacheKey = JSON.stringify({
|
|
scope: 'pdd-goods-detail-summary',
|
|
userId: req.user.sub,
|
|
startDate,
|
|
endDate,
|
|
shopName,
|
|
goodsId,
|
|
department,
|
|
idOwners,
|
|
scopeDepartments: accessScope.allowedDepartments,
|
|
isAllData: accessScope.isAllData,
|
|
});
|
|
let total = getMemoryCache(pddCountCache, countCacheKey);
|
|
if (total === null) {
|
|
const totalRows = await cloudDb.query(fallbackCountSql, params);
|
|
total = Number(totalRows?.[0]?.total || 0);
|
|
setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS);
|
|
}
|
|
const rowsFromSql = needJoinForCount
|
|
? `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${baseJoinSql}
|
|
${spreadSubquerySql}
|
|
`
|
|
: `
|
|
${buildPddPagedBaseSubquery({
|
|
schemaName: PDD_DATA_GOODS_SCHEMA,
|
|
tableName: PDD_DATA_GOODS_TABLE,
|
|
whereSql,
|
|
orderExpr,
|
|
orderDirection,
|
|
fallbackOrderExpr: defaultOrderExpr,
|
|
})}
|
|
${buildPddTargetApplySql({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
})}
|
|
${spreadApplySql}
|
|
`;
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const rows = total > 0
|
|
? await cloudDb.query(
|
|
`
|
|
SELECT ${selectFieldsSql}
|
|
${rowsFromSql}
|
|
${needJoinForCount ? whereSql : ''}
|
|
ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''}
|
|
`,
|
|
pagingParams
|
|
)
|
|
: [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
columns: summaryDisplayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: rows || [],
|
|
sortField: sortFieldDef?.key || '',
|
|
sortOrder: sortFieldDef ? sortOrder || 'descend' : '',
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/evaluation-summary/list', authMiddleware, async (req, res) => {
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const sortField = (req.query.sortField || '').trim();
|
|
const sortOrder = (req.query.sortOrder || '').trim().toLowerCase();
|
|
const startDate = (req.query.startDate || '').trim();
|
|
const endDate = (req.query.endDate || '').trim();
|
|
const shopName = (req.query.shopName || '').trim();
|
|
const goodsId = (req.query.goodsId || '').trim();
|
|
const department = (req.query.department || '').trim();
|
|
const idOwners = normalizeMultiValueQueryParam(req.query.idOwners || req.query.idOwner);
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_DATA_GOODS_TABLE);
|
|
if (!allColumns || allColumns.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_DataGoods 表',
|
|
});
|
|
}
|
|
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const selectedFieldDefs = PDD_DATA_GOODS_FIELDS.filter((fieldDef) => availableColumns.has(fieldDef.key));
|
|
if (selectedFieldDefs.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'dbo.pdd_DataGoods 表存在,但未匹配到预期字段',
|
|
});
|
|
}
|
|
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
const createTimeDataType = columnTypeMap.get('createTime');
|
|
const goodsIdDataType = columnTypeMap.get('goodsId');
|
|
const shopDataType = shopField ? columnTypeMap.get(shopField) : null;
|
|
const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType);
|
|
const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType);
|
|
const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType);
|
|
const joinSql = buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
});
|
|
const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, {
|
|
extraFields: PDD_JOIN_EXTRA_FIELDS,
|
|
extraFieldsAfterKey: 'hdThumbUrl',
|
|
insertFieldGroups: [
|
|
{
|
|
afterKey: 'goodsVcr',
|
|
fields: PDD_DATA_GOODS_DERIVED_FIELDS,
|
|
},
|
|
],
|
|
});
|
|
displayFieldDefs.push({
|
|
key: 'badReviewCount',
|
|
title: '差评条数',
|
|
expr: 'bad.badReviewCount',
|
|
sortExpr: 'TRY_CONVERT(INT, bad.badReviewCount)',
|
|
});
|
|
|
|
const badReviewWhereParts = [
|
|
'TRY_CONVERT(INT, e.[descScore]) IN (1, 2, 3)',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), e.[goodsId]))) <> ''",
|
|
'TRY_CONVERT(date, e.[createTime]) IS NOT NULL',
|
|
];
|
|
if (startDate) {
|
|
badReviewWhereParts.push('TRY_CONVERT(date, e.[createTime]) >= @startDate');
|
|
}
|
|
if (endDate) {
|
|
badReviewWhereParts.push('TRY_CONVERT(date, e.[createTime]) <= @endDate');
|
|
}
|
|
const badReviewApplySql = `
|
|
CROSS APPLY (
|
|
SELECT COUNT(1) AS [badReviewCount]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e
|
|
WHERE TRY_CONVERT(INT, e.[descScore]) IN (1, 2, 3)
|
|
AND ${getPddGoodsIdJoinExpr('e')} = ${getPddGoodsIdJoinExpr('g')}
|
|
AND TRY_CONVERT(date, e.[createTime]) = TRY_CONVERT(date, g.[createTime])
|
|
HAVING COUNT(1) > 0
|
|
) bad
|
|
`;
|
|
const badReviewJoinSql = `
|
|
INNER JOIN (
|
|
SELECT
|
|
${getPddGoodsIdJoinExpr('e')} AS goodsIdKey,
|
|
TRY_CONVERT(date, e.[createTime]) AS dataDate,
|
|
COUNT(1) AS badReviewCount
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e
|
|
WHERE ${badReviewWhereParts.join('\n AND ')}
|
|
GROUP BY
|
|
${getPddGoodsIdJoinExpr('e')},
|
|
TRY_CONVERT(date, e.[createTime])
|
|
) bad
|
|
ON ${getPddGoodsIdJoinExpr('g')} = bad.goodsIdKey
|
|
AND TRY_CONVERT(date, g.[createTime]) = bad.dataDate
|
|
`;
|
|
|
|
const whereParts = [];
|
|
const params = {};
|
|
|
|
if (startDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] >= @startDate');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate');
|
|
}
|
|
params.startDate = startDate;
|
|
}
|
|
if (endDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate');
|
|
}
|
|
params.endDate = endDate;
|
|
}
|
|
if (shopName && shopField) {
|
|
if (hasTextShopField) {
|
|
whereParts.push(`g.[${shopField}] = @shopName`);
|
|
} else {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`);
|
|
}
|
|
params.shopName = shopName;
|
|
}
|
|
if (goodsId && availableColumns.has('goodsId')) {
|
|
if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧',
|
|
});
|
|
}
|
|
whereParts.push('g.[goodsId] = @goodsId');
|
|
params.goodsId = goodsId;
|
|
}
|
|
if (department) {
|
|
whereParts.push('tm.[dept] = @department');
|
|
params.department = department;
|
|
}
|
|
if (idOwners.length > 0) {
|
|
appendNamedInCondition(whereParts, params, idOwners, 'tm.[ider]', 'idOwner');
|
|
}
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
const whereSql = whereParts.length ? ` WHERE ${whereParts.join(' AND ')}` : '';
|
|
const sortFieldDef = displayFieldDefs.find((fieldDef) => fieldDef.key === sortField) || null;
|
|
const defaultOrderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : (displayFieldDefs[0].sortExpr || displayFieldDefs[0].expr);
|
|
const orderExpr = sortFieldDef?.sortExpr || sortFieldDef?.expr || defaultOrderExpr;
|
|
const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC';
|
|
const needJoinForCount = Boolean(department) || idOwners.length > 0 || !accessScope.isAllData;
|
|
const selectFieldsSql = displayFieldDefs.map((fieldDef) => {
|
|
if (!needJoinForCount && fieldDef.key === 'badReviewCount') {
|
|
return `g.[badReviewCount] AS [${fieldDef.key}]`;
|
|
}
|
|
return `${fieldDef.expr} AS [${fieldDef.key}]`;
|
|
}).join(', ');
|
|
const countFromSql = needJoinForCount
|
|
? `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${joinSql}
|
|
${badReviewJoinSql}
|
|
${whereSql}
|
|
`
|
|
: `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${badReviewJoinSql}
|
|
${whereSql}
|
|
`;
|
|
const fallbackCountSql = `
|
|
SELECT COUNT(1) AS total
|
|
${countFromSql}
|
|
`;
|
|
const countCacheKey = JSON.stringify({
|
|
scope: 'pdd-evaluation-summary',
|
|
userId: req.user.sub,
|
|
startDate,
|
|
endDate,
|
|
shopName,
|
|
goodsId,
|
|
department,
|
|
idOwners,
|
|
scopeDepartments: accessScope.allowedDepartments,
|
|
isAllData: accessScope.isAllData,
|
|
});
|
|
let total = getMemoryCache(pddCountCache, countCacheKey);
|
|
if (total === null) {
|
|
const totalRows = await cloudDb.query(fallbackCountSql, params);
|
|
total = Number(totalRows?.[0]?.total || 0);
|
|
setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS);
|
|
}
|
|
const rowsFromSql = needJoinForCount
|
|
? `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${joinSql}
|
|
${badReviewJoinSql}
|
|
`
|
|
: `
|
|
FROM (
|
|
SELECT
|
|
g.*,
|
|
bad.[badReviewCount]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${badReviewJoinSql}
|
|
${whereSql}
|
|
ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''}
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
|
|
) g
|
|
${buildPddTargetApplySql({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
})}
|
|
`;
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const rows = total > 0
|
|
? await cloudDb.query(
|
|
`
|
|
SELECT ${selectFieldsSql}
|
|
${rowsFromSql}
|
|
${needJoinForCount ? whereSql : ''}
|
|
ORDER BY ${orderExpr} ${orderDirection}${defaultOrderExpr && defaultOrderExpr !== orderExpr ? `, ${defaultOrderExpr} DESC` : ''}
|
|
`,
|
|
pagingParams
|
|
)
|
|
: [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: rows || [],
|
|
sortField: sortFieldDef?.key || '',
|
|
sortOrder: sortFieldDef ? sortOrder || 'descend' : '',
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/evaluation/details', authMiddleware, async (req, res) => {
|
|
try {
|
|
const goodsId = (req.query.goodsId || '').trim();
|
|
const dataDate = (req.query.dataDate || '').trim();
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
if (!goodsId || !dataDate) {
|
|
return res.status(400).json({ success: false, error: 'goodsId 鍜?dataDate 蹇呭~' });
|
|
}
|
|
|
|
const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_EVALUATION_TABLE);
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const createTimeDataType = columnTypeMap.get('createTime');
|
|
const descScoreDataType = columnTypeMap.get('descScore');
|
|
const needDeptJoin = !accessScope.isAllData;
|
|
const joinSql = needDeptJoin
|
|
? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: false,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
})
|
|
: '';
|
|
const whereParts = [
|
|
SQL_NUMBER_TYPES.has(descScoreDataType)
|
|
? 'g.[descScore] IN (1, 2, 3)'
|
|
: 'TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)',
|
|
`${getPddGoodsIdJoinExpr('g')} = @goodsId`,
|
|
];
|
|
const params = {
|
|
goodsId,
|
|
dataDate,
|
|
dataDateStart: dataDate,
|
|
};
|
|
|
|
if (SQL_DATE_TYPES.has(createTimeDataType)) {
|
|
whereParts.push('g.[createTime] >= @dataDateStart');
|
|
whereParts.push('g.[createTime] < DATEADD(DAY, 1, @dataDateStart)');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) = @dataDate');
|
|
}
|
|
|
|
if (needDeptJoin) {
|
|
if (accessScope.allowedDepartments.length === 0) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
const rows = await cloudDb.query(
|
|
`
|
|
WITH filteredEvaluation AS (
|
|
SELECT
|
|
g.[descScore] AS [descScore],
|
|
g.[comment] AS [comment],
|
|
CASE
|
|
WHEN TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])) IS NULL
|
|
THEN TRY_CONVERT(NVARCHAR(50), g.[created])
|
|
ELSE CONVERT(VARCHAR(19), DATEADD(SECOND, TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])), '1970-01-01'), 120)
|
|
END AS [created],
|
|
g.[orderSn] AS [orderSn],
|
|
g.[name] AS [name],
|
|
g.[goodsName] AS [goodsName],
|
|
g.[goodsId] AS [goodsId],
|
|
${getPddGoodsIdJoinExpr('g')} AS [goodsIdKey],
|
|
TRY_CONVERT(NVARCHAR(255), g.[orderSn]) AS [orderSnKey],
|
|
g.[thumbUrl] AS [thumbUrl],
|
|
g.[specs] AS [specs],
|
|
g.[keywords] AS [keywords],
|
|
g.[shopName] AS [shopName],
|
|
g.[createTime] AS [createTime]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g
|
|
${joinSql}
|
|
WHERE ${whereParts.join(' AND ')}
|
|
),
|
|
dwsSkuMap AS (
|
|
SELECT
|
|
keys.[goodsIdKey],
|
|
keys.[orderSnKey],
|
|
STUFF((
|
|
SELECT DISTINCT ',' + TRY_CONVERT(NVARCHAR(255), d2.[skuoid])
|
|
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d2
|
|
WHERE TRY_CONVERT(NVARCHAR(255), d2.[item_id]) = keys.[goodsIdKey]
|
|
AND TRY_CONVERT(NVARCHAR(255), d2.[sourceno]) = keys.[orderSnKey]
|
|
AND d2.[skuoid] IS NOT NULL
|
|
AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d2.[skuoid]))) <> ''
|
|
FOR XML PATH(''), TYPE
|
|
).value('.', 'NVARCHAR(MAX)'), 1, 1, '') AS [skuoid]
|
|
FROM (
|
|
SELECT DISTINCT [goodsIdKey], [orderSnKey]
|
|
FROM filteredEvaluation
|
|
WHERE [goodsIdKey] IS NOT NULL
|
|
AND [goodsIdKey] <> ''
|
|
AND [orderSnKey] IS NOT NULL
|
|
AND [orderSnKey] <> ''
|
|
) keys
|
|
)
|
|
SELECT
|
|
f.[descScore],
|
|
f.[comment],
|
|
f.[created],
|
|
f.[orderSn],
|
|
f.[name],
|
|
f.[goodsName],
|
|
f.[goodsId],
|
|
ISNULL(m.[skuoid], '') AS [skuoid],
|
|
f.[thumbUrl],
|
|
f.[specs],
|
|
f.[keywords],
|
|
f.[shopName],
|
|
f.[createTime]
|
|
FROM filteredEvaluation f
|
|
LEFT JOIN dwsSkuMap m
|
|
ON f.[goodsIdKey] = m.[goodsIdKey]
|
|
AND f.[orderSnKey] = m.[orderSnKey]
|
|
ORDER BY f.[createTime] DESC, f.[created] DESC
|
|
`,
|
|
params
|
|
);
|
|
|
|
res.json({ success: true, data: rows || [] });
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/evaluation/shop-options', authMiddleware, async (req, res) => {
|
|
try {
|
|
const keyword = (req.query.keyword || '').trim();
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
if (!accessScope.isAllData && accessScope.allowedDepartments.length === 0) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
|
|
const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_EVALUATION_TABLE);
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
if (!shopField || !availableColumns.has('goodsId')) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
|
|
const whereParts = [
|
|
'TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)',
|
|
`g.[${shopField}] IS NOT NULL`,
|
|
`LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[${shopField}]))) <> ''`,
|
|
];
|
|
const params = {};
|
|
let joinSql = '';
|
|
|
|
if (!accessScope.isAllData) {
|
|
joinSql = buildPddTargetJoinSql({ includeIdOwner: false }).replace('LEFT JOIN', 'INNER JOIN');
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
if (keyword) {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) LIKE @keyword`);
|
|
params.keyword = `%${keyword}%`;
|
|
}
|
|
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const rows = await cloudDb.query(
|
|
`
|
|
SELECT TOP (1000)
|
|
TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) AS shopName,
|
|
COUNT(1) AS shopCount
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g
|
|
${joinSql}
|
|
${whereSql}
|
|
GROUP BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}])
|
|
ORDER BY TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) ASC
|
|
`,
|
|
params
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: (rows || []).map((row) => ({
|
|
shopName: row.shopName,
|
|
rowCount: Number(row.shopCount || 0),
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/evaluation/list', authMiddleware, async (req, res) => {
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const startDate = (req.query.startDate || '').trim();
|
|
const endDate = (req.query.endDate || '').trim();
|
|
const shopName = (req.query.shopName || '').trim();
|
|
const goodsId = (req.query.goodsId || '').trim();
|
|
const department = (req.query.department || '').trim();
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const allColumns = await getPddTableColumns(PDD_DATA_GOODS_SCHEMA, PDD_EVALUATION_TABLE);
|
|
if (!allColumns || allColumns.length === 0) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: '未读取到字段,请确认业务数据库已配置且存在 dbo.pdd_Evaluation 表',
|
|
});
|
|
}
|
|
|
|
const availableColumns = new Set((allColumns || []).map((c) => c.columnName));
|
|
const columnTypeMap = new Map((allColumns || []).map((c) => [c.columnName, String(c.dataType || '').toLowerCase()]));
|
|
const selectedFieldDefs = PDD_EVALUATION_FIELDS.filter((fieldDef) => availableColumns.has(fieldDef.key));
|
|
if (selectedFieldDefs.length === 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'dbo.pdd_Evaluation 表存在,但未匹配到预期字段',
|
|
});
|
|
}
|
|
|
|
const shopField = PDD_SHOP_FIELD_CANDIDATES.find((field) => availableColumns.has(field)) || null;
|
|
const createTimeDataType = columnTypeMap.get('createTime');
|
|
const goodsIdDataType = columnTypeMap.get('goodsId');
|
|
const shopDataType = shopField ? columnTypeMap.get(shopField) : null;
|
|
const hasDateCreateTime = SQL_DATE_TYPES.has(createTimeDataType);
|
|
const hasTextShopField = SQL_TEXT_TYPES.has(shopDataType);
|
|
const hasNumericGoodsId = SQL_NUMBER_TYPES.has(goodsIdDataType);
|
|
const joinSql = availableColumns.has('goodsId')
|
|
? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
})
|
|
: '';
|
|
|
|
const displayFieldDefs = buildPddDisplayFieldDefs(selectedFieldDefs, {
|
|
extraFields: PDD_JOIN_EXTRA_FIELDS,
|
|
extraFieldsAfterKey: 'thumbUrl',
|
|
getExpr: (fieldDef) => {
|
|
if (fieldDef.key === 'created') {
|
|
return "CASE WHEN TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])) IS NULL THEN TRY_CONVERT(NVARCHAR(50), g.[created]) ELSE CONVERT(VARCHAR(19), DATEADD(SECOND, TRY_CONVERT(INT, TRY_CONVERT(NVARCHAR(50), g.[created])), '1970-01-01'), 120) END";
|
|
}
|
|
return `g.[${fieldDef.key}]`;
|
|
},
|
|
});
|
|
|
|
const whereParts = ['TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)'];
|
|
const params = {};
|
|
|
|
if (startDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] >= @startDate');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) >= @startDate');
|
|
}
|
|
params.startDate = startDate;
|
|
}
|
|
if (endDate && availableColumns.has('createTime')) {
|
|
if (hasDateCreateTime) {
|
|
whereParts.push('g.[createTime] < DATEADD(DAY, 1, @endDate)');
|
|
} else {
|
|
whereParts.push('TRY_CONVERT(date, g.[createTime]) <= @endDate');
|
|
}
|
|
params.endDate = endDate;
|
|
}
|
|
if (shopName && shopField) {
|
|
if (hasTextShopField) {
|
|
whereParts.push(`g.[${shopField}] = @shopName`);
|
|
} else {
|
|
whereParts.push(`TRY_CONVERT(NVARCHAR(255), g.[${shopField}]) = @shopName`);
|
|
}
|
|
params.shopName = shopName;
|
|
}
|
|
if (goodsId && availableColumns.has('goodsId')) {
|
|
if (hasNumericGoodsId && !/^\d+$/.test(goodsId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'goodsId 涓烘暟鍊煎瓧娈碉紝璇疯緭鍏ョ函鏁板瓧',
|
|
});
|
|
}
|
|
whereParts.push('g.[goodsId] = @goodsId');
|
|
params.goodsId = goodsId;
|
|
}
|
|
if (department && availableColumns.has('goodsId')) {
|
|
whereParts.push('tm.[dept] = @department');
|
|
params.department = department;
|
|
}
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0 || !availableColumns.has('goodsId')) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
}
|
|
appendNamedInCondition(whereParts, params, accessScope.allowedDepartments, 'tm.[dept]', 'scopeDept');
|
|
}
|
|
|
|
const whereSql = ` WHERE ${whereParts.join(' AND ')}`;
|
|
const orderExpr = availableColumns.has('createTime') ? 'g.[createTime]' : 'g.[descScore]';
|
|
const selectFieldsSql = displayFieldDefs.map((fieldDef) => `${fieldDef.expr} AS [${fieldDef.key}]`).join(', ');
|
|
const countCacheKey = JSON.stringify({
|
|
scope: 'pdd-evaluation',
|
|
userId: req.user.sub,
|
|
startDate,
|
|
endDate,
|
|
shopName,
|
|
goodsId,
|
|
department,
|
|
scopeDepartments: accessScope.allowedDepartments,
|
|
isAllData: accessScope.isAllData,
|
|
});
|
|
|
|
const needJoinForCount = Boolean(department) || !accessScope.isAllData;
|
|
let total = getMemoryCache(pddCountCache, countCacheKey);
|
|
if (total === null) {
|
|
const totalRows = await cloudDb.query(
|
|
`
|
|
SELECT COUNT(1) AS total
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g
|
|
${needJoinForCount ? joinSql : ''}
|
|
${whereSql}
|
|
`,
|
|
params
|
|
);
|
|
total = Number(totalRows?.[0]?.total || 0);
|
|
setMemoryCache(pddCountCache, countCacheKey, total, PDD_COUNT_CACHE_TTL_MS);
|
|
}
|
|
|
|
const pagingParams = {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const rowsFromSql = needJoinForCount
|
|
? `
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g
|
|
${joinSql}
|
|
`
|
|
: `
|
|
${buildPddPagedBaseSubquery({
|
|
schemaName: PDD_DATA_GOODS_SCHEMA,
|
|
tableName: PDD_EVALUATION_TABLE,
|
|
whereSql,
|
|
orderExpr,
|
|
})}
|
|
${buildPddTargetApplySql({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: getPddGoodsIdJoinExpr('g'),
|
|
})}
|
|
`;
|
|
const rows = total > 0
|
|
? await cloudDb.query(
|
|
`
|
|
SELECT ${selectFieldsSql}
|
|
${rowsFromSql}
|
|
${needJoinForCount ? whereSql : ''}
|
|
ORDER BY ${orderExpr} DESC
|
|
`,
|
|
pagingParams
|
|
)
|
|
: [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
columns: displayFieldDefs.map((fieldDef) => ({ key: fieldDef.key, title: fieldDef.title })),
|
|
rows: rows || [],
|
|
departmentField: 'op_TargetManagement.dept',
|
|
departmentOptions: [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/brand-analysis/list', authMiddleware, async (req, res) => {
|
|
try {
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const result = await getBrandAnalysisList({
|
|
accessScope,
|
|
brandName: req.query.brandName,
|
|
startDate: req.query.startDate,
|
|
endDate: req.query.endDate,
|
|
page: req.query.page,
|
|
pageSize: req.query.pageSize,
|
|
onlyWithIds: req.query.onlyWithIds,
|
|
});
|
|
|
|
if (result.pending) {
|
|
res.set('Retry-After', '5');
|
|
}
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
...result,
|
|
brandOptions: BRAND_OPTIONS,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/stock-selection/data-dates', authMiddleware, async (_req, res) => {
|
|
try {
|
|
await ensurePlanningStockSelectionSchema();
|
|
const rows = await stockSelectionDb.query(
|
|
`
|
|
SELECT
|
|
statDate
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\`
|
|
GROUP BY statDate
|
|
ORDER BY statDate DESC
|
|
LIMIT 30
|
|
`
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: (rows || []).map((row) => ({
|
|
statDate: row.statDate,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/stock-selection/overview', authMiddleware, async (req, res) => {
|
|
try {
|
|
const requestedStatDate = (req.query.statDate || '').trim();
|
|
const poolType = (req.query.poolType || '').trim();
|
|
const ruleCode = (req.query.ruleCode || '').trim();
|
|
const barcode = (req.query.barcode || '').trim();
|
|
|
|
const statDate = requestedStatDate || await getLatestStockSelectionStatDate();
|
|
if (!statDate) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
statDate: null,
|
|
totals: {
|
|
totalCount: 0,
|
|
pickPoolCount: 0,
|
|
goodPickPoolCount: 0,
|
|
clearPoolCount: 0,
|
|
banPoolCount: 0,
|
|
reviewPoolCount: 0,
|
|
naCount: 0,
|
|
},
|
|
poolDistribution: [],
|
|
ruleDistribution: [],
|
|
summaryRows: [],
|
|
ruleOptions: [],
|
|
poolOptions: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
const { whereSql, params } = buildStockSelectionWhereClause({
|
|
statDate,
|
|
poolType,
|
|
ruleCode,
|
|
barcode,
|
|
});
|
|
|
|
await ensurePlanningStockSelectionSchema();
|
|
const totalsRows = await stockSelectionDb.query(
|
|
`
|
|
SELECT
|
|
? AS statDate,
|
|
COUNT(1) AS totalCount,
|
|
SUM(CASE WHEN r.\`poolType\` = '选品待选池' THEN 1 ELSE 0 END) AS pickPoolCount,
|
|
SUM(CASE WHEN r.\`poolType\` = '优质选品待选池' THEN 1 ELSE 0 END) AS goodPickPoolCount,
|
|
SUM(CASE WHEN r.\`poolType\` = '清货待选池' THEN 1 ELSE 0 END) AS clearPoolCount,
|
|
SUM(CASE WHEN r.\`poolType\` = '禁止选品' THEN 1 ELSE 0 END) AS banPoolCount,
|
|
SUM(CASE WHEN r.\`poolType\` = '企划复盘' THEN 1 ELSE 0 END) AS reviewPoolCount,
|
|
SUM(CASE WHEN r.\`poolType\` = '未命中' THEN 1 ELSE 0 END) AS naCount
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
${whereSql}
|
|
`,
|
|
[ statDate, ...params ]
|
|
);
|
|
const poolDistribution = await stockSelectionDb.query(
|
|
`
|
|
SELECT
|
|
r.\`poolType\` AS name,
|
|
COUNT(1) AS value
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
${whereSql}
|
|
GROUP BY r.\`poolType\`
|
|
ORDER BY COUNT(1) DESC, r.\`poolType\`
|
|
`,
|
|
params
|
|
);
|
|
const ruleDistribution = await stockSelectionDb.query(
|
|
`
|
|
SELECT
|
|
r.\`ruleCode\` AS name,
|
|
COUNT(1) AS value
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
${whereSql}
|
|
GROUP BY r.\`ruleCode\`
|
|
ORDER BY COUNT(1) DESC, r.\`ruleCode\`
|
|
`,
|
|
params
|
|
);
|
|
const summaryRowsRaw = await stockSelectionDb.query(
|
|
`
|
|
SELECT
|
|
r.\`ruleCode\` AS ruleCode,
|
|
r.\`poolType\` AS poolType,
|
|
MAX(r.\`ruleTag\`) AS ruleTag,
|
|
COUNT(1) AS hitCount
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
${whereSql}
|
|
GROUP BY r.\`ruleCode\`, r.\`poolType\`
|
|
ORDER BY COUNT(1) DESC, r.\`ruleCode\`, r.\`poolType\`
|
|
`,
|
|
params
|
|
);
|
|
const ruleOptionsRows = await stockSelectionDb.query(
|
|
`
|
|
SELECT DISTINCT r.\`ruleCode\` AS ruleCode
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
WHERE r.\`statDate\` = ?
|
|
ORDER BY r.\`ruleCode\`
|
|
`,
|
|
[ statDate ]
|
|
);
|
|
const poolOptionsRows = await stockSelectionDb.query(
|
|
`
|
|
SELECT DISTINCT r.\`poolType\` AS poolType
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
WHERE r.\`statDate\` = ?
|
|
ORDER BY r.\`poolType\`
|
|
`,
|
|
[ statDate ]
|
|
);
|
|
|
|
const totals = totalsRows?.[0] || {};
|
|
const ruleOptions = (ruleOptionsRows || []).map((row) => row.ruleCode).filter(Boolean);
|
|
const poolOptions = (poolOptionsRows || []).map((row) => row.poolType).filter(Boolean);
|
|
const totalCount = Number(totals.totalCount || 0);
|
|
|
|
const summaryRows = summaryRowsRaw.map((row, index) => ({
|
|
key: `${row.ruleCode || 'NA'}-${row.poolType || 'NA'}-${index}`,
|
|
ruleCode: row.ruleCode,
|
|
poolType: row.poolType,
|
|
ruleTag: row.ruleTag,
|
|
hitCount: Number(row.hitCount || 0),
|
|
hitRate: totalCount > 0 ? Number(row.hitCount || 0) / totalCount : 0,
|
|
}));
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
statDate,
|
|
totals: {
|
|
totalCount: Number(totals.totalCount || 0),
|
|
pickPoolCount: Number(totals.pickPoolCount || 0),
|
|
goodPickPoolCount: Number(totals.goodPickPoolCount || 0),
|
|
clearPoolCount: Number(totals.clearPoolCount || 0),
|
|
banPoolCount: Number(totals.banPoolCount || 0),
|
|
reviewPoolCount: Number(totals.reviewPoolCount || 0),
|
|
naCount: Number(totals.naCount || 0),
|
|
},
|
|
poolDistribution: poolDistribution.map((row) => ({ name: row.name || '未分类', value: Number(row.value || 0) })),
|
|
ruleDistribution: ruleDistribution.map((row) => ({ name: row.name || 'NA', value: Number(row.value || 0) })),
|
|
summaryRows,
|
|
ruleOptions,
|
|
poolOptions,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/brand-analysis/details', authMiddleware, async (req, res) => {
|
|
try {
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const result = await getBrandAnalysisDetails({
|
|
accessScope,
|
|
brandSpu: req.query.brandSpu,
|
|
startDate: req.query.startDate,
|
|
endDate: req.query.endDate,
|
|
});
|
|
|
|
res.json({ success: true, data: result });
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/stock-selection/list', authMiddleware, async (req, res) => {
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const requestedStatDate = (req.query.statDate || '').trim();
|
|
const poolType = (req.query.poolType || '').trim();
|
|
const ruleCode = (req.query.ruleCode || '').trim();
|
|
const barcode = (req.query.barcode || '').trim();
|
|
|
|
const statDate = requestedStatDate || await getLatestStockSelectionStatDate();
|
|
if (!statDate) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
statDate: null,
|
|
page,
|
|
pageSize,
|
|
total: 0,
|
|
rows: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
const { whereSql, params } = buildStockSelectionWhereClause({
|
|
statDate,
|
|
poolType,
|
|
ruleCode,
|
|
barcode,
|
|
});
|
|
|
|
await ensurePlanningStockSelectionSchema();
|
|
const totalRows = await stockSelectionDb.query(
|
|
`
|
|
SELECT COUNT(1) AS total
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
${whereSql}
|
|
`,
|
|
params
|
|
);
|
|
const total = Number(totalRows?.[0]?.total || 0);
|
|
|
|
const rows = total > 0
|
|
? await stockSelectionDb.query(
|
|
`
|
|
SELECT
|
|
r.\`statDate\` AS statDate,
|
|
r.\`channelName\` AS channelName,
|
|
r.\`barcode\` AS barcode,
|
|
r.\`ruleCode\` AS ruleCode,
|
|
r.\`poolType\` AS poolType,
|
|
r.\`ruleTag\` AS ruleTag,
|
|
r.\`ruleExplain\` AS ruleExplain
|
|
FROM \`${PDD_STOCK_SELECTION_RESULT_TABLE}\` r
|
|
${whereSql}
|
|
ORDER BY
|
|
CASE WHEN r.\`ruleCode\` = 'NA' THEN 1 ELSE 0 END,
|
|
r.\`poolType\` ASC,
|
|
r.\`ruleCode\` ASC,
|
|
r.\`barcode\` ASC
|
|
LIMIT ?
|
|
OFFSET ?
|
|
`,
|
|
[ ...params, pageSize, (page - 1) * pageSize ]
|
|
)
|
|
: [];
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
statDate,
|
|
page,
|
|
pageSize,
|
|
total,
|
|
rows: rows || [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
router.get('/pdd/new-product-selection/categories', authMiddleware, async (_req, res) => {
|
|
try {
|
|
const categories = await getPddNewProductSelectionCategories();
|
|
res.json({
|
|
success: true,
|
|
data: categories,
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/new-product-selection/library/filters', authMiddleware, async (_req, res) => {
|
|
try {
|
|
const data = await getPddNewProductSelectionLibraryFilterOptions();
|
|
res.json({
|
|
success: true,
|
|
data,
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/new-product-selection/library', authMiddleware, async (req, res) => {
|
|
try {
|
|
const data = await getPddNewProductSelectionLibraryList({
|
|
page: req.query.page,
|
|
pageSize: req.query.pageSize,
|
|
categoryName: req.query.categoryName,
|
|
keyword: req.query.keyword,
|
|
styleTag: req.query.styleTag,
|
|
colorTag: req.query.colorTag,
|
|
materialTag: req.query.materialTag,
|
|
});
|
|
res.json({
|
|
success: true,
|
|
data,
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/new-product-selection/logs', authMiddleware, async (req, res) => {
|
|
try {
|
|
const data = await getPddNewProductSelectionLogs({
|
|
limit: req.query.limit,
|
|
page: req.query.page,
|
|
pageSize: req.query.pageSize,
|
|
operatorKeyword: req.query.operatorKeyword,
|
|
sourceType: req.query.sourceType,
|
|
selectedCategoryTag: req.query.selectedCategoryTag,
|
|
status: req.query.status,
|
|
keyword: req.query.keyword,
|
|
});
|
|
res.json({
|
|
success: true,
|
|
data,
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/new-product-selection/logs/:id', authMiddleware, async (req, res) => {
|
|
try {
|
|
const detail = await getPddNewProductSelectionLogDetail(req.params.id);
|
|
res.json({
|
|
success: true,
|
|
data: detail,
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.post('/pdd/new-product-selection/search', authMiddleware, async (req, res) => {
|
|
try {
|
|
const result = await searchPddNewProductSelection({
|
|
userId: req.user.sub,
|
|
imageUrl: req.body?.imageUrl,
|
|
imageBase64: req.body?.imageBase64,
|
|
selectedCategoryTag: req.body?.selectedCategoryTag,
|
|
similarityThreshold: req.body?.similarityThreshold,
|
|
limit: req.body?.limit,
|
|
sourceInput: req.body?.sourceInput,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result,
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/invalid-paid/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'spend', title: '近7天总花费' },
|
|
{ key: 'gmv', title: '近7天交易额' },
|
|
{ key: 'orderNum', title: '近7天成交笔数' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_SPREAD_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'invalid-paid',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate });
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[spend]), 0)) / 1000.0 AS [spend],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[gmv]), 0)) / 1000.0 AS [gmv],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)) AS [orderNum]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[spend]), 0)) > 0
|
|
AND SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[spend]), 0)) < 10000
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([spend] AS DECIMAL(38, 2)) AS [spend],
|
|
CAST([gmv] AS DECIMAL(38, 2)) AS [gmv],
|
|
CAST([orderNum] AS DECIMAL(38, 2)) AS [orderNum]
|
|
`,
|
|
orderBySql: '[spend] ASC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_SPREAD_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/bad-review-feedback/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'badReviewCount', title: '近7天差评条数' },
|
|
{ key: 'score1Count', title: '1星条数' },
|
|
{ key: 'score2Count', title: '2星条数' },
|
|
{ key: 'score3Count', title: '3星条数' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_EVALUATION_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'bad-review-feedback',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize };
|
|
const whereParts = [
|
|
'TRY_CONVERT(INT, g.[descScore]) IN (1, 2, 3)',
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate });
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
COUNT(1) AS [badReviewCount],
|
|
SUM(CASE WHEN TRY_CONVERT(INT, g.[descScore]) = 1 THEN 1 ELSE 0 END) AS [score1Count],
|
|
SUM(CASE WHEN TRY_CONVERT(INT, g.[descScore]) = 2 THEN 1 ELSE 0 END) AS [score2Count],
|
|
SUM(CASE WHEN TRY_CONVERT(INT, g.[descScore]) = 3 THEN 1 ELSE 0 END) AS [score3Count]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
[badReviewCount],
|
|
[score1Count],
|
|
[score2Count],
|
|
[score3Count]
|
|
`,
|
|
orderBySql: '[badReviewCount] DESC, [score1Count] DESC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_EVALUATION_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/inquiry-board/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'inquiryNum', title: '近7天询单量' },
|
|
{ key: 'inquirySpend', title: '近7天询单花费' },
|
|
{ key: 'avgInquiryCost', title: '近7天平均询单成本' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_SPREAD_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'inquiry-board',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate });
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[multiGoalInquiryNum]), 0)) AS [inquiryNum],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[inquirySpend]), 0)) / 1000.0 AS [inquirySpend]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[multiGoalInquiryNum]), 0)) > 0
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([inquiryNum] AS DECIMAL(38, 2)) AS [inquiryNum],
|
|
CAST([inquirySpend] AS DECIMAL(38, 2)) AS [inquirySpend],
|
|
CAST([inquirySpend] / NULLIF([inquiryNum], 0) AS DECIMAL(38, 2)) AS [avgInquiryCost]
|
|
`,
|
|
orderBySql: '[inquiryNum] DESC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_SPREAD_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/after-sale-consulting/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'warningTag', title: '标签' },
|
|
{ key: 'currentOrderNum', title: '近7天订单量' },
|
|
{ key: 'prevOrderNum', title: '前7天订单量' },
|
|
{ key: 'orderDelta', title: '订单量变化' },
|
|
{ key: 'currentBadReviewCount', title: '近7天差评量' },
|
|
{ key: 'prevBadReviewCount', title: '前7天差评量' },
|
|
{ key: 'badReviewDelta', title: '差评量变化' },
|
|
{ key: 'currentAfterSaleCount', title: '近7天售后单量' },
|
|
{ key: 'prevAfterSaleCount', title: '前7天售后单量' },
|
|
{ key: 'afterSaleDelta', title: '售后单量变化' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate, prevStartDate, prevEndDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE, PDD_EVALUATION_TABLE]);
|
|
if (!endDate || !prevStartDate || !prevEndDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'after-sale-consulting',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
extraCachePayload: {
|
|
prevStartDate,
|
|
prevEndDate,
|
|
},
|
|
extraResponseData: {
|
|
prevStartDate,
|
|
prevEndDate,
|
|
},
|
|
loader: async () => {
|
|
const params = {
|
|
startDate,
|
|
endDate,
|
|
prevStartDate,
|
|
prevEndDate,
|
|
currentStartDateTime: `${dayjs(startDate).format('YYYY-MM-DD')} 00:00:00`,
|
|
currentEndDateExclusive: dayjs(endDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'),
|
|
prevStartDateTime: `${dayjs(prevStartDate).format('YYYY-MM-DD')} 00:00:00`,
|
|
prevEndDateExclusive: dayjs(prevEndDate).add(1, 'day').format('YYYY-MM-DD 00:00:00'),
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const scopeWhereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
];
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts: scopeWhereParts,
|
|
params,
|
|
fieldExpr: 'g.[createTime]',
|
|
startDate: prevStartDate,
|
|
endDate,
|
|
startParamName: 'scopeStartDateTime',
|
|
endParamName: 'scopeEndDateExclusive',
|
|
});
|
|
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts: scopeWhereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
goodsIdExpr: 'CONVERT(NVARCHAR(50), g.[goodsId])',
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const scopeWhereSql = `WHERE ${scopeWhereParts.join(' AND ')}`;
|
|
const batchSql = `
|
|
IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope;
|
|
IF OBJECT_ID('tempdb..#order_agg') IS NOT NULL DROP TABLE #order_agg;
|
|
IF OBJECT_ID('tempdb..#review_agg') IS NOT NULL DROP TABLE #review_agg;
|
|
IF OBJECT_ID('tempdb..#after_sale_agg') IS NOT NULL DROP TABLE #after_sale_agg;
|
|
IF OBJECT_ID('tempdb..#base') IS NOT NULL DROP TABLE #base;
|
|
|
|
SELECT DISTINCT
|
|
g.[goodsId] AS [goodsId]
|
|
INTO #goods_scope
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'CONVERT(NVARCHAR(50), g.[goodsId])',
|
|
}) : ''}
|
|
${scopeWhereSql};
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_goods_scope_goodsId ON #goods_scope([goodsId]);
|
|
|
|
SELECT
|
|
g.[goodsId] AS [goodsId],
|
|
SUM(CASE
|
|
WHEN g.[createTime] >= @currentStartDateTime
|
|
AND g.[createTime] < @currentEndDateExclusive
|
|
THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)
|
|
ELSE 0
|
|
END) AS [currentOrderNum],
|
|
SUM(CASE
|
|
WHEN g.[createTime] >= @prevStartDateTime
|
|
AND g.[createTime] < @prevEndDateExclusive
|
|
THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)
|
|
ELSE 0
|
|
END) AS [prevOrderNum]
|
|
INTO #order_agg
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
INNER JOIN #goods_scope gs
|
|
ON gs.[goodsId] = g.[goodsId]
|
|
WHERE g.[createTime] >= @scopeStartDateTime
|
|
AND g.[createTime] < @scopeEndDateExclusive
|
|
GROUP BY g.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_order_agg_goodsId ON #order_agg([goodsId]);
|
|
|
|
SELECT
|
|
e.[goodsId] AS [goodsId],
|
|
SUM(CASE
|
|
WHEN e.[createTime] >= @currentStartDateTime
|
|
AND e.[createTime] < @currentEndDateExclusive
|
|
THEN 1
|
|
ELSE 0
|
|
END) AS [currentBadReviewCount],
|
|
SUM(CASE
|
|
WHEN e.[createTime] >= @prevStartDateTime
|
|
AND e.[createTime] < @prevEndDateExclusive
|
|
THEN 1
|
|
ELSE 0
|
|
END) AS [prevBadReviewCount]
|
|
INTO #review_agg
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e
|
|
INNER JOIN #goods_scope gs
|
|
ON gs.[goodsId] = e.[goodsId]
|
|
WHERE e.[descScore] IN (1, 2, 3)
|
|
AND e.[createTime] >= @scopeStartDateTime
|
|
AND e.[createTime] < @scopeEndDateExclusive
|
|
GROUP BY e.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_review_agg_goodsId ON #review_agg([goodsId]);
|
|
|
|
SELECT
|
|
gs.[goodsId] AS [goodsId],
|
|
SUM(CASE
|
|
WHEN d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate)
|
|
THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0)
|
|
ELSE 0
|
|
END) AS [currentAfterSaleCount],
|
|
SUM(CASE
|
|
WHEN d.[stat_date] BETWEEN TRY_CONVERT(DATE, @prevStartDate) AND TRY_CONVERT(DATE, @prevEndDate)
|
|
THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0)
|
|
ELSE 0
|
|
END) AS [prevAfterSaleCount]
|
|
INTO #after_sale_agg
|
|
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d
|
|
INNER JOIN #goods_scope gs
|
|
ON d.[item_id] = CONVERT(VARCHAR(50), gs.[goodsId])
|
|
WHERE d.[stat_date] BETWEEN TRY_CONVERT(DATE, @prevStartDate) AND TRY_CONVERT(DATE, @endDate)
|
|
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多')
|
|
GROUP BY gs.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_after_sale_agg_goodsId ON #after_sale_agg([goodsId]);
|
|
|
|
SELECT
|
|
gs.[goodsId],
|
|
CAST(ISNULL(o.[currentOrderNum], 0) AS DECIMAL(38, 2)) AS [currentOrderNum],
|
|
CAST(ISNULL(o.[prevOrderNum], 0) AS DECIMAL(38, 2)) AS [prevOrderNum],
|
|
CAST(ISNULL(o.[currentOrderNum], 0) - ISNULL(o.[prevOrderNum], 0) AS DECIMAL(38, 2)) AS [orderDelta],
|
|
CAST(ISNULL(r.[currentBadReviewCount], 0) AS DECIMAL(38, 2)) AS [currentBadReviewCount],
|
|
CAST(ISNULL(r.[prevBadReviewCount], 0) AS DECIMAL(38, 2)) AS [prevBadReviewCount],
|
|
CAST(ISNULL(r.[currentBadReviewCount], 0) - ISNULL(r.[prevBadReviewCount], 0) AS DECIMAL(38, 2)) AS [badReviewDelta],
|
|
CAST(ISNULL(a.[currentAfterSaleCount], 0) AS DECIMAL(38, 2)) AS [currentAfterSaleCount],
|
|
CAST(ISNULL(a.[prevAfterSaleCount], 0) AS DECIMAL(38, 2)) AS [prevAfterSaleCount],
|
|
CAST(ISNULL(a.[currentAfterSaleCount], 0) - ISNULL(a.[prevAfterSaleCount], 0) AS DECIMAL(38, 2)) AS [afterSaleDelta],
|
|
CASE
|
|
WHEN ISNULL(o.[currentOrderNum], 0) <= ISNULL(o.[prevOrderNum], 0)
|
|
AND ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0)
|
|
AND ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0)
|
|
THEN N'售后单量上升,请立即排查记录'
|
|
WHEN ISNULL(o.[currentOrderNum], 0) > ISNULL(o.[prevOrderNum], 0)
|
|
AND (
|
|
ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0)
|
|
OR ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0)
|
|
)
|
|
THEN N'售后单量持续上升注意排查'
|
|
ELSE NULL
|
|
END AS [warningTag],
|
|
CASE
|
|
WHEN ISNULL(o.[currentOrderNum], 0) <= ISNULL(o.[prevOrderNum], 0)
|
|
AND ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0)
|
|
AND ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0)
|
|
THEN 2
|
|
WHEN ISNULL(o.[currentOrderNum], 0) > ISNULL(o.[prevOrderNum], 0)
|
|
AND (
|
|
ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0)
|
|
OR ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0)
|
|
)
|
|
THEN 1
|
|
ELSE 0
|
|
END AS [warningPriority]
|
|
INTO #base
|
|
FROM #goods_scope gs
|
|
LEFT JOIN #order_agg o ON o.[goodsId] = gs.[goodsId]
|
|
LEFT JOIN #review_agg r ON r.[goodsId] = gs.[goodsId]
|
|
LEFT JOIN #after_sale_agg a ON a.[goodsId] = gs.[goodsId]
|
|
WHERE (
|
|
ISNULL(o.[currentOrderNum], 0) <= ISNULL(o.[prevOrderNum], 0)
|
|
AND ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0)
|
|
AND ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0)
|
|
)
|
|
OR (
|
|
ISNULL(o.[currentOrderNum], 0) > ISNULL(o.[prevOrderNum], 0)
|
|
AND (
|
|
ISNULL(r.[currentBadReviewCount], 0) > ISNULL(r.[prevBadReviewCount], 0)
|
|
OR ISNULL(a.[currentAfterSaleCount], 0) > ISNULL(a.[prevAfterSaleCount], 0)
|
|
)
|
|
);
|
|
|
|
CREATE CLUSTERED INDEX IX_base_priority ON #base([warningPriority] DESC, [afterSaleDelta] DESC, [badReviewDelta] DESC, [goodsId] ASC);
|
|
|
|
SELECT
|
|
CONVERT(NVARCHAR(50), [goodsId]) AS [goodsId],
|
|
[warningTag],
|
|
[currentOrderNum],
|
|
[prevOrderNum],
|
|
[orderDelta],
|
|
[currentBadReviewCount],
|
|
[prevBadReviewCount],
|
|
[badReviewDelta],
|
|
[currentAfterSaleCount],
|
|
[prevAfterSaleCount],
|
|
[afterSaleDelta],
|
|
COUNT(1) OVER() AS [__total]
|
|
FROM #base
|
|
ORDER BY [warningPriority] DESC, [afterSaleDelta] DESC, [badReviewDelta] DESC, [goodsId] ASC
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY;
|
|
`;
|
|
|
|
const rows = await cloudDb.query(batchSql, params, { requestTimeout: 120000 });
|
|
const total = Number(rows?.[0]?.__total || 0);
|
|
const result = {
|
|
total,
|
|
rows: (rows || []).map((row) => {
|
|
const nextRow = { ...row };
|
|
delete nextRow.__total;
|
|
return nextRow;
|
|
}),
|
|
};
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/indirect-deal/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'orderNum', title: '近7天总成交笔数' },
|
|
{ key: 'indirectOrderNum', title: '近7天间接成交笔数' },
|
|
{ key: 'indirectRate', title: '间接成交占比' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_SPREAD_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'indirect-deal',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate });
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)) AS [orderNum],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[indirectOrderNum]), 0)) AS [indirectOrderNum]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)) > 0
|
|
AND SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[indirectOrderNum]), 0))
|
|
/ NULLIF(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[orderNum]), 0)), 0) > 0.2
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([orderNum] AS DECIMAL(38, 2)) AS [orderNum],
|
|
CAST([indirectOrderNum] AS DECIMAL(38, 2)) AS [indirectOrderNum],
|
|
CAST([indirectOrderNum] / NULLIF([orderNum], 0) * 100.0 AS DECIMAL(38, 2)) AS [indirectRate]
|
|
`,
|
|
orderBySql: '([indirectOrderNum] / NULLIF([orderNum], 0)) DESC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_SPREAD_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/urgent-review-link/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'currentGmv', title: '近7天GMV' },
|
|
{ key: 'prevGmv', title: '前7天GMV' },
|
|
{ key: 'gmvChangeRate', title: 'GMV环比' },
|
|
{ key: 'currentOrderNum', title: '近7天订单数' },
|
|
{ key: 'prevOrderNum', title: '前7天订单数' },
|
|
{ key: 'orderChangeRate', title: '订单环比' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate, prevStartDate, prevEndDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'urgent-review-link',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
extraCachePayload: { prevStartDate, prevEndDate },
|
|
loader: async () => {
|
|
const params = { startDate, endDate, prevStartDate, prevEndDate, offset: (page - 1) * pageSize, pageSize };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts,
|
|
params,
|
|
fieldExpr: 'g.[createTime]',
|
|
startDate: prevStartDate,
|
|
endDate,
|
|
startParamName: 'prevStartDateTime',
|
|
endParamName: 'endDateExclusive',
|
|
});
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [currentGmv],
|
|
SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [prevGmv],
|
|
SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [currentOrderNum],
|
|
SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [prevOrderNum]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING
|
|
SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) > 0
|
|
AND SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) > 0
|
|
AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END)
|
|
< SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END)
|
|
AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END)
|
|
< SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END)
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([currentGmv] AS DECIMAL(38, 2)) AS [currentGmv],
|
|
CAST([prevGmv] AS DECIMAL(38, 2)) AS [prevGmv],
|
|
CAST(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0) * 100.0 AS DECIMAL(38, 2)) AS [gmvChangeRate],
|
|
CAST([currentOrderNum] AS DECIMAL(38, 2)) AS [currentOrderNum],
|
|
CAST([prevOrderNum] AS DECIMAL(38, 2)) AS [prevOrderNum],
|
|
CAST(([currentOrderNum] - [prevOrderNum]) / NULLIF([prevOrderNum], 0) * 100.0 AS DECIMAL(38, 2)) AS [orderChangeRate]
|
|
`,
|
|
orderBySql: '(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0)) ASC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/amplify-board/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'currentGmv', title: '近7天GMV' },
|
|
{ key: 'prevGmv', title: '前7天GMV' },
|
|
{ key: 'gmvChangeRate', title: 'GMV环比' },
|
|
{ key: 'currentOrderNum', title: '近7天订单数' },
|
|
{ key: 'prevOrderNum', title: '前7天订单数' },
|
|
{ key: 'orderChangeRate', title: '订单环比' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate, prevStartDate, prevEndDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'amplify-board',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
extraCachePayload: { prevStartDate, prevEndDate },
|
|
loader: async () => {
|
|
const params = { startDate, endDate, prevStartDate, prevEndDate, offset: (page - 1) * pageSize, pageSize };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts,
|
|
params,
|
|
fieldExpr: 'g.[createTime]',
|
|
startDate: prevStartDate,
|
|
endDate,
|
|
startParamName: 'prevStartDateTime',
|
|
endParamName: 'endDateExclusive',
|
|
});
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [currentGmv],
|
|
SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) AS [prevGmv],
|
|
SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [currentOrderNum],
|
|
SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) AS [prevOrderNum]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING
|
|
SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END) > 0
|
|
AND SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END) > 0
|
|
AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END)
|
|
> SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0) ELSE 0 END)
|
|
AND SUM(CASE WHEN g.[createTime] >= @startDateTime AND g.[createTime] < @endDateExclusive THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END)
|
|
> SUM(CASE WHEN g.[createTime] >= @prevStartDateTime AND g.[createTime] < @startDateTime THEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0) ELSE 0 END)
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([currentGmv] AS DECIMAL(38, 2)) AS [currentGmv],
|
|
CAST([prevGmv] AS DECIMAL(38, 2)) AS [prevGmv],
|
|
CAST(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0) * 100.0 AS DECIMAL(38, 2)) AS [gmvChangeRate],
|
|
CAST([currentOrderNum] AS DECIMAL(38, 2)) AS [currentOrderNum],
|
|
CAST([prevOrderNum] AS DECIMAL(38, 2)) AS [prevOrderNum],
|
|
CAST(([currentOrderNum] - [prevOrderNum]) / NULLIF([prevOrderNum], 0) * 100.0 AS DECIMAL(38, 2)) AS [orderChangeRate]
|
|
`,
|
|
orderBySql: '(([currentGmv] - [prevGmv]) / NULLIF([prevGmv], 0)) DESC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/deal-cost/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'pieceUnitPrice', title: '近7天件单价' },
|
|
{ key: 'avgDealCost', title: '近7天成交花费' },
|
|
{ key: 'costRate', title: '成交花费占件单价比' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE, PDD_SPREAD_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'deal-cost',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize };
|
|
const dgWhereParts = [
|
|
'dg.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) <> ''",
|
|
];
|
|
const spWhereParts = [
|
|
'sp.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts: dgWhereParts, params, fieldExpr: 'dg.[createTime]', startDate, endDate });
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts: spWhereParts,
|
|
params,
|
|
fieldExpr: 'sp.[createTime]',
|
|
startDate,
|
|
endDate,
|
|
startParamName: 'spreadStartDateTime',
|
|
endParamName: 'spreadEndDateExclusive',
|
|
});
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts: dgWhereParts,
|
|
params,
|
|
accessScope,
|
|
shopNameExpr: "TRY_CONVERT(NVARCHAR(255), dg.[shopName])",
|
|
goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))",
|
|
departmentExpr: 'tm1.[dept]',
|
|
idOwnerExpr: 'tm1.[ider]',
|
|
scopeParamPrefix: 'scopeDeptDg',
|
|
shopNameParamName: 'shopNameDg',
|
|
goodsIdParamName: 'goodsIdDg',
|
|
departmentParamName: 'departmentDg',
|
|
idOwnerParamPrefix: 'idOwnerDg',
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const needsSpreadTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, { isAllData: true, allowedDepartments: [] })
|
|
|| Boolean(String(req.query.department || '').trim());
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts: spWhereParts,
|
|
params,
|
|
accessScope: { isAllData: true, allowedDepartments: [] },
|
|
shopNameExpr: "TRY_CONVERT(NVARCHAR(255), sp.[shopName])",
|
|
goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))",
|
|
departmentExpr: 'tm2.[dept]',
|
|
idOwnerExpr: null,
|
|
shopNameParamName: 'shopNameSp',
|
|
goodsIdParamName: 'goodsIdSp',
|
|
departmentParamName: 'departmentSp',
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const baseCteSql = `
|
|
WITH dg_base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrAmt]), 0)) AS [payAmt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrGoodsQty]), 0)) AS [goodsQty]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] dg
|
|
${aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))',
|
|
}), 'tm1')}
|
|
WHERE ${dgWhereParts.join(' AND ')}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))
|
|
),
|
|
sp_base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[spend]), 0)) / 1000.0 AS [spendYuan],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[orderNum]), 0)) AS [orderNum]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] sp
|
|
${needsSpreadTargetJoin ? aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: false,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))',
|
|
}), 'tm2') : ''}
|
|
WHERE ${spWhereParts.join(' AND ')}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))
|
|
),
|
|
base AS (
|
|
SELECT
|
|
dg.[goodsId],
|
|
dg.[payAmt] / NULLIF(dg.[goodsQty], 0) AS [pieceUnitPrice],
|
|
sp.[spendYuan] / NULLIF(sp.[orderNum], 0) AS [avgDealCost]
|
|
FROM dg_base dg
|
|
INNER JOIN sp_base sp ON dg.[goodsId] = sp.[goodsId]
|
|
WHERE dg.[goodsQty] > 0
|
|
AND sp.[orderNum] > 0
|
|
AND sp.[spendYuan] / NULLIF(sp.[orderNum], 0) > (dg.[payAmt] / NULLIF(dg.[goodsQty], 0)) * 0.2
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([pieceUnitPrice] AS DECIMAL(38, 2)) AS [pieceUnitPrice],
|
|
CAST([avgDealCost] AS DECIMAL(38, 2)) AS [avgDealCost],
|
|
CAST([avgDealCost] / NULLIF([pieceUnitPrice], 0) * 100.0 AS DECIMAL(38, 2)) AS [costRate]
|
|
`,
|
|
orderBySql: '([avgDealCost] / NULLIF([pieceUnitPrice], 0)) DESC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/inquiry-cost/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'pieceUnitPrice', title: '近7天件单价' },
|
|
{ key: 'avgInquiryCost', title: '近7天平均询单成本' },
|
|
{ key: 'costRate', title: '询单花费占件单价比' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE, PDD_SPREAD_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'inquiry-cost',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize };
|
|
const dgWhereParts = [
|
|
'dg.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) <> ''",
|
|
];
|
|
const spWhereParts = [
|
|
'sp.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts: dgWhereParts, params, fieldExpr: 'dg.[createTime]', startDate, endDate });
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts: spWhereParts,
|
|
params,
|
|
fieldExpr: 'sp.[createTime]',
|
|
startDate,
|
|
endDate,
|
|
startParamName: 'spreadStartDateTime',
|
|
endParamName: 'spreadEndDateExclusive',
|
|
});
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts: dgWhereParts,
|
|
params,
|
|
accessScope,
|
|
shopNameExpr: "TRY_CONVERT(NVARCHAR(255), dg.[shopName])",
|
|
goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))",
|
|
departmentExpr: 'tm1.[dept]',
|
|
idOwnerExpr: 'tm1.[ider]',
|
|
scopeParamPrefix: 'scopeDeptDg',
|
|
shopNameParamName: 'shopNameDg',
|
|
goodsIdParamName: 'goodsIdDg',
|
|
departmentParamName: 'departmentDg',
|
|
idOwnerParamPrefix: 'idOwnerDg',
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const needsSpreadTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, { isAllData: true, allowedDepartments: [] })
|
|
|| Boolean(String(req.query.department || '').trim());
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts: spWhereParts,
|
|
params,
|
|
accessScope: { isAllData: true, allowedDepartments: [] },
|
|
shopNameExpr: "TRY_CONVERT(NVARCHAR(255), sp.[shopName])",
|
|
goodsIdExpr: "LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))",
|
|
departmentExpr: 'tm2.[dept]',
|
|
idOwnerExpr: null,
|
|
shopNameParamName: 'shopNameSp',
|
|
goodsIdParamName: 'goodsIdSp',
|
|
departmentParamName: 'departmentSp',
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
const baseCteSql = `
|
|
WITH dg_base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrAmt]), 0)) AS [payAmt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), dg.[payOrdrGoodsQty]), 0)) AS [goodsQty]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] dg
|
|
${aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))',
|
|
}), 'tm1')}
|
|
WHERE ${dgWhereParts.join(' AND ')}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), dg.[goodsId])))
|
|
),
|
|
sp_base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[inquirySpend]), 0)) / 1000.0 AS [inquirySpendYuan],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), sp.[multiGoalInquiryNum]), 0)) AS [inquiryNum]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] sp
|
|
${needsSpreadTargetJoin ? aliasPddTargetJoinSql(buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: false,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))',
|
|
}), 'tm2') : ''}
|
|
WHERE ${spWhereParts.join(' AND ')}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), sp.[goodsId])))
|
|
),
|
|
base AS (
|
|
SELECT
|
|
dg.[goodsId],
|
|
dg.[payAmt] / NULLIF(dg.[goodsQty], 0) AS [pieceUnitPrice],
|
|
sp.[inquirySpendYuan] / NULLIF(sp.[inquiryNum], 0) AS [avgInquiryCost]
|
|
FROM dg_base dg
|
|
INNER JOIN sp_base sp ON dg.[goodsId] = sp.[goodsId]
|
|
WHERE dg.[goodsQty] > 0
|
|
AND sp.[inquiryNum] > 0
|
|
AND sp.[inquirySpendYuan] / NULLIF(sp.[inquiryNum], 0) > (dg.[payAmt] / NULLIF(dg.[goodsQty], 0)) * 0.4
|
|
)
|
|
`;
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([pieceUnitPrice] AS DECIMAL(38, 2)) AS [pieceUnitPrice],
|
|
CAST([avgInquiryCost] AS DECIMAL(38, 2)) AS [avgInquiryCost],
|
|
CAST([avgInquiryCost] / NULLIF([pieceUnitPrice], 0) * 100.0 AS DECIMAL(38, 2)) AS [costRate]
|
|
`,
|
|
orderBySql: '([avgInquiryCost] / NULLIF([pieceUnitPrice], 0)) DESC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/price-break-monitor/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'spu', title: 'SPU' },
|
|
{ key: 'oper', title: '运营负责人' },
|
|
{ key: 'pieceUnitPrice', title: '近7天件单价' },
|
|
{ key: 'effectiveLimitPrice', title: '有效限价' },
|
|
{ key: 'breakDiffAmount', title: '破价差额' },
|
|
{ key: 'breakDiffRate', title: '破价幅度' },
|
|
{ key: 'limitFieldUsed', title: '命中限价字段' },
|
|
{ key: 'priceLimitSourceTable', title: '限价来源表' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'price-break-monitor',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const priceLimitState = await getPddPriceLimitSourceState();
|
|
if (!priceLimitState?.ok || Number(priceLimitState.totalRows || 0) <= 0) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const params = { startDate, endDate };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate });
|
|
if (!appendPddProductDashboardCommonFilters({ req, whereParts, params, accessScope })) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const baseRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
MAX(TRY_CONVERT(NVARCHAR(500), g.[goodsName])) AS [goodsName],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), g.[shopName])) AS [shopName],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), tm.[ider])) AS [idOwner],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), tm.[dept])) AS [ownerDept],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), tm.[oper])) AS [oper],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), tm.[spu])) AS [spu],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payAmt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS [goodsQty]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${buildPddTargetExtendedJoinSqlByExpr({
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
})}
|
|
WHERE ${whereParts.join(' AND ')}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) > 0
|
|
`,
|
|
params,
|
|
{ requestTimeout: 120000 }
|
|
);
|
|
|
|
if (!Array.isArray(baseRows) || baseRows.length === 0) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const uniqueSpus = Array.from(new Set(
|
|
baseRows
|
|
.map((row) => String(row.spu || '').trim())
|
|
.filter(Boolean)
|
|
));
|
|
const spuIdentifierMap = new Map();
|
|
if (uniqueSpus.length > 0) {
|
|
const skuParams = {};
|
|
const skuWhereParts = [
|
|
'src.[other_1] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) <> ''",
|
|
];
|
|
appendNamedInCondition(skuWhereParts, skuParams, uniqueSpus, 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1])))', 'spu');
|
|
|
|
const skuRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_1]))) AS [spu],
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[sku_id]))) AS [skuId],
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[other_7]))) AS [productNo],
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), src.[i_id]))) AS [styleCode]
|
|
FROM ${PDD_JST_SKU_TABLE_FULL_NAME} src
|
|
WHERE ${skuWhereParts.join(' AND ')}
|
|
`,
|
|
skuParams,
|
|
{ requestTimeout: 120000 }
|
|
);
|
|
|
|
for (const row of skuRows || []) {
|
|
const spu = String(row.spu || '').trim();
|
|
if (!spu) {
|
|
continue;
|
|
}
|
|
if (!spuIdentifierMap.has(spu)) {
|
|
spuIdentifierMap.set(spu, new Set([ spu ]));
|
|
}
|
|
[ row.skuId, row.productNo, row.styleCode ].forEach((value) => {
|
|
const normalizedValue = String(value || '').trim();
|
|
if (normalizedValue) {
|
|
spuIdentifierMap.get(spu).add(normalizedValue);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const activeTables = (priceLimitState.tables || [])
|
|
.filter((item) => Number(item.rowCount || 0) > 0)
|
|
.map((item) => item.tableName);
|
|
const priceLimitIndexes = await loadPddPriceLimitIndexes(activeTables);
|
|
|
|
const matchedRows = [];
|
|
for (const row of baseRows) {
|
|
const pieceUnitPrice = Number(row.goodsQty) > 0 ? Number(row.payAmt) / Number(row.goodsQty) : null;
|
|
if (!Number.isFinite(pieceUnitPrice) || pieceUnitPrice <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const spu = String(row.spu || '').trim();
|
|
const identifierSet = new Set([ spu ].filter(Boolean));
|
|
const extraIdentifiers = spuIdentifierMap.get(spu);
|
|
if (extraIdentifiers) {
|
|
extraIdentifiers.forEach((value) => identifierSet.add(value));
|
|
}
|
|
|
|
const candidates = queryPddPriceLimitCandidates({
|
|
identifiers: Array.from(identifierSet),
|
|
ownerOper: row.oper,
|
|
}, priceLimitIndexes);
|
|
const bestCandidate = candidates[0] || null;
|
|
if (!bestCandidate || !Number.isFinite(bestCandidate.effectiveLimitPrice)) {
|
|
continue;
|
|
}
|
|
if (pieceUnitPrice >= Number(bestCandidate.effectiveLimitPrice)) {
|
|
continue;
|
|
}
|
|
|
|
const breakDiffAmount = Number(bestCandidate.effectiveLimitPrice) - pieceUnitPrice;
|
|
const breakDiffRate = Number(bestCandidate.effectiveLimitPrice) > 0
|
|
? (breakDiffAmount / Number(bestCandidate.effectiveLimitPrice)) * 100
|
|
: null;
|
|
matchedRows.push({
|
|
goodsId: row.goodsId,
|
|
goodsName: row.goodsName,
|
|
idOwner: row.idOwner,
|
|
ownerDept: row.ownerDept,
|
|
shopName: row.shopName,
|
|
spu: row.spu,
|
|
oper: row.oper,
|
|
pieceUnitPrice: Number(pieceUnitPrice.toFixed(2)),
|
|
effectiveLimitPrice: Number(Number(bestCandidate.effectiveLimitPrice).toFixed(2)),
|
|
breakDiffAmount: Number(breakDiffAmount.toFixed(2)),
|
|
breakDiffRate: breakDiffRate === null ? null : Number(breakDiffRate.toFixed(2)),
|
|
limitFieldUsed: bestCandidate.limitFieldUsed,
|
|
priceLimitSourceTable: bestCandidate.tableName,
|
|
});
|
|
}
|
|
|
|
matchedRows.sort((left, right) => (
|
|
Number(right.breakDiffAmount || 0) - Number(left.breakDiffAmount || 0)
|
|
|| Number(right.breakDiffRate || 0) - Number(left.breakDiffRate || 0)
|
|
|| String(left.goodsId || '').localeCompare(String(right.goodsId || ''))
|
|
));
|
|
|
|
const total = matchedRows.length;
|
|
const pagedRows = matchedRows.slice((page - 1) * pageSize, page * pageSize);
|
|
if (pagedRows.length > 0) {
|
|
const imageParams = {};
|
|
appendNamedInCondition(
|
|
[],
|
|
imageParams,
|
|
pagedRows.map((item) => item.goodsId),
|
|
'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId])))',
|
|
'goodsId'
|
|
);
|
|
const imageRows = await cloudDb.query(
|
|
`
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId]))) AS [goodsId],
|
|
MAX(TRY_CONVERT(NVARCHAR(1000), src.[hdThumbUrl])) AS [hdThumbUrl]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] src
|
|
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId]))) IN (${pagedRows.map((_, index) => `@goodsId${index}`).join(', ')})
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), src.[goodsId])))
|
|
`,
|
|
imageParams
|
|
);
|
|
const imageMap = new Map((imageRows || []).map((item) => [ String(item.goodsId || ''), item.hdThumbUrl || null ]));
|
|
pagedRows.forEach((item) => {
|
|
item.hdThumbUrl = imageMap.get(String(item.goodsId || '')) || null;
|
|
});
|
|
}
|
|
|
|
return {
|
|
total,
|
|
rows: pagedRows,
|
|
};
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/loss-id/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'negativeDays', title: '近7天亏损天数' },
|
|
{ key: 'realPerformance', title: '近7天真实业绩' },
|
|
{ key: 'realConcludeFee', title: '近7天实际站点费用' },
|
|
{ key: 'profit', title: '近7天亏损金额' },
|
|
{ key: 'feeRatio', title: '近7天费比' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]);
|
|
if (!startDate || !endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({ page, pageSize, startDate, endDate, columns }));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'loss-id',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
loader: async () => {
|
|
const params = {
|
|
startDate,
|
|
endDate,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const whereParts = [
|
|
'a.[item_id] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id]))) <> ''",
|
|
'TRY_CONVERT(DATE, a.[stat_date]) BETWEEN @startDate AND @endDate',
|
|
"ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[platform]))), N'') IN (N'Pinduoduo', N'拼多多')",
|
|
];
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
shopNameExpr: 'TRY_CONVERT(NVARCHAR(255), a.[shop_name])',
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id])))',
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH daily AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id]))) AS [goodsId],
|
|
TRY_CONVERT(DATE, a.[stat_date]) AS [statDate],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_performance]), 0)) AS [realPerformance],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_conclude_fee]), 0)) AS [realConcludeFee]
|
|
FROM [JSDataWarehouse].[ads].[ads_itemid_sales_cost_ratio_v2_di] a
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id]))),
|
|
TRY_CONVERT(DATE, a.[stat_date])
|
|
),
|
|
base AS (
|
|
SELECT
|
|
[goodsId],
|
|
SUM(CASE WHEN [realPerformance] - [realConcludeFee] < 0 THEN 1 ELSE 0 END) AS [negativeDays],
|
|
SUM([realPerformance]) AS [realPerformance],
|
|
SUM([realConcludeFee]) AS [realConcludeFee],
|
|
SUM([realPerformance] - [realConcludeFee]) AS [profit]
|
|
FROM daily
|
|
GROUP BY [goodsId]
|
|
HAVING SUM([realPerformance] - [realConcludeFee]) <= -1
|
|
)
|
|
`;
|
|
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([negativeDays] AS DECIMAL(38, 0)) AS [negativeDays],
|
|
CAST([realPerformance] AS DECIMAL(38, 2)) AS [realPerformance],
|
|
CAST([realConcludeFee] AS DECIMAL(38, 2)) AS [realConcludeFee],
|
|
CAST([profit] AS DECIMAL(38, 2)) AS [profit],
|
|
CAST(CASE
|
|
WHEN [realPerformance] = 0 THEN 0
|
|
ELSE [realConcludeFee] * 100.0 / NULLIF([realPerformance], 0)
|
|
END AS DECIMAL(38, 2)) AS [feeRatio]
|
|
`,
|
|
orderBySql: '[profit] ASC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
return result;
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/loss-amount/list', authMiddleware, async (req, res) => {
|
|
const columns = getPddProductDashboardBaseColumns([
|
|
{ key: 'goodsUv', title: '近7天访客数' },
|
|
{ key: 'orderUserCount', title: '近7天下单人数' },
|
|
{ key: 'payOrdrUsrCnt', title: '近7天成交买家数' },
|
|
{ key: 'unpaidUserCount', title: '近7天未支付人数' },
|
|
{ key: 'customerUnitPrice', title: '近7天客单价' },
|
|
{ key: 'lossAmount', title: '近7天损失金额' },
|
|
]);
|
|
|
|
try {
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [PDD_DATA_GOODS_TABLE]);
|
|
if (!endDate) {
|
|
return res.json({ success: true, data: { page, pageSize, total: 0, startDate, endDate, columns, rows: [] } });
|
|
}
|
|
|
|
const params = { startDate, endDate, offset: (page - 1) * pageSize, pageSize };
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate });
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return res.json({ success: true, data: { page, pageSize, total: 0, startDate, endDate, columns, rows: [] } });
|
|
}
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS [goodsUv],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS [payOrdrUsrCnt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payOrdrAmt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0) * ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]), 0)) AS [orderUserCount]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
)
|
|
`;
|
|
|
|
const result = await queryPddDashboardBasePagedRows({
|
|
baseCteSql,
|
|
selectFieldsSql: `
|
|
[goodsId],
|
|
CAST([goodsUv] AS DECIMAL(38, 2)) AS [goodsUv],
|
|
CAST([orderUserCount] AS DECIMAL(38, 2)) AS [orderUserCount],
|
|
CAST([payOrdrUsrCnt] AS DECIMAL(38, 2)) AS [payOrdrUsrCnt],
|
|
CAST([orderUserCount] - [payOrdrUsrCnt] AS DECIMAL(38, 2)) AS [unpaidUserCount],
|
|
CAST([payOrdrAmt] / NULLIF([payOrdrUsrCnt], 0) AS DECIMAL(38, 2)) AS [customerUnitPrice],
|
|
CAST(([orderUserCount] - [payOrdrUsrCnt]) * ([payOrdrAmt] / NULLIF([payOrdrUsrCnt], 0)) AS DECIMAL(38, 2)) AS [lossAmount]
|
|
`,
|
|
orderBySql: '(([orderUserCount] - [payOrdrUsrCnt]) * ([payOrdrAmt] / NULLIF([payOrdrUsrCnt], 0))) DESC, [goodsId] ASC',
|
|
params,
|
|
page,
|
|
pageSize,
|
|
});
|
|
result.rows = await hydratePddDashboardRowsWithGoodsDetails(result.rows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
page,
|
|
pageSize,
|
|
total: result.total,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
rows: result.rows || [],
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/invalid-link/list', authMiddleware, async (req, res) => {
|
|
const columns = [
|
|
{ key: 'goodsId', title: '商品id' },
|
|
{ key: 'goodsName', title: '商品名称' },
|
|
{ key: 'hdThumbUrl', title: '商品主图' },
|
|
{ key: 'idOwner', title: 'id负责人' },
|
|
{ key: 'ownerDept', title: '所属部门' },
|
|
{ key: 'shopName', title: '店铺名称' },
|
|
{ key: 'goodsUv', title: '近7天商品访客数' },
|
|
{ key: 'goodsPv', title: '近7天商品浏览量' },
|
|
{ key: 'payOrdrCnt', title: '近7天成交订单数' },
|
|
{ key: 'payOrdrUsrCnt', title: '近7天成交买家数' },
|
|
{ key: 'payOrdrAmt', title: '近7天成交金额' },
|
|
];
|
|
|
|
try {
|
|
const page = safePositiveInt(req.query.page, 1, 1, 1000000);
|
|
const pageSize = safePositiveInt(req.query.pageSize, 20, 1, 200);
|
|
const minGoodsUv = Number.isFinite(Number(req.query.minGoodsUv))
|
|
? Number(req.query.minGoodsUv)
|
|
: 100;
|
|
const accessScope = await getPddDataAccessScope(req.user.sub);
|
|
if (!accessScope) {
|
|
return res.status(401).json({ success: false, error: 'user not found' });
|
|
}
|
|
|
|
const latestBizDate = await getLatestPddDashboardBizDate([PDD_DATA_GOODS_TABLE]);
|
|
if (!latestBizDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({
|
|
page,
|
|
pageSize,
|
|
startDate: null,
|
|
endDate: null,
|
|
columns,
|
|
extra: { minGoodsUv },
|
|
}));
|
|
}
|
|
|
|
const endDate = dayjs(latestBizDate).format('YYYY-MM-DD');
|
|
const startDate = dayjs(latestBizDate).subtract(6, 'day').format('YYYY-MM-DD');
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'invalid-link',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
extraResponseData: { minGoodsUv },
|
|
extraCachePayload: { minGoodsUv },
|
|
loader: async () => {
|
|
const params = {
|
|
startDate,
|
|
endDate,
|
|
minGoodsUv,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
};
|
|
const whereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({ whereParts, params, fieldExpr: 'g.[createTime]', startDate, endDate });
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const whereSql = `WHERE ${whereParts.join(' AND ')}`;
|
|
const baseCteSql = `
|
|
WITH base AS (
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS [goodsUv],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsPv]), 0)) AS [goodsPv],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) AS [payOrdrCnt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS [payOrdrUsrCnt],
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS [payOrdrAmt]
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${whereSql}
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
HAVING
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) >= @minGoodsUv
|
|
AND SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) = 0
|
|
),
|
|
paged AS (
|
|
SELECT
|
|
[goodsId],
|
|
CAST([goodsUv] AS DECIMAL(38, 2)) AS [goodsUv],
|
|
CAST([goodsPv] AS DECIMAL(38, 2)) AS [goodsPv],
|
|
CAST([payOrdrCnt] AS DECIMAL(38, 2)) AS [payOrdrCnt],
|
|
CAST([payOrdrUsrCnt] AS DECIMAL(38, 2)) AS [payOrdrUsrCnt],
|
|
CAST([payOrdrAmt] AS DECIMAL(38, 2)) AS [payOrdrAmt],
|
|
COUNT(1) OVER() AS [__total]
|
|
FROM base
|
|
ORDER BY [goodsUv] DESC, [goodsId] ASC
|
|
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
|
|
)
|
|
`;
|
|
|
|
const result = await cloudDb.query(
|
|
`
|
|
${baseCteSql}
|
|
SELECT
|
|
p.[goodsId],
|
|
p.[goodsUv],
|
|
p.[goodsPv],
|
|
p.[payOrdrCnt],
|
|
p.[payOrdrUsrCnt],
|
|
p.[payOrdrAmt],
|
|
p.[__total]
|
|
FROM paged p
|
|
ORDER BY p.[goodsUv] DESC, p.[goodsId] ASC
|
|
`,
|
|
params
|
|
);
|
|
|
|
const total = Number(result?.[0]?.__total || 0);
|
|
const baseRows = (result || []).map((row) => {
|
|
const nextRow = { ...row };
|
|
delete nextRow.__total;
|
|
return nextRow;
|
|
});
|
|
const rows = await hydratePddDashboardRowsWithGoodsDetails(baseRows, {
|
|
detailTableName: PDD_DATA_GOODS_TABLE,
|
|
startDate,
|
|
endDate,
|
|
includeIdOwner: true,
|
|
includeImage: true,
|
|
});
|
|
|
|
return { total, rows };
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/id-dimension-board/list', authMiddleware, async (req, res) => {
|
|
const columns = [
|
|
...getPddProductDashboardBaseColumns([
|
|
{ key: 'spu', title: 'SPU' },
|
|
{ key: 'goodsUv', title: '近7天访客数' },
|
|
{ key: 'inquiryNum', title: '近7天询单量' },
|
|
{ key: 'goodsFavCnt', title: '近7天收藏用户' },
|
|
{ key: 'goodsFavNum', title: '近7天收藏量' },
|
|
{ key: 'orderUserCount', title: '近7天下单人数' },
|
|
{ key: 'ctr', title: '近7天点击率' },
|
|
{ key: 'clickConversionRate', title: '近7天点击转化率' },
|
|
{ key: 'goodsVcr', title: '近7天成交转化率' },
|
|
{ key: 'ordrVstrRto', title: '近7天下单率' },
|
|
{ key: 'payOrdrRto', title: '近7天成交率' },
|
|
{ key: 'imprUsrCnt', title: '近7天流失指数' },
|
|
{ key: 'payOrdrGoodsQty', title: '近7天成交件数' },
|
|
{ key: 'orderNum', title: '近7天成交订单数' },
|
|
{ key: 'payOrdrAmt', title: '近7天真实业绩' },
|
|
{ key: 'payOrdrUsrCnt', title: '近7天买家数' },
|
|
{ key: 'unpaidUserCount', title: '近7天未支付人数' },
|
|
{ key: 'customerUnitPrice', title: '近7天客单价' },
|
|
{ key: 'pieceUnitPrice', title: '近7天件单价' },
|
|
{ key: 'spend', title: '近7天推广花费' },
|
|
{ key: 'feeRatio', title: '近7天费比' },
|
|
{ key: 'badReviewCount', title: '近7天差评量' },
|
|
{ key: 'afterSaleCount', title: '近7天售后单量' },
|
|
{ key: 'afterSaleRate', title: '近7天售后率' },
|
|
]),
|
|
];
|
|
|
|
try {
|
|
const sortField = (req.query.sortField || '').trim();
|
|
const sortOrder = (req.query.sortOrder || '').trim().toLowerCase();
|
|
const sortFieldDefs = [
|
|
{ key: 'goodsId', sortExpr: 'gl.[goodsId]' },
|
|
{ key: 'goodsName', sortExpr: 'gl.[goodsName]' },
|
|
{ key: 'idOwner', sortExpr: 'gl.[idOwner]' },
|
|
{ key: 'ownerDept', sortExpr: 'gl.[ownerDept]' },
|
|
{ key: 'shopName', sortExpr: 'gl.[shopName]' },
|
|
{ key: 'spu', sortExpr: 'gl.[spu]' },
|
|
{ key: 'goodsUv', sortExpr: 'ISNULL(gl.[goodsUv], 0)' },
|
|
{ key: 'inquiryNum', sortExpr: 'ISNULL(gl.[inquiryNum], 0)' },
|
|
{ key: 'goodsFavCnt', sortExpr: 'ISNULL(gl.[goodsFavCnt], 0)' },
|
|
{ key: 'goodsFavNum', sortExpr: 'ISNULL(gl.[goodsFavNum], 0)' },
|
|
{ key: 'orderUserCount', sortExpr: 'ISNULL(gl.[orderUserCount], 0)' },
|
|
{ key: 'ctr', sortExpr: 'ISNULL(gl.[ctr], 0)' },
|
|
{ key: 'clickConversionRate', sortExpr: 'ISNULL(gl.[clickConversionRate], 0)' },
|
|
{ key: 'goodsVcr', sortExpr: 'ISNULL(gl.[goodsVcr], 0)' },
|
|
{ key: 'ordrVstrRto', sortExpr: 'ISNULL(gl.[ordrVstrRto], 0)' },
|
|
{ key: 'payOrdrRto', sortExpr: 'ISNULL(gl.[payOrdrRto], 0)' },
|
|
{ key: 'imprUsrCnt', sortExpr: 'ISNULL(gl.[imprUsrCnt], 0)' },
|
|
{ key: 'payOrdrGoodsQty', sortExpr: 'ISNULL(gl.[payOrdrGoodsQty], 0)' },
|
|
{ key: 'orderNum', sortExpr: 'ISNULL(gl.[orderNum], 0)' },
|
|
{ key: 'payOrdrAmt', sortExpr: 'ISNULL(gl.[payOrdrAmt], 0)' },
|
|
{ key: 'payOrdrUsrCnt', sortExpr: 'ISNULL(gl.[payOrdrUsrCnt], 0)' },
|
|
{ key: 'unpaidUserCount', sortExpr: 'ISNULL(gl.[unpaidUserCount], 0)' },
|
|
{ key: 'customerUnitPrice', sortExpr: 'ISNULL(gl.[customerUnitPrice], 0)' },
|
|
{ key: 'pieceUnitPrice', sortExpr: 'ISNULL(gl.[pieceUnitPrice], 0)' },
|
|
{ key: 'spend', sortExpr: 'ISNULL(gl.[spend], 0)' },
|
|
{ key: 'feeRatio', sortExpr: 'ISNULL(gl.[feeRatio], 0)' },
|
|
{ key: 'badReviewCount', sortExpr: 'ISNULL(gl.[badReviewCount], 0)' },
|
|
{ key: 'afterSaleCount', sortExpr: 'ISNULL(gl.[afterSaleCount], 0)' },
|
|
{ key: 'afterSaleRate', sortExpr: 'ISNULL(gl.[afterSaleRate], 0)' },
|
|
];
|
|
const sortFieldDef = sortFieldDefs.find((item) => item.key === sortField) || null;
|
|
const orderExpr = sortFieldDef?.sortExpr || 'ISNULL(gl.[payOrdrAmt], 0)';
|
|
const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC';
|
|
const orderTieBreaker = sortFieldDef?.key === 'goodsId' ? '' : ', gl.[goodsId] ASC';
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [
|
|
PDD_DATA_GOODS_TABLE,
|
|
PDD_SPREAD_GOODS_TABLE,
|
|
PDD_EVALUATION_TABLE,
|
|
]);
|
|
|
|
if (!startDate || !endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({
|
|
page,
|
|
pageSize,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
}));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'id-dimension-board',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
extraResponseData: {
|
|
sortField: sortFieldDef?.key || '',
|
|
sortOrder: sortFieldDef ? (sortOrder || 'descend') : '',
|
|
},
|
|
loader: async () => {
|
|
const params = {
|
|
startDate,
|
|
endDate,
|
|
};
|
|
const scopeWhereParts = [
|
|
'g.[goodsId] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) <> ''",
|
|
];
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts: scopeWhereParts,
|
|
params,
|
|
fieldExpr: 'g.[createTime]',
|
|
startDate,
|
|
endDate,
|
|
});
|
|
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts: scopeWhereParts,
|
|
params,
|
|
accessScope,
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const scopeWhereSql = `WHERE ${scopeWhereParts.join(' AND ')}`;
|
|
const batchSql = `
|
|
IF OBJECT_ID('tempdb..#goods_scope') IS NOT NULL DROP TABLE #goods_scope;
|
|
IF OBJECT_ID('tempdb..#goods_agg') IS NOT NULL DROP TABLE #goods_agg;
|
|
IF OBJECT_ID('tempdb..#paged_goods') IS NOT NULL DROP TABLE #paged_goods;
|
|
IF OBJECT_ID('tempdb..#goods_detail') IS NOT NULL DROP TABLE #goods_detail;
|
|
IF OBJECT_ID('tempdb..#target_agg') IS NOT NULL DROP TABLE #target_agg;
|
|
IF OBJECT_ID('tempdb..#spread_agg') IS NOT NULL DROP TABLE #spread_agg;
|
|
IF OBJECT_ID('tempdb..#review_agg') IS NOT NULL DROP TABLE #review_agg;
|
|
IF OBJECT_ID('tempdb..#dws_order_agg') IS NOT NULL DROP TABLE #dws_order_agg;
|
|
|
|
SELECT DISTINCT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) AS [goodsId]
|
|
INTO #goods_scope
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${scopeWhereSql};
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_goods_scope_goodsId ON #goods_scope([goodsId]);
|
|
|
|
SELECT
|
|
gs.[goodsId],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS DECIMAL(38, 2)) AS [goodsUv],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsFavCnt]), 0)) AS DECIMAL(38, 2)) AS [goodsFavCnt],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[imprUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [imprUsrCnt],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS DECIMAL(38, 2)) AS [payOrdrGoodsQty],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) AS DECIMAL(38, 2)) AS [orderNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrAmt]), 0)) AS DECIMAL(38, 2)) AS [payOrdrAmt],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [payOrdrUsrCnt],
|
|
CAST(ROUND(SUM(
|
|
ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)
|
|
* (
|
|
ISNULL(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto]), '%', '')), 0)
|
|
/ CASE
|
|
WHEN CHARINDEX('%', TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto])) > 0 THEN 100.0
|
|
WHEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]), 0) > 1 THEN 100.0
|
|
ELSE 1.0
|
|
END
|
|
)
|
|
), 0) AS BIGINT) AS [orderUserCount]
|
|
INTO #goods_agg
|
|
FROM #goods_scope gs
|
|
INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) = gs.[goodsId]
|
|
WHERE g.[createTime] >= @startDateTime
|
|
AND g.[createTime] < @endDateExclusive
|
|
GROUP BY gs.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_goods_agg_goodsId ON #goods_agg([goodsId]);
|
|
|
|
DECLARE @total BIGINT;
|
|
SET @total = (SELECT COUNT(1) FROM #goods_agg);
|
|
|
|
SELECT
|
|
gs.[goodsId],
|
|
MAX(TRY_CONVERT(NVARCHAR(500), g.[goodsName])) AS [goodsName],
|
|
MAX(TRY_CONVERT(NVARCHAR(255), g.[shopName])) AS [shopName],
|
|
MAX(TRY_CONVERT(NVARCHAR(1000), g.[hdThumbUrl])) AS [hdThumbUrl]
|
|
INTO #goods_detail
|
|
FROM #goods_scope gs
|
|
INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId]))) = gs.[goodsId]
|
|
WHERE g.[createTime] >= @startDateTime
|
|
AND g.[createTime] < @endDateExclusive
|
|
GROUP BY gs.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_goods_detail_goodsId ON #goods_detail([goodsId]);
|
|
|
|
SELECT
|
|
gs.[goodsId],
|
|
MAX(src.[ider]) AS [idOwner],
|
|
MAX(src.[dept]) AS [ownerDept],
|
|
MAX(src.[spu]) AS [spu]
|
|
INTO #target_agg
|
|
FROM #goods_scope gs
|
|
LEFT JOIN ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
ON src.[shopproid] = gs.[goodsId]
|
|
GROUP BY gs.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_target_agg_goodsId ON #target_agg([goodsId]);
|
|
|
|
SELECT
|
|
gs.[goodsId],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[spend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [spend],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[click]), 0)) AS DECIMAL(38, 2)) AS [click],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[impression]), 0)) AS DECIMAL(38, 2)) AS [impression],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderNum]), 0)) AS DECIMAL(38, 2)) AS [spreadOrderNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalInquiryNum]), 0)) AS DECIMAL(38, 2)) AS [inquiryNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalGoodsFavNum]), 0)) AS DECIMAL(38, 2)) AS [goodsFavNum]
|
|
INTO #spread_agg
|
|
FROM #goods_scope gs
|
|
INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s
|
|
ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.[goodsId]))) = gs.[goodsId]
|
|
WHERE s.[createTime] >= @startDateTime
|
|
AND s.[createTime] < @endDateExclusive
|
|
GROUP BY gs.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_spread_agg_goodsId ON #spread_agg([goodsId]);
|
|
|
|
SELECT
|
|
gs.[goodsId],
|
|
COUNT(1) AS [badReviewCount]
|
|
INTO #review_agg
|
|
FROM #goods_scope gs
|
|
INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_EVALUATION_TABLE}] e
|
|
ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), e.[goodsId]))) = gs.[goodsId]
|
|
WHERE e.[createTime] >= @startDateTime
|
|
AND e.[createTime] < @endDateExclusive
|
|
AND TRY_CONVERT(INT, e.[descScore]) IN (1, 2, 3)
|
|
GROUP BY gs.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_review_agg_goodsId ON #review_agg([goodsId]);
|
|
|
|
SELECT
|
|
gs.[goodsId],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[sales_amount]), 0)) AS DECIMAL(38, 2)) AS [dwsSalesAmount],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[goods_count]), 0)) AS DECIMAL(38, 2)) AS [dwsGoodsCount],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [dwsRealConcludeFee],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [dwsRealPerformance],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[after_sale_amount]), 0)) AS DECIMAL(38, 2)) AS [afterSaleAmount],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0)) AS DECIMAL(38, 2)) AS [afterSaleCount],
|
|
CAST(COUNT(DISTINCT CASE
|
|
WHEN NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))), N'') IS NOT NULL
|
|
THEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno])))
|
|
ELSE NULL
|
|
END) AS DECIMAL(38, 2)) AS [dwsOrderNum]
|
|
INTO #dws_order_agg
|
|
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d
|
|
INNER JOIN #goods_scope gs
|
|
ON d.[item_id] = CONVERT(VARCHAR(50), gs.[goodsId])
|
|
WHERE d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate)
|
|
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多')
|
|
GROUP BY gs.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_dws_order_agg_goodsId ON #dws_order_agg([goodsId]);
|
|
|
|
SELECT
|
|
ga.[goodsId],
|
|
gd.[goodsName],
|
|
gd.[hdThumbUrl],
|
|
ta.[idOwner],
|
|
ta.[ownerDept],
|
|
gd.[shopName],
|
|
ta.[spu],
|
|
ga.[goodsUv],
|
|
ISNULL(sp.[inquiryNum], 0) AS [inquiryNum],
|
|
ga.[goodsFavCnt],
|
|
ISNULL(sp.[goodsFavNum], 0) AS [goodsFavNum],
|
|
ISNULL(ga.[orderUserCount], 0) AS [orderUserCount],
|
|
CAST(CASE
|
|
WHEN ISNULL(sp.[impression], 0) = 0 THEN 0
|
|
ELSE ISNULL(sp.[click], 0) * 100.0 / NULLIF(sp.[impression], 0)
|
|
END AS DECIMAL(38, 2)) AS [ctr],
|
|
CAST(CASE
|
|
WHEN ISNULL(sp.[click], 0) = 0 THEN 0
|
|
ELSE ISNULL(sp.[spreadOrderNum], 0) * 100.0 / NULLIF(sp.[click], 0)
|
|
END AS DECIMAL(38, 2)) AS [clickConversionRate],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0
|
|
ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[goodsUv], 0)
|
|
END AS DECIMAL(38, 2)) AS [goodsVcr],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0
|
|
ELSE ISNULL(ga.[orderUserCount], 0) * 100.0 / NULLIF(ga.[goodsUv], 0)
|
|
END AS DECIMAL(38, 2)) AS [ordrVstrRto],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[orderUserCount], 0) = 0 THEN 0
|
|
ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[orderUserCount], 0)
|
|
END AS DECIMAL(38, 2)) AS [payOrdrRto],
|
|
ga.[imprUsrCnt],
|
|
ISNULL(da.[dwsGoodsCount], 0) AS [payOrdrGoodsQty],
|
|
ISNULL(da.[dwsOrderNum], 0) AS [orderNum],
|
|
ISNULL(da.[dwsRealPerformance], 0) AS [payOrdrAmt],
|
|
ga.[payOrdrUsrCnt],
|
|
CAST(ROUND(ISNULL(ga.[orderUserCount], 0) - ISNULL(ga.[payOrdrUsrCnt], 0), 0) AS BIGINT) AS [unpaidUserCount],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[payOrdrUsrCnt], 0) = 0 THEN 0
|
|
ELSE ISNULL(da.[dwsRealPerformance], 0) / NULLIF(ga.[payOrdrUsrCnt], 0)
|
|
END AS DECIMAL(38, 2)) AS [customerUnitPrice],
|
|
CAST(CASE
|
|
WHEN ISNULL(da.[dwsGoodsCount], 0) = 0 THEN 0
|
|
ELSE ISNULL(da.[dwsRealPerformance], 0) / NULLIF(da.[dwsGoodsCount], 0)
|
|
END AS DECIMAL(38, 2)) AS [pieceUnitPrice],
|
|
ISNULL(sp.[spend], 0) AS [spend],
|
|
CAST(CASE
|
|
WHEN ISNULL(da.[dwsRealPerformance], 0) = 0 THEN 0
|
|
ELSE ISNULL(da.[dwsRealConcludeFee], 0) * 100.0 / NULLIF(da.[dwsRealPerformance], 0)
|
|
END AS DECIMAL(38, 2)) AS [feeRatio],
|
|
CAST(ISNULL(rv.[badReviewCount], 0) AS DECIMAL(38, 2)) AS [badReviewCount],
|
|
ISNULL(da.[afterSaleCount], 0) AS [afterSaleCount],
|
|
CAST(CASE
|
|
WHEN ISNULL(da.[dwsRealPerformance], 0) = 0 THEN 0
|
|
ELSE ISNULL(da.[afterSaleAmount], 0) * 100.0 / NULLIF(da.[dwsRealPerformance], 0)
|
|
END AS DECIMAL(38, 2)) AS [afterSaleRate]
|
|
INTO #goods_list
|
|
FROM #goods_agg ga
|
|
LEFT JOIN #goods_detail gd
|
|
ON gd.[goodsId] = ga.[goodsId]
|
|
LEFT JOIN #target_agg ta
|
|
ON ta.[goodsId] = ga.[goodsId]
|
|
LEFT JOIN #spread_agg sp
|
|
ON sp.[goodsId] = ga.[goodsId]
|
|
LEFT JOIN #dws_order_agg da
|
|
ON da.[goodsId] = ga.[goodsId]
|
|
LEFT JOIN #review_agg rv
|
|
ON rv.[goodsId] = ga.[goodsId];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_goods_list_goodsId ON #goods_list([goodsId]);
|
|
|
|
SET @total = (SELECT COUNT(1) FROM #goods_list);
|
|
|
|
SELECT *
|
|
INTO #paged_goods
|
|
FROM (
|
|
SELECT
|
|
gl.*,
|
|
ROW_NUMBER() OVER (ORDER BY ${orderExpr} ${orderDirection}${orderTieBreaker}) AS [rn]
|
|
FROM #goods_list gl
|
|
) src
|
|
WHERE src.[rn] > @offset
|
|
AND src.[rn] <= (@offset + @pageSize);
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_paged_goods_goodsId ON #paged_goods([goodsId]);
|
|
|
|
SELECT
|
|
pg.*,
|
|
CAST(@total AS BIGINT) AS [__total]
|
|
FROM #paged_goods pg
|
|
ORDER BY pg.[rn];
|
|
`;
|
|
|
|
const result = await cloudDb.query(batchSql, {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
}, {
|
|
requestTimeout: 120000,
|
|
});
|
|
|
|
const total = Number(result?.[0]?.__total || 0);
|
|
const normalizedRows = (result || []).map((row) => {
|
|
const nextRow = { ...row };
|
|
delete nextRow.__total;
|
|
delete nextRow.rn;
|
|
return nextRow;
|
|
});
|
|
|
|
return {
|
|
total,
|
|
rows: normalizedRows,
|
|
sortField: sortFieldDef?.key || '',
|
|
sortOrder: sortFieldDef ? (sortOrder || 'descend') : '',
|
|
};
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/shop-dimension-board/list', authMiddleware, async (req, res) => {
|
|
const columns = [
|
|
{ key: 'shopName', title: '店铺名称' },
|
|
{ key: 'ownerDept', title: '所属部门' },
|
|
{ key: 'goodsUv', title: '近7天访客数' },
|
|
{ key: 'inquiryNum', title: '近7天询单量' },
|
|
{ key: 'goodsFavCnt', title: '近7天收藏用户' },
|
|
{ key: 'goodsFavNum', title: '近7天收藏量' },
|
|
{ key: 'orderUserCount', title: '近7天下单人数' },
|
|
{ key: 'ctr', title: '近7天点击率' },
|
|
{ key: 'clickConversionRate', title: '近7天点击转化率' },
|
|
{ key: 'goodsVcr', title: '近7天成交转化率' },
|
|
{ key: 'ordrVstrRto', title: '近7天下单率' },
|
|
{ key: 'payOrdrRto', title: '近7天成交率' },
|
|
{ key: 'imprUsrCnt', title: '近7天流失指数' },
|
|
{ key: 'unpaidUserCount', title: '近7天未支付人数' },
|
|
{ key: 'payOrdrAmt', title: '近7天真实业绩' },
|
|
{ key: 'payOrdrGoodsQty', title: '近7天成交件数' },
|
|
{ key: 'orderNum', title: '近7天订单量' },
|
|
{ key: 'payOrdrUsrCnt', title: '近7天买家数' },
|
|
{ key: 'customerUnitPrice', title: '近7天客单价' },
|
|
{ key: 'pieceUnitPrice', title: '近7天件单价' },
|
|
{ key: 'spreadGmv', title: '近7天总交易额' },
|
|
{ key: 'indirectGmv', title: '近7天间接交易额' },
|
|
{ key: 'indirectOrderNum', title: '近7天间接成交笔数' },
|
|
{ key: 'spend', title: '近7天总花费' },
|
|
{ key: 'realConcludeFee', title: '近7天实际站点费用' },
|
|
{ key: 'feeRatio', title: '近7天费比' },
|
|
{ key: 'overallRoi', title: '近7天实际投产比' },
|
|
{ key: 'netRoi', title: '近7天净实际投产比' },
|
|
{ key: 'avgDealCost', title: '近7天每笔成交花费' },
|
|
{ key: 'avgInquiryCost', title: '近7天平均询单成本' },
|
|
];
|
|
|
|
try {
|
|
const sortField = (req.query.sortField || '').trim();
|
|
const sortOrder = (req.query.sortOrder || '').trim().toLowerCase();
|
|
const sortFieldDefs = [
|
|
{ key: 'shopName', sortExpr: 'sl.[shopName]' },
|
|
{ key: 'ownerDept', sortExpr: 'sl.[ownerDept]' },
|
|
{ key: 'goodsUv', sortExpr: 'ISNULL(sl.[goodsUv], 0)' },
|
|
{ key: 'inquiryNum', sortExpr: 'ISNULL(sl.[inquiryNum], 0)' },
|
|
{ key: 'goodsFavCnt', sortExpr: 'ISNULL(sl.[goodsFavCnt], 0)' },
|
|
{ key: 'goodsFavNum', sortExpr: 'ISNULL(sl.[goodsFavNum], 0)' },
|
|
{ key: 'orderUserCount', sortExpr: 'ISNULL(sl.[orderUserCount], 0)' },
|
|
{ key: 'ctr', sortExpr: 'ISNULL(sl.[ctr], 0)' },
|
|
{ key: 'clickConversionRate', sortExpr: 'ISNULL(sl.[clickConversionRate], 0)' },
|
|
{ key: 'goodsVcr', sortExpr: 'ISNULL(sl.[goodsVcr], 0)' },
|
|
{ key: 'ordrVstrRto', sortExpr: 'ISNULL(sl.[ordrVstrRto], 0)' },
|
|
{ key: 'payOrdrRto', sortExpr: 'ISNULL(sl.[payOrdrRto], 0)' },
|
|
{ key: 'imprUsrCnt', sortExpr: 'ISNULL(sl.[imprUsrCnt], 0)' },
|
|
{ key: 'unpaidUserCount', sortExpr: 'ISNULL(sl.[unpaidUserCount], 0)' },
|
|
{ key: 'payOrdrAmt', sortExpr: 'ISNULL(sl.[payOrdrAmt], 0)' },
|
|
{ key: 'payOrdrGoodsQty', sortExpr: 'ISNULL(sl.[payOrdrGoodsQty], 0)' },
|
|
{ key: 'orderNum', sortExpr: 'ISNULL(sl.[orderNum], 0)' },
|
|
{ key: 'payOrdrUsrCnt', sortExpr: 'ISNULL(sl.[payOrdrUsrCnt], 0)' },
|
|
{ key: 'customerUnitPrice', sortExpr: 'ISNULL(sl.[customerUnitPrice], 0)' },
|
|
{ key: 'pieceUnitPrice', sortExpr: 'ISNULL(sl.[pieceUnitPrice], 0)' },
|
|
{ key: 'spreadGmv', sortExpr: 'ISNULL(sl.[spreadGmv], 0)' },
|
|
{ key: 'indirectGmv', sortExpr: 'ISNULL(sl.[indirectGmv], 0)' },
|
|
{ key: 'indirectOrderNum', sortExpr: 'ISNULL(sl.[indirectOrderNum], 0)' },
|
|
{ key: 'spend', sortExpr: 'ISNULL(sl.[spend], 0)' },
|
|
{ key: 'realConcludeFee', sortExpr: 'ISNULL(sl.[realConcludeFee], 0)' },
|
|
{ key: 'feeRatio', sortExpr: 'ISNULL(sl.[feeRatio], 0)' },
|
|
{ key: 'overallRoi', sortExpr: 'ISNULL(sl.[overallRoi], 0)' },
|
|
{ key: 'netRoi', sortExpr: 'ISNULL(sl.[netRoi], 0)' },
|
|
{ key: 'avgDealCost', sortExpr: 'ISNULL(sl.[avgDealCost], 0)' },
|
|
{ key: 'avgInquiryCost', sortExpr: 'ISNULL(sl.[avgInquiryCost], 0)' },
|
|
];
|
|
const sortFieldDef = sortFieldDefs.find((item) => item.key === sortField) || null;
|
|
const orderExpr = sortFieldDef?.sortExpr || 'ISNULL(sl.[payOrdrAmt], 0)';
|
|
const orderDirection = sortOrder === 'ascend' ? 'ASC' : 'DESC';
|
|
const orderTieBreaker = sortFieldDef?.key === 'shopName' ? '' : ', sl.[shopName] ASC';
|
|
const { page, pageSize, accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [
|
|
PDD_DATA_GOODS_TABLE,
|
|
PDD_SPREAD_GOODS_TABLE,
|
|
PDD_EVALUATION_TABLE,
|
|
]);
|
|
|
|
if (!startDate || !endDate) {
|
|
return res.json(buildPddProductDashboardEmptyResponse({
|
|
page,
|
|
pageSize,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
}));
|
|
}
|
|
|
|
const payload = await getCachedPddProductDashboardPayload({
|
|
boardKey: 'shop-dimension-board',
|
|
reqQuery: req.query,
|
|
page,
|
|
pageSize,
|
|
accessScope,
|
|
startDate,
|
|
endDate,
|
|
columns,
|
|
extraResponseData: {
|
|
sortField: sortFieldDef?.key || '',
|
|
sortOrder: sortFieldDef ? (sortOrder || 'descend') : '',
|
|
},
|
|
loader: async () => {
|
|
const params = {
|
|
startDate,
|
|
endDate,
|
|
};
|
|
|
|
const scopeWhereParts = [
|
|
'g.[shopName] IS NOT NULL',
|
|
"LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) <> ''",
|
|
];
|
|
|
|
appendPddCreateTimeRangeFilters({
|
|
whereParts: scopeWhereParts,
|
|
params,
|
|
fieldExpr: 'g.[createTime]',
|
|
startDate,
|
|
endDate,
|
|
});
|
|
|
|
const needsTargetJoin = shouldUsePddTargetJoinForDashboardFilters(req, accessScope);
|
|
if (!appendPddProductDashboardCommonFilters({
|
|
req,
|
|
whereParts: scopeWhereParts,
|
|
params,
|
|
shopNameExpr: 'TRY_CONVERT(NVARCHAR(255), g.[shopName])',
|
|
departmentExpr: needsTargetJoin ? 'tm.[dept]' : null,
|
|
idOwnerExpr: needsTargetJoin ? 'tm.[ider]' : null,
|
|
accessScope,
|
|
})) {
|
|
return { total: 0, rows: [] };
|
|
}
|
|
|
|
const scopeWhereSql = `WHERE ${scopeWhereParts.join(' AND ')}`;
|
|
const batchSql = `
|
|
IF OBJECT_ID('tempdb..#shop_scope') IS NOT NULL DROP TABLE #shop_scope;
|
|
IF OBJECT_ID('tempdb..#target_agg') IS NOT NULL DROP TABLE #target_agg;
|
|
IF OBJECT_ID('tempdb..#goods_agg') IS NOT NULL DROP TABLE #goods_agg;
|
|
IF OBJECT_ID('tempdb..#spread_agg') IS NOT NULL DROP TABLE #spread_agg;
|
|
IF OBJECT_ID('tempdb..#ads_agg') IS NOT NULL DROP TABLE #ads_agg;
|
|
IF OBJECT_ID('tempdb..#shop_list') IS NOT NULL DROP TABLE #shop_list;
|
|
IF OBJECT_ID('tempdb..#paged_shop') IS NOT NULL DROP TABLE #paged_shop;
|
|
|
|
SELECT DISTINCT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) AS [shopName]
|
|
INTO #shop_scope
|
|
FROM [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))',
|
|
}) : ''}
|
|
${scopeWhereSql};
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_shop_scope_shopName ON #shop_scope([shopName]);
|
|
|
|
SELECT
|
|
ss.[shopName],
|
|
MAX(src.[ider]) AS [idOwner],
|
|
MAX(src.[dept]) AS [ownerDept]
|
|
INTO #target_agg
|
|
FROM #shop_scope ss
|
|
LEFT JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) = ss.[shopName]
|
|
AND g.[createTime] >= @startDateTime
|
|
AND g.[createTime] < @endDateExclusive
|
|
LEFT JOIN ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
ON src.[shopproid] = LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), g.[goodsId])))
|
|
GROUP BY ss.[shopName];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_target_agg_shopName ON #target_agg([shopName]);
|
|
|
|
SELECT
|
|
ss.[shopName],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)) AS DECIMAL(38, 2)) AS [goodsUv],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsFavCnt]), 0)) AS DECIMAL(38, 2)) AS [goodsFavCnt],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[imprUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [imprUsrCnt],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrGoodsQty]), 0)) AS DECIMAL(38, 2)) AS [payOrdrGoodsQty],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrCnt]), 0)) AS DECIMAL(38, 2)) AS [orderNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[payOrdrUsrCnt]), 0)) AS DECIMAL(38, 2)) AS [payOrdrUsrCnt],
|
|
CAST(ROUND(SUM(
|
|
ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[goodsUv]), 0)
|
|
* (
|
|
ISNULL(TRY_CONVERT(DECIMAL(38, 6), REPLACE(TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto]), '%', '')), 0)
|
|
/ CASE
|
|
WHEN CHARINDEX('%', TRY_CONVERT(NVARCHAR(50), g.[ordrVstrRto])) > 0 THEN 100.0
|
|
WHEN ISNULL(TRY_CONVERT(DECIMAL(38, 6), g.[ordrVstrRto]), 0) > 1 THEN 100.0
|
|
ELSE 1.0
|
|
END
|
|
)
|
|
), 0) AS BIGINT) AS [orderUserCount]
|
|
INTO #goods_agg
|
|
FROM #shop_scope ss
|
|
INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_DATA_GOODS_TABLE}] g
|
|
ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), g.[shopName]))) = ss.[shopName]
|
|
WHERE g.[createTime] >= @startDateTime
|
|
AND g.[createTime] < @endDateExclusive
|
|
GROUP BY ss.[shopName];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_goods_agg_shopName ON #goods_agg([shopName]);
|
|
|
|
SELECT
|
|
ss.[shopName],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[spend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [spend],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[gmv]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [spreadGmv],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[indirectGmv]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [indirectGmv],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[indirectOrderNum]), 0)) AS DECIMAL(38, 2)) AS [indirectOrderNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[click]), 0)) AS DECIMAL(38, 2)) AS [click],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[impression]), 0)) AS DECIMAL(38, 2)) AS [impression],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderNum]), 0)) AS DECIMAL(38, 2)) AS [spreadOrderNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalInquiryNum]), 0)) AS DECIMAL(38, 2)) AS [inquiryNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[multiGoalGoodsFavNum]), 0)) AS DECIMAL(38, 2)) AS [goodsFavNum],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[orderSpend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [orderSpend],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[inquirySpend]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [inquirySpend],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), s.[netGmv]), 0)) / 1000.0 AS DECIMAL(38, 2)) AS [netGmv]
|
|
INTO #spread_agg
|
|
FROM #shop_scope ss
|
|
INNER JOIN [${PDD_DATA_GOODS_SCHEMA}].[${PDD_SPREAD_GOODS_TABLE}] s
|
|
ON LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.[shopName]))) = ss.[shopName]
|
|
WHERE s.[createTime] >= @startDateTime
|
|
AND s.[createTime] < @endDateExclusive
|
|
GROUP BY ss.[shopName];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_spread_agg_shopName ON #spread_agg([shopName]);
|
|
|
|
SELECT
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), a.[shop_name]))) AS [shopName],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [realPerformance],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), a.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [realConcludeFee]
|
|
INTO #ads_agg
|
|
FROM [JSDataWarehouse].[ads].[ads_itemid_sales_cost_ratio_v2_di] a
|
|
${needsTargetJoin ? buildPddTargetJoinSqlByExpr({
|
|
includeIdOwner: true,
|
|
goodsIdExpr: 'LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[item_id])))',
|
|
}) : ''}
|
|
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), a.[shop_name]))) IN (SELECT [shopName] FROM #shop_scope)
|
|
AND TRY_CONVERT(DATE, a.[stat_date]) BETWEEN @startDate AND @endDate
|
|
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), a.[platform]))), N'') IN (N'Pinduoduo', N'拼多多')
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), a.[shop_name])));
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_ads_agg_shopName ON #ads_agg([shopName]);
|
|
|
|
SELECT
|
|
ss.[shopName],
|
|
ta.[ownerDept],
|
|
ISNULL(ga.[goodsUv], 0) AS [goodsUv],
|
|
ISNULL(sa.[inquiryNum], 0) AS [inquiryNum],
|
|
ISNULL(ga.[goodsFavCnt], 0) AS [goodsFavCnt],
|
|
ISNULL(sa.[goodsFavNum], 0) AS [goodsFavNum],
|
|
ISNULL(ga.[orderUserCount], 0) AS [orderUserCount],
|
|
CAST(CASE
|
|
WHEN ISNULL(sa.[impression], 0) = 0 THEN 0
|
|
ELSE ISNULL(sa.[click], 0) * 100.0 / NULLIF(sa.[impression], 0)
|
|
END AS DECIMAL(38, 2)) AS [ctr],
|
|
CAST(CASE
|
|
WHEN ISNULL(sa.[click], 0) = 0 THEN 0
|
|
ELSE ISNULL(sa.[spreadOrderNum], 0) * 100.0 / NULLIF(sa.[click], 0)
|
|
END AS DECIMAL(38, 2)) AS [clickConversionRate],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0
|
|
ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[goodsUv], 0)
|
|
END AS DECIMAL(38, 2)) AS [goodsVcr],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[goodsUv], 0) = 0 THEN 0
|
|
ELSE ISNULL(ga.[orderUserCount], 0) * 100.0 / NULLIF(ga.[goodsUv], 0)
|
|
END AS DECIMAL(38, 2)) AS [ordrVstrRto],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[orderUserCount], 0) = 0 THEN 0
|
|
ELSE ISNULL(ga.[payOrdrUsrCnt], 0) * 100.0 / NULLIF(ga.[orderUserCount], 0)
|
|
END AS DECIMAL(38, 2)) AS [payOrdrRto],
|
|
ISNULL(ga.[imprUsrCnt], 0) AS [imprUsrCnt],
|
|
CAST(ROUND(ISNULL(ga.[orderUserCount], 0) - ISNULL(ga.[payOrdrUsrCnt], 0), 0) AS BIGINT) AS [unpaidUserCount],
|
|
ISNULL(aa.[realPerformance], 0) AS [payOrdrAmt],
|
|
ISNULL(ga.[payOrdrGoodsQty], 0) AS [payOrdrGoodsQty],
|
|
ISNULL(ga.[orderNum], 0) AS [orderNum],
|
|
ISNULL(ga.[payOrdrUsrCnt], 0) AS [payOrdrUsrCnt],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[payOrdrUsrCnt], 0) = 0 THEN 0
|
|
ELSE ISNULL(aa.[realPerformance], 0) / NULLIF(ga.[payOrdrUsrCnt], 0)
|
|
END AS DECIMAL(38, 2)) AS [customerUnitPrice],
|
|
CAST(CASE
|
|
WHEN ISNULL(ga.[payOrdrGoodsQty], 0) = 0 THEN 0
|
|
ELSE ISNULL(aa.[realPerformance], 0) / NULLIF(ga.[payOrdrGoodsQty], 0)
|
|
END AS DECIMAL(38, 2)) AS [pieceUnitPrice],
|
|
ISNULL(sa.[spreadGmv], 0) AS [spreadGmv],
|
|
ISNULL(sa.[indirectGmv], 0) AS [indirectGmv],
|
|
ISNULL(sa.[indirectOrderNum], 0) AS [indirectOrderNum],
|
|
ISNULL(sa.[spend], 0) AS [spend],
|
|
ISNULL(aa.[realConcludeFee], 0) AS [realConcludeFee],
|
|
CAST(CASE
|
|
WHEN ISNULL(aa.[realPerformance], 0) = 0 THEN 0
|
|
ELSE ISNULL(aa.[realConcludeFee], 0) * 100.0 / NULLIF(aa.[realPerformance], 0)
|
|
END AS DECIMAL(38, 2)) AS [feeRatio],
|
|
CAST(CASE
|
|
WHEN ISNULL(sa.[spend], 0) = 0 THEN 0
|
|
ELSE ISNULL(sa.[spreadGmv], 0) / NULLIF(sa.[spend], 0)
|
|
END AS DECIMAL(38, 2)) AS [overallRoi],
|
|
CAST(CASE
|
|
WHEN ISNULL(sa.[orderSpend], 0) = 0 THEN 0
|
|
ELSE ISNULL(sa.[netGmv], 0) / NULLIF(sa.[orderSpend], 0)
|
|
END AS DECIMAL(38, 2)) AS [netRoi],
|
|
CAST(CASE
|
|
WHEN ISNULL(sa.[spreadOrderNum], 0) = 0 THEN 0
|
|
ELSE ISNULL(sa.[orderSpend], 0) / NULLIF(sa.[spreadOrderNum], 0)
|
|
END AS DECIMAL(38, 2)) AS [avgDealCost],
|
|
CAST(CASE
|
|
WHEN ISNULL(sa.[inquiryNum], 0) = 0 THEN 0
|
|
ELSE ISNULL(sa.[inquirySpend], 0) / NULLIF(sa.[inquiryNum], 0)
|
|
END AS DECIMAL(38, 2)) AS [avgInquiryCost]
|
|
INTO #shop_list
|
|
FROM #shop_scope ss
|
|
LEFT JOIN #target_agg ta
|
|
ON ta.[shopName] = ss.[shopName]
|
|
LEFT JOIN #goods_agg ga
|
|
ON ga.[shopName] = ss.[shopName]
|
|
LEFT JOIN #spread_agg sa
|
|
ON sa.[shopName] = ss.[shopName]
|
|
LEFT JOIN #ads_agg aa
|
|
ON aa.[shopName] = ss.[shopName];
|
|
|
|
CREATE UNIQUE CLUSTERED INDEX IX_shop_list_shopName ON #shop_list([shopName]);
|
|
|
|
DECLARE @total BIGINT;
|
|
SET @total = (SELECT COUNT(1) FROM #shop_list);
|
|
|
|
SELECT *
|
|
INTO #paged_shop
|
|
FROM (
|
|
SELECT
|
|
sl.*,
|
|
ROW_NUMBER() OVER (ORDER BY ${orderExpr} ${orderDirection}${orderTieBreaker}) AS [rn]
|
|
FROM #shop_list sl
|
|
) src
|
|
WHERE src.[rn] > @offset
|
|
AND src.[rn] <= (@offset + @pageSize);
|
|
|
|
SELECT
|
|
ps.*,
|
|
CAST(@total AS BIGINT) AS [__total]
|
|
FROM #paged_shop ps
|
|
ORDER BY ps.[rn];
|
|
`;
|
|
|
|
const result = await cloudDb.query(batchSql, {
|
|
...params,
|
|
offset: (page - 1) * pageSize,
|
|
pageSize,
|
|
}, {
|
|
requestTimeout: 120000,
|
|
});
|
|
|
|
const total = Number(result?.[0]?.__total || 0);
|
|
const normalizedRows = (result || []).map((row) => {
|
|
const nextRow = { ...row };
|
|
delete nextRow.__total;
|
|
delete nextRow.rn;
|
|
return nextRow;
|
|
});
|
|
|
|
return {
|
|
total,
|
|
rows: normalizedRows,
|
|
};
|
|
},
|
|
});
|
|
|
|
res.json(payload);
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/pdd/product-dashboard/id-dimension-board/barcode-detail', authMiddleware, async (req, res) => {
|
|
try {
|
|
const goodsId = String(req.query.goodsId || '').trim();
|
|
if (!goodsId) {
|
|
return res.status(400).json({ success: false, error: 'goodsId is required' });
|
|
}
|
|
|
|
const { accessScope, startDate, endDate } = await getPddProductDashboardRequestContext(req, [
|
|
PDD_DATA_GOODS_TABLE,
|
|
PDD_SPREAD_GOODS_TABLE,
|
|
PDD_EVALUATION_TABLE,
|
|
]);
|
|
|
|
if (!startDate || !endDate) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
goodsId,
|
|
startDate,
|
|
endDate,
|
|
rows: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
const params = {
|
|
goodsId,
|
|
startDate,
|
|
endDate,
|
|
};
|
|
|
|
if (!accessScope.isAllData) {
|
|
if (accessScope.allowedDepartments.length === 0) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
goodsId,
|
|
startDate,
|
|
endDate,
|
|
rows: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
const permissionParams = {
|
|
goodsId,
|
|
};
|
|
const permissionWhereParts = [
|
|
'src.[shopproid] = @goodsId',
|
|
];
|
|
appendNamedInCondition(
|
|
permissionWhereParts,
|
|
permissionParams,
|
|
accessScope.allowedDepartments,
|
|
'src.[dept]',
|
|
'detailScopeDept'
|
|
);
|
|
|
|
const permissionRows = await cloudDb.query(
|
|
`
|
|
SELECT TOP (1) 1 AS [allowed]
|
|
FROM ${PDD_TARGET_TABLE_FULL_NAME} src
|
|
WHERE ${permissionWhereParts.join(' AND ')}
|
|
`,
|
|
permissionParams,
|
|
{
|
|
requestTimeout: 30000,
|
|
}
|
|
);
|
|
|
|
if (!Array.isArray(permissionRows) || permissionRows.length === 0) {
|
|
return res.json({
|
|
success: true,
|
|
data: {
|
|
goodsId,
|
|
startDate,
|
|
endDate,
|
|
rows: [],
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const cacheKey = getPddDashboardCacheKey('pdd:id-dimension:barcode-detail', {
|
|
goodsId,
|
|
startDate,
|
|
endDate,
|
|
accessScope: buildPddDashboardAccessScopeCacheFragment(accessScope),
|
|
});
|
|
|
|
const rows = await getOrSetPddDashboardResultCache(cacheKey, async () => {
|
|
return cloudDb.query(
|
|
`
|
|
SELECT
|
|
@goodsId AS [goodsId],
|
|
LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid]))) AS [barcode],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[sales_amount]), 0)) AS DECIMAL(38, 2)) AS [salesAmount],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[goods_count]), 0)) AS DECIMAL(38, 2)) AS [goodsCount],
|
|
CAST(COUNT(DISTINCT CASE
|
|
WHEN NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno]))), N'') IS NOT NULL
|
|
THEN LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.[sourceno])))
|
|
ELSE NULL
|
|
END) AS DECIMAL(38, 2)) AS [orderCount],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) AS DECIMAL(38, 2)) AS [realConcludeFee],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) AS DECIMAL(38, 2)) AS [realPerformance],
|
|
CAST(CASE
|
|
WHEN SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)) = 0 THEN 0
|
|
ELSE
|
|
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_conclude_fee]), 0)) * 100.0
|
|
/ NULLIF(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[real_performance]), 0)), 0)
|
|
END AS DECIMAL(38, 2)) AS [feeRatio],
|
|
CAST(SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.[refund_count]), 0)) AS DECIMAL(38, 2)) AS [afterSaleCount]
|
|
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d
|
|
WHERE d.[item_id] = CONVERT(VARCHAR(50), @goodsId)
|
|
AND d.[stat_date] BETWEEN TRY_CONVERT(DATE, @startDate) AND TRY_CONVERT(DATE, @endDate)
|
|
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.[platform]))), N'') IN (N'Pinduoduo', N'拼多多')
|
|
AND NULLIF(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid]))), N'') IS NOT NULL
|
|
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), d.[skuoid])))
|
|
ORDER BY [realPerformance] DESC, [goodsCount] DESC, [barcode] ASC;
|
|
`,
|
|
params,
|
|
{
|
|
requestTimeout: 120000,
|
|
}
|
|
);
|
|
}, 300000);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
goodsId,
|
|
startDate,
|
|
endDate,
|
|
rows,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
registerPddSkuDimensionBoard({
|
|
router,
|
|
authMiddleware,
|
|
cloudDb,
|
|
PDD_DATA_GOODS_SCHEMA,
|
|
PDD_DATA_GOODS_TABLE,
|
|
PDD_SPREAD_GOODS_TABLE,
|
|
PDD_EVALUATION_TABLE,
|
|
PDD_TARGET_TABLE_FULL_NAME,
|
|
PDD_JST_SKU_TABLE_FULL_NAME,
|
|
getPddProductDashboardRequestContext,
|
|
buildPddProductDashboardEmptyResponse,
|
|
getCachedPddProductDashboardPayload,
|
|
getPddDashboardCacheKey,
|
|
getOrSetPddDashboardResultCache,
|
|
appendPddCreateTimeRangeFilters,
|
|
appendPddProductDashboardCommonFilters,
|
|
loadPddPriceBreakFlagByGoodsIds,
|
|
});
|
|
|
|
router.prewarmHotAnalysisPages = prewarmHotAnalysisPages;
|
|
|
|
router.get('/logistics/pickup-board', async (req, res) => {
|
|
try {
|
|
const {
|
|
startDate,
|
|
endDate,
|
|
shops,
|
|
warehouses,
|
|
purchasers,
|
|
suppliers,
|
|
skuIds,
|
|
} = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
const hasFilters = [shops, warehouses, purchasers, suppliers, skuIds].some((value) => value && String(value).trim() !== '');
|
|
const effectiveEnd = dayjs(end).isAfter(dayjs().subtract(1, 'day'), 'day')
|
|
? dayjs().subtract(1, 'day').format('YYYY-MM-DD')
|
|
: end;
|
|
|
|
if (!hasFilters) {
|
|
const snapshot = readLogisticsPickupBoardSnapshot(start, end);
|
|
if (snapshot) {
|
|
return res.json({ success: true, data: normalizeSnapshotData(snapshot, effectiveEnd), cached: true, source: 'snapshot' });
|
|
}
|
|
}
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'logistics:pickup-board',
|
|
payload: { startDate: start, endDate: end, shops, warehouses, purchasers, suppliers, skuIds },
|
|
label: 'analysis:logistics:pickup-board',
|
|
loader: () => queryLogisticsPickupBoard({
|
|
startDate: start,
|
|
endDate: end,
|
|
shops,
|
|
warehouses,
|
|
purchasers,
|
|
suppliers,
|
|
skuIds,
|
|
}),
|
|
memoryTtlSeconds: 30,
|
|
redisTtlSeconds: 120,
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/logistics/delivery-duration-board', async (req, res) => {
|
|
try {
|
|
const {
|
|
startDate,
|
|
endDate,
|
|
warehouses,
|
|
couriers,
|
|
provinces,
|
|
} = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const cached = await getCachedAnalysisPayload({
|
|
scope: 'logistics:delivery-duration-board',
|
|
payload: { startDate: start, endDate: end, warehouses, couriers, provinces },
|
|
label: 'analysis:logistics:delivery-duration-board',
|
|
loader: () => queryLogisticsDeliveryDurationBoard({
|
|
startDate: start,
|
|
endDate: end,
|
|
warehouses,
|
|
couriers,
|
|
provinces,
|
|
}),
|
|
memoryTtlSeconds: 30,
|
|
redisTtlSeconds: 120,
|
|
});
|
|
|
|
res.json({ success: true, data: cached.value, cached: cached.hit !== 'miss' });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/logistics/delivery-duration-board/details', async (req, res) => {
|
|
try {
|
|
const {
|
|
startDate,
|
|
endDate,
|
|
warehouses,
|
|
couriers,
|
|
provinces,
|
|
page,
|
|
pageSize,
|
|
} = req.query;
|
|
const start = startDate || dayjs().startOf('month').format('YYYY-MM-DD');
|
|
const end = endDate || dayjs().format('YYYY-MM-DD');
|
|
|
|
const data = await queryLogisticsDeliveryDurationDetails({
|
|
startDate: start,
|
|
endDate: end,
|
|
warehouses,
|
|
couriers,
|
|
provinces,
|
|
page,
|
|
pageSize,
|
|
});
|
|
|
|
res.json({ success: true, data });
|
|
} catch (error) {
|
|
res.status(500).json({ success: false, error: error.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|