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, };