feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询

This commit is contained in:
freedakgmail
2026-08-01 13:10:22 +08:00
parent a90c133578
commit dab06ba109
43 changed files with 9135 additions and 309 deletions
+30 -21
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
import { sendSuccess, sendError, parsePagination, parseMonth } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
@@ -150,8 +150,9 @@ router.get('/traffic-heatmap-staffing', async (req: AuthRequest, res) => {
router.get('/dow-traffic', async (req: AuthRequest, res) => {
try {
const storeName = req.query.store as string
let where = "WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= '2026/04/01' AND c175 < '2026/05/01'"
const params: any[] = []
const month = parseMonth(req)
const params: any[] = [month]
let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= $1 AND c175 < $1::date + interval '1 month'`
if (storeName) {
params.push(storeName)
where += ` AND c003 = $${params.length}`
@@ -220,6 +221,7 @@ router.get('/traffic-overview', async (req: AuthRequest, res) => {
router.get('/staffing-match', async (req: AuthRequest, res) => {
try {
const storeName = req.query.store as string || '七里庄店'
const month = parseMonth(req)
const result = await query(`
WITH punch_times AS (
SELECT employee_code, d.day_num, d.day_val
@@ -290,7 +292,7 @@ router.get('/staffing-match', async (req: AuthRequest, res) => {
round(sum(c178::numeric), 0) AS total_guests
FROM bill_records
WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= '2026-04-01' AND c175::timestamp < '2026-05-01'
AND c175::timestamp >= $2 AND c175::timestamp < $2::date + interval '1 month'
GROUP BY hour
)
SELECT s.hour,
@@ -308,7 +310,7 @@ router.get('/staffing-match', async (req: AuthRequest, res) => {
FROM hourly_staff_avg s
LEFT JOIN hourly_bills b ON s.hour = b.hour
ORDER BY s.hour
`, [storeName])
`, [storeName, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -443,6 +445,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
const storeName = req.query.store as string || '七里庄店'
const frontTarget = parseInt(req.query.front_target as string) || 15
const kitchenTarget = parseInt(req.query.kitchen_target as string) || 25
const month = parseMonth(req)
const result = await query(`
WITH daily_hourly AS (
@@ -452,7 +455,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
count(*) AS bills
FROM bill_records
WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != ''
AND c175 >= '2026/04/01' AND c175 < '2026/05/01'
AND c175 >= $4 AND c175 < $4::date + interval '1 month'
GROUP BY hour, day_type, bill_date
),
hourly_stats AS (
@@ -584,7 +587,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
LEFT JOIN current_other co ON hs.hour = co.hour
LEFT JOIN current_total ct ON hs.hour = ct.hour
ORDER BY hs.hour, hs.day_type
`, [storeName, frontTarget, kitchenTarget])
`, [storeName, frontTarget, kitchenTarget, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -697,6 +700,7 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const sort = (req.query.sort as string) || 'gross_pay'
const month = parseMonth(req)
const order = (req.query.order as string) || 'desc'
const storeName = req.query.store as string
@@ -717,6 +721,8 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
params.push(storeName)
where += ` AND org_level5 = $${params.length}`
}
params.push(month)
const monthParam = `$${params.length}`
const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params)
const total = countResult.rows[0].count
@@ -743,8 +749,8 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
round(perf_amount / nullif(gross_pay, 0) * 100, 2) AS perf_rate,
round(gross_pay / nullif(actual_hours, 0), 2) AS effective_hourly_rate,
CASE
WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01' THEN '离职'
WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= '2026-04-01' THEN '新员工'
WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= ${monthParam} THEN '离职'
WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= ${monthParam} THEN '新员工'
ELSE '在职'
END AS emp_status
FROM salary_detail_records
@@ -788,18 +794,19 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
// 离职率统计
router.get('/turnover-stats', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT org_level5 AS store_name,
count(*) AS total_emp,
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate,
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate,
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
GROUP BY org_level5
ORDER BY turnover_rate DESC NULLS LAST
`)
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -810,6 +817,7 @@ router.get('/turnover-stats', async (req: AuthRequest, res) => {
router.get('/overall-analysis', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [efficiency, attendance, turnover, trafficOverview, mealPeriod] = await Promise.all([
query(`
WITH salary_stats AS (
@@ -851,13 +859,13 @@ router.get('/overall-analysis', async (req: AuthRequest, res) => {
query(`
SELECT org_level5 AS store_name,
count(*) AS total_emp,
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
GROUP BY org_level5
`),
`, [month]),
query(`
WITH hourly AS (
SELECT store_name, hour, sum(bills) AS bills
@@ -1095,6 +1103,7 @@ router.get('/overall-analysis', async (req: AuthRequest, res) => {
router.get('/staffing-forecast', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const [roleStats, storeRevenue, storeTraffic] = await Promise.all([
query(`
SELECT
@@ -1114,12 +1123,12 @@ router.get('/staffing-forecast', async (req: AuthRequest, res) => {
round(avg(actual_attend)::numeric, 1) AS avg_attend,
round(avg(actual_hours)::numeric, 0) AS avg_hours,
round(sum(actual_hours)::numeric, 0) AS total_hours,
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count
FROM salary_detail_records
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
GROUP BY 1, 2
`),
`, [month]),
query(`
SELECT s.salary_name AS store_name, r.revenue, r.bill_count
FROM (SELECT DISTINCT org_level5 AS salary_name FROM salary_detail_records WHERE org_level2='西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '') s