cangku/backend/src/services/presaleTaskService.js

266 lines
8.6 KiB
JavaScript

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