forked from lunanting/cangku
834 lines
30 KiB
JavaScript
834 lines
30 KiB
JavaScript
const express = require('express');
|
||
const XLSX = require('xlsx');
|
||
const database = require('../config/database');
|
||
const {
|
||
getCompanySkusBySpu,
|
||
fetchFreightProfile,
|
||
getSelectionFissionProfitPricingContext,
|
||
getSelectionFissionProductCodeMeta,
|
||
saveSelectionFissionProfitPricingTable,
|
||
} = require('../services/planningSelectionFissionService');
|
||
|
||
const router = express.Router();
|
||
|
||
const HISTORY_TABLE = 'pricing_calculator_history';
|
||
// 运费/售后画像查询设定超时,避免整页等待。
|
||
const FREIGHT_PROFILE_TIMEOUT_MS = Math.max(
|
||
Number.parseInt(process.env.PRICING_CALC_FREIGHT_TIMEOUT_MS || '30000', 10) || 30000,
|
||
5000
|
||
);
|
||
const MAX_HISTORY_ROWS = 500;
|
||
const MAX_PAYLOAD_BYTES = 1024 * 1024;
|
||
const CALCULATOR_TYPES = new Set(['company', 'alibaba1688']);
|
||
|
||
let historyTableReady = null;
|
||
|
||
async function ensureHistoryTable() {
|
||
if (!historyTableReady) {
|
||
historyTableReady = (async () => {
|
||
// 先查表是否存在:生产/隧道账号通常只有 SELECT/INSERT/UPDATE/DELETE,
|
||
// MariaDB 对 CREATE TABLE IF NOT EXISTS 也会校验 CREATE 权限,存在时直接跳过。
|
||
const existing = await database.local.query(
|
||
`SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = DATABASE()
|
||
AND table_name = ?
|
||
LIMIT 1`,
|
||
[HISTORY_TABLE]
|
||
);
|
||
if (existing.length > 0) {
|
||
return;
|
||
}
|
||
await database.local.query(
|
||
`CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
|
||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
|
||
user_id BIGINT NOT NULL COMMENT '测算人用户ID',
|
||
user_name VARCHAR(100) NULL COMMENT '测算人显示名',
|
||
spu_code VARCHAR(160) NOT NULL COMMENT '公司SPU',
|
||
brand VARCHAR(100) NULL COMMENT '品牌名称',
|
||
note VARCHAR(500) NULL COMMENT '备注',
|
||
row_count INT NOT NULL DEFAULT 0 COMMENT '测算行数',
|
||
payload_json LONGTEXT NOT NULL COMMENT '完整测算快照 JSON',
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||
INDEX idx_pricing_calc_history_user_created (user_id, created_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算历史记录'`
|
||
);
|
||
})();
|
||
}
|
||
try {
|
||
await historyTableReady;
|
||
} catch (error) {
|
||
// 建表失败后允许下次请求重试
|
||
historyTableReady = null;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function sendError(res, error, fallbackMessage) {
|
||
const status = error.status || error.statusCode || 500;
|
||
res.status(status).json({
|
||
success: false,
|
||
error: error.message || fallbackMessage,
|
||
});
|
||
}
|
||
|
||
function getRequestUser(req) {
|
||
const user = req.user || {};
|
||
return {
|
||
id: Number(user.sub || user.id || 0) || null,
|
||
name: user.name || user.username || '',
|
||
isAdmin: user.role === 'admin',
|
||
};
|
||
}
|
||
|
||
function requireUserId(req, res) {
|
||
const { id } = getRequestUser(req);
|
||
if (!id) {
|
||
res.status(401).json({ success: false, error: '当前登录状态无效,请重新登录' });
|
||
return null;
|
||
}
|
||
return id;
|
||
}
|
||
|
||
const normalizeText = (value, maxLength) => String(value ?? '').trim().slice(0, maxLength);
|
||
|
||
const normalizeCalculatorType = (value) => (
|
||
CALCULATOR_TYPES.has(value) ? value : 'company'
|
||
);
|
||
|
||
function sanitizeHistoryProduct(input) {
|
||
const product = input && typeof input === 'object' ? input : {};
|
||
const dimensions = (Array.isArray(product.skuDimensions) ? product.skuDimensions : [])
|
||
.slice(0, 4)
|
||
.map((dimension) => ({
|
||
name: normalizeText(dimension?.name, 120),
|
||
values: (Array.isArray(dimension?.values) ? dimension.values : [])
|
||
.slice(0, 500)
|
||
.map((value) => normalizeText(value, 500))
|
||
.filter(Boolean),
|
||
}))
|
||
.filter((dimension) => dimension.name);
|
||
return {
|
||
productId: normalizeText(product.productId, 80),
|
||
title: normalizeText(product.title, 500),
|
||
mainImage: normalizeText(product.mainImage, 2000),
|
||
sourceUrl: normalizeText(product.sourceUrl, 2000),
|
||
storeName: normalizeText(product.storeName, 300),
|
||
category: normalizeText(product.category, 500),
|
||
skuDimensions: dimensions,
|
||
};
|
||
}
|
||
|
||
function mapSkuRow(row) {
|
||
const qgyf = Number(row?.raw?.qgyf);
|
||
return {
|
||
skuCode: row.skuCode || '',
|
||
status: row.saleStatus || '',
|
||
stock: row.stock ?? null,
|
||
stockWarehouses: Array.isArray(row.stockWarehouses) ? row.stockWarehouses : [],
|
||
name: row.skuName || row.productName || '',
|
||
image: row.skuImageUrl || '',
|
||
size: row.size || '',
|
||
color: row.colorName || '',
|
||
cost: row.cost ?? null,
|
||
minimumPrice: row.minimumPrice ?? null,
|
||
salePrice: row?.raw?.sale_price ?? row?.raw?.salePrice ?? null,
|
||
// qgyf = 聚水潭接口返回的全国平均运费(按条码)
|
||
freight: Number.isFinite(qgyf) ? qgyf : null,
|
||
supplier: row.supplierName || '',
|
||
category: row.categoryName || '',
|
||
lengthCm: row.lengthCm ?? row?.raw?.length_cm ?? row?.raw?.length ?? null,
|
||
widthCm: row.widthCm ?? row?.raw?.width_cm ?? row?.raw?.width ?? null,
|
||
heightCm: row.heightCm ?? row?.raw?.height_cm ?? row?.raw?.height ?? null,
|
||
weight: row.weight ?? row?.raw?.weight ?? row?.raw?.gross_weight ?? null,
|
||
};
|
||
}
|
||
|
||
async function fetchFreightProfileBestEffort(spuCode, skuRows, user) {
|
||
const emptyProfile = {
|
||
nationalFreight: null,
|
||
zone1Freight: null,
|
||
zone2Freight: null,
|
||
zone3Freight: null,
|
||
zone4Freight: null,
|
||
aftersaleRatio: null,
|
||
minimumPricesBySku: {},
|
||
raw: null,
|
||
};
|
||
try {
|
||
const result = await Promise.race([
|
||
fetchFreightProfile({ spuCode, skuRows }, user),
|
||
new Promise((resolve) => {
|
||
setTimeout(() => resolve({ ...emptyProfile, warning: '运费/售后信息查询超时,可手动填写运费' }), FREIGHT_PROFILE_TIMEOUT_MS);
|
||
}),
|
||
]);
|
||
return result && typeof result === 'object' ? { ...emptyProfile, ...result } : emptyProfile;
|
||
} catch (error) {
|
||
return { ...emptyProfile, warning: error.message || '运费/售后信息查询失败' };
|
||
}
|
||
}
|
||
|
||
// 拉取 SPU 下全部条码(含销售、退市、停产)+ 运费/售后画像(尽力而为)
|
||
// 运费由聚水潭接口 qgyf 字段按条码直出,
|
||
// 默认跳过;如需售后占比/最低限价画像,可设 PRICING_CALC_FREIGHT_PROFILE=on 重新开启。
|
||
const FREIGHT_PROFILE_ENABLED = String(process.env.PRICING_CALC_FREIGHT_PROFILE || 'off').toLowerCase() === 'on';
|
||
|
||
function getSelectionSkuCodeSet(selectionContext) {
|
||
if (!selectionContext) return null;
|
||
const selectedSkuCodes = Array.isArray(selectionContext.skuSceneImages?.selectedSkuCodes)
|
||
? selectionContext.skuSceneImages.selectedSkuCodes
|
||
: [];
|
||
const fallbackSkuCodes = Array.isArray(selectionContext.rows)
|
||
? selectionContext.rows.map((row) => row.skuCode)
|
||
: [];
|
||
return new Set(
|
||
(selectedSkuCodes.length > 0 ? selectedSkuCodes : fallbackSkuCodes)
|
||
.map((skuCode) => normalizeText(skuCode, 160).toUpperCase())
|
||
.filter(Boolean)
|
||
);
|
||
}
|
||
|
||
function filterSkuRowsForSelectionContext(skuRows, selectionContext) {
|
||
const rows = Array.isArray(skuRows) ? skuRows : [];
|
||
const selectedSkuCodes = getSelectionSkuCodeSet(selectionContext);
|
||
if (!selectedSkuCodes) return rows;
|
||
return rows.filter((row) => selectedSkuCodes.has(
|
||
normalizeText(row?.skuCode || row?.sku_code || row?.barcode, 160).toUpperCase()
|
||
));
|
||
}
|
||
|
||
router.get('/product-code/meta', async (req, res) => {
|
||
const userId = requireUserId(req, res);
|
||
if (!userId) return;
|
||
try {
|
||
res.json({ success: true, data: await getSelectionFissionProductCodeMeta() });
|
||
} catch (error) {
|
||
sendError(res, error, '公司SKU编码配置加载失败');
|
||
}
|
||
});
|
||
|
||
router.get('/spu/:spuCode', async (req, res) => {
|
||
const spuCode = normalizeText(req.params.spuCode, 160).toUpperCase();
|
||
if (!spuCode) {
|
||
return res.status(400).json({ success: false, error: '请输入公司 SPU' });
|
||
}
|
||
|
||
try {
|
||
const planId = Number.parseInt(req.query.planId, 10) || 0;
|
||
const itemId = Number.parseInt(req.query.itemId, 10) || 0;
|
||
if (Boolean(planId) !== Boolean(itemId)) {
|
||
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
|
||
}
|
||
const skuResult = await getCompanySkusBySpu(spuCode, { includeInactive: true });
|
||
const allSkuRows = Array.from(skuResult.rows || []);
|
||
if (allSkuRows.length === 0) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
error: `未查询到 SPU「${spuCode}」的条码,请确认 SPU 是否正确`,
|
||
});
|
||
}
|
||
|
||
let selectionContext = null;
|
||
if (planId && itemId) {
|
||
selectionContext = await getSelectionFissionProfitPricingContext(planId, itemId);
|
||
if (selectionContext.spuCode.toUpperCase() !== spuCode) {
|
||
return res.status(409).json({ success: false, error: `该选品任务绑定的是SPU ${selectionContext.spuCode}` });
|
||
}
|
||
}
|
||
const skuRows = filterSkuRowsForSelectionContext(allSkuRows, selectionContext);
|
||
if (selectionContext && skuRows.length === 0) {
|
||
return res.status(409).json({
|
||
success: false,
|
||
error: '选品任务已选 SKU 不在当前 SPU 条码列表中,请返回 SKU 节点重新确认',
|
||
});
|
||
}
|
||
|
||
const freightProfile = FREIGHT_PROFILE_ENABLED
|
||
? await fetchFreightProfileBestEffort(spuCode, skuRows, req.user)
|
||
: { nationalFreight: null, aftersaleRatio: null, minimumPricesBySku: {}, warning: null };
|
||
const warnings = [...(skuResult.warnings || [])];
|
||
if (freightProfile.warning) {
|
||
warnings.push(freightProfile.warning);
|
||
}
|
||
|
||
const savedRowsBySku = new Map((selectionContext?.rows || []).map((row) => [
|
||
String(row.skuCode || '').toUpperCase(),
|
||
row,
|
||
]));
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
spu: spuCode,
|
||
source: skuResult.source,
|
||
warnings,
|
||
freightProfile: {
|
||
nationalFreight: freightProfile.nationalFreight ?? null,
|
||
zone1Freight: freightProfile.zone1Freight ?? null,
|
||
zone2Freight: freightProfile.zone2Freight ?? null,
|
||
zone3Freight: freightProfile.zone3Freight ?? null,
|
||
zone4Freight: freightProfile.zone4Freight ?? null,
|
||
aftersaleRatio: freightProfile.aftersaleRatio ?? null,
|
||
minimumPricesBySku: freightProfile.minimumPricesBySku || {},
|
||
},
|
||
selectionContext: selectionContext ? {
|
||
planId: selectionContext.planId,
|
||
itemId: selectionContext.itemId,
|
||
taskCode: selectionContext.taskCode,
|
||
schemeCode: selectionContext.schemeCode,
|
||
listedItemId: selectionContext.listedItemId,
|
||
pricingTableSavedAt: selectionContext.pricingTableSavedAt,
|
||
pricingTableSavedBy: selectionContext.pricingTableSavedBy,
|
||
pricingTableVersion: selectionContext.pricingTableVersion,
|
||
workflowProfile: selectionContext.workflowProfile,
|
||
productCodeStatus: selectionContext.productCodeStatus,
|
||
productCodeStyleCode: selectionContext.productCodeStyleCode,
|
||
productCodeConfig: selectionContext.productCodeConfig,
|
||
productCodeErrorMessage: selectionContext.productCodeErrorMessage,
|
||
productCodeRequestedAt: selectionContext.productCodeRequestedAt,
|
||
productCodeCompletedAt: selectionContext.productCodeCompletedAt,
|
||
} : null,
|
||
rows: skuRows.map((skuRow) => {
|
||
const mapped = mapSkuRow(skuRow);
|
||
const saved = savedRowsBySku.get(String(mapped.skuCode || '').toUpperCase());
|
||
return {
|
||
...mapped,
|
||
savedPricing: saved ? {
|
||
price: saved.finalPrice,
|
||
groupPrice: saved.groupPrice,
|
||
installFee: saved.installationFee,
|
||
coupon: saved.discountAmount,
|
||
activityDiscount: saved.activityDiscount,
|
||
shipping: saved.freightNational,
|
||
commission: saved.platformCommissionRatio,
|
||
promoRate: saved.promotionRatio,
|
||
adFee: saved.marketingAdRatio,
|
||
seeding: saved.farmingRatio,
|
||
afterSale: saved.aftersaleRatio,
|
||
insurance: saved.insuranceRatio,
|
||
brandFee: saved.brandUsageRatio,
|
||
targetMargin: saved.operatingProfitRatio,
|
||
originalSkuName: saved.originalSkuName,
|
||
selectedSkuName: saved.selectedSkuName,
|
||
internalProductCode: saved.internalProductCode,
|
||
productCodeProductName: saved.productCodeProductName,
|
||
productCodeColorSpec: saved.productCodeColorSpec,
|
||
} : null,
|
||
};
|
||
}),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
sendError(res, error, 'SPU 条码查询失败,请稍后重试');
|
||
}
|
||
});
|
||
|
||
// 保存一条测算历史(完整行快照,含输入与公式结果)
|
||
router.post('/history', async (req, res) => {
|
||
const userId = requireUserId(req, res);
|
||
if (!userId) return;
|
||
|
||
const body = req.body || {};
|
||
const spuCode = normalizeText(body.spuCode, 160).toUpperCase();
|
||
const calculatorType = normalizeCalculatorType(body.calculatorType);
|
||
const rows = Array.isArray(body.rows) ? body.rows : [];
|
||
if (!spuCode) {
|
||
return res.status(400).json({ success: false, error: '缺少 SPU 编码' });
|
||
}
|
||
if (rows.length === 0) {
|
||
return res.status(400).json({ success: false, error: '测算行为空,无法保存' });
|
||
}
|
||
if (rows.length > MAX_HISTORY_ROWS) {
|
||
return res.status(400).json({ success: false, error: `单条历史最多保存 ${MAX_HISTORY_ROWS} 行` });
|
||
}
|
||
|
||
const payload = {
|
||
version: 4,
|
||
calculatorType,
|
||
spuCode,
|
||
isBlackLabelShop: body.isBlackLabelShop === true,
|
||
brand: normalizeText(body.brand, 100),
|
||
note: normalizeText(body.note, 500),
|
||
savedAt: new Date().toISOString(),
|
||
summary: body.summary && typeof body.summary === 'object' ? body.summary : null,
|
||
freightProfile: body.freightProfile && typeof body.freightProfile === 'object' ? body.freightProfile : null,
|
||
product: calculatorType === 'alibaba1688' ? sanitizeHistoryProduct(body.product) : null,
|
||
rows,
|
||
};
|
||
const payloadJson = JSON.stringify(payload);
|
||
if (Buffer.byteLength(payloadJson, 'utf8') > MAX_PAYLOAD_BYTES) {
|
||
return res.status(400).json({ success: false, error: '测算快照过大,无法保存' });
|
||
}
|
||
|
||
try {
|
||
let selectionPricing = null;
|
||
const planId = Number.parseInt(body.selectionContext?.planId || body.planId, 10) || 0;
|
||
const itemId = Number.parseInt(body.selectionContext?.itemId || body.itemId, 10) || 0;
|
||
if (Boolean(planId) !== Boolean(itemId)) {
|
||
return res.status(400).json({ success: false, error: '选品定价上下文缺少方案ID或SPU任务ID' });
|
||
}
|
||
if (planId && itemId) {
|
||
// 测算页会展示实时接口中的全部条码;自动上架保存用户保留的任务 SKU 子集。
|
||
const pricingContext = await getSelectionFissionProfitPricingContext(planId, itemId);
|
||
const listingSkuCodes = new Set((pricingContext.rows || []).map((row) => (
|
||
normalizeText(row.skuCode, 160).toUpperCase()
|
||
)));
|
||
const listingRows = rows.filter((row) => listingSkuCodes.has(
|
||
normalizeText(row.skuCode || row.sku || row.barcode, 160).toUpperCase()
|
||
));
|
||
selectionPricing = await saveSelectionFissionProfitPricingTable(
|
||
planId,
|
||
itemId,
|
||
{
|
||
spuCode,
|
||
rows: listingRows,
|
||
note: payload.note,
|
||
productCodeConfig: body.productCodeConfig,
|
||
},
|
||
req.user,
|
||
{ pricingContext }
|
||
);
|
||
}
|
||
let insertId = null;
|
||
if (!selectionPricing) {
|
||
await ensureHistoryTable();
|
||
const inserted = await database.local.insert(HISTORY_TABLE, {
|
||
user_id: userId,
|
||
user_name: getRequestUser(req).name || null,
|
||
spu_code: spuCode,
|
||
brand: payload.brand || null,
|
||
note: payload.note || null,
|
||
row_count: rows.length,
|
||
payload_json: payloadJson,
|
||
});
|
||
insertId = inserted.insertId;
|
||
}
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
id: insertId,
|
||
spuCode,
|
||
rowCount: rows.length,
|
||
selectionPricing: selectionPricing ? {
|
||
planId: selectionPricing.planId,
|
||
itemId: selectionPricing.itemId,
|
||
taskCode: selectionPricing.taskCode,
|
||
pricingTableSavedAt: selectionPricing.pricingTableSavedAt,
|
||
pricingTableVersion: selectionPricing.pricingTableVersion,
|
||
productCodeStatus: selectionPricing.productCodeStatus,
|
||
productCodeStyleCode: selectionPricing.productCodeStyleCode,
|
||
productCodeConfig: selectionPricing.productCodeConfig,
|
||
productCodeCompletedAt: selectionPricing.productCodeCompletedAt,
|
||
productCodes: (selectionPricing.rows || []).map((row) => ({
|
||
skuCode: row.skuCode,
|
||
originalSkuName: row.originalSkuName,
|
||
selectedSkuName: row.selectedSkuName,
|
||
internalProductCode: row.internalProductCode,
|
||
productCodeProductName: row.productCodeProductName,
|
||
})),
|
||
} : null,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
sendError(res, error, '保存测算记录失败');
|
||
}
|
||
});
|
||
|
||
// 历史列表(管理员可见全部,普通用户仅本人,不含快照正文)
|
||
router.get('/history', async (req, res) => {
|
||
const userId = requireUserId(req, res);
|
||
if (!userId) return;
|
||
const { isAdmin } = getRequestUser(req);
|
||
|
||
const page = Math.max(Number.parseInt(req.query.page, 10) || 1, 1);
|
||
const pageSize = Math.min(Math.max(Number.parseInt(req.query.pageSize, 10) || 20, 1), 100);
|
||
const spuCode = normalizeText(req.query.spuCode, 160).toUpperCase();
|
||
const calculatorType = normalizeText(req.query.calculatorType, 30);
|
||
const offset = (page - 1) * pageSize;
|
||
|
||
try {
|
||
await ensureHistoryTable();
|
||
const where = [];
|
||
const params = [];
|
||
if (!isAdmin) {
|
||
where.push('user_id = ?');
|
||
params.push(userId);
|
||
}
|
||
if (spuCode) {
|
||
where.push('spu_code = ?');
|
||
params.push(spuCode);
|
||
}
|
||
if (calculatorType === 'alibaba1688') {
|
||
where.push('payload_json LIKE ?');
|
||
params.push('%"calculatorType":"alibaba1688"%');
|
||
} else if (calculatorType === 'company') {
|
||
// 兼容 version 1-3 的公司品历史,它们还没有 calculatorType 字段。
|
||
where.push('payload_json NOT LIKE ?');
|
||
params.push('%"calculatorType":"alibaba1688"%');
|
||
}
|
||
const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
||
|
||
const [countRows, list] = await Promise.all([
|
||
database.local.query(`SELECT COUNT(*) AS total FROM ${HISTORY_TABLE} ${whereSql}`, params),
|
||
database.local.query(
|
||
`SELECT id, user_id, user_name, spu_code, brand, note, row_count, created_at, updated_at
|
||
FROM ${HISTORY_TABLE}
|
||
${whereSql}
|
||
ORDER BY id DESC
|
||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||
params
|
||
),
|
||
]);
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
list: list.map((row) => ({
|
||
id: row.id,
|
||
userName: row.user_name || '',
|
||
spuCode: row.spu_code,
|
||
brand: row.brand || '',
|
||
note: row.note || '',
|
||
rowCount: row.row_count,
|
||
calculatorType: calculatorType === 'alibaba1688' ? 'alibaba1688' : 'company',
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
})),
|
||
total: Number(countRows[0]?.total || 0),
|
||
page,
|
||
pageSize,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
sendError(res, error, '查询测算历史失败');
|
||
}
|
||
});
|
||
|
||
async function loadOwnHistoryRow(req, res) {
|
||
const userId = requireUserId(req, res);
|
||
if (!userId) return null;
|
||
const { isAdmin } = getRequestUser(req);
|
||
|
||
const id = Number.parseInt(req.params.id, 10);
|
||
if (!Number.isFinite(id) || id <= 0) {
|
||
res.status(400).json({ success: false, error: '无效的历史记录 ID' });
|
||
return null;
|
||
}
|
||
|
||
await ensureHistoryTable();
|
||
// 服务端强制数据边界:管理员可见全部,普通用户的他人记录表现为不存在
|
||
const rows = isAdmin
|
||
? await database.local.query(`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? LIMIT 1`, [id])
|
||
: await database.local.query(
|
||
`SELECT * FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ? LIMIT 1`,
|
||
[id, userId]
|
||
);
|
||
if (rows.length === 0) {
|
||
res.status(404).json({ success: false, error: '测算记录不存在或已被删除' });
|
||
return null;
|
||
}
|
||
return rows[0];
|
||
}
|
||
|
||
// 历史详情(仅本人)
|
||
router.get('/history/:id', async (req, res) => {
|
||
try {
|
||
const row = await loadOwnHistoryRow(req, res);
|
||
if (!row) return;
|
||
|
||
let payload = null;
|
||
try {
|
||
payload = JSON.parse(row.payload_json);
|
||
} catch {
|
||
payload = null;
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
id: row.id,
|
||
userName: row.user_name || '',
|
||
spuCode: row.spu_code,
|
||
brand: row.brand || '',
|
||
note: row.note || '',
|
||
rowCount: row.row_count,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at,
|
||
payload,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
sendError(res, error, '查询测算记录失败');
|
||
}
|
||
});
|
||
|
||
// 删除历史(本人,或管理员可管理全部)
|
||
router.delete('/history/:id', async (req, res) => {
|
||
try {
|
||
const row = await loadOwnHistoryRow(req, res);
|
||
if (!row) return;
|
||
|
||
await database.local.query(
|
||
`DELETE FROM ${HISTORY_TABLE} WHERE id = ? AND user_id = ?`,
|
||
[row.id, row.user_id]
|
||
);
|
||
res.json({ success: true, data: { id: row.id } });
|
||
} catch (error) {
|
||
sendError(res, error, '删除测算记录失败');
|
||
}
|
||
});
|
||
|
||
// 导出测算表:按前端给定的列顺序(与页面显示一致)与行数据生成 xlsx 下载
|
||
const MAX_EXPORT_ROWS = 2000;
|
||
const MAX_EXPORT_COLUMNS = 40;
|
||
|
||
router.post('/export', async (req, res) => {
|
||
const userId = requireUserId(req, res);
|
||
if (!userId) return;
|
||
|
||
const body = req.body || {};
|
||
const spuCode = normalizeText(body.spu, 160).toUpperCase() || 'SPU';
|
||
const columns = Array.isArray(body.columns) ? body.columns : [];
|
||
const rows = Array.isArray(body.rows) ? body.rows : [];
|
||
|
||
const validColumns = columns
|
||
.filter((col) => col && typeof col === 'object')
|
||
.slice(0, MAX_EXPORT_COLUMNS)
|
||
.map((col) => ({
|
||
key: normalizeText(col.key, 60),
|
||
label: normalizeText(col.label, 60) || normalizeText(col.key, 60),
|
||
percent: col.percent === true,
|
||
}))
|
||
.filter((col) => col.key);
|
||
|
||
if (validColumns.length === 0) {
|
||
return res.status(400).json({ success: false, error: '缺少导出列配置' });
|
||
}
|
||
if (rows.length === 0) {
|
||
return res.status(400).json({ success: false, error: '导出数据为空' });
|
||
}
|
||
if (rows.length > MAX_EXPORT_ROWS) {
|
||
return res.status(400).json({ success: false, error: `单次最多导出 ${MAX_EXPORT_ROWS} 行` });
|
||
}
|
||
|
||
try {
|
||
const aoa = [validColumns.map((col) => col.label)];
|
||
rows.forEach((row) => {
|
||
aoa.push(validColumns.map((col) => {
|
||
const value = row && typeof row === 'object' ? row[col.key] : null;
|
||
return value === null || value === undefined ? '' : value;
|
||
}));
|
||
});
|
||
const sheet = XLSX.utils.aoa_to_sheet(aoa);
|
||
// 百分比列:保持数值,设置百分比数字格式(0.06 在 Excel 显示为 6.00%)
|
||
validColumns.forEach((col, colIndex) => {
|
||
if (!col.percent) return;
|
||
for (let rowIndex = 1; rowIndex < aoa.length; rowIndex += 1) {
|
||
const addr = XLSX.utils.encode_cell({ c: colIndex, r: rowIndex });
|
||
const cell = sheet[addr];
|
||
if (cell && typeof cell.v === 'number') {
|
||
cell.z = '0.00%';
|
||
}
|
||
}
|
||
});
|
||
sheet['!cols'] = validColumns.map((col) => ({ wch: Math.max(12, String(col.label).length * 2 + 6) }));
|
||
const workbook = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(workbook, sheet, '上架测算');
|
||
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||
|
||
const stamp = new Date();
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const filename = `上架测算_${spuCode}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
|
||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`);
|
||
res.send(buffer);
|
||
} catch (error) {
|
||
res.status(500).json({ success: false, error: `导出失败:${error.message}` });
|
||
}
|
||
});
|
||
|
||
// ===== 用户个性化默认值(每运营维护自己的配置,无配置时前端用系统默认) =====
|
||
const PREFS_TABLE = 'pricing_calculator_user_prefs';
|
||
let prefsTableReady = null;
|
||
|
||
async function ensurePrefsTable() {
|
||
if (!prefsTableReady) {
|
||
prefsTableReady = (async () => {
|
||
const existing = await database.local.query(
|
||
`SELECT 1
|
||
FROM information_schema.tables
|
||
WHERE table_schema = DATABASE()
|
||
AND table_name = ?
|
||
LIMIT 1`,
|
||
[PREFS_TABLE]
|
||
);
|
||
if (existing.length > 0) {
|
||
return;
|
||
}
|
||
await database.local.query(
|
||
`CREATE TABLE IF NOT EXISTS ${PREFS_TABLE} (
|
||
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
|
||
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化默认值 JSON',
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上架测算用户个性化默认值'`
|
||
);
|
||
})();
|
||
}
|
||
try {
|
||
await prefsTableReady;
|
||
} catch (error) {
|
||
prefsTableReady = null;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 允许的偏好字段与取值范围(targetMargin 为小数,0.15 = 15%)
|
||
const PREF_FIELD_RANGES = {
|
||
coupon: { min: 0, max: 100000 },
|
||
activityDiscount: { min: 0.01, max: 1 },
|
||
installFee: { min: 0, max: 100000 },
|
||
targetMargin: { min: -1, max: 1 },
|
||
};
|
||
|
||
function sanitizePrefs(input) {
|
||
const src = input && typeof input === 'object' ? input : {};
|
||
const prefs = {};
|
||
Object.entries(PREF_FIELD_RANGES).forEach(([key, range]) => {
|
||
const num = Number(src[key]);
|
||
if (Number.isFinite(num)) {
|
||
prefs[key] = Math.min(range.max, Math.max(range.min, num));
|
||
}
|
||
});
|
||
return prefs;
|
||
}
|
||
|
||
const PREFS_PROFILE_KEY = '_profiles';
|
||
const PREFS_PROFILES = new Set(['company', 'alibaba1688']);
|
||
|
||
function normalizePrefsProfile(value) {
|
||
return PREFS_PROFILES.has(value) ? value : 'company';
|
||
}
|
||
|
||
function parsePrefsStorage(value) {
|
||
try {
|
||
const parsed = typeof value === 'string' ? JSON.parse(value || 'null') : value;
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function readPrefsProfile(storageValue, profile = 'company') {
|
||
const storage = parsePrefsStorage(storageValue);
|
||
const normalizedProfile = normalizePrefsProfile(profile);
|
||
const source = normalizedProfile === 'company'
|
||
? storage
|
||
: storage[PREFS_PROFILE_KEY]?.[normalizedProfile];
|
||
const prefs = sanitizePrefs(source);
|
||
return Object.keys(prefs).length > 0 ? prefs : null;
|
||
}
|
||
|
||
function writePrefsProfile(storageValue, profile, prefs) {
|
||
const storage = parsePrefsStorage(storageValue);
|
||
const normalizedProfile = normalizePrefsProfile(profile);
|
||
if (normalizedProfile === 'company') {
|
||
return { ...storage, ...prefs };
|
||
}
|
||
return {
|
||
...storage,
|
||
[PREFS_PROFILE_KEY]: {
|
||
...(storage[PREFS_PROFILE_KEY] && typeof storage[PREFS_PROFILE_KEY] === 'object'
|
||
? storage[PREFS_PROFILE_KEY]
|
||
: {}),
|
||
[normalizedProfile]: prefs,
|
||
},
|
||
};
|
||
}
|
||
|
||
function createPrefsPatch(profile, prefs) {
|
||
const normalizedProfile = normalizePrefsProfile(profile);
|
||
return normalizedProfile === 'company'
|
||
? prefs
|
||
: { [PREFS_PROFILE_KEY]: { [normalizedProfile]: prefs } };
|
||
}
|
||
|
||
async function persistPrefsProfile(userId, profile, prefs) {
|
||
const patch = JSON.stringify(createPrefsPatch(profile, prefs));
|
||
try {
|
||
// JSON_MERGE_PATCH 在数据库内原子合并,保留另一测算类型的配置,且只需一次网络往返。
|
||
await database.local.query(
|
||
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
|
||
ON DUPLICATE KEY UPDATE prefs_json = JSON_MERGE_PATCH(
|
||
IF(JSON_VALID(prefs_json), prefs_json, '{}'),
|
||
VALUES(prefs_json)
|
||
)`,
|
||
[userId, patch]
|
||
);
|
||
} catch (error) {
|
||
const canFallback = ['ER_SP_DOES_NOT_EXIST', 'ER_PARSE_ERROR', 'ER_NOT_SUPPORTED_YET']
|
||
.includes(error.code);
|
||
if (!canFallback) throw error;
|
||
|
||
// 兼容不支持 JSON_MERGE_PATCH 的旧版 MySQL / MariaDB。
|
||
const existingRows = await database.local.query(
|
||
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
|
||
[userId]
|
||
);
|
||
const storage = writePrefsProfile(existingRows[0]?.prefs_json, profile, prefs);
|
||
await database.local.query(
|
||
`INSERT INTO ${PREFS_TABLE} (user_id, prefs_json) VALUES (?, ?)
|
||
ON DUPLICATE KEY UPDATE prefs_json = VALUES(prefs_json)`,
|
||
[userId, JSON.stringify(storage)]
|
||
);
|
||
}
|
||
}
|
||
|
||
router.get('/prefs', async (req, res) => {
|
||
const userId = requireUserId(req, res);
|
||
if (!userId) return;
|
||
try {
|
||
await ensurePrefsTable();
|
||
const rows = await database.local.query(
|
||
`SELECT prefs_json FROM ${PREFS_TABLE} WHERE user_id = ? LIMIT 1`,
|
||
[userId]
|
||
);
|
||
const profile = normalizePrefsProfile(req.query.profile);
|
||
const prefs = rows.length > 0
|
||
? readPrefsProfile(rows[0].prefs_json, profile)
|
||
: null;
|
||
res.json({ success: true, data: { prefs } });
|
||
} catch (error) {
|
||
sendError(res, error, '读取个性化配置失败');
|
||
}
|
||
});
|
||
|
||
router.put('/prefs', async (req, res) => {
|
||
const userId = requireUserId(req, res);
|
||
if (!userId) return;
|
||
const prefs = sanitizePrefs(req.body?.prefs ?? req.body);
|
||
const profile = normalizePrefsProfile(req.body?.profile);
|
||
if (Object.keys(prefs).length === 0) {
|
||
return res.status(400).json({ success: false, error: '缺少有效的配置项' });
|
||
}
|
||
try {
|
||
await ensurePrefsTable();
|
||
await persistPrefsProfile(userId, profile, prefs);
|
||
res.json({ success: true, data: { prefs } });
|
||
} catch (error) {
|
||
sendError(res, error, '保存个性化配置失败');
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|
||
module.exports._test = {
|
||
filterSkuRowsForSelectionContext,
|
||
createPrefsPatch,
|
||
getSelectionSkuCodeSet,
|
||
normalizeCalculatorType,
|
||
readPrefsProfile,
|
||
sanitizeHistoryProduct,
|
||
sanitizePrefs,
|
||
writePrefsProfile,
|
||
};
|