'use strict'; const jianSunDb = require('../config/jianSunDatabase'); const DEFAULT_PAGE_SIZE = 100; const MAX_PAGE_SIZE = 500; const ACTION_REMARKS = { create: '新增预售', extend: '延长预售', cancel: '取消预售', }; function normalizeText(value, maxLength = 255) { return String(value ?? '').trim().slice(0, maxLength); } function normalizePositiveInteger(value, fallback, max = Number.MAX_SAFE_INTEGER) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; return Math.min(parsed, max); } function normalizeBoolean(value, fallback = false) { if (value === true || value === false) return value; const text = String(value ?? '').trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(text)) return true; if (['0', 'false', 'no', 'off'].includes(text)) return false; return fallback; } function normalizeRunDate(value) { const runDate = normalizeText(value, 10); if (!runDate) return ''; if (!/^\d{4}-\d{2}-\d{2}$/.test(runDate)) { const error = new Error('runDate 必须使用 YYYY-MM-DD 格式'); error.statusCode = 400; throw error; } const parsed = new Date(`${runDate}T00:00:00Z`); if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== runDate) { const error = new Error('runDate 不是有效日期'); error.statusCode = 400; throw error; } return runDate; } function normalizeInput(input = {}) { const dingtalkUserId = normalizeText( input.dingtalkUserId || input.dingtalk_user_id || input.iderUserId || input.ider_userid || input.userId || input.user_id, 120 ); const dingtalkUserName = normalizeText( input.dingtalkUserName || input.dingtalk_user_name || input.dingtalkUsername || input.userName || input.user_name, 100 ); if (!dingtalkUserId && !dingtalkUserName) { const error = new Error('缺少人员信息,请提供 dingtalkUserId 或 dingtalkUserName'); error.statusCode = 400; throw error; } const page = normalizePositiveInteger(input.page, 1); const pageSize = normalizePositiveInteger( input.pageSize || input.page_size, DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE ); return { dingtalkUserId, dingtalkUserName, runDate: normalizeRunDate(input.runDate || input.run_date || input.date), actionableOnly: normalizeBoolean(input.actionableOnly || input.actionable_only, false), page, pageSize, offset: (page - 1) * pageSize, }; } function buildRecipientFilter(input) { if (input.dingtalkUserId) { return { sql: 'ider_userid = @dingtalkUserId', params: { dingtalkUserId: input.dingtalkUserId }, }; } return { sql: 'ider = @dingtalkUserName', params: { dingtalkUserName: input.dingtalkUserName }, }; } function getActionType(row = {}) { const noticeRemark = normalizeText(row.notice_remark ?? row.noticeRemark, 100); const presalePolicy = normalizeText(row.presale_policy ?? row.presalePolicy, 100); if (noticeRemark === ACTION_REMARKS.cancel || presalePolicy === ACTION_REMARKS.cancel) { return 'CANCEL_PRESALE'; } if (noticeRemark === ACTION_REMARKS.create) return 'CREATE_PRESALE'; if (noticeRemark === ACTION_REMARKS.extend) return 'EXTEND_PRESALE'; return 'NO_ACTION'; } function mapTaskRow(row = {}) { const actionType = getActionType(row); return { runDate: row.run_date || null, noticeTime: row.notice_time || null, noticeRemark: row.notice_remark || '', actionValue: row.action_value || '', actionType, requiresAction: actionType !== 'NO_ACTION', operatorName: row.ider || '', operatorUserId: row.ider_userid || '', shopHead: row.shop_head || '', shopName: row.shopname || '', department: row.dept || '', currentCategory: row.currCater || '', shopId: row.shop_id || '', channel: row.channel || '', companyId: row.co_id || '', productId: row.shop_i_id || '', shopSkuId: row.shop_sku_id || '', erpSkuId: row.sku_id || '', skuName: row.sku_name || '', shopQuantity: Number(row.shop_qty || 0), outOfStockQuantity: Number(row.out_of_stock_qty || 0), jsUseDays: row.js_use_days === null || row.js_use_days === undefined ? null : Number(row.js_use_days), stockoutWarning: row.stockout_warning || '', presalePolicy: row.presale_policy || '', presaleDeadline: row.presale_deadline || null, }; } function mapSummary(row = {}) { return { totalRows: Number(row.total_rows || 0), productCount: Number(row.product_count || 0), actionableRows: Number(row.actionable_rows || 0), createPresaleRows: Number(row.create_presale_rows || 0), extendPresaleRows: Number(row.extend_presale_rows || 0), cancelPresaleRows: Number(row.cancel_presale_rows || 0), noActionRows: Number(row.no_action_rows || 0), }; } async function listPresaleTasks(rawInput = {}, options = {}) { const input = normalizeInput(rawInput); const query = options.query || jianSunDb.query; const recipient = buildRecipientFilter(input); const actionParams = { createRemark: ACTION_REMARKS.create, extendRemark: ACTION_REMARKS.extend, cancelRemark: ACTION_REMARKS.cancel, }; const baseParams = { ...recipient.params, ...actionParams }; let runDate = input.runDate; if (!runDate) { const latestRows = await query( `SELECT CONVERT(varchar(10), MAX(run_date), 120) AS run_date FROM dbo.dm_stockout_id_daily WITH (NOLOCK) WHERE ${recipient.sql}`, recipient.params ); runDate = latestRows[0]?.run_date || ''; } if (!runDate) { return { runDate: null, recipient: { dingtalkUserId: input.dingtalkUserId || '', dingtalkUserName: input.dingtalkUserName || '', }, summary: mapSummary(), pagination: { page: input.page, pageSize: input.pageSize, total: 0, totalPages: 0 }, items: [], }; } const filters = ['run_date = @runDate', recipient.sql]; if (input.actionableOnly) { filters.push(`( notice_remark IN (@createRemark, @extendRemark, @cancelRemark) OR presale_policy = @cancelRemark )`); } const whereSql = filters.join('\n AND '); const params = { ...baseParams, runDate }; const [summaryRows, taskRows] = await Promise.all([ query( `SELECT COUNT(*) AS total_rows, COUNT(DISTINCT shop_i_id) AS product_count, COALESCE(SUM(CASE WHEN notice_remark IN (@createRemark, @extendRemark, @cancelRemark) OR presale_policy = @cancelRemark THEN 1 ELSE 0 END), 0) AS actionable_rows, COALESCE(SUM(CASE WHEN notice_remark = @createRemark THEN 1 ELSE 0 END), 0) AS create_presale_rows, COALESCE(SUM(CASE WHEN notice_remark = @extendRemark THEN 1 ELSE 0 END), 0) AS extend_presale_rows, COALESCE(SUM(CASE WHEN notice_remark = @cancelRemark OR presale_policy = @cancelRemark THEN 1 ELSE 0 END), 0) AS cancel_presale_rows, COALESCE(SUM(CASE WHEN notice_remark NOT IN (@createRemark, @extendRemark, @cancelRemark) AND presale_policy <> @cancelRemark THEN 1 ELSE 0 END), 0) AS no_action_rows FROM dbo.dm_stockout_id_daily WITH (NOLOCK) WHERE ${whereSql}`, params ), query( `SELECT CONVERT(varchar(10), run_date, 120) AS run_date, CONVERT(varchar(19), notice_time, 120) AS notice_time, notice_remark, action_value, ider, ider_userid, shop_head, shopname, dept, currCater, shop_id, channel, co_id, shop_i_id, shop_sku_id, sku_id, sku_name, shop_qty, out_of_stock_qty, js_use_days, stockout_warning, presale_policy, CONVERT(varchar(19), presale_deadline, 120) AS presale_deadline FROM dbo.dm_stockout_id_daily WITH (NOLOCK) WHERE ${whereSql} ORDER BY notice_time DESC, shop_i_id, shop_sku_id, sku_id OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY`, { ...params, offset: input.offset, pageSize: input.pageSize } ), ]); const items = taskRows.map(mapTaskRow); const summary = mapSummary(summaryRows[0]); const firstItem = items[0]; return { runDate, recipient: { dingtalkUserId: firstItem?.operatorUserId || input.dingtalkUserId || '', dingtalkUserName: firstItem?.operatorName || input.dingtalkUserName || '', }, actionableOnly: input.actionableOnly, summary, pagination: { page: input.page, pageSize: input.pageSize, total: summary.totalRows, totalPages: Math.ceil(summary.totalRows / input.pageSize), }, items, }; } module.exports = { listPresaleTasks, _test: { getActionType, mapTaskRow, normalizeInput, normalizeRunDate, }, };