feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user