forked from jiaoly/financial_system
更新UI相关部分
This commit is contained in:
parent
faa09101a1
commit
f213848719
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -0,0 +1,16 @@
|
||||
.git/
|
||||
.idea/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
data/
|
||||
logs/
|
||||
outputs/
|
||||
uploads/
|
||||
static/uploads/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
9
.env
Normal file
9
.env
Normal file
@ -0,0 +1,9 @@
|
||||
# Copy this file to .env and edit DATABASE_URL before running docker compose.
|
||||
#
|
||||
# If MySQL runs on the same host as Docker, use host.docker.internal.
|
||||
# docker-compose.yml maps it to the Linux Docker host with host-gateway.
|
||||
# If MySQL runs on another server, use that server's reachable IP address or domain name.
|
||||
DATABASE_URL=mysql+pymysql://root:Plo6lvzOPtMNzVIA@host.docker.internal:3306/financial_system?charset=utf8mb4
|
||||
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5173
|
||||
9
.env.docker.example
Normal file
9
.env.docker.example
Normal file
@ -0,0 +1,9 @@
|
||||
# Copy this file to .env and edit DATABASE_URL before running docker compose.
|
||||
#
|
||||
# If MySQL runs on the same host as Docker, use host.docker.internal.
|
||||
# docker-compose.yml maps it to the Linux Docker host with host-gateway.
|
||||
# If MySQL runs on another server, use that server's reachable IP address or domain name.
|
||||
DATABASE_URL=mysql+pymysql://root:12345678@host.docker.internal:3306/financial_system?charset=utf8mb4
|
||||
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5173
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV APP_CONFIG_FILE=/app/config/app_settings.json
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
COPY config ./config
|
||||
COPY financial_system ./financial_system
|
||||
COPY static ./static
|
||||
|
||||
RUN mkdir -p logs uploads outputs static/uploads/avatars
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
38
README.md
38
README.md
@ -85,6 +85,44 @@ cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Docker 运行(外部 MySQL)
|
||||
|
||||
项目已提供后端镜像、前端 Nginx 镜像和 `docker-compose.yml`。数据库使用外部 MySQL,不会在 compose 里启动 MySQL 容器。
|
||||
|
||||
1. 准备环境变量:
|
||||
|
||||
```bash
|
||||
cp .env.docker.example .env
|
||||
```
|
||||
|
||||
2. 修改 `.env` 里的 `DATABASE_URL`:
|
||||
|
||||
```env
|
||||
DATABASE_URL=mysql+pymysql://用户名:密码@MySQL地址:3306/financial_system?charset=utf8mb4
|
||||
```
|
||||
|
||||
如果 MySQL 就运行在当前电脑,并且你使用 Docker Desktop,MySQL 地址通常写:
|
||||
|
||||
```env
|
||||
DATABASE_URL=mysql+pymysql://root:12345678@host.docker.internal:3306/financial_system?charset=utf8mb4
|
||||
```
|
||||
|
||||
如果 MySQL 在另一台服务器,写那台服务器的内网 IP、外网 IP 或域名。不要在容器里使用 `localhost` 连接外部 MySQL,因为 `localhost` 指向的是后端容器本身。
|
||||
|
||||
3. 启动:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
默认访问:
|
||||
|
||||
- 前端系统:http://127.0.0.1:5173
|
||||
- 后端接口:http://127.0.0.1:8000
|
||||
- 接口文档:http://127.0.0.1:5173/docs
|
||||
|
||||
后端容器启动时会按外部 MySQL 自动建库、建表并初始化默认超级管理员。请确认外部 MySQL 已允许该账号远程连接,并且账号有创建数据库和建表权限。
|
||||
|
||||
## 统一配置
|
||||
|
||||
后端配置统一放在:
|
||||
|
||||
46
docker-compose.yml
Normal file
46
docker-compose.yml
Normal file
@ -0,0 +1,46 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: financial-system-backend
|
||||
environment:
|
||||
APP_CONFIG_FILE: /app/config/app_settings.json
|
||||
DATABASE_URL: ${DATABASE_URL:?Please set DATABASE_URL in .env}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ./config/app_settings.json:/app/config/app_settings.json:ro
|
||||
- ./uploads:/app/uploads
|
||||
- ./outputs:/app/outputs
|
||||
- ./logs:/app/logs
|
||||
- ./static/uploads:/app/static/uploads
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)",
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ""
|
||||
container_name: financial-system-frontend
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:80"
|
||||
restart: unless-stopped
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
ARG VITE_API_BASE_URL=
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
File diff suppressed because it is too large
Load Diff
@ -11,14 +11,14 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="stat-card" :class="`tone-${tone || 'neutral'}`">
|
||||
<div class="stat-head">
|
||||
<div class="stat-icon">
|
||||
<component :is="icon" :size="23" aria-hidden="true" />
|
||||
</div>
|
||||
<span v-if="meta" class="stat-meta">{{ meta }}</span>
|
||||
<section class="summary-metric" :class="`tone-${tone || 'neutral'}`">
|
||||
<div class="summary-icon">
|
||||
<component :is="icon" :size="16" aria-hidden="true" />
|
||||
</div>
|
||||
<p>{{ title }}</p>
|
||||
<strong>{{ value }}</strong>
|
||||
<div class="summary-copy">
|
||||
<span>{{ title }}</span>
|
||||
<strong>{{ value }}</strong>
|
||||
</div>
|
||||
<em v-if="meta" class="summary-meta">{{ meta }}</em>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@ -744,48 +744,48 @@ function radiusVariables(radius: RadiusChoice): Record<string, string> {
|
||||
function densityVariables(density: DensityChoice): Record<string, string> {
|
||||
const densityMap: Record<DensityChoice, Record<string, string>> = {
|
||||
compact: {
|
||||
"--topbar-height": "64px",
|
||||
"--nav-height": "42px",
|
||||
"--control-height": "40px",
|
||||
"--base-font-size": "15px",
|
||||
"--page-padding": "24px",
|
||||
"--content-gap": "18px",
|
||||
"--panel-padding": "20px",
|
||||
"--table-cell-y": "12px",
|
||||
"--table-cell-x": "18px",
|
||||
"--topbar-height": "60px",
|
||||
"--nav-height": "38px",
|
||||
"--control-height": "38px",
|
||||
"--base-font-size": "14px",
|
||||
"--page-padding": "20px",
|
||||
"--content-gap": "14px",
|
||||
"--panel-padding": "16px",
|
||||
"--table-cell-y": "10px",
|
||||
"--table-cell-x": "14px",
|
||||
},
|
||||
default: {
|
||||
"--topbar-height": "66px",
|
||||
"--nav-height": "44px",
|
||||
"--control-height": "42px",
|
||||
"--base-font-size": "15px",
|
||||
"--page-padding": "26px",
|
||||
"--content-gap": "18px",
|
||||
"--panel-padding": "20px",
|
||||
"--table-cell-y": "14px",
|
||||
"--table-cell-x": "18px",
|
||||
},
|
||||
comfortable: {
|
||||
"--topbar-height": "72px",
|
||||
"--nav-height": "48px",
|
||||
"--control-height": "44px",
|
||||
"--nav-height": "50px",
|
||||
"--control-height": "46px",
|
||||
"--base-font-size": "16px",
|
||||
"--page-padding": "32px",
|
||||
"--content-gap": "24px",
|
||||
"--panel-padding": "24px",
|
||||
"--table-cell-y": "18px",
|
||||
"--table-cell-x": "24px",
|
||||
},
|
||||
comfortable: {
|
||||
"--topbar-height": "78px",
|
||||
"--nav-height": "54px",
|
||||
"--control-height": "48px",
|
||||
"--base-font-size": "16px",
|
||||
"--page-padding": "36px",
|
||||
"--content-gap": "28px",
|
||||
"--panel-padding": "28px",
|
||||
"--table-cell-y": "22px",
|
||||
"--table-cell-x": "28px",
|
||||
"--table-cell-x": "22px",
|
||||
},
|
||||
large: {
|
||||
"--topbar-height": "88px",
|
||||
"--nav-height": "62px",
|
||||
"--control-height": "56px",
|
||||
"--base-font-size": "18px",
|
||||
"--page-padding": "44px",
|
||||
"--content-gap": "34px",
|
||||
"--panel-padding": "34px",
|
||||
"--table-cell-y": "28px",
|
||||
"--table-cell-x": "32px",
|
||||
"--topbar-height": "82px",
|
||||
"--nav-height": "58px",
|
||||
"--control-height": "52px",
|
||||
"--base-font-size": "17px",
|
||||
"--page-padding": "38px",
|
||||
"--content-gap": "30px",
|
||||
"--panel-padding": "30px",
|
||||
"--table-cell-y": "24px",
|
||||
"--table-cell-x": "28px",
|
||||
},
|
||||
};
|
||||
return densityMap[density];
|
||||
|
||||
@ -229,7 +229,7 @@ function money(value: number): string {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<section class="summary-strip">
|
||||
<StatCard title="当前记录" :value="String(records.length)" :icon="FileSpreadsheet" tone="blue" meta="本页" />
|
||||
<StatCard title="筛选总数" :value="String(total)" :icon="HandCoins" tone="neutral" meta="数据库" />
|
||||
<StatCard title="本页提成" :value="money(totalAmount)" :icon="HandCoins" tone="green" meta="当前页合计" />
|
||||
|
||||
@ -95,7 +95,7 @@ async function saveConfig(config: SalaryConfigRecord): Promise<void> {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<section class="summary-strip">
|
||||
<StatCard title="配置项" :value="String(configs.length)" :icon="SlidersHorizontal" tone="blue" meta="全部规则" />
|
||||
<StatCard title="启用项" :value="String(enabledCount)" :icon="Settings2" tone="green" meta="参与计算" />
|
||||
<StatCard title="当前分组" :value="activeGroup || '全部'" :icon="Settings2" tone="neutral" meta="筛选" />
|
||||
|
||||
@ -44,7 +44,7 @@ async function download(job: RecentPayrollJob): Promise<void> {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<section class="summary-strip">
|
||||
<StatCard
|
||||
title="最近计算员工"
|
||||
:value="String(latestJob?.employee_count || 0)"
|
||||
|
||||
@ -175,7 +175,7 @@ function targetLabel(log: OperationLog): string {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<section class="summary-strip">
|
||||
<StatCard title="日志总数" :value="String(response.total)" :icon="ScrollText" tone="blue" meta="全部记录" />
|
||||
<StatCard title="本页成功" :value="String(successCount)" :icon="CheckCircle2" tone="green" meta="当前筛选" />
|
||||
<StatCard title="本页失败" :value="String(failedCount)" :icon="XCircle" tone="red" meta="当前筛选" />
|
||||
|
||||
@ -226,7 +226,7 @@ function money(value: number): string {
|
||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
||||
|
||||
<section v-if="activeResult" class="stats-grid">
|
||||
<section v-if="activeResult" class="summary-strip">
|
||||
<StatCard title="员工数量" :value="String(activeResult.employee_count)" :icon="FileSpreadsheet" tone="blue" :meta="activeResult.job_id" />
|
||||
<StatCard title="剩余加班合计" :value="`${totalOvertime}h`" :icon="CloudCog" tone="green" meta="加班-请假" />
|
||||
<StatCard title="扣款合计" :value="totalDeduction" :icon="Trash2" tone="red" meta="迟到+缺卡" />
|
||||
|
||||
@ -5,7 +5,6 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { ApiError } from "../api/http";
|
||||
import { getPayrollReport } from "../api/reports";
|
||||
import type { PayrollReportResponse } from "../api/types";
|
||||
import StatCard from "../components/StatCard.vue";
|
||||
|
||||
const salaryMonth = ref(new Date().toISOString().slice(0, 7));
|
||||
const report = ref<PayrollReportResponse | null>(null);
|
||||
@ -49,7 +48,7 @@ function modeLabel(value: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-stack">
|
||||
<div class="view-stack report-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>统计报表</h1>
|
||||
@ -72,26 +71,117 @@ function modeLabel(value: string): string {
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<StatCard title="员工数" :value="String(totals.employee_count || 0)" :icon="WalletCards" tone="blue" :meta="salaryMonth" />
|
||||
<StatCard title="应发工资" :value="money(totals.gross_salary)" :icon="Banknote" tone="neutral" meta="基础+提成+加班" />
|
||||
<StatCard title="实发工资" :value="money(totals.net_salary)" :icon="Banknote" tone="green" meta="扣款后" />
|
||||
<StatCard title="扣款合计" :value="money(totals.deduction_amount)" :icon="Clock3" tone="red" meta="迟到+缺卡" />
|
||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||
|
||||
<section class="panel table-panel report-ledger-workspace report-record-panel" aria-label="工资明细台账">
|
||||
<div class="report-ledger-toolbar">
|
||||
<div class="report-ledger-title">
|
||||
<span class="status-chip neutral">{{ report?.salary_month || salaryMonth }}</span>
|
||||
<div>
|
||||
<span class="report-ledger-eyebrow">工资汇总</span>
|
||||
<h2>工资明细台账</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="report-ledger-totals" aria-label="工资汇总">
|
||||
<div class="report-total-item">
|
||||
<span>应发工资</span>
|
||||
<strong>{{ money(totals.gross_salary) }}</strong>
|
||||
<small>基础 + 提成 + 加班</small>
|
||||
</div>
|
||||
<div class="report-total-item">
|
||||
<span>扣款合计</span>
|
||||
<strong>{{ money(totals.deduction_amount) }}</strong>
|
||||
<small>迟到 + 缺卡</small>
|
||||
</div>
|
||||
<div class="report-total-item is-primary">
|
||||
<span>实发工资</span>
|
||||
<strong>{{ money(totals.net_salary) }}</strong>
|
||||
<small>财务最终金额</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-inline-metrics" aria-label="报表核心指标">
|
||||
<span>
|
||||
<WalletCards :size="15" aria-hidden="true" />
|
||||
员工数
|
||||
<strong>{{ totals.employee_count || 0 }}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<Banknote :size="15" aria-hidden="true" />
|
||||
提成
|
||||
<strong>{{ money(totals.commission_amount) }}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 :size="15" aria-hidden="true" />
|
||||
加班费
|
||||
<strong>{{ money(totals.overtime_pay) }}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 :size="15" aria-hidden="true" />
|
||||
加班工时
|
||||
<strong>{{ Number(attendance.overtime_hours || 0).toFixed(1) }}h</strong>
|
||||
</span>
|
||||
<span>
|
||||
<Clock3 :size="15" aria-hidden="true" />
|
||||
请假工时
|
||||
<strong>{{ Number(attendance.leave_hours || 0).toFixed(1) }}h</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="report-table-heading">
|
||||
<div>
|
||||
<h3>最近工资明细</h3>
|
||||
<p>{{ report?.recent_records.length || 0 }} 条记录,按员工展示工资构成。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table report-record-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工</th>
|
||||
<th>部门 / 月份</th>
|
||||
<th>模式</th>
|
||||
<th>基础工资</th>
|
||||
<th>提成</th>
|
||||
<th>加班费</th>
|
||||
<th>扣款</th>
|
||||
<th>应发</th>
|
||||
<th>实发</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in report?.recent_records || []" :key="item.id">
|
||||
<td>
|
||||
<strong>{{ item.employee_name }}</strong>
|
||||
<span>{{ item.employee_no || "-" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{{ item.department || "-" }}</strong>
|
||||
<span>{{ item.salary_month }}</span>
|
||||
</td>
|
||||
<td><span class="status-chip neutral">{{ modeLabel(item.salary_mode) }}</span></td>
|
||||
<td class="mono">{{ money(item.base_pay) }}</td>
|
||||
<td class="mono">{{ money(item.commission_amount) }}</td>
|
||||
<td class="mono">{{ money(item.overtime_pay) }}</td>
|
||||
<td class="mono">{{ money(item.deduction_amount) }}</td>
|
||||
<td class="mono">{{ money(item.gross_salary) }}</td>
|
||||
<td class="mono strong report-net-cell">{{ money(item.net_salary) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="!report?.recent_records.length && !loading" class="empty-state">
|
||||
<p>暂无工资记录</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-grid">
|
||||
<StatCard title="提成合计" :value="money(totals.commission_amount)" :icon="Banknote" tone="green" meta="当月维护" />
|
||||
<StatCard title="加班费合计" :value="money(totals.overtime_pay)" :icon="Clock3" tone="blue" meta="三类加班" />
|
||||
<StatCard title="加班工时" :value="`${Number(attendance.overtime_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="neutral" meta="打卡推算" />
|
||||
<StatCard title="请假工时" :value="`${Number(attendance.leave_hours || 0).toFixed(1)}h`" :icon="Clock3" tone="red" meta="统一折算" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-grid">
|
||||
<section class="report-detail-grid">
|
||||
<div class="panel table-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>部门工资汇总</h2>
|
||||
<p>{{ report?.departments.length || 0 }} 个部门</p>
|
||||
<p>按部门查看人数、应发和实发成本。</p>
|
||||
</div>
|
||||
<Building2 :size="22" aria-hidden="true" />
|
||||
</div>
|
||||
@ -120,44 +210,30 @@ function modeLabel(value: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel table-panel">
|
||||
<div class="panel report-attendance-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>最近工资记录</h2>
|
||||
<p>{{ report?.recent_records.length || 0 }} 条</p>
|
||||
<h2>考勤工时拆分</h2>
|
||||
<p>加班和请假会影响剩余加班及工资结果。</p>
|
||||
</div>
|
||||
<Clock3 :size="22" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工</th>
|
||||
<th>模式</th>
|
||||
<th>基础</th>
|
||||
<th>提成</th>
|
||||
<th>实发</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in report?.recent_records || []" :key="item.id">
|
||||
<td>
|
||||
<strong>{{ item.employee_name }}</strong>
|
||||
<span>{{ item.employee_no || item.department }}</span>
|
||||
</td>
|
||||
<td><span class="status-chip neutral">{{ modeLabel(item.salary_mode) }}</span></td>
|
||||
<td class="mono">{{ money(item.base_pay) }}</td>
|
||||
<td class="mono">{{ money(item.commission_amount) }}</td>
|
||||
<td class="mono strong">{{ money(item.net_salary) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="!report?.recent_records.length && !loading" class="empty-state">
|
||||
<p>暂无工资记录</p>
|
||||
<div class="report-attendance-list">
|
||||
<div class="report-attendance-card">
|
||||
<span>加班工时</span>
|
||||
<strong>{{ Number(attendance.overtime_hours || 0).toFixed(1) }}h</strong>
|
||||
</div>
|
||||
<div class="report-attendance-card">
|
||||
<span>请假工时</span>
|
||||
<strong>{{ Number(attendance.leave_hours || 0).toFixed(1) }}h</strong>
|
||||
</div>
|
||||
<div class="report-attendance-card">
|
||||
<span>缺勤天数</span>
|
||||
<strong>{{ Number(attendance.absence_days || 0).toFixed(1) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -4,11 +4,13 @@ import {
|
||||
HandCoins,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
TimerReset,
|
||||
WalletCards,
|
||||
X,
|
||||
} from "@lucide/vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
@ -16,12 +18,12 @@ import { listEmployees } from "../api/employees";
|
||||
import { ApiError } from "../api/http";
|
||||
import { listSalaryProfiles, saveSalaryProfile } from "../api/salaryProfiles";
|
||||
import type { EmployeeRecord, SalaryProfileRecord, SalaryProfileRequest } from "../api/types";
|
||||
import StatCard from "../components/StatCard.vue";
|
||||
|
||||
const employees = ref<EmployeeRecord[]>([]);
|
||||
const profiles = ref<SalaryProfileRecord[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const showEditor = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
const keyword = ref("");
|
||||
@ -59,6 +61,9 @@ const avgOvertimeRate = computed(() => {
|
||||
}
|
||||
return profiles.value.reduce((total, item) => total + Number(item.overtime_rate || 0), 0) / profiles.value.length;
|
||||
});
|
||||
const editorTitle = computed(() => {
|
||||
return profiles.value.some((item) => item.employee_id === form.employee_id) ? "编辑薪资" : "新增薪资";
|
||||
});
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
@ -108,6 +113,7 @@ async function submit(): Promise<void> {
|
||||
});
|
||||
profiles.value = [profile, ...profiles.value.filter((item) => item.employee_id !== profile.employee_id)];
|
||||
successMessage.value = "薪资档案已保存";
|
||||
showEditor.value = false;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof ApiError ? error.message : "薪资档案保存失败";
|
||||
} finally {
|
||||
@ -135,6 +141,18 @@ function editProfile(profile: SalaryProfileRecord): void {
|
||||
});
|
||||
successMessage.value = "";
|
||||
errorMessage.value = "";
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
function openCreateProfile(): void {
|
||||
resetForm();
|
||||
successMessage.value = "";
|
||||
errorMessage.value = "";
|
||||
showEditor.value = true;
|
||||
}
|
||||
|
||||
function closeEditor(): void {
|
||||
showEditor.value = false;
|
||||
}
|
||||
|
||||
function resetForm(): void {
|
||||
@ -186,7 +204,7 @@ function money(value: number): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-stack">
|
||||
<div class="view-stack salary-profile-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>薪资维护</h1>
|
||||
@ -199,145 +217,70 @@ function money(value: number): string {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<StatCard title="薪资档案" :value="String(profiles.length)" :icon="WalletCards" tone="blue" meta="已维护" />
|
||||
<StatCard title="基础工资合计" :value="money(totalBaseSalary)" :icon="BadgeDollarSign" tone="neutral" meta="当前筛选" />
|
||||
<StatCard title="提成合计" :value="money(totalCommission)" :icon="HandCoins" tone="green" meta="手工维护" />
|
||||
<StatCard title="平均加班单价" :value="money(avgOvertimeRate)" :icon="TimerReset" tone="red" meta="独立维护" />
|
||||
<section class="salary-profile-summary-strip" aria-label="薪资档案指标">
|
||||
<div class="salary-profile-summary-item">
|
||||
<WalletCards :size="18" aria-hidden="true" />
|
||||
<span>薪资档案</span>
|
||||
<strong>{{ profiles.length }}</strong>
|
||||
<small>已维护员工</small>
|
||||
</div>
|
||||
<div class="salary-profile-summary-item">
|
||||
<BadgeDollarSign :size="18" aria-hidden="true" />
|
||||
<span>基础工资合计</span>
|
||||
<strong>{{ money(totalBaseSalary) }}</strong>
|
||||
<small>当前筛选</small>
|
||||
</div>
|
||||
<div class="salary-profile-summary-item">
|
||||
<HandCoins :size="18" aria-hidden="true" />
|
||||
<span>提成合计</span>
|
||||
<strong>{{ money(totalCommission) }}</strong>
|
||||
<small>手工维护</small>
|
||||
</div>
|
||||
<div class="salary-profile-summary-item">
|
||||
<TimerReset :size="18" aria-hidden="true" />
|
||||
<span>平均加班单价</span>
|
||||
<strong>{{ money(avgOvertimeRate) }}</strong>
|
||||
<small>独立维护</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<p v-if="errorMessage && !showEditor" class="form-error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
||||
|
||||
<section class="panel salary-profile-list-panel">
|
||||
<div class="panel-header salary-profile-list-header">
|
||||
<div>
|
||||
<h2>查询薪资档案</h2>
|
||||
<p>按员工编号、姓名、部门或岗位查询</p>
|
||||
<h2>薪资结构一览</h2>
|
||||
<p>{{ profiles.length }} 条薪资档案,按员工编号、姓名、部门或岗位查询</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="search-form" @submit.prevent="loadData">
|
||||
<label class="field">
|
||||
<span>关键字</span>
|
||||
<form class="salary-profile-list-tools salary-profile-toolbar" @submit.prevent="loadData">
|
||||
<input v-model="keyword" type="search" placeholder="员工姓名、编号、部门..." />
|
||||
</label>
|
||||
<button class="primary-button" type="submit" :disabled="loading">
|
||||
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
||||
<Search v-else :size="18" aria-hidden="true" />
|
||||
<span>{{ loading ? "查询中" : "查询" }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>维护薪资档案</h2>
|
||||
<p>一名员工维护一条当前薪资档案</p>
|
||||
</div>
|
||||
<button class="secondary-button" type="button" @click="resetForm">清空</button>
|
||||
</div>
|
||||
<form class="form-grid salary-profile-form" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span>关联员工</span>
|
||||
<select v-model.number="form.employee_id" required>
|
||||
<option :value="0">请选择员工</option>
|
||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||
{{ employee.name }} / {{ employee.employee_no }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>薪资模式</span>
|
||||
<select v-model="form.salary_mode">
|
||||
<option v-for="item in salaryModeOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>基础工资/底薪</span>
|
||||
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>计时小时单价</span>
|
||||
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>计件数量</span>
|
||||
<input v-model.number="form.piece_quantity" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>计件单价</span>
|
||||
<input v-model.number="form.piece_unit_price" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>试用期方式</span>
|
||||
<select v-model="form.probation_type">
|
||||
<option value="ratio">固定比例</option>
|
||||
<option value="fixed">固定金额</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>试用期比例</span>
|
||||
<input v-model.number="form.probation_ratio" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>试用期固定金额</span>
|
||||
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>默认提成</span>
|
||||
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>工作日加班单价</span>
|
||||
<input v-model.number="form.overtime_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>周末加班单价</span>
|
||||
<input v-model.number="form.weekend_overtime_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>节假日加班单价</span>
|
||||
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>生效月份</span>
|
||||
<input v-model="form.effective_month" type="month" />
|
||||
</label>
|
||||
<label class="field span-row">
|
||||
<span>备注</span>
|
||||
<textarea v-model="form.remark" />
|
||||
</label>
|
||||
<div class="form-actions align-right span-row">
|
||||
<button class="primary-button" type="submit" :disabled="saving">
|
||||
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
|
||||
<Save v-else :size="18" aria-hidden="true" />
|
||||
<span>{{ saving ? "保存中" : "保存薪资" }}</span>
|
||||
<button class="secondary-button compact-button" type="submit" :disabled="loading">
|
||||
<Loader2 v-if="loading" class="spin" :size="16" aria-hidden="true" />
|
||||
<Search v-else :size="16" aria-hidden="true" />
|
||||
<span>{{ loading ? "查询中" : "查询" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="form-success">{{ successMessage }}</p>
|
||||
</section>
|
||||
|
||||
<section class="panel table-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>薪资档案列表</h2>
|
||||
<p>{{ profiles.length }} 条薪资档案</p>
|
||||
</div>
|
||||
<button class="primary-button compact-button" type="button" @click="openCreateProfile">
|
||||
<Plus :size="16" aria-hidden="true" />
|
||||
<span>新增档案</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<div v-if="loading" class="empty-state">
|
||||
<Loader2 class="spin" :size="18" aria-hidden="true" />
|
||||
<p>正在加载薪资档案</p>
|
||||
</div>
|
||||
<div v-else-if="profiles.length" class="table-wrap">
|
||||
<table class="data-table salary-profile-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>员工</th>
|
||||
<th>部门/岗位</th>
|
||||
<th>部门 / 岗位</th>
|
||||
<th>薪资模式</th>
|
||||
<th>计算规则</th>
|
||||
<th>默认提成</th>
|
||||
<th>底薪 / 提成</th>
|
||||
<th>加班单价</th>
|
||||
<th>生效月份</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -353,27 +296,163 @@ function money(value: number): string {
|
||||
</td>
|
||||
<td><span class="status-chip neutral">{{ salaryModeLabel(profile.salary_mode) }}</span></td>
|
||||
<td class="mono strong">{{ profileRuleText(profile) }}</td>
|
||||
<td class="mono">{{ money(profile.commission_amount) }}</td>
|
||||
<td class="mono">
|
||||
工作日 {{ money(profile.overtime_rate) }}/h
|
||||
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
|
||||
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
|
||||
<td class="salary-profile-pay-cell">
|
||||
<strong>{{ money(profile.base_salary) }}</strong>
|
||||
<span>提成 {{ money(profile.commission_amount) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="salary-profile-rate-stack">
|
||||
<span>工作日 {{ money(profile.overtime_rate) }}/h</span>
|
||||
<span>周末 {{ money(profile.weekend_overtime_rate) }}/h</span>
|
||||
<span>节假日 {{ money(profile.holiday_overtime_rate) }}/h</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ profile.effective_month || "长期" }}</td>
|
||||
<td class="note-cell">{{ profile.remark || "-" }}</td>
|
||||
<td>
|
||||
<button class="link-button" type="button" @click="editProfile(profile)">
|
||||
<Pencil :size="15" aria-hidden="true" />
|
||||
<span>编辑</span>
|
||||
<span>编辑薪资</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="!profiles.length && !loading" class="empty-state">
|
||||
<div v-else class="empty-state">
|
||||
<p>暂无薪资档案</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="showEditor" class="modal-backdrop salary-profile-edit-modal" @click.self="closeEditor">
|
||||
<section class="profile-modal salary-profile-edit-card" aria-label="薪资档案维护">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2>{{ editorTitle }}</h2>
|
||||
<p>在弹出卡片内维护员工当前薪资档案和独立加班单价。</p>
|
||||
</div>
|
||||
<button class="icon-button" type="button" title="关闭" @click="closeEditor">
|
||||
<X :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<form class="salary-profile-form salary-profile-modal-form" @submit.prevent="submit">
|
||||
<section class="salary-profile-modal-section">
|
||||
<div class="salary-profile-modal-section-title">
|
||||
<strong>基础档案</strong>
|
||||
<span>员工、薪资模式和生效月份</span>
|
||||
</div>
|
||||
<div class="salary-profile-modal-fields">
|
||||
<label class="field">
|
||||
<span>关联员工</span>
|
||||
<select v-model.number="form.employee_id" required>
|
||||
<option :value="0">请选择员工</option>
|
||||
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
|
||||
{{ employee.name }} / {{ employee.employee_no }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>薪资模式</span>
|
||||
<select v-model="form.salary_mode">
|
||||
<option v-for="item in salaryModeOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>基础工资/底薪</span>
|
||||
<input v-model.number="form.base_salary" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>生效月份</span>
|
||||
<input v-model="form.effective_month" type="month" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="salary-profile-modal-section">
|
||||
<div class="salary-profile-modal-section-title">
|
||||
<strong>计时 / 计件 / 试用期</strong>
|
||||
<span>按员工类型维护对应口径</span>
|
||||
</div>
|
||||
<div class="salary-profile-modal-fields">
|
||||
<label class="field">
|
||||
<span>计时小时单价</span>
|
||||
<input v-model.number="form.hourly_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>计件数量</span>
|
||||
<input v-model.number="form.piece_quantity" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>计件单价</span>
|
||||
<input v-model.number="form.piece_unit_price" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>试用期方式</span>
|
||||
<select v-model="form.probation_type">
|
||||
<option value="ratio">固定比例</option>
|
||||
<option value="fixed">固定金额</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>试用期比例</span>
|
||||
<input v-model.number="form.probation_ratio" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>试用期固定金额</span>
|
||||
<input v-model.number="form.probation_salary" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="salary-profile-modal-section">
|
||||
<div class="salary-profile-modal-section-title">
|
||||
<strong>提成与加班费</strong>
|
||||
<span>提成和不同加班单价单独维护</span>
|
||||
</div>
|
||||
<div class="salary-profile-modal-fields">
|
||||
<label class="field">
|
||||
<span>默认提成</span>
|
||||
<input v-model.number="form.commission_amount" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>工作日加班单价</span>
|
||||
<input v-model.number="form.overtime_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>周末加班单价</span>
|
||||
<input v-model.number="form.weekend_overtime_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>节假日加班单价</span>
|
||||
<input v-model.number="form.holiday_overtime_rate" min="0" step="0.01" type="number" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="salary-profile-modal-section salary-profile-modal-section-muted">
|
||||
<div class="salary-profile-modal-section-title">
|
||||
<strong>备注</strong>
|
||||
<span>记录特殊薪资说明</span>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>备注</span>
|
||||
<textarea v-model="form.remark" />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<p v-if="errorMessage" class="form-error salary-profile-modal-message">{{ errorMessage }}</p>
|
||||
<div class="form-actions align-right salary-profile-modal-actions">
|
||||
<button class="secondary-button" type="button" @click="resetForm">清空</button>
|
||||
<button class="secondary-button" type="button" @click="closeEditor">取消</button>
|
||||
<button class="primary-button" type="submit" :disabled="saving">
|
||||
<Loader2 v-if="saving" class="spin" :size="18" aria-hidden="true" />
|
||||
<Save v-else :size="18" aria-hidden="true" />
|
||||
<span>{{ saving ? "保存中" : "保存薪资" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -106,7 +106,7 @@ function roleLabel(role: string): string {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<section class="summary-strip">
|
||||
<StatCard title="超级用户" :value="String(superuserCount)" :icon="ShieldCheck" tone="blue" meta="全部权限" />
|
||||
<StatCard title="管理者" :value="String(managerCount)" :icon="UsersRound" tone="green" meta="计算权限" />
|
||||
<StatCard title="查看权限" :value="String(viewerCount)" :icon="UsersRound" tone="neutral" meta="只读权限" />
|
||||
|
||||
51
frontend/tests/cardSystemConsistency.test.ts
Normal file
51
frontend/tests/cardSystemConsistency.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
declare const process: { cwd(): string };
|
||||
declare function require(name: string): {
|
||||
readFileSync?: (path: string, encoding: string) => string;
|
||||
join?: (...parts: string[]) => string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
if (!readFileSync || !join) {
|
||||
throw new Error("测试运行环境缺少文件读取能力");
|
||||
}
|
||||
|
||||
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||
|
||||
const rootBlock = cssBlock(":root");
|
||||
assertIncludes(rootBlock, "--card-border: 1px solid var(--line);");
|
||||
assertIncludes(rootBlock, "--card-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.035);");
|
||||
assertIncludes(rootBlock, "--card-inner-border: 1px solid var(--line-soft);");
|
||||
assertIncludes(rootBlock, "--card-inner-bg: var(--surface);");
|
||||
assertIncludes(rootBlock, "--card-inner-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.56);");
|
||||
|
||||
const mainCardBlock = cssBlock(".panel,\\s*\\n\\.action-card,\\s*\\n\\.salary-profile-summary-strip,\\s*\\n\\.organization-summary-strip");
|
||||
assertIncludes(mainCardBlock, "border: var(--card-border);");
|
||||
assertIncludes(mainCardBlock, "background: var(--card);");
|
||||
assertIncludes(mainCardBlock, "box-shadow: var(--card-shadow);");
|
||||
|
||||
const summaryStripBlock = cssBlock(".summary-strip");
|
||||
assertIncludes(summaryStripBlock, "border: var(--card-border);");
|
||||
assertIncludes(summaryStripBlock, "background: var(--card);");
|
||||
assertIncludes(summaryStripBlock, "box-shadow: var(--card-shadow);");
|
||||
|
||||
const innerCardBlock = cssBlock(".summary-metric,\\s*\\n\\.compact-stat,\\s*\\n\\.salary-profile-summary-item,\\s*\\n\\.organization-summary-strip div,\\s*\\n\\.organization-node-summary div,\\s*\\n\\.employee-inline-metric,\\s*\\n\\.workflow-steps span,\\s*\\n\\.recent-item,\\s*\\n\\.salary-profile-modal-section,\\s*\\n\\.report-total-item,\\s*\\n\\.report-inline-metrics span,\\s*\\n\\.report-attendance-card");
|
||||
assertIncludes(innerCardBlock, "border: var(--card-inner-border);");
|
||||
assertIncludes(innerCardBlock, "background: var(--card-inner-bg);");
|
||||
assertIncludes(innerCardBlock, "box-shadow: var(--card-inner-shadow);");
|
||||
|
||||
function cssBlock(selector: string): string {
|
||||
const pattern = new RegExp(`${selector}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
|
||||
const match = css.match(pattern);
|
||||
if (!match) {
|
||||
throw new Error(`缺少样式块 ${selector}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function assertIncludes(content: string, expected: string): void {
|
||||
if (!content.includes(expected)) {
|
||||
throw new Error(`期望包含 ${expected}`);
|
||||
}
|
||||
}
|
||||
69
frontend/tests/compactSummaryStrip.test.ts
Normal file
69
frontend/tests/compactSummaryStrip.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
declare const process: { cwd(): string };
|
||||
declare function require(name: string): {
|
||||
readFileSync?: (path: string, encoding: string) => string;
|
||||
join?: (...parts: string[]) => string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
if (!readFileSync || !join) {
|
||||
throw new Error("测试运行环境缺少文件读取能力");
|
||||
}
|
||||
|
||||
const root = process.cwd();
|
||||
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
|
||||
const statCard = readFileSync(join(root, "src/components/StatCard.vue"), "utf-8");
|
||||
|
||||
const compactPages = [
|
||||
"DashboardView.vue",
|
||||
"CommissionsView.vue",
|
||||
"ConfigCenterView.vue",
|
||||
"UsersView.vue",
|
||||
"OperationLogsView.vue",
|
||||
"PayrollView.vue",
|
||||
];
|
||||
|
||||
for (const page of compactPages) {
|
||||
const view = readFileSync(join(root, "src/views", page), "utf-8");
|
||||
assertIncludes(view, "summary-strip", `${page} 应使用紧凑摘要条`);
|
||||
assertNotIncludes(view, "<section class=\"stats-grid", `${page} 不应再使用大统计卡片区`);
|
||||
}
|
||||
|
||||
assertIncludes(statCard, "summary-metric", "StatCard 应输出紧凑指标项");
|
||||
assertNotIncludes(statCard, "class=\"stat-card\"", "StatCard 不应再输出旧大卡片类名");
|
||||
|
||||
const summaryStripBlock = cssBlock(".summary-strip");
|
||||
assertIncludes(summaryStripBlock, "display: flex;");
|
||||
assertIncludes(summaryStripBlock, "padding: 6px 8px;");
|
||||
|
||||
const summaryMetricBlock = cssBlock(".summary-metric");
|
||||
assertIncludes(summaryMetricBlock, "min-height: 42px;");
|
||||
assertIncludes(summaryMetricBlock, "padding: 5px 8px;");
|
||||
|
||||
assertNotIncludes(css, "min-height: 118px;", "旧大统计卡片高度不应保留");
|
||||
|
||||
function cssBlock(selector: string): string {
|
||||
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
|
||||
const match = css.match(pattern);
|
||||
if (!match) {
|
||||
throw new Error(`缺少样式块 ${selector}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function assertIncludes(content: string, expected: string, message?: string): void {
|
||||
if (!content.includes(expected)) {
|
||||
throw new Error(message || `期望包含 ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotIncludes(content: string, unexpected: string, message?: string): void {
|
||||
if (content.includes(unexpected)) {
|
||||
throw new Error(message || `不应包含 ${unexpected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
96
frontend/tests/globalUiAtmosphere.test.ts
Normal file
96
frontend/tests/globalUiAtmosphere.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
declare const process: { cwd(): string };
|
||||
declare function require(name: string): {
|
||||
readFileSync?: (path: string, encoding: string) => string;
|
||||
join?: (...parts: string[]) => string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
if (!readFileSync || !join) {
|
||||
throw new Error("测试运行环境缺少文件读取能力");
|
||||
}
|
||||
|
||||
const root = process.cwd();
|
||||
const css = readFileSync(join(root, "src/assets/styles.css"), "utf-8");
|
||||
const themeStore = readFileSync(join(root, "src/stores/theme.ts"), "utf-8");
|
||||
|
||||
const rootBlock = cssBlock(":root");
|
||||
assertIncludes(rootBlock, "--base-font-size: 14px;");
|
||||
assertIncludes(rootBlock, "--page-padding: 20px;");
|
||||
assertIncludes(rootBlock, "--content-gap: 14px;");
|
||||
assertIncludes(rootBlock, "--panel-padding: 16px;");
|
||||
|
||||
const pageHeaderBlock = cssBlock(".page-header");
|
||||
assertIncludes(pageHeaderBlock, "min-height: 52px;");
|
||||
assertIncludes(pageHeaderBlock, "padding-bottom: 2px;");
|
||||
|
||||
const pageTitleBlock = cssBlock(".page-header h1");
|
||||
assertIncludes(pageTitleBlock, "font-size: 26px;");
|
||||
|
||||
const summaryStripBlock = cssBlock(".summary-strip");
|
||||
assertIncludes(summaryStripBlock, "display: flex;");
|
||||
assertIncludes(summaryStripBlock, "padding: 6px 8px;");
|
||||
|
||||
const summaryMetricBlock = cssBlock(".summary-metric");
|
||||
assertIncludes(summaryMetricBlock, "min-height: 42px;");
|
||||
assertIncludes(summaryMetricBlock, "padding: 5px 8px;");
|
||||
|
||||
const summaryIconBlock = cssBlock(".summary-icon");
|
||||
assertIncludes(summaryIconBlock, "width: 26px;");
|
||||
assertIncludes(summaryIconBlock, "height: 26px;");
|
||||
|
||||
const panelHeaderBlock = cssBlock(".panel-header");
|
||||
assertIncludes(panelHeaderBlock, "padding: 14px var(--panel-padding);");
|
||||
|
||||
const tableFilterBlock = cssBlock(".table-filter-bar");
|
||||
assertIncludes(tableFilterBlock, "padding: 10px var(--panel-padding);");
|
||||
assertIncludes(tableFilterBlock, "background: var(--surface);");
|
||||
|
||||
const tableHeaderBlock = cssBlock(".data-table th");
|
||||
assertIncludes(tableHeaderBlock, "position: sticky;");
|
||||
assertIncludes(tableHeaderBlock, "top: 0;");
|
||||
|
||||
const modalBackdropBlock = cssBlock(".modal-backdrop");
|
||||
assertIncludes(modalBackdropBlock, "backdrop-filter: blur(8px);");
|
||||
|
||||
const employeeMetricBlock = cssBlock(".employee-inline-metric");
|
||||
assertIncludes(employeeMetricBlock, "min-height: 42px;");
|
||||
assertIncludes(employeeMetricBlock, "padding: 6px 10px;");
|
||||
|
||||
const userFormBlock = cssBlock(".user-form");
|
||||
assertIncludes(userFormBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
|
||||
|
||||
const paginationActionsBlock = cssBlock(".table-pagination .form-actions");
|
||||
assertIncludes(paginationActionsBlock, "justify-content: flex-end;");
|
||||
assertIncludes(paginationActionsBlock, "background: transparent;");
|
||||
|
||||
assertIncludes(themeStore, '"--base-font-size": "14px"');
|
||||
assertIncludes(themeStore, '"--page-padding": "20px"');
|
||||
assertIncludes(themeStore, '"--content-gap": "14px"');
|
||||
assertIncludes(themeStore, '"--panel-padding": "16px"');
|
||||
|
||||
function cssBlock(selector: string): string {
|
||||
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
|
||||
const match = css.match(pattern);
|
||||
if (!match) {
|
||||
throw new Error(`缺少样式块 ${selector}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function assertIncludes(content: string, expected: string): void {
|
||||
if (!content.includes(expected)) {
|
||||
throw new Error(`期望包含 ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotIncludes(content: string, unexpected: string): void {
|
||||
if (content.includes(unexpected)) {
|
||||
throw new Error(`不应包含 ${unexpected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
87
frontend/tests/reportsViewLayout.test.ts
Normal file
87
frontend/tests/reportsViewLayout.test.ts
Normal file
@ -0,0 +1,87 @@
|
||||
declare const process: { cwd(): string };
|
||||
declare function require(name: string): {
|
||||
readFileSync?: (path: string, encoding: string) => string;
|
||||
join?: (...parts: string[]) => string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
if (!readFileSync || !join) {
|
||||
throw new Error("测试运行环境缺少文件读取能力");
|
||||
}
|
||||
|
||||
const view = readFileSync(join(process.cwd(), "src/views/ReportsView.vue"), "utf-8");
|
||||
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||
|
||||
assertIncludes(view, "report-page");
|
||||
assertIncludes(view, "report-ledger-workspace");
|
||||
assertIncludes(view, "工资明细台账");
|
||||
assertIncludes(view, "report-ledger-toolbar");
|
||||
assertIncludes(view, "report-ledger-totals");
|
||||
assertIncludes(view, "report-inline-metrics");
|
||||
assertIncludes(view, "report-detail-grid");
|
||||
assertIncludes(view, "report-record-panel");
|
||||
assertIncludes(view, "最近工资明细");
|
||||
assertNotIncludes(view, "<section class=\"stats-grid\">");
|
||||
assertNotIncludes(view, "StatCard");
|
||||
assertNotIncludes(view, "report-summary-panel");
|
||||
assertNotIncludes(view, "report-metric-card");
|
||||
assertNotIncludes(view, "先看明细再看拆分");
|
||||
assertNotIncludes(view, "汇总金额和最近工资明细");
|
||||
assertBefore(view, "report-ledger-workspace", "report-detail-grid");
|
||||
assertBefore(view, "最近工资明细", "report-detail-grid");
|
||||
|
||||
assertIncludes(css, ".report-ledger-workspace");
|
||||
assertIncludes(css, ".report-ledger-toolbar");
|
||||
assertIncludes(css, ".report-ledger-totals");
|
||||
assertIncludes(css, ".report-inline-metrics");
|
||||
assertIncludes(css, ".report-record-panel");
|
||||
assertIncludes(css, ".report-detail-grid");
|
||||
assertNotIncludes(css, ".report-summary-panel");
|
||||
assertNotIncludes(css, ".report-metric-card");
|
||||
|
||||
const ledgerToolbarBlock = cssBlock(".report-ledger-toolbar");
|
||||
assertIncludes(ledgerToolbarBlock, "padding: 10px var(--panel-padding) 8px;");
|
||||
|
||||
const totalItemBlock = cssBlock(".report-total-item");
|
||||
assertIncludes(totalItemBlock, "min-height: 52px;");
|
||||
|
||||
const inlineMetricsBlock = cssBlock(".report-inline-metrics");
|
||||
assertIncludes(inlineMetricsBlock, "padding: 7px var(--panel-padding);");
|
||||
|
||||
const tableHeadingBlock = cssBlock(".report-table-heading");
|
||||
assertIncludes(tableHeadingBlock, "padding: 9px var(--panel-padding) 6px;");
|
||||
|
||||
function assertIncludes(content: string, expected: string): void {
|
||||
if (!content.includes(expected)) {
|
||||
throw new Error(`期望包含 ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotIncludes(content: string, unexpected: string): void {
|
||||
if (content.includes(unexpected)) {
|
||||
throw new Error(`不应包含 ${unexpected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBefore(content: string, earlier: string, later: string): void {
|
||||
const earlierIndex = content.indexOf(earlier);
|
||||
const laterIndex = content.indexOf(later);
|
||||
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
|
||||
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
|
||||
}
|
||||
}
|
||||
|
||||
function cssBlock(selector: string): string {
|
||||
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
|
||||
const match = css.match(pattern);
|
||||
if (!match) {
|
||||
throw new Error(`缺少样式块 ${selector}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
71
frontend/tests/salaryProfileAtmosphere.test.ts
Normal file
71
frontend/tests/salaryProfileAtmosphere.test.ts
Normal file
@ -0,0 +1,71 @@
|
||||
declare const process: { cwd(): string };
|
||||
declare function require(name: string): {
|
||||
readFileSync?: (path: string, encoding: string) => string;
|
||||
join?: (...parts: string[]) => string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
if (!readFileSync || !join) {
|
||||
throw new Error("测试运行环境缺少文件读取能力");
|
||||
}
|
||||
|
||||
const view = readFileSync(join(process.cwd(), "src/views/SalaryProfilesView.vue"), "utf-8");
|
||||
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||
|
||||
assertIncludes(view, "salary-profile-page");
|
||||
assertIncludes(view, "salary-profile-summary-strip");
|
||||
assertIncludes(view, "salary-profile-summary-item");
|
||||
assertIncludes(view, "salary-profile-toolbar");
|
||||
assertIncludes(view, "salary-profile-modal-section");
|
||||
assertIncludes(view, "salary-profile-modal-section-title");
|
||||
assertBefore(view, "salary-profile-summary-strip", "薪资结构一览");
|
||||
assertBefore(view, "薪资结构一览", "salary-profile-edit-modal");
|
||||
|
||||
const summaryBlock = cssBlock(".salary-profile-summary-strip");
|
||||
assertIncludes(summaryBlock, "grid-template-columns: repeat(4, minmax(0, 1fr));");
|
||||
assertIncludes(summaryBlock, "padding: 10px;");
|
||||
assertNotIncludes(summaryBlock, "min-height: 178px;");
|
||||
|
||||
const toolbarBlock = cssBlock(".salary-profile-toolbar");
|
||||
assertIncludes(toolbarBlock, "display: grid;");
|
||||
assertIncludes(toolbarBlock, "grid-template-columns:");
|
||||
|
||||
const modalSectionBlock = cssBlock(".salary-profile-modal-section");
|
||||
assertIncludes(modalSectionBlock, "border: var(--card-inner-border);");
|
||||
assertIncludes(css, ".salary-profile-modal-section {\n display: grid;");
|
||||
assertIncludes(css, "padding: 14px;");
|
||||
assertIncludes(css, ".salary-profile-modal-fields");
|
||||
assertIncludes(css, ".salary-profile-summary-strip,");
|
||||
assertIncludes(css, ".salary-profile-toolbar");
|
||||
|
||||
function cssBlock(selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`${escaped}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
|
||||
const match = css.match(pattern);
|
||||
if (!match) {
|
||||
throw new Error(`缺少样式块 ${selector}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function assertIncludes(content: string, expected: string): void {
|
||||
if (!content.includes(expected)) {
|
||||
throw new Error(`期望包含 ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotIncludes(content: string, unexpected: string): void {
|
||||
if (content.includes(unexpected)) {
|
||||
throw new Error(`不应包含 ${unexpected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBefore(content: string, earlier: string, later: string): void {
|
||||
const earlierIndex = content.indexOf(earlier);
|
||||
const laterIndex = content.indexOf(later);
|
||||
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
|
||||
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
|
||||
}
|
||||
}
|
||||
50
frontend/tests/salaryProfileCardsLayout.test.ts
Normal file
50
frontend/tests/salaryProfileCardsLayout.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
declare const process: { cwd(): string };
|
||||
declare function require(name: string): {
|
||||
readFileSync?: (path: string, encoding: string) => string;
|
||||
join?: (...parts: string[]) => string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
if (!readFileSync || !join) {
|
||||
throw new Error("测试运行环境缺少文件读取能力");
|
||||
}
|
||||
|
||||
const view = readFileSync(join(process.cwd(), "src/views/SalaryProfilesView.vue"), "utf-8");
|
||||
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||
|
||||
assertIncludes(view, "salary-profile-list-tools");
|
||||
assertIncludes(view, "salary-profile-edit-modal");
|
||||
assertIncludes(view, "salary-profile-edit-card");
|
||||
assertIncludes(view, "薪资结构一览");
|
||||
assertIncludes(view, "编辑薪资");
|
||||
assertIncludes(view, "<table class=\"data-table salary-profile-table\">");
|
||||
assertNotIncludes(view, "salary-profile-card-grid");
|
||||
assertNotIncludes(view, "salary-profile-form-panel");
|
||||
assertBefore(view, "薪资结构一览", "salary-profile-edit-modal");
|
||||
|
||||
assertIncludes(css, ".salary-profile-list-tools");
|
||||
assertIncludes(css, ".salary-profile-table");
|
||||
assertIncludes(css, ".salary-profile-edit-modal");
|
||||
assertIncludes(css, ".salary-profile-edit-card");
|
||||
|
||||
function assertIncludes(content: string, expected: string): void {
|
||||
if (!content.includes(expected)) {
|
||||
throw new Error(`期望包含 ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotIncludes(content: string, unexpected: string): void {
|
||||
if (content.includes(unexpected)) {
|
||||
throw new Error(`不应包含 ${unexpected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBefore(content: string, earlier: string, later: string): void {
|
||||
const earlierIndex = content.indexOf(earlier);
|
||||
const laterIndex = content.indexOf(later);
|
||||
if (earlierIndex === -1 || laterIndex === -1 || earlierIndex >= laterIndex) {
|
||||
throw new Error(`期望 ${earlier} 出现在 ${later} 之前`);
|
||||
}
|
||||
}
|
||||
46
frontend/tests/toastMessagesCss.test.ts
Normal file
46
frontend/tests/toastMessagesCss.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
declare const process: { cwd(): string };
|
||||
declare function require(name: string): {
|
||||
readFileSync?: (path: string, encoding: string) => string;
|
||||
join?: (...parts: string[]) => string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
if (!readFileSync || !join) {
|
||||
throw new Error("测试运行环境缺少文件读取能力");
|
||||
}
|
||||
|
||||
const css = readFileSync(join(process.cwd(), "src/assets/styles.css"), "utf-8");
|
||||
const messageBlock = cssBlock(".form-error,\\s*\\n\\.form-success");
|
||||
|
||||
assertIncludes(messageBlock, "position: fixed;");
|
||||
assertIncludes(messageBlock, "z-index: 120;");
|
||||
assertIncludes(messageBlock, "left: 50%;");
|
||||
assertIncludes(messageBlock, "right: auto;");
|
||||
assertIncludes(messageBlock, "animation: toast-autohide");
|
||||
assertIncludes(messageBlock, "pointer-events: none;");
|
||||
assertNotIncludes(messageBlock, "right: 28px;");
|
||||
assertIncludes(css, "@keyframes toast-autohide");
|
||||
assertIncludes(css, "visibility: hidden;");
|
||||
|
||||
function cssBlock(selector: string): string {
|
||||
const pattern = new RegExp(`${selector}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
|
||||
const match = css.match(pattern);
|
||||
if (!match) {
|
||||
throw new Error(`缺少样式块 ${selector}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function assertIncludes(content: string, expected: string): void {
|
||||
if (!content.includes(expected)) {
|
||||
throw new Error(`期望包含 ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNotIncludes(content: string, unexpected: string): void {
|
||||
if (content.includes(unexpected)) {
|
||||
throw new Error(`不应包含 ${unexpected}`);
|
||||
}
|
||||
}
|
||||
@ -25,6 +25,13 @@ assertIncludes(ledgerVisual, "padding: 32px;");
|
||||
const appearancePanel = cssBlock(".appearance-panel");
|
||||
assertIncludes(appearancePanel, "width: min(480px, calc(100vw - 20px));");
|
||||
|
||||
const primaryFocus = cssBlock(".primary-button:focus-visible");
|
||||
assertIncludes(primaryFocus, "outline: 0;");
|
||||
assertIncludes(primaryFocus, "box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.16);");
|
||||
|
||||
const tableRows = cssBlock(".data-table tbody tr");
|
||||
assertIncludes(tableRows, "transition: background var(--motion-duration-fast) ease;");
|
||||
|
||||
function cssBlock(selector: string): string {
|
||||
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{([\\s\\S]*?)\\n\\}`, "m");
|
||||
const match = css.match(pattern);
|
||||
|
||||
55
tests/payroll_exceptions_test.py
Normal file
55
tests/payroll_exceptions_test.py
Normal file
@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from financial_system.domain.models import EmployeeAttendance, PayrollResult
|
||||
from financial_system.domain.payroll_exceptions import detect_payroll_exceptions
|
||||
|
||||
|
||||
def test_detect_payroll_exceptions_flags_core_payroll_risks() -> None:
|
||||
result = PayrollResult(
|
||||
employee=EmployeeAttendance(name="张三", employee_no="ZA0001", department="生产中心"),
|
||||
salary_month="2026-06",
|
||||
salary_mode="monthly",
|
||||
attendance_days=0,
|
||||
absence_days=22,
|
||||
actual_work_hours=0,
|
||||
punch_overtime_hours=1,
|
||||
leave_hours=3,
|
||||
remaining_overtime_hours=-2,
|
||||
workday_overtime_hours=0,
|
||||
weekend_overtime_hours=0,
|
||||
holiday_overtime_hours=0,
|
||||
minor_late_count=4,
|
||||
major_late_count=1,
|
||||
penalized_late_count=2,
|
||||
late_deduction=60,
|
||||
missing_card_count=1,
|
||||
missing_card_deduction=20,
|
||||
total_deduction=80,
|
||||
base_salary=None,
|
||||
base_pay=None,
|
||||
commission_amount=0,
|
||||
overtime_rate=0,
|
||||
weekend_overtime_rate=0,
|
||||
holiday_overtime_rate=0,
|
||||
overtime_pay=0,
|
||||
gross_salary=None,
|
||||
net_salary=None,
|
||||
note="未配置薪资档案",
|
||||
)
|
||||
|
||||
exception_types = {item.exception_type for item in detect_payroll_exceptions([result])}
|
||||
|
||||
assert "missing_salary_profile" in exception_types
|
||||
assert "missing_card" in exception_types
|
||||
assert "late_penalty" in exception_types
|
||||
assert "negative_remaining_overtime" in exception_types
|
||||
assert "empty_attendance" in exception_types
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_detect_payroll_exceptions_flags_core_payroll_risks()
|
||||
80
tests/realtime_attendance_service_test.py
Normal file
80
tests/realtime_attendance_service_test.py
Normal file
@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from financial_system.services.realtime_attendance_service import TodayAttendanceItemData, build_realtime_summary
|
||||
|
||||
|
||||
def test_build_realtime_summary_counts_today_status() -> None:
|
||||
items = [
|
||||
TodayAttendanceItemData(
|
||||
employee_id=1,
|
||||
employee_no="ZA0001",
|
||||
employee_name="张三",
|
||||
department="生产中心",
|
||||
position="技工",
|
||||
first_punch="08:58",
|
||||
last_punch="18:30",
|
||||
attendance_status="present",
|
||||
late_minutes=0,
|
||||
missing_card_count=0,
|
||||
leave_hours=0,
|
||||
today_overtime_hours=1,
|
||||
month_remaining_overtime_hours=4,
|
||||
estimated_net_salary=6800,
|
||||
note="",
|
||||
),
|
||||
TodayAttendanceItemData(
|
||||
employee_id=2,
|
||||
employee_no="ZA0002",
|
||||
employee_name="李四",
|
||||
department="行政人事部",
|
||||
position="人事专员",
|
||||
first_punch="09:08",
|
||||
last_punch="",
|
||||
attendance_status="missing",
|
||||
late_minutes=8,
|
||||
missing_card_count=1,
|
||||
leave_hours=2,
|
||||
today_overtime_hours=0,
|
||||
month_remaining_overtime_hours=-2,
|
||||
estimated_net_salary=None,
|
||||
note="缺少下班卡",
|
||||
),
|
||||
TodayAttendanceItemData(
|
||||
employee_id=3,
|
||||
employee_no="ZA0003",
|
||||
employee_name="王五",
|
||||
department="财务部",
|
||||
position="财务",
|
||||
first_punch="",
|
||||
last_punch="",
|
||||
attendance_status="unchecked",
|
||||
late_minutes=0,
|
||||
missing_card_count=0,
|
||||
leave_hours=0,
|
||||
today_overtime_hours=0,
|
||||
month_remaining_overtime_hours=0,
|
||||
estimated_net_salary=5000,
|
||||
note="",
|
||||
),
|
||||
]
|
||||
|
||||
summary = build_realtime_summary(date(2026, 6, 22), items)
|
||||
|
||||
assert summary.employee_total == 3
|
||||
assert summary.checked_in_count == 2
|
||||
assert summary.unchecked_count == 1
|
||||
assert summary.late_count == 1
|
||||
assert summary.leave_count == 1
|
||||
assert summary.missing_card_count == 1
|
||||
assert summary.estimated_overtime_count == 1
|
||||
assert summary.estimated_net_total == 11800
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_build_realtime_summary_counts_today_status()
|
||||
47
tests/settings_env_test.py
Normal file
47
tests/settings_env_test.py
Normal file
@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from financial_system.core.settings import get_settings
|
||||
|
||||
|
||||
def test_database_url_environment_variable_overrides_config_file() -> None:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
config_path = Path(temp_dir) / "app_settings.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"database": {
|
||||
"url": "mysql+pymysql://json-user:json-pass@json-host:3306/financial_system?charset=utf8mb4"
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
docker_database_url = (
|
||||
"mysql+pymysql://docker-user:docker-pass@mysql.example.com:3306/"
|
||||
"financial_system?charset=utf8mb4"
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"APP_CONFIG_FILE": str(config_path),
|
||||
"DATABASE_URL": docker_database_url,
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
settings = get_settings()
|
||||
|
||||
assert settings.database_url == docker_database_url
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_database_url_environment_variable_overrides_config_file()
|
||||
Loading…
Reference in New Issue
Block a user