cangku/backend/src/services/productCodeBoard.js

1839 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const database = require('../config/database');
const xlsx = require('xlsx');
const PRODUCT_CODE_TABLE = 'product_code_items';
const PRODUCT_CODE_STYLE_TABLE = 'product_code_style_registry';
const MAX_BATCH_PRODUCTS = 50;
const MAX_BATCH_SKUS = 200;
const MAX_DELETE_PRODUCT_CODES = 500;
const PRODUCT_CODE_EXPORT_COLUMNS = [
{ key: 'operationCenter', title: '运营中心' },
{ key: 'centerAbbr', title: '中心缩写' },
{ key: 'categoryAbbr', title: '类目缩写' },
{ key: 'serialNumber', title: '序号' },
{ key: 'currentSequence', title: '目前编写序号' },
{ key: 'styleCode', title: '款式编码' },
{ key: 'productCode', title: '商品编码' },
{ key: 'productName', title: '商品名称' },
{ key: 'colorSpec', title: '颜色及规格' },
{ key: 'brand', title: '品牌' },
{ key: 'categoryName', title: '分类' },
{ key: 'virtualCategory', title: '虚拟分类' },
{ key: 'productTag', title: '商品标签' },
{ key: 'productStatus', title: '商品状态' },
{ key: 'inventorySync', title: '库存同步' },
{ key: 'productAttribute', title: '商品属性' },
{ key: 'weight', title: '重量kg' },
{ key: 'lengthCm', title: '长cm' },
{ key: 'widthCm', title: '宽cm' },
{ key: 'heightCm', title: '高cm' },
{ key: 'volume', title: '体积' },
{ key: 'unit', title: '单位' },
{ key: 'otherAttr7', title: '其它属性7' },
{ key: 'otherAttr5', title: '其它属性5' },
{ key: 'createdByName', title: '创建人' },
{ key: 'createdAt', title: '创建时间' },
];
const BATCH_IMPORT_TEMPLATE_FIELDS = [
{ key: 'operationCenter', title: '运营中心', rule: '必填,仅支持模板辅助表中的选项,填写其它内容将无法导入' },
{ key: 'categoryName', title: '分类', rule: '必填,仅支持模板辅助表中的选项,填写其它内容将无法导入' },
{ key: 'productIndex', title: '商品序号', rule: '必填,仅用于归并同一款式的多个规格,不是系统真实序号;真实序号会按系统当前最大值自动顺延生成' },
{ key: 'productName', title: '商品名称', rule: '必填,每一行填写当前规格对应的商品名称;同一商品序号下可不同' },
{ key: 'colorName', title: '颜色', rule: '必填,例如原木色' },
{ key: 'lengthCm', title: '长cm', rule: '选填,单位厘米;如填写则必须大于 0' },
{ key: 'widthCm', title: '宽cm', rule: '选填,单位厘米;如填写则必须大于 0' },
{ key: 'heightCm', title: '高cm', rule: '选填,单位厘米;如填写则必须大于 0' },
{ key: 'weight', title: '重量kg', rule: '选填,单位kg' },
];
const FIXED_VALUES = {
brand: '一件代发',
virtualCategory: 'ODM',
productTag: '1688一件代发',
productStatus: '启用',
inventorySync: '否',
productAttribute: '成品',
unit: '个',
otherAttr5: '销售',
};
const OPERATION_CENTER_OPTIONS = [
{ name: '事业一部', abbreviation: 'A1' },
{ name: '事业二部', abbreviation: 'A2' },
{ name: '事业三部', abbreviation: 'A3' },
{ name: '事业四部', abbreviation: 'A4' },
{ name: '运营中心B', abbreviation: 'B1' },
{ name: '运营中心C', abbreviation: 'C1' },
{ name: '运营中心G', abbreviation: 'G1' },
];
const CATEGORY_OPTIONS = [
{ name: '住宅家具 - 书架', abbreviation: 'SJ' },
{ name: '住宅家具 - 电脑桌', abbreviation: 'DNZ' },
{ name: '住宅家具 - 鞋柜', abbreviation: 'XG' },
{ name: '住宅家具 - 床头柜', abbreviation: 'CTG' },
{ name: '住宅家具 - 书桌', abbreviation: 'SZ' },
{ name: '住宅家具 - 餐边柜', abbreviation: 'CBG' },
{ name: '住宅家具 - 矮凳', abbreviation: 'AD' },
{ name: '住宅家具 - 茶几', abbreviation: 'CJ' },
{ name: '住宅家具 - 书柜', abbreviation: 'SG' },
{ name: '住宅家具 - 厨房置物架/角架', abbreviation: 'CFZW' },
{ name: '住宅家具 - 角几/边几', abbreviation: 'BJ' },
{ name: '住宅家具 - 餐桌', abbreviation: 'CZ' },
{ name: '住宅家具 - 梳妆台/桌', abbreviation: 'SZT' },
{ name: '住宅家具 - 懒人沙发', abbreviation: 'LRSF' },
{ name: '住宅家具 - 书桌收纳置物架', abbreviation: 'SJ' },
{ name: '住宅家具 - 电视柜', abbreviation: 'DSG' },
{ name: '住宅家具 - 换鞋凳', abbreviation: 'HXD' },
{ name: '住宅家具 - 搁板/置物架', abbreviation: 'ZWJ' },
{ name: '住宅家具 - 衣帽架', abbreviation: 'YMJ' },
{ name: '住宅家具 - 厨房储物架/厨房置物架', abbreviation: 'CFZW' },
{ name: '住宅家具 - 电脑椅', abbreviation: 'DNY' },
{ name: '住宅家具 - 餐椅', abbreviation: 'CY' },
{ name: '住宅家具 - 化妆镜', abbreviation: 'HZJ' },
{ name: '商业/办公家具 - 文具边柜', abbreviation: 'BG' },
{ name: '住宅家具 - 鞋架', abbreviation: 'XJ' },
{ name: '其他 - 配件', abbreviation: 'PJ' },
{ name: '住宅家具 - 斗柜', abbreviation: 'DG' },
{ name: '住宅家具 - 家居收纳产品', abbreviation: 'SN' },
{ name: '住宅家具 - 角柜', abbreviation: 'JG' },
{ name: '住宅家具 - 花架/花几', abbreviation: 'HJ' },
{ name: '住宅家具 - 办公升降台/升降桌', abbreviation: 'DNZ' },
{ name: '住宅家具 - 折叠桌', abbreviation: 'ZDZ' },
{ name: '商业/办公家具 - 组合/屏风工作位', abbreviation: 'GZW' },
{ name: '住宅家具 - 穿衣镜', abbreviation: 'CYJ' },
{ name: '住宅家具 - 功能沙发', abbreviation: 'GNSF' },
{ name: '住宅家具 - 梳妆凳', abbreviation: 'SZD' },
{ name: '住宅家具 - 布艺沙发', abbreviation: 'BYSF' },
{ name: '住宅家具 - 办公桌', abbreviation: 'DNZ' },
{ name: '住宅家具 - 折叠床/午休床', abbreviation: 'ZDC' },
{ name: '住宅家具 - 折叠椅/躺椅', abbreviation: 'ZDY' },
{ name: '住宅家具 - 电竞椅', abbreviation: 'DJY' },
{ name: '住宅家具 - 沙发床', abbreviation: 'SFC' },
{ name: '住宅家具 - 衣柜', abbreviation: 'YG' },
{ name: '住宅家具 - 收纳洞洞板', abbreviation: 'DDB' },
{ name: '住宅家具 - 飘窗柜', abbreviation: 'PCG' },
{ name: '住宅家具 - 床垫类', abbreviation: 'CD' },
{ name: '住宅家具 - 门厅/玄关柜/玄关架', abbreviation: 'XGG' },
{ name: '住宅家具 - 其它家具配件', abbreviation: 'PJ' },
{ name: '住宅家具 - 卫浴/置物用具', abbreviation: 'WYZW' },
{ name: '住宅家具 - 折叠床', abbreviation: 'ZDC' },
{ name: '商业/办公家具 - 演讲台', abbreviation: 'YJT' },
{ name: '住宅家具 - 休闲椅', abbreviation: 'XXY' },
{ name: '其他 - 福利品', abbreviation: 'FL' },
{ name: '住宅家具 - 家用梯', abbreviation: 'TZ' },
{ name: '住宅家具 - 收纳柜', abbreviation: 'SN' },
{ name: '住宅家具 - 板木结合床', abbreviation: 'BMC' },
{ name: '住宅家具 - 儿童餐椅', abbreviation: 'CY' },
{ name: '商业/办公家具 - 会议桌', abbreviation: 'HYZ' },
{ name: '住宅家具 - 洞洞板', abbreviation: 'DDB' },
{ name: '住宅家具 - 衣物洗/晒/护理用具', abbreviation: 'QT' },
{ name: '住宅家具 - 炕几', abbreviation: 'KJ' },
{ name: '商业/办公家具 - 前台/接待台(新增)', abbreviation: 'QT' },
{ name: '住宅家具 - 平板手推车/工具车', abbreviation: 'QT' },
{ name: '住宅家具 - 收纳筐', abbreviation: 'SN' },
{ name: '其他 - 虚拟产品', abbreviation: 'XN' },
{ name: '住宅家具 - 电动升降桌', abbreviation: 'SJZ' },
{ name: '住宅家具 - 窝/屋/帐篷', abbreviation: 'QT' },
{ name: '其他 - 赠品', abbreviation: 'ZP' },
{ name: '住宅家具 - 酒吧椅', abbreviation: 'JBY' },
{ name: '住宅家具 - 主机箱托架', abbreviation: 'QT' },
{ name: '住宅家具 - 吊柜/壁柜', abbreviation: 'DG' },
{ name: '住宅家具 - 儿童学习椅', abbreviation: 'XXY' },
{ name: '住宅家具 - 儿童学习桌', abbreviation: 'XXZ' },
{ name: '住宅家具 - 实木床', abbreviation: 'SMC' },
{ name: '住宅家具 - 其它凳子', abbreviation: 'DZ' },
];
const TEMPLATE_FIELDS = [
{ key: 'operationCenter', title: '运营中心', rule: '选择,选项见【辅助表-勿动】D列' },
{ key: 'centerAbbr', title: '中心缩写', rule: '用A列匹配【辅助表-勿动】D-F列' },
{ key: 'categoryAbbr', title: '类目缩写', rule: '用K列匹配【辅助表-勿动】A-B列' },
{ key: 'serialNumber', title: '序号', rule: '当下输入的序号=目前编写序号+1,填入后目前编写序号会变' },
{ key: 'currentSequence', title: '目前编写序号', rule: '按中心缩写+类目缩写取当前最大序号' },
{ key: 'styleCode', title: '款式编码', rule: 'DF- + 中心缩写 + 类目缩写 + 4位序号' },
{ key: 'productCode', title: '商品编码', rule: '款式编码 + 2位SKU序号' },
{ key: 'productName', title: '商品名称', rule: '必填,运营填写' },
{ key: 'colorSpec', title: '颜色及规格', rule: '运营填写,格式可写为:颜色;长/宽/高,单位为厘米;长宽高可留空' },
{ key: 'brand', title: '品牌', rule: '固定内容下拉' },
{ key: 'categoryName', title: '分类', rule: '选择,选项见【辅助表-勿动】A列' },
{ key: 'virtualCategory', title: '虚拟分类', rule: '固定内容下拉' },
{ key: 'productTag', title: '商品标签', rule: '固定内容下拉' },
{ key: 'productStatus', title: '商品状态', rule: '固定内容下拉' },
{ key: 'inventorySync', title: '库存同步', rule: '固定内容下拉' },
{ key: 'productAttribute', title: '商品属性', rule: '固定内容下拉' },
{ key: 'weight', title: '重量', rule: '选填,单位为kg' },
{ key: 'lengthCm', title: '长', rule: '选填,运营填写单位是厘米;如填写则必须大于 0' },
{ key: 'widthCm', title: '宽', rule: '选填,运营填写单位是厘米;如填写则必须大于 0' },
{ key: 'heightCm', title: '高', rule: '选填,运营填写单位是厘米;如填写则必须大于 0' },
{ key: 'volume', title: '体积', rule: '长*宽*高;长宽高为空时可留空' },
{ key: 'unit', title: '单位', rule: '固定内容下拉' },
{ key: 'otherAttr7', title: '其它属性7', rule: '等于商品编码' },
{ key: 'otherAttr5', title: '其它属性5', rule: '固定内容下拉' },
];
const centerMap = new Map(OPERATION_CENTER_OPTIONS.map((item) => [item.name, item]));
const categoryMap = new Map(CATEGORY_OPTIONS.map((item) => [item.name, item]));
const fieldTitleMap = new Map(TEMPLATE_FIELDS.map((field) => [field.title, field]));
const batchImportFieldTitleMap = new Map(BATCH_IMPORT_TEMPLATE_FIELDS.map((field) => [field.title, field]));
function parsePositiveInt(value, fallback, maxValue = 200) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.min(parsed, maxValue);
}
function normalizeString(value) {
return String(value || '').trim();
}
function parseListParam(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return [...new Set(value.flatMap((item) => String(item).split(',')).map((item) => item.trim()).filter(Boolean))];
}
return [...new Set(String(value).split(',').map((item) => item.trim()).filter(Boolean))];
}
function normalizeDateTimeFilter(value, label, endOfDay = false) {
const raw = normalizeString(value);
if (!raw) {
return '';
}
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
return `${raw} ${endOfDay ? '23:59:59' : '00:00:00'}`;
}
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(raw)) {
return `${raw}:${endOfDay ? '59' : '00'}`;
}
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) {
return raw;
}
const error = new Error(`${label}格式不正确`);
error.status = 400;
throw error;
}
function padNumber(value, length) {
return String(value).padStart(length, '0');
}
function buildStyleCode(centerAbbr, categoryAbbr, serialNumber) {
return `DF-${centerAbbr}${categoryAbbr}${padNumber(serialNumber, 4)}`;
}
function buildProductCode(styleCode, skuIndex) {
return `${styleCode}${padNumber(skuIndex, 2)}`;
}
function isProductCodeConflictError(error) {
if (!error) {
return false;
}
if (String(error.code || '') === 'ER_DUP_ENTRY') {
return true;
}
const message = String(error.message || '').toLowerCase();
return (
message.includes('duplicate entry')
|| message.includes('uk_product_code_items_product_code')
|| message.includes('uk_product_code_style_registry_style_code')
|| message.includes('uk_product_code_style_registry_center_category_serial')
);
}
function buildProductCodeStyleRow({ center, category, serialNumber, styleCode, user = {} }) {
return {
operation_center: center.name,
center_abbr: center.abbreviation,
category_abbr: category.abbreviation,
serial_number: serialNumber,
style_code: styleCode,
category_name: category.name,
created_by_user_id: user.sub || null,
created_by_username: user.username || '',
created_by_name: user.name || user.username || '',
created_department_id: user.departmentId || null,
created_department_name: user.departmentName || '',
};
}
async function syncProductCodeStyleRegistryGroup(centerAbbr, categoryAbbr) {
const conflictingRows = await database.local.query(
`SELECT
serial_number AS serialNumber,
GROUP_CONCAT(DISTINCT style_code ORDER BY style_code SEPARATOR ', ') AS styleCodes
FROM ${PRODUCT_CODE_TABLE}
WHERE center_abbr = ? AND category_abbr = ?
GROUP BY serial_number
HAVING COUNT(DISTINCT style_code) > 1
ORDER BY serial_number
LIMIT 5`,
[centerAbbr, categoryAbbr]
);
if (conflictingRows.length) {
const summary = conflictingRows
.map((row) => `序号 ${row.serialNumber} 对应多个款式编码:${row.styleCodes}`)
.join(';');
const error = new Error(`检测到 ${centerAbbr}/${categoryAbbr} 下存在序号与款式编码不一致的数据,${summary}。请检查导入文件或先修正历史数据后再导入`);
error.status = 400;
throw error;
}
await database.local.query(
`DELETE FROM ${PRODUCT_CODE_STYLE_TABLE}
WHERE center_abbr = ? AND category_abbr = ?`,
[centerAbbr, categoryAbbr]
);
await database.local.query(
`INSERT INTO ${PRODUCT_CODE_STYLE_TABLE} (
operation_center,
center_abbr,
category_abbr,
serial_number,
style_code,
category_name,
created_by_user_id,
created_by_username,
created_by_name,
created_department_id,
created_department_name,
created_at,
updated_at
)
SELECT
MIN(operation_center) AS operation_center,
center_abbr,
category_abbr,
serial_number,
style_code,
MIN(category_name) AS category_name,
MAX(created_by_user_id) AS created_by_user_id,
MAX(created_by_username) AS created_by_username,
MAX(created_by_name) AS created_by_name,
MAX(created_department_id) AS created_department_id,
MAX(created_department_name) AS created_department_name,
MIN(created_at) AS created_at,
MAX(updated_at) AS updated_at
FROM ${PRODUCT_CODE_TABLE}
WHERE center_abbr = ? AND category_abbr = ?
GROUP BY center_abbr, category_abbr, serial_number, style_code`,
[centerAbbr, categoryAbbr]
);
}
function parseSerialFromStyleCode(styleCode) {
const matched = normalizeString(styleCode).match(/(\d{4})$/);
return matched ? Number(matched[1]) : 0;
}
function deriveStyleCodeFromProductCode(productCode) {
const raw = normalizeString(productCode);
return raw.length > 2 ? raw.slice(0, -2) : '';
}
function parseOptionalPositiveDecimal(value, fieldName) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
const error = new Error(`${fieldName}必须大于 0`);
error.status = 400;
throw error;
}
return parsed;
}
function parseOptionalDecimal(value, fieldName) {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
const error = new Error(`${fieldName}不能小于 0`);
error.status = 400;
throw error;
}
return parsed;
}
function formatDimension(value) {
const fixed = Number(value).toFixed(2);
return fixed.replace(/\.?0+$/, '');
}
function hasDimensionValue(value) {
if (value === null || value === undefined || value === '') {
return false;
}
return Number.isFinite(Number(value));
}
function buildVolume(lengthCm, widthCm, heightCm) {
if (!hasDimensionValue(lengthCm) || !hasDimensionValue(widthCm) || !hasDimensionValue(heightCm)) {
return null;
}
return Number((Number(lengthCm) * Number(widthCm) * Number(heightCm)).toFixed(4));
}
function buildColorSpec(sku) {
const explicit = normalizeString(sku.colorSpec);
if (explicit) {
return explicit;
}
const colorName = normalizeString(sku.colorName);
const dimensions = [
{ label: '长', value: sku.lengthCm },
{ label: '宽', value: sku.widthCm },
{ label: '高', value: sku.heightCm },
].filter((item) => hasDimensionValue(item.value));
let sizeText = '';
if (dimensions.length === 3) {
sizeText = dimensions.map((item) => formatDimension(item.value)).join('/');
} else if (dimensions.length > 0) {
sizeText = dimensions.map((item) => `${item.label}${formatDimension(item.value)}`).join('/');
}
if (colorName && sizeText) {
return `${colorName};${sizeText}`;
}
return colorName || sizeText;
}
function resolveOperationCenter(operationCenter) {
const center = centerMap.get(normalizeString(operationCenter));
if (!center) {
const error = new Error('请选择有效的运营中心');
error.status = 400;
throw error;
}
return center;
}
function resolveCategory(categoryName) {
const category = categoryMap.get(normalizeString(categoryName));
if (!category) {
const error = new Error('请选择有效的分类');
error.status = 400;
throw error;
}
return category;
}
function normalizeSkuItems(rawItems, ownerLabel = '') {
const items = Array.isArray(rawItems) ? rawItems : [];
if (items.length === 0) {
const error = new Error(`${ownerLabel || '请'}至少填写一个商品规格`);
error.status = 400;
throw error;
}
return items.map((rawItem, index) => {
const label = ownerLabel ? `${ownerLabel}第 ${index + 1} 个规格` : `第 ${index + 1} 个规格`;
const productName = normalizeString(rawItem.productName || rawItem.defaultProductName);
const lengthCm = parseOptionalPositiveDecimal(rawItem.lengthCm, `${label}的长`);
const widthCm = parseOptionalPositiveDecimal(rawItem.widthCm, `${label}的宽`);
const heightCm = parseOptionalPositiveDecimal(rawItem.heightCm, `${label}的高`);
const weight = parseOptionalDecimal(rawItem.weight, `${label}的重量`);
const colorName = normalizeString(rawItem.colorName);
if (!productName) {
const error = new Error(`${label}请填写商品名称`);
error.status = 400;
throw error;
}
if (!normalizeString(rawItem.colorSpec) && !colorName) {
const error = new Error(`${label}请填写颜色`);
error.status = 400;
throw error;
}
return {
productName,
colorName,
colorSpec: normalizeString(rawItem.colorSpec),
weight,
lengthCm,
widthCm,
heightCm,
volume: buildVolume(lengthCm, widthCm, heightCm),
};
});
}
function normalizeCreateProduct(rawProduct = {}, index = 0) {
return {
skuItems: normalizeSkuItems(
(Array.isArray(rawProduct.skuItems) ? rawProduct.skuItems : []).map((item) => ({
...item,
defaultProductName: rawProduct.productName,
})),
`第 ${index + 1} 个商品`
),
};
}
function normalizeCreateProducts(payload = {}) {
const rawProducts = Array.isArray(payload.products)
? payload.products
: [{
productName: payload.productName,
skuItems: payload.skuItems,
}];
if (rawProducts.length === 0) {
const error = new Error('请至少填写一个商品');
error.status = 400;
throw error;
}
if (rawProducts.length > MAX_BATCH_PRODUCTS) {
const error = new Error(`单次最多批量新增 ${MAX_BATCH_PRODUCTS} 个商品`);
error.status = 400;
throw error;
}
const products = rawProducts.map(normalizeCreateProduct);
const skuCount = products.reduce((sum, product) => sum + product.skuItems.length, 0);
if (skuCount > MAX_BATCH_SKUS) {
const error = new Error(`单次最多批量新增 ${MAX_BATCH_SKUS} 个商品规格`);
error.status = 400;
throw error;
}
return products;
}
function buildWhereClause(filters = {}) {
const clauses = [];
const values = [];
const keyword = normalizeString(filters.keyword);
if (keyword) {
clauses.push('(product_name LIKE ? OR product_code LIKE ? OR style_code LIKE ? OR color_spec LIKE ?)');
values.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
const operationCenter = normalizeString(filters.operationCenter);
if (operationCenter) {
clauses.push('operation_center = ?');
values.push(operationCenter);
}
const categoryName = normalizeString(filters.categoryName);
if (categoryName) {
clauses.push('category_name = ?');
values.push(categoryName);
}
const productStatus = normalizeString(filters.productStatus);
if (productStatus) {
clauses.push('product_status = ?');
values.push(productStatus);
}
const createdByNames = parseListParam(filters.createdByNames);
if (createdByNames.length) {
clauses.push('created_by_name IN (?)');
values.push(createdByNames);
}
const createdAtStart = normalizeDateTimeFilter(filters.createdAtStart, '创建开始时间');
if (createdAtStart) {
clauses.push('created_at >= ?');
values.push(createdAtStart);
}
const createdAtEnd = normalizeDateTimeFilter(filters.createdAtEnd, '创建结束时间', true);
if (createdAtEnd) {
clauses.push('created_at <= ?');
values.push(createdAtEnd);
}
return {
whereSql: clauses.length ? `WHERE ${clauses.join(' AND ')}` : '',
values,
};
}
function mapProductCodeRow(row) {
return {
id: row.id,
operationCenter: row.operationCenter,
centerAbbr: row.centerAbbr,
categoryAbbr: row.categoryAbbr,
serialNumber: row.serialNumber,
currentSequence: row.currentSequence,
styleCode: row.styleCode,
productCode: row.productCode,
productName: row.productName,
colorSpec: row.colorSpec,
brand: row.brand,
categoryName: row.categoryName,
virtualCategory: row.virtualCategory,
productTag: row.productTag,
productStatus: row.productStatus,
inventorySync: row.inventorySync,
productAttribute: row.productAttribute,
weight: row.weight,
lengthCm: row.lengthCm,
widthCm: row.widthCm,
heightCm: row.heightCm,
volume: row.volume,
unit: row.unit,
otherAttr7: row.otherAttr7,
otherAttr5: row.otherAttr5,
createdByName: row.createdByName,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async function getProductCodeMeta() {
const createdByRows = await database.local.query(
`SELECT created_by_name AS createdByName
FROM ${PRODUCT_CODE_TABLE}
WHERE TRIM(COALESCE(created_by_name, '')) <> ''
GROUP BY created_by_name
ORDER BY MAX(created_at) DESC, created_by_name ASC`
);
return {
templateFields: TEMPLATE_FIELDS,
operationCenters: OPERATION_CENTER_OPTIONS,
categories: CATEGORY_OPTIONS,
createdByNames: createdByRows.map((row) => normalizeString(row.createdByName)).filter(Boolean),
fixedValues: FIXED_VALUES,
};
}
function formatExportDateTime(value) {
if (!value) {
return '';
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return String(value);
}
const year = date.getFullYear();
const month = padNumber(date.getMonth() + 1, 2);
const day = padNumber(date.getDate(), 2);
const hours = padNumber(date.getHours(), 2);
const minutes = padNumber(date.getMinutes(), 2);
const seconds = padNumber(date.getSeconds(), 2);
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
async function queryProductCodeRows(filters = {}) {
const { whereSql, values } = buildWhereClause(filters);
const rows = await database.local.query(
`SELECT
id,
operation_center AS operationCenter,
center_abbr AS centerAbbr,
category_abbr AS categoryAbbr,
serial_number AS serialNumber,
current_sequence AS currentSequence,
style_code AS styleCode,
product_code AS productCode,
product_name AS productName,
color_spec AS colorSpec,
brand,
category_name AS categoryName,
virtual_category AS virtualCategory,
product_tag AS productTag,
product_status AS productStatus,
inventory_sync AS inventorySync,
product_attribute AS productAttribute,
weight,
length_cm AS lengthCm,
width_cm AS widthCm,
height_cm AS heightCm,
volume,
unit,
other_attr7 AS otherAttr7,
other_attr5 AS otherAttr5,
created_by_name AS createdByName,
created_at AS createdAt,
updated_at AS updatedAt
FROM ${PRODUCT_CODE_TABLE}
${whereSql}
ORDER BY created_at DESC, id DESC`,
values
);
return rows.map(mapProductCodeRow);
}
function buildProductCodeImportTemplateBuffer() {
const headers = TEMPLATE_FIELDS.map((field) => field.title);
const rules = TEMPLATE_FIELDS.map((field) => field.rule);
const example = [
'事业一部',
'A1',
'DNZ',
1,
1,
'DF-A1DNZ0001',
'DF-A1DNZ000101',
'示例电脑桌',
'原木色',
FIXED_VALUES.brand,
'住宅家具 - 电脑桌',
FIXED_VALUES.virtualCategory,
FIXED_VALUES.productTag,
FIXED_VALUES.productStatus,
FIXED_VALUES.inventorySync,
FIXED_VALUES.productAttribute,
12.5,
'',
'',
'',
'',
FIXED_VALUES.unit,
'DF-A1DNZ000101',
FIXED_VALUES.otherAttr5,
];
const templateSheet = xlsx.utils.aoa_to_sheet([rules, headers, example]);
templateSheet['!cols'] = headers.map((title) => ({ wch: Math.max(12, Math.min(28, title.length * 2 + 8)) }));
const helperRows = [
['类目', '缩写', '', '运营中心', '缩写'],
];
const maxRows = Math.max(CATEGORY_OPTIONS.length, OPERATION_CENTER_OPTIONS.length);
for (let index = 0; index < maxRows; index += 1) {
helperRows.push([
CATEGORY_OPTIONS[index]?.name || '',
CATEGORY_OPTIONS[index]?.abbreviation || '',
'',
OPERATION_CENTER_OPTIONS[index]?.name || '',
OPERATION_CENTER_OPTIONS[index]?.abbreviation || '',
]);
}
const helperSheet = xlsx.utils.aoa_to_sheet(helperRows);
helperSheet['!cols'] = [{ wch: 36 }, { wch: 10 }, { wch: 4 }, { wch: 18 }, { wch: 10 }];
const workbook = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(workbook, templateSheet, '测款导入模板');
xlsx.utils.book_append_sheet(workbook, helperSheet, '辅助表-勿动');
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
function buildProductCodeBatchImportTemplateBuffer() {
const headers = BATCH_IMPORT_TEMPLATE_FIELDS.map((field) => field.title);
const rules = BATCH_IMPORT_TEMPLATE_FIELDS.map((field) => field.rule);
const examples = [
['事业一部', '住宅家具 - 电脑桌', 1, '80cm电脑桌-原木色', '原木色', 120, 60, 75, 12.5],
['事业一部', '住宅家具 - 电脑桌', 1, '80cm电脑桌-胡桃色', '胡桃色', '', '', '', 12.8],
['事业二部', '住宅家具 - 书架', 1, '五层书架-白色', '白色', 80, 30, 180, 16],
];
const sheet = xlsx.utils.aoa_to_sheet([rules, headers, ...examples]);
sheet['!cols'] = headers.map((title, index) => ({
wch: Math.max(12, Math.min(26, title.length * 2 + (index === 1 ? 12 : 8))),
}));
const helperRows = [
['分类', '缩写', '', '运营中心', '缩写'],
];
const maxRows = Math.max(CATEGORY_OPTIONS.length, OPERATION_CENTER_OPTIONS.length);
for (let index = 0; index < maxRows; index += 1) {
helperRows.push([
CATEGORY_OPTIONS[index]?.name || '',
CATEGORY_OPTIONS[index]?.abbreviation || '',
'',
OPERATION_CENTER_OPTIONS[index]?.name || '',
OPERATION_CENTER_OPTIONS[index]?.abbreviation || '',
]);
}
const helperSheet = xlsx.utils.aoa_to_sheet(helperRows);
helperSheet['!cols'] = [{ wch: 36 }, { wch: 10 }, { wch: 4 }, { wch: 18 }, { wch: 10 }];
const workbook = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(workbook, sheet, '批量新增模板');
xlsx.utils.book_append_sheet(workbook, helperSheet, '辅助表-勿动');
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
}
function getCellText(value) {
if (value === null || value === undefined) {
return '';
}
if (value instanceof Date) {
return value.toISOString();
}
return String(value).trim();
}
function parseImportNumber(value, fieldName, { required = false, min = null } = {}) {
const raw = getCellText(value).replace(/,/g, '');
if (!raw) {
if (required) {
const error = new Error(`${fieldName}不能为空`);
error.status = 400;
throw error;
}
return null;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
const error = new Error(`${fieldName}必须是数字`);
error.status = 400;
throw error;
}
if (min !== null && parsed < min) {
const error = new Error(`${fieldName}不能小于 ${min}`);
error.status = 400;
throw error;
}
return parsed;
}
function selectImportSheet(workbook) {
const preferredNames = ['编码模板', '测款导入模板', '商品编码模板'];
const sheetName = preferredNames.find((name) => workbook.SheetNames.includes(name)) || workbook.SheetNames[0];
return {
sheetName,
sheet: workbook.Sheets[sheetName],
};
}
function selectBatchImportSheet(workbook) {
const preferredNames = ['批量新增模板', '批量新增商品模板', '测款批量新增模板'];
const sheetName = preferredNames.find((name) => workbook.SheetNames.includes(name)) || workbook.SheetNames[0];
return {
sheetName,
sheet: workbook.Sheets[sheetName],
};
}
function findHeaderRow(rows) {
return rows.findIndex((row) => {
const titles = new Set((row || []).map((value) => getCellText(value)));
return titles.has('运营中心') && titles.has('商品编码') && titles.has('分类');
});
}
function findBatchImportHeaderRow(rows) {
return rows.findIndex((row) => {
const titles = new Set((row || []).map((value) => getCellText(value)));
return titles.has('运营中心')
&& titles.has('分类')
&& titles.has('商品序号')
&& titles.has('商品名称')
&& titles.has('颜色')
&& titles.has('长cm')
&& titles.has('宽cm')
&& titles.has('高cm');
});
}
function readImportRows(buffer) {
const workbook = xlsx.read(buffer, {
type: 'buffer',
cellDates: true,
cellFormula: true,
raw: true,
});
const { sheetName, sheet } = selectImportSheet(workbook);
if (!sheet) {
const error = new Error('未识别到可导入的工作表');
error.status = 400;
throw error;
}
const rows = xlsx.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true });
const headerRowIndex = findHeaderRow(rows);
if (headerRowIndex < 0) {
const error = new Error('导入文件缺少表头行,请使用“测款导入模板”或原“编码模板”格式');
error.status = 400;
throw error;
}
const headers = rows[headerRowIndex].map((value) => getCellText(value));
const dataRows = rows.slice(headerRowIndex + 1);
return {
sheetName,
headers,
headerRowIndex,
dataRows,
};
}
function readBatchImportRows(buffer) {
const workbook = xlsx.read(buffer, {
type: 'buffer',
cellDates: true,
cellFormula: true,
raw: true,
});
const { sheetName, sheet } = selectBatchImportSheet(workbook);
if (!sheet) {
const error = new Error('未识别到可导入的工作表');
error.status = 400;
throw error;
}
const rows = xlsx.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: true });
const headerRowIndex = findBatchImportHeaderRow(rows);
if (headerRowIndex < 0) {
const error = new Error('导入文件缺少批量新增表头,请使用“批量新增模板”');
error.status = 400;
throw error;
}
const headers = rows[headerRowIndex].map((value) => getCellText(value));
const dataRows = rows.slice(headerRowIndex + 1);
return {
sheetName,
headers,
dataRows,
};
}
function buildHeaderIndexes(headers) {
return headers.reduce((acc, title, index) => {
if (fieldTitleMap.has(title)) {
acc[fieldTitleMap.get(title).key] = index;
}
return acc;
}, {});
}
function buildBatchImportHeaderIndexes(headers) {
return headers.reduce((acc, title, index) => {
if (batchImportFieldTitleMap.has(title)) {
acc[batchImportFieldTitleMap.get(title).key] = index;
}
return acc;
}, {});
}
function getImportValue(row, indexes, key) {
const index = indexes[key];
return index === undefined ? '' : row[index];
}
function createProductCodeImportValidationError({ sheetName, headers, rowErrors }) {
const error = new Error(`导入校验失败,共 ${rowErrors.length} 行存在问题`);
error.status = 400;
error.code = 'PRODUCT_CODE_IMPORT_VALIDATION_FAILED';
error.details = {
sheetName,
headers,
rowErrors,
};
return error;
}
function normalizeImportedRows(buffer, user = {}) {
const { sheetName, headers, dataRows, headerRowIndex } = readImportRows(buffer);
const indexes = buildHeaderIndexes(headers);
const uploadStyleCounters = new Map();
const missingProductCodeRows = [];
const normalizedRows = [];
const seenProductCodes = new Set();
const rowErrors = [];
dataRows.forEach((row, rowOffset) => {
const rowNumber = headerRowIndex + rowOffset + 2;
const hasValue = row.some((value) => getCellText(value));
if (!hasValue) {
return;
}
try {
const operationCenter = getCellText(getImportValue(row, indexes, 'operationCenter'));
const categoryName = getCellText(getImportValue(row, indexes, 'categoryName'));
const productName = getCellText(getImportValue(row, indexes, 'productName'));
const colorSpec = getCellText(getImportValue(row, indexes, 'colorSpec'));
if (!operationCenter || !categoryName || !productName || !colorSpec) {
const error = new Error(`第 ${rowNumber} 行缺少必填字段:运营中心、分类、商品名称、颜色及规格`);
error.status = 400;
throw error;
}
const center = resolveOperationCenter(operationCenter);
const category = resolveCategory(categoryName);
let styleCode = getCellText(getImportValue(row, indexes, 'styleCode')).toUpperCase();
let productCode = getCellText(getImportValue(row, indexes, 'productCode')).toUpperCase();
let serialNumber = parseImportNumber(getImportValue(row, indexes, 'serialNumber'), `第 ${rowNumber} 行序号`, { min: 1 });
if (!styleCode && productCode) {
styleCode = deriveStyleCodeFromProductCode(productCode);
}
if (!serialNumber && styleCode) {
serialNumber = parseSerialFromStyleCode(styleCode);
}
if (!serialNumber) {
const error = new Error(`第 ${rowNumber} 行缺少序号或有效款式编码`);
error.status = 400;
throw error;
}
const expectedStyleCode = buildStyleCode(center.abbreviation, category.abbreviation, serialNumber);
if (styleCode && styleCode !== expectedStyleCode) {
const error = new Error(`第 ${rowNumber} 行款式编码与运营中心、分类、序号不匹配,应为 ${expectedStyleCode}`);
error.status = 400;
throw error;
}
styleCode = expectedStyleCode;
if (productCode) {
const derivedStyleCode = deriveStyleCodeFromProductCode(productCode).toUpperCase();
if (derivedStyleCode !== expectedStyleCode) {
const error = new Error(`第 ${rowNumber} 行商品编码与运营中心、分类、序号不匹配,应以 ${expectedStyleCode} 开头`);
error.status = 400;
throw error;
}
const suffix = Number(productCode.slice(-2));
if (!Number.isInteger(suffix) || suffix < 1) {
const error = new Error(`第 ${rowNumber} 行商品编码后两位必须是大于 0 的 SKU 序号`);
error.status = 400;
throw error;
}
}
if (!productCode) {
const currentCount = uploadStyleCounters.get(styleCode) || 0;
const nextCount = currentCount + 1;
uploadStyleCounters.set(styleCode, nextCount);
productCode = buildProductCode(styleCode, nextCount);
missingProductCodeRows.push({ styleCode, productCode });
} else {
const suffix = Number(productCode.slice(-2));
if (Number.isFinite(suffix) && suffix > (uploadStyleCounters.get(styleCode) || 0)) {
uploadStyleCounters.set(styleCode, suffix);
}
}
if (seenProductCodes.has(productCode)) {
const error = new Error(`第 ${rowNumber} 行商品编码 ${productCode} 在文件内重复`);
error.status = 400;
throw error;
}
seenProductCodes.add(productCode);
const lengthCm = parseImportNumber(getImportValue(row, indexes, 'lengthCm'), `第 ${rowNumber} 行长`, { min: 0.01 });
const widthCm = parseImportNumber(getImportValue(row, indexes, 'widthCm'), `第 ${rowNumber} 行宽`, { min: 0.01 });
const heightCm = parseImportNumber(getImportValue(row, indexes, 'heightCm'), `第 ${rowNumber} 行高`, { min: 0.01 });
const weight = parseImportNumber(getImportValue(row, indexes, 'weight'), `第 ${rowNumber} 行重量`, { min: 0 });
const volume = parseImportNumber(getImportValue(row, indexes, 'volume'), `第 ${rowNumber} 行体积`, { min: 0 })
?? buildVolume(lengthCm, widthCm, heightCm);
const currentSequence = parseImportNumber(getImportValue(row, indexes, 'currentSequence'), `第 ${rowNumber} 行目前编写序号`, { min: 0 })
|| serialNumber;
normalizedRows.push({
operation_center: center.name,
center_abbr: center.abbreviation,
category_abbr: category.abbreviation,
serial_number: serialNumber,
current_sequence: currentSequence,
style_code: styleCode,
product_code: productCode,
product_name: productName,
color_spec: colorSpec,
brand: getCellText(getImportValue(row, indexes, 'brand')) || FIXED_VALUES.brand,
category_name: category.name,
virtual_category: getCellText(getImportValue(row, indexes, 'virtualCategory')) || FIXED_VALUES.virtualCategory,
product_tag: getCellText(getImportValue(row, indexes, 'productTag')) || FIXED_VALUES.productTag,
product_status: getCellText(getImportValue(row, indexes, 'productStatus')) || FIXED_VALUES.productStatus,
inventory_sync: getCellText(getImportValue(row, indexes, 'inventorySync')) || FIXED_VALUES.inventorySync,
product_attribute: getCellText(getImportValue(row, indexes, 'productAttribute')) || FIXED_VALUES.productAttribute,
weight,
length_cm: lengthCm,
width_cm: widthCm,
height_cm: heightCm,
volume,
unit: getCellText(getImportValue(row, indexes, 'unit')) || FIXED_VALUES.unit,
other_attr7: getCellText(getImportValue(row, indexes, 'otherAttr7')) || productCode,
other_attr5: getCellText(getImportValue(row, indexes, 'otherAttr5')) || FIXED_VALUES.otherAttr5,
created_by_user_id: user.sub || null,
created_by_username: user.username || '',
created_by_name: user.name || user.username || '',
created_department_id: user.departmentId || null,
created_department_name: user.departmentName || '',
});
} catch (error) {
rowErrors.push({
excelRowNumber: rowNumber,
errorMessage: error.message || `第 ${rowNumber} 行导入失败`,
rowData: headers.reduce((acc, header, index) => {
acc[header] = getCellText(row[index]);
return acc;
}, {}),
});
}
});
if (rowErrors.length) {
throw createProductCodeImportValidationError({
sheetName,
headers,
rowErrors,
});
}
if (normalizedRows.length === 0) {
const error = new Error('导入文件没有可导入的数据行');
error.status = 400;
throw error;
}
return {
sheetName,
headers,
rows: normalizedRows,
generatedProductCodeCount: missingProductCodeRows.length,
};
}
function normalizeBatchImportProducts(buffer) {
const { sheetName, headers, dataRows } = readBatchImportRows(buffer);
const indexes = buildBatchImportHeaderIndexes(headers);
const groupMap = new Map();
const groupOrder = [];
dataRows.forEach((row, rowOffset) => {
const rowNumber = rowOffset + 1;
const hasValue = row.some((value) => getCellText(value));
if (!hasValue) {
return;
}
const operationCenter = getCellText(getImportValue(row, indexes, 'operationCenter'));
if (!operationCenter) {
const error = new Error(`第 ${rowNumber} 行运营中心不能为空`);
error.status = 400;
throw error;
}
let center;
try {
center = resolveOperationCenter(operationCenter);
} catch (_error) {
const error = new Error(`第 ${rowNumber} 行运营中心无效,请使用模板中的选项填写`);
error.status = 400;
throw error;
}
const categoryName = getCellText(getImportValue(row, indexes, 'categoryName'));
if (!categoryName) {
const error = new Error(`第 ${rowNumber} 行分类不能为空`);
error.status = 400;
throw error;
}
let category;
try {
category = resolveCategory(categoryName);
} catch (_error) {
const error = new Error(`第 ${rowNumber} 行分类无效,请使用模板中的选项填写`);
error.status = 400;
throw error;
}
const groupKey = `${center.name}|||${category.name}`;
if (!groupMap.has(groupKey)) {
groupMap.set(groupKey, {
operationCenter: center.name,
categoryName: category.name,
productMap: new Map(),
productOrder: [],
skuCount: 0,
});
groupOrder.push(groupKey);
}
const group = groupMap.get(groupKey);
const productIndex = parseImportNumber(getImportValue(row, indexes, 'productIndex'), `第 ${rowNumber} 行商品序号`, {
required: true,
min: 1,
});
if (!Number.isInteger(productIndex)) {
const error = new Error(`第 ${rowNumber} 行商品序号必须是整数`);
error.status = 400;
throw error;
}
const productName = getCellText(getImportValue(row, indexes, 'productName'));
if (!productName) {
const error = new Error(`第 ${rowNumber} 行商品名称不能为空`);
error.status = 400;
throw error;
}
const colorName = getCellText(getImportValue(row, indexes, 'colorName'));
if (!colorName) {
const error = new Error(`第 ${rowNumber} 行颜色不能为空`);
error.status = 400;
throw error;
}
const skuItem = {
productName,
colorName,
lengthCm: parseImportNumber(getImportValue(row, indexes, 'lengthCm'), `第 ${rowNumber} 行长`, { min: 0.01 }),
widthCm: parseImportNumber(getImportValue(row, indexes, 'widthCm'), `第 ${rowNumber} 行宽`, { min: 0.01 }),
heightCm: parseImportNumber(getImportValue(row, indexes, 'heightCm'), `第 ${rowNumber} 行高`, { min: 0.01 }),
weight: parseImportNumber(getImportValue(row, indexes, 'weight'), `第 ${rowNumber} 行重量`, { min: 0 }),
};
if (!group.productMap.has(productIndex)) {
group.productMap.set(productIndex, {
skuItems: [],
});
group.productOrder.push(productIndex);
}
const product = group.productMap.get(productIndex);
product.skuItems.push(skuItem);
group.skuCount += 1;
});
if (groupOrder.length === 0) {
const error = new Error('导入文件没有可导入的商品数据');
error.status = 400;
throw error;
}
let totalProductCount = 0;
let totalSkuCount = 0;
const groups = groupOrder.map((groupKey) => {
const group = groupMap.get(groupKey);
const products = group.productOrder.map((key) => group.productMap.get(key));
if (products.length > MAX_BATCH_PRODUCTS) {
const error = new Error(`${group.operationCenter} / ${group.categoryName} 单次最多批量新增 ${MAX_BATCH_PRODUCTS} 个商品`);
error.status = 400;
throw error;
}
if (group.skuCount > MAX_BATCH_SKUS) {
const error = new Error(`${group.operationCenter} / ${group.categoryName} 单次最多批量新增 ${MAX_BATCH_SKUS} 个商品规格`);
error.status = 400;
throw error;
}
totalProductCount += products.length;
totalSkuCount += group.skuCount;
return {
operationCenter: group.operationCenter,
categoryName: group.categoryName,
products,
productCount: products.length,
skuCount: group.skuCount,
};
});
return {
sheetName,
groups,
groupCount: groups.length,
productCount: totalProductCount,
skuCount: totalSkuCount,
};
}
async function importProductCodeWorkbook(buffer, user = {}) {
const { sheetName, rows, generatedProductCodeCount } = normalizeImportedRows(buffer, user);
const productCodes = rows.map((row) => row.product_code);
const existingRows = await database.local.query(
`SELECT product_code AS productCode
FROM ${PRODUCT_CODE_TABLE}
WHERE product_code IN (?)`,
[productCodes]
);
const existingSet = new Set(existingRows.map((row) => row.productCode));
const columns = [
'operation_center',
'center_abbr',
'category_abbr',
'serial_number',
'current_sequence',
'style_code',
'product_code',
'product_name',
'color_spec',
'brand',
'category_name',
'virtual_category',
'product_tag',
'product_status',
'inventory_sync',
'product_attribute',
'weight',
'length_cm',
'width_cm',
'height_cm',
'volume',
'unit',
'other_attr7',
'other_attr5',
'created_by_user_id',
'created_by_username',
'created_by_name',
'created_department_id',
'created_department_name',
];
const updateColumns = columns.filter((column) => !column.startsWith('created_by') && column !== 'product_code');
const placeholders = columns.map(() => '?').join(', ');
const quotedColumns = columns.map((column) => `\`${column}\``).join(', ');
const updateClause = updateColumns.map((column) => `\`${column}\` = VALUES(\`${column}\`)`).join(', ');
const groups = new Set();
for (const row of rows) {
await database.local.query(
`INSERT INTO ${PRODUCT_CODE_TABLE} (${quotedColumns})
VALUES (${placeholders})
ON DUPLICATE KEY UPDATE ${updateClause}, updated_at = CURRENT_TIMESTAMP`,
columns.map((column) => row[column])
);
groups.add(`${row.center_abbr}|||${row.category_abbr}`);
}
for (const groupKey of groups) {
const [centerAbbr, categoryAbbr] = groupKey.split('|||');
await database.local.query(
`UPDATE ${PRODUCT_CODE_TABLE} target
JOIN (
SELECT center_abbr, category_abbr, MAX(serial_number) AS max_serial
FROM ${PRODUCT_CODE_TABLE}
WHERE center_abbr = ? AND category_abbr = ?
GROUP BY center_abbr, category_abbr
) seq ON target.center_abbr = seq.center_abbr AND target.category_abbr = seq.category_abbr
SET target.current_sequence = seq.max_serial
WHERE target.center_abbr = ? AND target.category_abbr = ?`,
[centerAbbr, categoryAbbr, centerAbbr, categoryAbbr]
);
await syncProductCodeStyleRegistryGroup(centerAbbr, categoryAbbr);
}
const updatedCount = rows.filter((row) => existingSet.has(row.product_code)).length;
return {
sheetName,
totalRows: rows.length,
insertedRows: rows.length - updatedCount,
updatedRows: updatedCount,
generatedProductCodeCount,
};
}
async function importProductCodeBatchWorkbook(buffer, _payload = {}, user = {}) {
const { sheetName, groups, groupCount, productCount, skuCount } = normalizeBatchImportProducts(buffer);
const results = [];
for (const group of groups) {
const result = await createProductCodeBatch({
operationCenter: group.operationCenter,
categoryName: group.categoryName,
products: group.products,
}, user);
results.push({
operationCenter: group.operationCenter,
categoryName: group.categoryName,
productCount: result.productCount,
skuCount: result.skuCount,
startSerial: result.startSerial,
endSerial: result.endSerial,
});
}
return {
sheetName,
groupCount,
importedProductCount: productCount,
importedSkuCount: skuCount,
groups: results,
};
}
async function deleteAllProductCodeItems() {
const connection = await database.local.pool.getConnection();
try {
const [itemSummaryRows] = await connection.query(
`SELECT
COUNT(*) AS totalRows,
COUNT(DISTINCT style_code) AS styleCount
FROM ${PRODUCT_CODE_TABLE}`
);
const [styleSummaryRows] = await connection.query(
`SELECT COUNT(*) AS totalStyles
FROM ${PRODUCT_CODE_STYLE_TABLE}`
);
await connection.beginTransaction();
const [itemDeleteResult] = await connection.query(`DELETE FROM ${PRODUCT_CODE_TABLE}`);
const [styleDeleteResult] = await connection.query(`DELETE FROM ${PRODUCT_CODE_STYLE_TABLE}`);
await connection.commit();
return {
deletedRows: Number(itemDeleteResult.affectedRows || itemSummaryRows[0]?.totalRows || 0),
deletedStyleRows: Number(styleDeleteResult.affectedRows || styleSummaryRows[0]?.totalStyles || 0),
totalRows: Number(itemSummaryRows[0]?.totalRows || 0),
styleCount: Number(itemSummaryRows[0]?.styleCount || 0),
};
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures so the original error can be returned.
}
throw error;
} finally {
connection.release();
}
}
async function deleteProductCodeItemsByCodes(productCodes = []) {
const normalizedCodes = Array.from(new Set(
(Array.isArray(productCodes) ? productCodes : [])
.map((item) => normalizeString(item))
.filter(Boolean)
));
if (!normalizedCodes.length) {
const error = new Error('请选择要删除的商品编码');
error.status = 400;
throw error;
}
if (normalizedCodes.length > MAX_DELETE_PRODUCT_CODES) {
const error = new Error(`单次最多删除 ${MAX_DELETE_PRODUCT_CODES} 个商品编码`);
error.status = 400;
throw error;
}
const connection = await database.local.pool.getConnection();
try {
await connection.beginTransaction();
const [matchedRows] = await connection.query(
`SELECT id, product_code AS productCode, style_code AS styleCode
FROM ${PRODUCT_CODE_TABLE}
WHERE product_code IN (?)
FOR UPDATE`,
[normalizedCodes]
);
const matchedCodes = matchedRows.map((row) => normalizeString(row.productCode));
const matchedCodeSet = new Set(matchedCodes);
const styleCodes = Array.from(new Set(
matchedRows
.map((row) => normalizeString(row.styleCode))
.filter(Boolean)
));
let deletedRows = 0;
let deletedStyleRows = 0;
if (matchedCodes.length) {
const [itemDeleteResult] = await connection.query(
`DELETE FROM ${PRODUCT_CODE_TABLE}
WHERE product_code IN (?)`,
[matchedCodes]
);
deletedRows = Number(itemDeleteResult.affectedRows || matchedCodes.length || 0);
if (styleCodes.length) {
const [styleDeleteResult] = await connection.query(
`DELETE registry
FROM ${PRODUCT_CODE_STYLE_TABLE} registry
LEFT JOIN ${PRODUCT_CODE_TABLE} items
ON items.style_code = registry.style_code
WHERE registry.style_code IN (?)
AND items.id IS NULL`,
[styleCodes]
);
deletedStyleRows = Number(styleDeleteResult.affectedRows || 0);
}
}
await connection.commit();
return {
requestedCodes: normalizedCodes,
matchedCodes,
notFoundCodes: normalizedCodes.filter((code) => !matchedCodeSet.has(code)),
deletedRows,
deletedStyleRows,
};
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures so the original error can be returned.
}
throw error;
} finally {
connection.release();
}
}
async function getProductCodeSequence({ operationCenter, categoryName }) {
const center = resolveOperationCenter(operationCenter);
const category = resolveCategory(categoryName);
const rows = await database.local.query(
`SELECT COALESCE(MAX(serial_number), 0) AS currentSequence
FROM ${PRODUCT_CODE_TABLE}
WHERE center_abbr = ? AND category_abbr = ?`,
[center.abbreviation, category.abbreviation]
);
const currentSequence = Number(rows[0]?.currentSequence || 0);
const nextSerial = currentSequence + 1;
const styleCode = buildStyleCode(center.abbreviation, category.abbreviation, nextSerial);
return {
operationCenter: center.name,
centerAbbr: center.abbreviation,
categoryName: category.name,
categoryAbbr: category.abbreviation,
currentSequence,
nextSerial,
styleCode,
firstProductCode: buildProductCode(styleCode, 1),
};
}
async function listProductCodes(filters = {}) {
const page = parsePositiveInt(filters.page, 1, 100000);
const pageSize = parsePositiveInt(filters.pageSize, 20, 100);
const offset = (page - 1) * pageSize;
const { whereSql, values } = buildWhereClause(filters);
const rows = await database.local.query(
`SELECT
id,
operation_center AS operationCenter,
center_abbr AS centerAbbr,
category_abbr AS categoryAbbr,
serial_number AS serialNumber,
current_sequence AS currentSequence,
style_code AS styleCode,
product_code AS productCode,
product_name AS productName,
color_spec AS colorSpec,
brand,
category_name AS categoryName,
virtual_category AS virtualCategory,
product_tag AS productTag,
product_status AS productStatus,
inventory_sync AS inventorySync,
product_attribute AS productAttribute,
weight,
length_cm AS lengthCm,
width_cm AS widthCm,
height_cm AS heightCm,
volume,
unit,
other_attr7 AS otherAttr7,
other_attr5 AS otherAttr5,
created_by_name AS createdByName,
created_at AS createdAt,
updated_at AS updatedAt
FROM ${PRODUCT_CODE_TABLE}
${whereSql}
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?`,
[...values, pageSize, offset]
);
const countRows = await database.local.query(
`SELECT COUNT(*) AS total FROM ${PRODUCT_CODE_TABLE} ${whereSql}`,
values
);
const summaryRows = await database.local.query(
`SELECT
COUNT(*) AS totalRows,
COUNT(DISTINCT style_code) AS styleCount,
COUNT(DISTINCT category_name) AS categoryCount,
COUNT(DISTINCT operation_center) AS centerCount,
MAX(created_at) AS latestCreatedAt
FROM ${PRODUCT_CODE_TABLE}`
);
return {
rows: rows.map(mapProductCodeRow),
total: Number(countRows[0]?.total || 0),
page,
pageSize,
summary: {
totalRows: Number(summaryRows[0]?.totalRows || 0),
styleCount: Number(summaryRows[0]?.styleCount || 0),
categoryCount: Number(summaryRows[0]?.categoryCount || 0),
centerCount: Number(summaryRows[0]?.centerCount || 0),
latestCreatedAt: summaryRows[0]?.latestCreatedAt || null,
},
};
}
async function buildProductCodeExportBuffer(filters = {}) {
const rows = await queryProductCodeRows(filters);
const headerRow = PRODUCT_CODE_EXPORT_COLUMNS.map((column) => column.title);
const bodyRows = rows.map((row) => PRODUCT_CODE_EXPORT_COLUMNS.map((column) => {
const value = row[column.key];
if (column.key === 'createdAt') {
return formatExportDateTime(value);
}
return value === null || value === undefined ? '' : value;
}));
const worksheet = xlsx.utils.aoa_to_sheet([headerRow, ...bodyRows]);
worksheet['!cols'] = PRODUCT_CODE_EXPORT_COLUMNS.map((column) => ({
wch: Math.max(12, Math.min(28, column.title.length * 2 + 8)),
}));
const workbook = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(workbook, worksheet, '测款数据');
return {
buffer: xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' }),
totalRows: rows.length,
};
}
async function createProductCodeBatch(payload = {}, user = {}) {
const center = resolveOperationCenter(payload.operationCenter);
const category = resolveCategory(payload.categoryName);
const products = normalizeCreateProducts(payload);
const lockName = `product-code:${center.abbreviation}:${category.abbreviation}`;
const connection = await database.local.pool.getConnection();
let lockAcquired = false;
try {
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 10) AS locked', [lockName]);
lockAcquired = Number(lockRows[0]?.locked || 0) === 1;
if (!lockAcquired) {
const error = new Error('当前分类正在生成编码,请稍后重试');
error.status = 409;
throw error;
}
await connection.beginTransaction();
const [sequenceRows] = await connection.query(
`SELECT COALESCE(MAX(serial_number), 0) AS currentSequence
FROM ${PRODUCT_CODE_TABLE}
WHERE center_abbr = ? AND category_abbr = ?`,
[center.abbreviation, category.abbreviation]
);
const currentSequence = Number(sequenceRows[0]?.currentSequence || 0);
const startSerial = currentSequence + 1;
const endSerial = currentSequence + products.length;
const createdProducts = [];
const createdRows = [];
for (let productIndex = 0; productIndex < products.length; productIndex += 1) {
const product = products[productIndex];
const serialNumber = startSerial + productIndex;
const styleCode = buildStyleCode(center.abbreviation, category.abbreviation, serialNumber);
const styleRow = buildProductCodeStyleRow({
center,
category,
serialNumber,
styleCode,
user,
});
const styleColumns = Object.keys(styleRow);
const stylePlaceholders = styleColumns.map(() => '?').join(', ');
const quotedStyleColumns = styleColumns.map((column) => `\`${column}\``).join(', ');
await connection.query(
`INSERT INTO ${PRODUCT_CODE_STYLE_TABLE} (${quotedStyleColumns}) VALUES (${stylePlaceholders})`,
Object.values(styleRow)
);
const productRows = [];
for (let skuIndex = 0; skuIndex < product.skuItems.length; skuIndex += 1) {
const sku = product.skuItems[skuIndex];
const productCode = buildProductCode(styleCode, skuIndex + 1);
const colorSpec = buildColorSpec(sku);
const rowData = {
operation_center: center.name,
center_abbr: center.abbreviation,
category_abbr: category.abbreviation,
serial_number: serialNumber,
current_sequence: endSerial,
style_code: styleCode,
product_code: productCode,
product_name: sku.productName,
color_spec: colorSpec,
brand: FIXED_VALUES.brand,
category_name: category.name,
virtual_category: FIXED_VALUES.virtualCategory,
product_tag: FIXED_VALUES.productTag,
product_status: FIXED_VALUES.productStatus,
inventory_sync: FIXED_VALUES.inventorySync,
product_attribute: FIXED_VALUES.productAttribute,
weight: sku.weight,
length_cm: sku.lengthCm,
width_cm: sku.widthCm,
height_cm: sku.heightCm,
volume: sku.volume,
unit: FIXED_VALUES.unit,
other_attr7: productCode,
other_attr5: FIXED_VALUES.otherAttr5,
created_by_user_id: user.sub || null,
created_by_username: user.username || '',
created_by_name: user.name || user.username || '',
created_department_id: user.departmentId || null,
created_department_name: user.departmentName || '',
};
const columns = Object.keys(rowData);
const placeholders = columns.map(() => '?').join(', ');
const quotedColumns = columns.map((column) => `\`${column}\``).join(', ');
const [insertResult] = await connection.query(
`INSERT INTO ${PRODUCT_CODE_TABLE} (${quotedColumns}) VALUES (${placeholders})`,
Object.values(rowData)
);
const createdRow = {
id: insertResult.insertId,
operationCenter: center.name,
centerAbbr: center.abbreviation,
categoryAbbr: category.abbreviation,
serialNumber,
currentSequence: endSerial,
styleCode,
productCode,
productName: sku.productName,
colorSpec,
brand: FIXED_VALUES.brand,
categoryName: category.name,
virtualCategory: FIXED_VALUES.virtualCategory,
productTag: FIXED_VALUES.productTag,
productStatus: FIXED_VALUES.productStatus,
inventorySync: FIXED_VALUES.inventorySync,
productAttribute: FIXED_VALUES.productAttribute,
weight: sku.weight,
lengthCm: sku.lengthCm,
widthCm: sku.widthCm,
heightCm: sku.heightCm,
volume: sku.volume,
unit: FIXED_VALUES.unit,
otherAttr7: productCode,
otherAttr5: FIXED_VALUES.otherAttr5,
};
productRows.push(createdRow);
createdRows.push(createdRow);
}
createdProducts.push({
serialNumber,
styleCode,
skuCount: productRows.length,
rows: productRows,
});
}
await connection.query(
`UPDATE ${PRODUCT_CODE_TABLE}
SET current_sequence = ?
WHERE center_abbr = ? AND category_abbr = ?`,
[endSerial, center.abbreviation, category.abbreviation]
);
await connection.commit();
return {
currentSequence,
startSerial,
endSerial,
productCount: createdProducts.length,
skuCount: createdRows.length,
products: createdProducts,
rows: createdRows,
};
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures so the original error can be returned.
}
if (isProductCodeConflictError(error)) {
const conflictError = new Error('编码已存在或序号已被其它操作占用,请刷新后重试');
conflictError.status = 409;
throw conflictError;
}
throw error;
} finally {
if (lockAcquired) {
try {
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
} catch (_releaseError) {
// MySQL releases named locks when the connection closes; this is best-effort cleanup.
}
}
connection.release();
}
}
async function createProductCodes(payload = {}, user = {}) {
const batchResult = await createProductCodeBatch({
operationCenter: payload.operationCenter,
categoryName: payload.categoryName,
products: [{
productName: payload.productName,
skuItems: payload.skuItems,
}],
}, user);
const product = batchResult.products[0];
return {
currentSequence: batchResult.currentSequence,
serialNumber: product.serialNumber,
styleCode: product.styleCode,
rows: product.rows,
};
}
module.exports = {
PRODUCT_CODE_TABLE,
getProductCodeMeta,
buildProductCodeImportTemplateBuffer,
buildProductCodeBatchImportTemplateBuffer,
buildProductCodeExportBuffer,
getProductCodeSequence,
listProductCodes,
createProductCodes,
createProductCodeBatch,
importProductCodeWorkbook,
importProductCodeBatchWorkbook,
deleteAllProductCodeItems,
deleteProductCodeItemsByCodes,
};