forked from lunanting/cangku
1839 lines
63 KiB
JavaScript
1839 lines
63 KiB
JavaScript
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,
|
||
};
|