# TBOSS OA 模块 — 工业级数据库表结构设计说明书

---

## 1. 基础元数据与技术栈 (Metadata & Tech Stack)

### 1.1 数据库类型与版本

| 项目 | 规格 |
| --- | --- |
| **数据库** | Microsoft SQL Server 2019+ (兼容 SQL Server 2022) |
| **宿主平台** | Windows Server |
| **字符集** | 默认排序规则（建议 `Chinese_PRC_CI_AS`） |
| **连接池规范** | .NET `SqlConnection` 默认连接池（Min Pool Size=5, Max Pool Size=100），连接字符串中 `Pooling=true` |
| **事务隔离级别** | 默认 `READ COMMITTED`；预算扣减操作使用 `READ COMMITTED` + 乐观锁（ROWVERSION） |

### 1.2 ORM / 驱动工具

| 层级 | 方案 |
| --- | --- |
| **数据访问层** | .NET Framework 4.8 + ADO.NET (`System.Data.SqlClient`) |
| **微 ORM（推荐）** | Dapper（轻量高效，适合复杂 SQL 手写场景） |
| **全功能 ORM（备选）** | Entity Framework 6.x（Code First 或 Database First） |
| **移动端** | Flutter 3.38.10，通过 HTTP API 与后端交互（不直连数据库） |

### 1.3 命名规范

| 范畴 | 规范 | 示例 |
| --- | --- | --- |
| **表名** | PascalCase，单数形式 | `SysUser`, `ExpenseDetail` |
| **列名** | PascalCase | `Id`, `UserName`, `CreateTime` |
| **主键** | 统一 `Id`，`BIGINT IDENTITY(1,1)` | `Id BIGINT IDENTITY(1,1) PRIMARY KEY` |
| **外键列** | `{引用表名去掉Sys前缀}Id` 或全名 | `DeptId`（引用 `SysDepartment.Id`） |
| **约束名** | `PK_表名` / `FK_表名_列名` / `UX_表名_列名` | `PK_SysUser`, `UX_SysUser_UserName` |
| **索引名** | `IX_表名_用途` / `UX_表名_用途` | `IX_Expense_List_Path` |
| **SQL 关键字** | 大写 | `SELECT`, `FROM`, `WHERE` |

---

## 2. 数据库全局设计约定

### 2.1 主键与标识

所有表主键统一使用 **`BIGINT IDENTITY(1,1)`**，起始值 1，步长 1。不对外暴露主键值，API 层使用业务单号（`ApplicationNo` / `ReportNo` / `VisitNo`）作为资源标识。

> **选型理由**：`BIGINT`（64 位有符号整数，最大值 9,223,372,036,854,775,807）足以支撑企业级 OA 系统的数据规模。整型主键在 B+ 树索引中比 GUID 更紧凑（8 字节 vs 16 字节），页分裂更少，JOIN 性能更高。`BIGINT` 不对外暴露，避免了自增 ID 的可猜测性风险。

### 2.2 精度规范

- **金额/预算字段**：统一使用 `DECIMAL(18,2)`
- **GPS 经纬度字段**：统一使用 `DECIMAL(10,6)`
- **税率字段**：`DECIMAL(5,4)`（如 0.0600, 0.0900, 0.1300）
- **比率字段**：`DECIMAL(3,2)`（如调休比例 0.30）
- **工时字段**：`DECIMAL(4,1)`（如加班净工时 3.5h）

### 2.3 全局必备字段

每张表必须包含：

| 字段 | 类型 | 约束 | 说明 |
| --- | --- | --- | --- |
| `CreateTime` | `DATETIME` | `NOT NULL DEFAULT GETDATE()` | 行创建时间（服务器授时） |
| `UpdateTime` | `DATETIME` | `NULL` | 最后修改时间（由应用层 `UPDATE` 时显式赋值为 `GETDATE()`） |

**例外**：`SysRoleChangeLog` 为不可变审计日志，仅追加不修改，无需 `UpdateTime`。

### 2.4 软删除机制

核心业务表与基础数据表统一挂载 `IsDeleted BIT NOT NULL DEFAULT 0`，用于逻辑删除。

**例外**：
- `SysRoleChangeLog`（审计日志不可删除）
- `SysProjectBudget`（预算记录由 Version 乐观锁控制，不做软删除）
- `ApprovalRecord`（审批记录本身不做软删除，通过 `IsValid` 标记有效性）

### 2.5 状态机规范

所有状态、类型字段统一使用 `VARCHAR(20)` 或 `VARCHAR(30)` 存储英文标识字符串。前端负责本地化国际化语言包的映射与渲染。完整取值说明见[第 6 节](#6-枚举字段的完整取值说明)。

### 2.6 单据编号生成规则

`ApplicationNo` / `ReportNo` / `VisitNo` 等单号统一格式为 `{前缀}-{YYYYMMDD}-{序号}`：

| 业务模块 | 前缀 | 示例 |
| --- | --- | --- |
| 事前申请 | `BXSQ` | `BXSQ-20260530-001` |
| 费用报销 | `BX` | `BX-20260530-001` |
| 加班申请 | `JB` | `JB-20260530-001` |
| 用车申请 | `YC` | `YC-20260530-001` |
| 外勤拜访日志 | `VST` | `VST-20260530-001` |

序号按天重置、高位补零至 3 位。后端使用 SQL Server `sp_getapplock` + `SELECT MAX(…)` 原子获取当日最大序号并 +1，确保高并发下无重复。

---

## 3. 数据库关系图 (ERD — Entity Relationship Diagram)

> 以下为文本形式的实体关系拓扑。外键列均为 `BIGINT`，引用目标表 `Id` 列（`BIGINT IDENTITY`）。

```
                         [SysRole] 
                             │ (1:N)
                       [SysUserRole] (复合岗位矩阵桥接表)
                             ▲
                             │ (N:1)
                    [SysRoleChangeLog] (角色变更审计，关联 OperatorId + TargetUserId)
    [SysDepartment] ──── [SysUser] (全局统一核心枢纽) ◄─────────────────────────────────┐
        │ (自引用)          │                                                           │
        │ ParentId          ├─(1:N)─► [SysCostCenter] (成本中心)                       │
        │                   │                                                           │
        │ (1:N)             ├─(1:N)─► [ExpenseApplication] ──(1:N)──► [ExpenseAppDetail]│
        │                   ├─► (1:N)─► [ExpenseApplicationAttachment]                 │
        ├─► [Expense]       ├─(1:N)─► [Expense] ───────────(1:N)──► [ExpenseDetail]    │
        ├─► [Overtime]      │ (N:1)      │ (N:1)            (N:1)                     │
        ├─► [Vehicle]       │ [SysCostCenter] └─(1:N)─► [ExpenseAttachment]            │
        └─► [OutingLog]     │                                                           │
            (DeptId)        │         [SysBank] (银行字典，供 Expense.BankName 下拉联想)  │
                            │                                                           │
                            ├─(1:N)─► [Overtime] (NetOtHours 净工时)                    │
                            ├─(1:N)─► [Vehicle] ──(1:N)──► [VehiclePassenger]           │
                            │            │            ┌─► (Odometer / ActualCost 还车核销)│
                            │            │ (N:1)      │                                  │
                            │       [SysVehicle] ◄────┘                                  │
                            ├─(1:N)─► [OutingLog] ──(1:N)──► [OutingLogComment]         │
                            │            │ (N:1)           └─► (CheckInAddress 强控防伪定位)│
                            │       [SysCustomer] ──(1:N)──► [SysCustomerContact]       │
                            │            │                                               │
                            │            └─(1:N)─► [OutingLogAttachment]                  │
                            ├─(1:N)─► [Announcement] (Publisher)                        │
                            │            ├─(1:N)─► [AnnouncementTarget]                   │
                            │            ├─(1:N)─► [AnnouncementAttachment]               │
                            │            └─(1:N)─► [AnnouncementReadLog] (DING 强力审计)  │
                            └─(1:N)─► [Message] (BizType & BizId 消息寻址核心组件)       │

  [SysProject] ──┬──► [SysProjectBudget] (AvailableAmount 核心动态预算强控制)           │
                 │ (N:1)                                                                 │
           [SysDepartment] (项目归属部门)                                                │
                                                                                         │
  [SysBudgetSubject] ┘ (自引用 ParentId)                                                 │

  [SysCostCategory] (费用类别字典，自引用 ParentId)                                        │

  [ApprovalChain] (BizType/DeptId 审批层级配置，关联 ApproverId/ApproverRole)              │

  [ApprovalRecord] (Action: approve/reject/transfer 三元控制，IsValid 标记历史失效链) ─────┘

  [SysBanner] (工作台轮播图，管理员维护)

```

> **注意**：
> - 所有主键均为 `BIGINT IDENTITY(1,1)`。
> - 所有外键列均为 `BIGINT`，对应引用表的 `Id` 列。
> - `ApprovalRecord.BizId` 和 `AnnouncementTarget.TargetId` 为多态外键（`BIGINT`），根据相应的类型字段（`BizType` / `TargetType`）路由到不同业务表。
> - SQL Server 不支持多态外键约束，由应用层校验引用完整性。

---

## 4. 详细表结构定义 (Table Definitions)

### 4.1 权限与组织架构

#### 4.1.1 SysUser（用户表）

**业务含义**：系统用户核心主表，存储所有员工账号、登录凭据、部门归属及默认收款银行信息。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 用户唯一标识 |
| **UserName** | VARCHAR(50) | ✅ | UNIQUE | 登录账号 / 标准工号（全局唯一，不可重用。软删除用户的工号不释放，新员工不可占用） |
| **RealName** | NVARCHAR(50) | ✅ |  | 员工真实姓名 |
| **PasswordHash** | VARCHAR(128) | ✅ |  | 密码加盐哈希值 |
| **DeptId** | BIGINT | ✅ | FK → SysDepartment.Id | 归属部门 |
| **Position** | NVARCHAR(50) |  |  | 岗位/职称名称 |
| **Phone** | VARCHAR(20) |  |  | 绑定手机号（用于接收原生短信网关通知） |
| **Email** | VARCHAR(100) |  |  | 企业邮箱地址 |
| **AvatarUrl** | VARCHAR(500) |  |  | 头像绝对存储路径（用于各类卡片及评论头像渲染） |
| **DefaultBankName** | NVARCHAR(100) |  |  | 默认收款银行全称（报销单首次提交审批通过后自动回写） |
| **DefaultAccountName** | NVARCHAR(50) |  |  | 默认收款户名（报销单首次提交审批通过后自动回写） |
| **DefaultBankAccount** | VARCHAR(50) |  |  | 默认收款银行账号（报销单首次提交审批通过后自动回写） |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 账号启用/停用状态（1=启用，0=禁用） |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 最后修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_User_Search` | NONCLUSTERED | (UserName, RealName) INCLUDE (DeptId, IsActive, IsDeleted) | 用户登录/搜索加速 |

---

#### 4.1.2 SysRole（系统角色表）

**业务含义**：标准 RBAC 角色字典，存储系统内所有可用角色编码及中文名称。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 角色唯一标识 |
| **RoleCode** | VARCHAR(30) | ✅ | UNIQUE | 角色编码 |
| **RoleName** | NVARCHAR(50) | ✅ |  | 角色中文名称 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

**预置角色取值**：

| RoleCode | RoleName | 说明 |
| --- | --- | --- |
| `employee` | 员工 | 基础用户，可发起各类申请 |
| `approver` | 经理 | 部门审批人，可在待办列表审批/拒绝/转交单据 |
| `finance` | 财务 | 财务核销专员，执行三字合规查验与付款标记 |
| `admin` | 系统管理员 | 最高权限，管理用户、角色、组织架构、审批链、基础数据 |

---

#### 4.1.3 SysUserRole（用户角色多对多映射表）

**业务含义**：实现一个用户拥有多个角色的复合授权能力，支持管理员通过复选框矩阵（`TDCheckboxGroup`）为员工自由分配角色。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 映射唯一标识 |
| **UserId** | BIGINT | ✅ | FK → SysUser.Id | 关联用户 |
| **RoleId** | BIGINT | ✅ | FK → SysRole.Id | 关联角色 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 授权时间 |
| **UpdateTime** | DATETIME |  |  | 变更时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记（移除角色时软删除，保留历史记录） |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `UX_UserRole_Active` | UNIQUE NONCLUSTERED | (UserId, RoleId) WHERE IsDeleted = 0 | 防止同一角色重复授权，同时允许删除后重新授权 |
| `IX_UserRole_Role_Reverse` | NONCLUSTERED | (RoleId, IsDeleted) INCLUDE (UserId) | 按角色反查用户——权限管理页 + 审批链动态寻人 |

---

#### 4.1.4 SysRoleChangeLog（角色变更审计日志表）

**业务含义**：记录管理员每次修改用户角色和启用/禁用操作的完整审计轨迹。不可物理删除，不可修改。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 日志唯一标识 |
| **TargetUserId** | BIGINT | ✅ | FK → SysUser.Id | 被操作的目标用户 |
| **OperatorId** | BIGINT | ✅ | FK → SysUser.Id | 执行操作的管理员 |
| **ChangeType** | VARCHAR(20) | ✅ |  | 变更类型 |
| **BeforeSnapshot** | NVARCHAR(MAX) | ✅ |  | 变更前 JSON 快照（含角色列表 + IsActive 状态） |
| **AfterSnapshot** | NVARCHAR(MAX) | ✅ |  | 变更后 JSON 快照 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 操作时间（不可修改，仅追加） |

> **注意**：本表无 `UpdateTime` 和 `IsDeleted` 字段——审计日志不可修改、不可删除。

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_RoleChangeLog_TargetUser` | NONCLUSTERED | (TargetUserId, CreateTime DESC) INCLUDE (OperatorId, ChangeType, BeforeSnapshot, AfterSnapshot) | 按目标用户查询角色变更审计历史 |

---

#### 4.1.5 SysDepartment（企业组织架构部门表）

**业务含义**：企业部门树形组织架构，支持自引用父子关系（`ParentId`），用于生成无限层级部门树。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 部门唯一标识 |
| **DeptName** | NVARCHAR(100) | ✅ |  | 部门完整名称 |
| **ParentId** | BIGINT |  | FK → SysDepartment.Id（自引用） | 上级部门 ID，无上级则为 NULL |
| **ManagerId** | BIGINT |  | FK → SysUser.Id | 部门负责人/主管 |
| **SortOrder** | INT | ✅ | DEFAULT 0 | 排序权重，值越小在架构树中越靠前 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 部门启用状态 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记（部门裁撤/合并时标记） |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Department_Tree` | NONCLUSTERED | (ParentId, SortOrder) INCLUDE (DeptName, ManagerId, IsActive) WHERE IsDeleted = 0 | 按上级部门加载子节点，加速部门树渲染 |

---

#### 4.1.6 SysBanner（工作台轮播图配置表）

**业务含义**：支持工作台顶部 `TDRotation` 轮播图组件的动态配置，管理员可维护图片、标题、跳转链接和排序。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 轮播项唯一标识 |
| **ImageUrl** | VARCHAR(500) | ✅ |  | 轮播图片云端存储绝对 URL |
| **Title** | NVARCHAR(100) |  |  | 轮播图片标题/alt 文本 |
| **LinkUrl** | VARCHAR(500) |  |  | 点击跳转链接（可空，为空则不可点击） |
| **SortOrder** | INT | ✅ | DEFAULT 0 | 排序权重，值越小越靠前 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 启用/停用 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Banner_Active` | NONCLUSTERED | (IsActive, SortOrder) INCLUDE (ImageUrl, Title, LinkUrl) WHERE IsDeleted = 0 | 按排序加载启用轮播项 |

---

### 4.2 财务控制、项目预算与成本中心

#### 4.2.1 SysProject（企业项目表）

**业务含义**：项目主数据。支持"项目 + 预算科目"级联选择器实时加载当前可用预算余额，超支时触发高管特批流。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 项目唯一标识 |
| **ProjectName** | NVARCHAR(200) | ✅ |  | 项目完整名称 |
| **ProjectCode** | VARCHAR(50) | ✅ | UNIQUE | 项目标准控制编码（如：PROJ-2026-001） |
| **DeptId** | BIGINT |  | FK → SysDepartment.Id | 归属主控部门 |
| **TotalBudget** | DECIMAL(18,2) | ✅ | DEFAULT 0 | 项目总核定预算金额 |
| **SpentBudget** | DECIMAL(18,2) | ✅ | DEFAULT 0 | 项目已累计消耗支出总额（用于经理看板进度条计算） |
| **StartDate** | DATE |  |  | 项目合同/预定启动日期 |
| **EndDate** | DATE |  |  | 项目预定结项日期 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 项目状态（1=进行中，0=已封账） |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

> **SpentBudget 与 SysProjectBudget 的关系**：`SpentBudget` 为项目级累计消耗汇总（= 该项目下所有 `SysProjectBudget.AvailableAmount` 扣减额之和），用于经理看板进度条快速读取。当 `SysProjectBudget.AvailableAmount` 扣减时，同步以相同金额累加到 `SpentBudget`，两者在同一事务内更新。
>
> **TotalBudget 约束**：应用层需保证该项目下所有 `SysProjectBudget.AllocatedAmount` 之和 ≤ `TotalBudget`，超分配应在配置时拒绝。

---

#### 4.2.2 SysBudgetSubject（预算科目字典表）

**业务含义**：财务核算科目树形字典（如差旅费、业务招待费、日常采购）。支持自引用二级分类，叶子节点才可绑定预算金额。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 科目唯一标识 |
| **SubjectName** | NVARCHAR(100) | ✅ |  | 科目中文名称 |
| **SubjectCode** | VARCHAR(50) | ✅ | UNIQUE | 科目代码（如：EXP-5001） |
| **ParentId** | BIGINT |  | FK → SysBudgetSubject.Id（自引用） | 上级科目 ID，叶子节点才可绑定金额 |
| **SortOrder** | INT | ✅ | DEFAULT 0 | 字典排序权重 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 启用/停用 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

---

#### 4.2.3 SysCostCategory（费用类别字典表）

**业务含义**：为事前申请预估明细和费用报销明细的"费用类别"下拉选择器提供数据源（交通费、住宿费、餐饮费、办公用品等），支持二级分类，叶子节点才可用于明细录入。通过 `BizScope` 和 `ExpenseType` 实现事前申请端与报销端的子类差异化过滤。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 类别唯一标识 |
| **CategoryName** | NVARCHAR(50) | ✅ |  | 类别名称 |
| **CategoryCode** | VARCHAR(30) | ✅ | UNIQUE | 类别编码 |
| **ParentId** | BIGINT |  | FK → SysCostCategory.Id（自引用） | 上级类别 ID，叶子节点才可用于明细录入 |
| **BizScope** | VARCHAR(20) | ✅ | DEFAULT 'both' | 适用业务范围 |
| **ExpenseType** | VARCHAR(20) |  |  | 绑定费用大类（仅 BizScope 含 `expense_apply` 时生效，NULL 表示通用） |
| **SortOrder** | INT | ✅ | DEFAULT 0 | 排序权重 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 启用/停用 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_CostCategory_Parent` | NONCLUSTERED | (ParentId, SortOrder) INCLUDE (CategoryName, CategoryCode) WHERE IsActive = 1 | 级联下拉子类别加载 |
| `IX_CostCategory_Scope_Type` | NONCLUSTERED | (BizScope, ExpenseType, ParentId, SortOrder) INCLUDE (CategoryName, CategoryCode) WHERE IsActive = 1 AND IsDeleted = 0 | 按业务范围 + 费用大类过滤——事前申请/报销端按类型联动子类下拉 |

---

#### 4.2.4 SysProjectBudget（项目-科目联合预算动态控制表）

**业务含义**：高并发预算强管控的核心热点表。前端在录入明细金额时，后台通过此表的数据进行实时差额逻辑校验。支持预算冻结/占用/释放的完整生命周期。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 联合预算唯一标识 |
| **ProjectId** | BIGINT | ✅ | FK → SysProject.Id | 关联项目 |
| **SubjectId** | BIGINT | ✅ | FK → SysBudgetSubject.Id | 关联预算科目 |
| **ExpenseType** | VARCHAR(20) |  |  | 费用类型维度（NULL 表示适用于所有费用类型） |
| **AllocatedAmount** | DECIMAL(18,2) | ✅ |  | 初始分配总额度 |
| **AvailableAmount** | DECIMAL(18,2) | ✅ |  | 实时动态可用余额（已扣减审批通过的金额） |
| **FrozenAmount** | DECIMAL(18,2) | ✅ | DEFAULT 0 | 冻结金额（已提交但未审批通过的申请金额） |
| **Version** | ROWVERSION | ✅ |  | 乐观锁并发控制：每次扣减余额时 WHERE Version = @oldVersion，防止超扣 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 变更校准时间 |

> **实际可用余额公式**：`AvailableAmount - FrozenAmount` = 员工发起新申请时可见的余额。前端展示时使用此公式计算。

> **注意**：本表无 `IsDeleted` 字段，预算记录由 `Version` 乐观锁控制。

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `UX_ProjectBudget_Concurrency_Control` | UNIQUE NONCLUSTERED | (ProjectId, SubjectId, ExpenseType) INCLUDE (AvailableAmount, FrozenAmount, AllocatedAmount) | 高并发预算余额校验唯一索引 |
| `UX_ProjectBudget_NULL_Type` | UNIQUE NONCLUSTERED | (ProjectId, SubjectId) WHERE ExpenseType IS NULL | 防止同一项目+科目下多条通用预算记录 |

---

#### 4.2.5 SysCostCenter（企业成本中心表）

**业务含义**：成本中心主数据，报销单关联成本中心用于财务核算归集。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 成本中心唯一标识 |
| **CenterName** | NVARCHAR(100) | ✅ |  | 成本中心名称 |
| **CenterCode** | VARCHAR(50) | ✅ | UNIQUE | 编码体系 |
| **DeptId** | BIGINT | ✅ | FK → SysDepartment.Id | 归属核算部门 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 是否启用 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

---

#### 4.2.6 SysBank（银行字典表）

**业务含义**：为费用报销表单"开户行全称"下拉联想输入提供数据源，管理员可维护银行列表。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 银行唯一标识 |
| **BankName** | NVARCHAR(100) | ✅ |  | 银行全称 |
| **BankCode** | VARCHAR(20) | ✅ | UNIQUE | 银行联行号 |
| **SortOrder** | INT | ✅ | DEFAULT 0 | 排序权重 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 启用/停用 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Bank_List` | NONCLUSTERED | (SortOrder, IsActive) INCLUDE (BankName, BankCode) | 下拉列表按排序加载 |

---

### 4.3 事前申请与费用报销模块

#### 4.3.1 ExpenseApplication（事前申请单主表）

**业务含义**：费用发生前的事前申请，支持草稿暂存、提交审批、关联项目与预算科目。审批通过后可被多张报销单分批引用，追踪剩余可报金额。按 `ExpenseType` 动态加载专用字段（差旅行程、招待合规、会议信息），数据库层全部允许 NULL，约束在应用层按类型校验。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 事前单据唯一标识 |
| **ApplicationNo** | VARCHAR(30) | ✅ | UNIQUE | 单号序列（格式：BXSQ-YYYYMMDD-XXX） |
| **ApplicantId** | BIGINT | ✅ | FK → SysUser.Id | 发起员工 |
| **DeptId** | BIGINT | ✅ | FK → SysDepartment.Id | 提单人当时所属部门 |
| **ApplicationDate** | DATE |  |  | 申请日期（草稿时可空或默认当天，提交时写入提交当日）。与 `CreateTime` 分离——`CreateTime` 为服务器物理写入时间，`ApplicationDate` 为业务申请日期 |
| **ProjectId** | BIGINT |  | FK → SysProject.Id | 关联控制项目（草稿时可空，提交时应用层校验非空） |
| **BudgetSubjectId** | BIGINT |  | FK → SysBudgetSubject.Id | 关联主控科目（草稿时可空，提交时应用层校验非空） |
| **EstimatedAmount** | DECIMAL(18,2) | ✅ | DEFAULT 0 | 预估申请总金额（明细行 Quantity × UnitPrice 汇总） |
| **Urgency** | VARCHAR(10) | ✅ | DEFAULT 'normal' | 紧急程度 |
| **ExpenseType** | VARCHAR(20) | ✅ |  | 费用类型（7 种枚举值，决定专用字段校验规则和子类下拉选项） |
| **Purpose** | NVARCHAR(500) | ✅ |  | 费用事由详细文本（限制 200 字） |
| **Status** | VARCHAR(20) | ✅ | DEFAULT 'draft' | 审批状态机 |
| **CurrentApproverId** | BIGINT |  | FK → SysUser.Id | 当前节点审批人。提交时写入首级审批人，每次审批动作完成后更新为下一级（或 NULL 表示流程结束） |
| **UsageStatus** | VARCHAR(20) | ✅ | DEFAULT 'unused' | 使用状态（unused / partially_used / fully_used）。由报销单引用/撤回/删除时自动重算更新 |
| **IsTaxIncluded** | BIT | ✅ | DEFAULT 0 | 预估金额是否含税（0=不含税，1=含税）。影响后续报销核销时的金额对比 |
| **ValidUntil** | DATE |  |  | 申请有效期截止日。超过此日期未使用的申请预算不再可用，由定时 Job 自动释放冻结额度 |
| **ReferenceNo** | VARCHAR(50) |  |  | 关联合同号/询价单号/采购单号（采购类申请使用） |
| **EstimatedStartDate** | DATE |  |  | 预计开始日期（travel/meeting 提交必填） |
| **EstimatedEndDate** | DATE |  |  | 预计结束日期（travel/meeting 提交必填） |
| **IsOvernight** | BIT |  |  | 是否过夜（travel 提交必填，1=需住宿，0=当天来回） |
| **TransportType** | VARCHAR(20) |  |  | 交通工具（travel 提交必填） |
| **EntertainmentTarget** | NVARCHAR(200) |  |  | 招待对象单位名称（entertainment 提交必填，税务合规底线） |
| **EntertainmentLevel** | VARCHAR(20) |  |  | 招待对象层级（entertainment 提交必填） |
| **GuestCount** | INT |  |  | 外部招待人数（entertainment 提交必填） |
| **CompanionCount** | INT |  |  | 内部陪同人数（entertainment 提交必填，应用层校验 ≤ GuestCount） |
| **Venue** | NVARCHAR(200) |  |  | 招待/会议地点（entertainment/meeting 提交必填） |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 发起/存草稿时间 |
| **UpdateTime** | DATETIME |  |  | 状态变动时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 逻辑删除标记 |

> **字段分层说明**：
> - **通用字段**（前 17 个，Id ~ ReferenceNo）：所有类型生效，草稿可空、提交按应用层规则校验
> - **差旅专用字段**（`EstimatedStartDate` / `EstimatedEndDate` / `IsOvernight` / `TransportType`）：仅 `ExpenseType='travel'` 时前端展示。住宿天数由前端根据 `IsOvernight=1` 时 `EstimatedEndDate - EstimatedStartDate` 自动计算
> - **招待专用字段**（`EntertainmentTarget` / `EntertainmentLevel` / `GuestCount` / `CompanionCount` / `Venue`）：仅 `ExpenseType='entertainment'` 时前端展示。人均金额由前端实时计算 `EstimatedAmount ÷ (GuestCount + CompanionCount)`
> - **会议字段**：起止日期和地点复用 `EstimatedStartDate` / `EstimatedEndDate` / `Venue`，会议名称含在 `Purpose` 中。人数在明细行中按 `Quantity` 体现（如"餐饮 × 30 人 × ¥80"）
> - 以上专用字段数据库层全部 `NULL` 允许，约束完全在应用层按 `ExpenseType` 动态校验

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_ExpenseApp_List_Path` | NONCLUSTERED | (ApplicantId, Status, CreateTime DESC) INCLUDE (ApplicationNo, ApplicationDate, EstimatedAmount, Purpose, ExpenseType, UsageStatus, ValidUntil) | 五状态 Chip 切换 + 时间倒序 |
| `IX_ExpenseApp_Import_Drawer` | NONCLUSTERED | (ApplicantId, Status, UsageStatus) WHERE Status = 'approved' AND UsageStatus IN ('unused', 'partially_used') | 一键导入时快速过滤已通过且尚有剩余额度的单据 |

---

#### 4.3.2 ExpenseAppDetail（事前费用申请预估明细子表）

**业务含义**：事前申请的费用预估明细行，支持多行录入，级联于主表。`Quantity × UnitPrice` 汇总至主表 `EstimatedAmount`。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 明细项唯一标识 |
| **ApplicationId** | BIGINT | ✅ | FK → ExpenseApplication.Id（级联删除） | 关联主表 |
| **ExpenseCategory** | VARCHAR(20) | ✅ |  | 费用细分类别（值来源于 SysCostCategory.CategoryCode，前端按 ExpenseType 过滤） |
| **Quantity** | INT | ✅ | DEFAULT 1 | 数量 |
| **UnitPrice** | DECIMAL(18,2) | ✅ |  | 单价 |
| **Unit** | VARCHAR(10) |  |  | 单位（张/间/人/天/套/个） |
| **EstimatedAmount** | DECIMAL(18,2) | ✅ |  | 单项预估金额（前端自动计算 = Quantity × UnitPrice，只读展示） |
| **Remark** | NVARCHAR(200) |  |  | 单项细目描述说明 |
| **SortOrder** | INT | ✅ | DEFAULT 1 | 移动端渲染序列权重 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 明细添加时间 |
| **UpdateTime** | DATETIME |  |  | 明细修改时间 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_ExpenseAppDetail_AppId` | NONCLUSTERED | (ApplicationId, SortOrder) INCLUDE (ExpenseCategory, EstimatedAmount, Remark) | 子表 FK 查询 |

---

#### 4.3.3 Expense（费用报销单主表）

**业务含义**：费用报销核心主表，支持从事前申请一键导入、手工录入明细、发票合规三字自查、银行收款信息录入及财务付款归档。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 报销主表唯一标识 |
| **ReportNo** | VARCHAR(30) | ✅ | UNIQUE | 报销单号（格式：BX-YYYYMMDD-XXX） |
| **SourceApplicationId** | BIGINT |  | FK → ExpenseApplication.Id | 关联导入的事前申请 ID（一键导入复制数据回填） |
| **ApplicantId** | BIGINT | ✅ | FK → SysUser.Id | 报销发起人 |
| **DeptId** | BIGINT | ✅ | FK → SysDepartment.Id | 报销部门 |
| **ApplicationDate** | DATE |  |  | 报销申请日期（草稿时可空或默认当天，提交时写入提交当日）。与 `CreateTime` 分离——`CreateTime` 为服务器物理写入时间，`ApplicationDate` 为业务日期 |
| **CostCenterId** | BIGINT |  | FK → SysCostCenter.Id | 成本中心 |
| **ProjectId** | BIGINT |  | FK → SysProject.Id | 关联项目（草稿时可空，提交时应用层校验非空） |
| **BudgetSubjectId** | BIGINT |  | FK → SysBudgetSubject.Id | 关联预算科目（草稿时可空，提交时应用层校验非空） |
| **TotalAmount** | DECIMAL(18,2) | ✅ | DEFAULT 0 | 实际报销总金额（系统自动计算累加明细） |
| **Purpose** | NVARCHAR(500) | ✅ |  | 报销事由 |
| **BankName** | NVARCHAR(100) | ✅ |  | 收款银行全称（值来源于 SysBank.BankName 下拉联想，非 FK 约束，用户可自由输入） |
| **AccountName** | NVARCHAR(50) | ✅ |  | 收款银行开户户名 |
| **BankAccount** | VARCHAR(50) | ✅ |  | 收款银行账号 |
| **IsInvoiceVerified** | BIT | ✅ | DEFAULT 0 | 财务核销自查标记一：发票系统已查验真实合法 |
| **IsTaxIdMatched** | BIT | ✅ | DEFAULT 0 | 财务核销自查标记二：发票抬头与公司税号一致 |
| **IsCategoryCompliant** | BIT | ✅ | DEFAULT 0 | 财务核销自查标记三：报销类目与发票项目合规 |
| **BankTransferNo** | VARCHAR(50) |  |  | 银行电子电汇流水号（转账唯一凭证） |
| **VoucherNo** | VARCHAR(50) |  |  | 金蝶/通用财务系统记账凭证号 |
| **PaymentStatus** | VARCHAR(20) | ✅ | DEFAULT 'unpaid' | 付款状态机 |
| **Status** | VARCHAR(20) | ✅ | DEFAULT 'draft' | 审批状态机 |
| **CurrentApproverId** | BIGINT |  | FK → SysUser.Id | 当前节点审批人 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 报销单创建时间 |
| **UpdateTime** | DATETIME |  |  | 财务核销归档付款时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 逻辑删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Expense_List_Path` | NONCLUSTERED | (ApplicantId, Status, CreateTime DESC) INCLUDE (TotalAmount, ReportNo, ApplicationDate, PaymentStatus) | 五状态 Chip 切换 + 时间倒序 |
| `IX_Expense_SourceApp` | NONCLUSTERED | (SourceApplicationId) WHERE SourceApplicationId IS NOT NULL | 一键导入时排除已被引用的申请 |

---

#### 4.3.4 ExpenseDetail（费用报销明细子表）

**业务含义**：费用报销的逐行明细，每行对应一张发票或一笔无发票费用。支持发票号码、代码、类型、税率录入。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 明细唯一标识 |
| **ExpenseId** | BIGINT | ✅ | FK → Expense.Id（级联删除） | 关联主表 |
| **ExpenseDate** | DATE | ✅ |  | 费用发生具体日期 |
| **ExpenseType** | VARCHAR(20) | ✅ |  | 细分费用类别（值来源于 SysCostCategory.CategoryCode） |
| **ExpenseDesc** | NVARCHAR(200) | ✅ |  | 费用详细摘要描述 |
| **Amount** | DECIMAL(18,2) | ✅ |  | 金额（不含税净价） |
| **TaxAmount** | DECIMAL(18,2) | ✅ | DEFAULT 0 | 进项税额（无发票则为 0） |
| **TotalAmount** | DECIMAL(18,2) | ✅ |  | 价税合计金额 |
| **InvoiceNo** | VARCHAR(50) |  |  | 发票号码（OCR 识别/手动录入） |
| **InvoiceCode** | VARCHAR(50) |  |  | 发票代码 |
| **InvoiceType** | VARCHAR(20) | ✅ |  | 发票类型 |
| **TaxRate** | DECIMAL(5,4) |  |  | 税率（如：0.0600, 0.0900, 0.1300） |
| **SortOrder** | INT | ✅ | DEFAULT 1 | 明细行排序号 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 明细录入时间 |
| **UpdateTime** | DATETIME |  |  | 明细修改时间 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_ExpenseDetail_ExpenseId` | NONCLUSTERED | (ExpenseId, SortOrder) INCLUDE (ExpenseDate, ExpenseType, ExpenseDesc, Amount, TotalAmount, InvoiceType) | 子表 FK 查询 |

---

#### 4.3.5 ExpenseAttachment（费用报销发票及附件表）

**业务含义**：报销单关联的发票影像及附件文件，支持绑定到具体明细行或整个报销单。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 附件唯一标识 |
| **ExpenseId** | BIGINT | ✅ | FK → Expense.Id | 关联报销主表 |
| **DetailId** | BIGINT |  | FK → ExpenseDetail.Id | 可空。绑定具体明细行的发票凭证 |
| **FileName** | NVARCHAR(200) | ✅ |  | 原始文件名称 |
| **FileUrl** | VARCHAR(500) | ✅ |  | 云端对象存储绝对 URL |
| **FileSize** | BIGINT |  |  | 文件体积（字节） |
| **FileType** | VARCHAR(20) | ✅ |  | 格式划分 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 上传时间 |
| **UpdateTime** | DATETIME |  |  | 附件更换/删除时间 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_ExpenseAttachment_ExpenseId` | NONCLUSTERED | (ExpenseId) INCLUDE (FileName, FileUrl, FileType, FileSize) | 子表 FK 查询 |

---

#### 4.3.6 ExpenseApplicationAttachment（事前申请支撑材料附件表）

**业务含义**：事前申请的业务合理性支撑材料（报价单、合同、出差审批截图等），与 `ExpenseAttachment` 职责分离。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 附件唯一标识 |
| **ApplicationId** | BIGINT | ✅ | FK → ExpenseApplication.Id（级联删除） | 关联事前申请 |
| **FileName** | NVARCHAR(200) | ✅ |  | 原始文件名称 |
| **FileUrl** | VARCHAR(500) | ✅ |  | 云端对象存储绝对 URL |
| **FileType** | VARCHAR(20) | ✅ |  | 格式划分 |
| **FileSize** | BIGINT |  |  | 文件体积（字节） |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 上传时间 |
| **UpdateTime** | DATETIME |  |  | 替换/删除时间 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_ExpenseAppAttachment_AppId` | NONCLUSTERED | (ApplicationId) INCLUDE (FileName, FileUrl, FileType, FileSize) | 子表 FK 查询 |

---

### 4.4 勤务考勤、公车调度与外勤日志模块

#### 4.4.1 Overtime（加班申请单业务表）

**业务含义**：员工加班申请，支持工作日/休息日/节假日三种加班类型，以及加班费结算/调休/混合三种补偿形式。后端自动扣除午休等盲区时段计算净工时。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 加班唯一标识 |
| **ApplicationNo** | VARCHAR(30) | ✅ | UNIQUE | 单号（格式：JB-YYYYMMDD-XXX） |
| **ApplicantId** | BIGINT | ✅ | FK → SysUser.Id | 加班员工 |
| **DeptId** | BIGINT | ✅ | FK → SysDepartment.Id | 关联部门 |
| **OtType** | VARCHAR(10) | ✅ |  | 加班类型 |
| **CompensationType** | VARCHAR(20) | ✅ |  | 补偿形式 |
| **CompLeaveRatio** | DECIMAL(3,2) |  |  | 混合模式下调休比例（0.00~1.00，仅 CompensationType='mixed' 时生效） |
| **StartTime** | DATETIME |  |  | 加班启动时间（草稿时可空，提交时应用层校验非空） |
| **EndTime** | DATETIME |  |  | 加班截止时间（草稿时可空，提交时应用层校验非空） |
| **NetOtHours** | DECIMAL(4,1) | ✅ | DEFAULT 0 | 扣除盲区后的净工时（草稿未计算时为 0） |
| **Reason** | NVARCHAR(500) | ✅ |  | 加班主因详细说明 |
| **Status** | VARCHAR(20) | ✅ | DEFAULT 'draft' | 审批状态机 |
| **CurrentApproverId** | BIGINT |  | FK → SysUser.Id | 当前节点审批人 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 提单创建时间 |
| **UpdateTime** | DATETIME |  |  | 状态/审批变更时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 逻辑删除标记 |

**约束**：
- `CHECK (StartTime IS NULL OR EndTime IS NULL OR StartTime < EndTime)`：开始时间必须早于结束时间
- `CHECK (CompLeaveRatio IS NULL OR (CompLeaveRatio > 0 AND CompLeaveRatio < 1))`：调休比例在 0~1 之间（不含边界）

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Overtime_List_Path` | NONCLUSTERED | (ApplicantId, Status, CreateTime DESC) INCLUDE (ApplicationNo, OtType, NetOtHours, CompensationType) | 五状态 Chip 切换 + 时间倒序 |

---

#### 4.4.2 Vehicle（用车申请与还车核销全生命周期表）

**业务含义**：覆盖从公车申请、审批、出车到还车登记核销的完整生命周期。支持排期冲突检测、里程表记录和实际费用报账。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 用车单据唯一标识 |
| **ApplicationNo** | VARCHAR(30) | ✅ | UNIQUE | 单号（格式：YC-YYYYMMDD-XXX） |
| **ApplicantId** | BIGINT | ✅ | FK → SysUser.Id | 用车申请人 |
| **DeptId** | BIGINT | ✅ | FK → SysDepartment.Id | 关联部门 |
| **VehicleId** | BIGINT |  | FK → SysVehicle.Id | 申请调配的公车（草稿时可空，提交时校验非空） |
| **Purpose** | VARCHAR(20) | ✅ |  | 用车目的类别 |
| **Reason** | NVARCHAR(500) | ✅ |  | 用车事由详细说明 |
| **Origin** | NVARCHAR(200) | ✅ |  | 行程预计出发地点 |
| **OriginLongitude** | DECIMAL(10,6) |  |  | 始发地经度（地图选点回填） |
| **OriginLatitude** | DECIMAL(10,6) |  |  | 始发地纬度 |
| **Destination** | NVARCHAR(200) | ✅ |  | 行程预计最终目的地 |
| **DestLongitude** | DECIMAL(10,6) |  |  | 目的地经度 |
| **DestLatitude** | DECIMAL(10,6) |  |  | 目的地纬度 |
| **PassengerCount** | INT |  | DEFAULT 1 | 同行总人数 |
| **StartTime** | DATETIME |  |  | 预计出车时间（草稿时可空，提交时校验非空） |
| **EndTime** | DATETIME |  |  | 预计还车时间（草稿时可空，提交时校验非空） |
| **ActualReturnTime** | DATETIME |  |  | 还车登记：实际归还时间 |
| **StartOdometer** | DECIMAL(10,2) |  |  | 还车登记：出车前里程表读数 |
| **EndOdometer** | DECIMAL(10,2) |  |  | 还车登记：还车后里程表读数 |
| **ActualCost** | DECIMAL(18,2) |  |  | 还车登记：实际路桥费/停车费总报账 |
| **CostRemark** | NVARCHAR(500) |  |  | 报账明细备注 |
| **Status** | VARCHAR(20) | ✅ | DEFAULT 'draft' | 复合状态机（含 `returned` 已还车归档） |
| **CurrentApproverId** | BIGINT |  | FK → SysUser.Id | 当前节点审批人 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 提单时间 |
| **UpdateTime** | DATETIME |  |  | 状态/还车/核销变更时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 逻辑删除标记 |

**约束**：
- `CHECK (StartTime IS NULL OR EndTime IS NULL OR StartTime < EndTime)`：出车时间必须早于还车时间
- `CHECK (EndOdometer IS NULL OR StartOdometer IS NULL OR EndOdometer > StartOdometer)`：还车时里程表必须递增

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Vehicle_List_Path` | NONCLUSTERED | (ApplicantId, Status, CreateTime DESC) INCLUDE (ApplicationNo, VehicleId, Purpose, Origin, Destination, StartTime) | 状态 Chip 切换 + 时间倒序 |
| `IX_Vehicle_Schedule_Collision_Defense` | NONCLUSTERED | (VehicleId, StartTime, EndTime) WHERE Status IN ('pending', 'approved') | 防止同一辆车时段重叠的排期冲突检测 |

---

#### 4.4.3 VehiclePassenger（随行同行人员映射表）

**业务含义**：支持通过原生通讯录添加多个随行同事，同时兼容添加外部纯文本客户姓名。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 乘客关联标识 |
| **ApplicationId** | BIGINT | ✅ | FK → Vehicle.Id | 关联用车单主表 |
| **UserId** | BIGINT |  | FK → SysUser.Id | 可空。内部同事绑定 SysUser.Id |
| **PassengerName** | NVARCHAR(50) | ✅ |  | 同行人员姓名文本 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 添加时间 |
| **UpdateTime** | DATETIME |  |  | 变更时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记（移除同行人时标记） |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_VehiclePassenger_AppId` | NONCLUSTERED | (ApplicationId, IsDeleted) INCLUDE (UserId, PassengerName) | 子表 FK 查询 |

---

#### 4.4.4 OutingLog（外勤外务拜访日志主表）

**业务含义**：业务员外勤拜访日志。核心专为防止作弊设计——经纬度与逆地理编码地址在前端设为只读，强制匹配现场防伪相机水印。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 日志唯一标识 |
| **VisitNo** | VARCHAR(30) | ✅ | UNIQUE | 单号（格式：VST-YYYYMMDD-XXX） |
| **SalespersonId** | BIGINT | ✅ | FK → SysUser.Id | 业务员 |
| **DeptId** | BIGINT | ✅ | FK → SysDepartment.Id | 关联部门 |
| **CustomerId** | BIGINT |  | FK → SysCustomer.Id | 联动企业核心客户池（草稿时可空，提交时校验非空）。输入不存在的客户名称时，提交时后端在同一事务内 INSERT 新客户后回填此字段 |
| **CustomerName** | NVARCHAR(200) | ✅ |  | 客户公司标准全称冗余字段 |
| **ContactId** | BIGINT |  | FK → SysCustomerContact.Id | 关联客户联系人 |
| **CheckInLongitude** | DECIMAL(10,6) |  |  | GPS 硬件定位经度（草稿时可空，提交时校验非空） |
| **CheckInLatitude** | DECIMAL(10,6) |  |  | GPS 硬件定位纬度（草稿时可空，提交时校验非空） |
| **CheckInAddress** | NVARCHAR(500) |  |  | 逆地理编码出的具体街道地址（禁止篡改，草稿时可空，提交时校验非空） |
| **VisitSummary** | NVARCHAR(2000) | ✅ |  | 外勤核心工作总结正文 |
| **NextPlan** | NVARCHAR(500) |  |  | 后续推进计划 |
| **Status** | VARCHAR(20) | ✅ | DEFAULT 'draft' | 状态（`draft` 暂存草稿 / `completed` 已提交） |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 服务器防伪授时创建时间 |
| **UpdateTime** | DATETIME |  |  | 日志修改/点评更新时间 |
| **LastViewedTime** | DATETIME |  |  | 员工最后一次查看详情页的时间（用于"新点评"红点判断） |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 逻辑删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_OutingLog_Sales_Timeline` | NONCLUSTERED | (SalespersonId, CreateTime DESC) INCLUDE (CustomerId, CustomerName, CheckInAddress) | 按业务员时间倒序滚动 |
| `IX_OutingLog_Dept_Timeline` | NONCLUSTERED | (DeptId, CreateTime DESC) INCLUDE (SalespersonId, CustomerId, CustomerName, CheckInAddress, Status) | 经理端按部门查看下属外勤日志 |

---

#### 4.4.5 OutingLogComment（主管考评打分与互动多轮点评历史表）

**业务含义**：支持经理在详情页底部进行星级评分（`TDRate`），并以多气泡流水样式进行指导性文字互动。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 点评项唯一标识 |
| **LogId** | BIGINT | ✅ | FK → OutingLog.Id（级联删除） | 关联外勤拜访日志 |
| **CommenterId** | BIGINT | ✅ | FK → SysUser.Id | 发表点评的经理或协同人 |
| **RatingStars** | INT |  |  | 主管点选的考评星级（1-5星，仅限主管首条考评记录生效） |
| **CommentText** | NVARCHAR(1000) | ✅ |  | 批示及指导意见具体文字内容 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 回复点评发布时间 |
| **UpdateTime** | DATETIME |  |  | 点评修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记（经理撤回点评时标记） |

**约束**：`CHECK (RatingStars IS NULL OR (RatingStars >= 1 AND RatingStars <= 5))`

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_OutingLogComment_LogId` | NONCLUSTERED | (LogId, IsDeleted) INCLUDE (CommenterId, RatingStars, CommentText, CreateTime) | 子表 FK 查询——外勤日志点评列表 |

---

#### 4.4.6 OutingLogAttachment（外勤日志防伪照片及附件表）

**业务含义**：现场强制拍照上传及照片墙展示。照片由原生相机拍摄后本地渲染防伪水印（服务器授时 + GPS 经纬度），上传至云端存储。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 附件唯一标识 |
| **LogId** | BIGINT | ✅ | FK → OutingLog.Id（级联删除） | 关联外勤日志 |
| **FileName** | NVARCHAR(200) | ✅ |  | 原始文件名/水印照片描述 |
| **FileUrl** | VARCHAR(500) | ✅ |  | 云端对象存储绝对 URL |
| **FileType** | VARCHAR(20) | ✅ |  | 用途分类 |
| **FileSize** | BIGINT |  |  | 文件体积（字节） |
| **SortOrder** | INT |  | DEFAULT 0 | 照片墙排序 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 上传时间 |
| **UpdateTime** | DATETIME |  |  | 替换/删除时间 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_OutingLogAttachment_LogId` | NONCLUSTERED | (LogId, SortOrder) INCLUDE (FileName, FileUrl, FileType) | 子表 FK 查询——外勤日志照片墙 |

---

#### 4.4.7 SysVehicle（企业车辆资产表）

**业务含义**：公车资产台账，用于公车排期冲突检测及车辆状态管理。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 车辆唯一标识 |
| **LicensePlate** | NVARCHAR(20) | ✅ | UNIQUE | 车牌号（含中文字符，如：粤B12345） |
| **VehicleType** | VARCHAR(20) | ✅ |  | 车辆类型 |
| **Brand** | NVARCHAR(50) |  |  | 品牌型号 |
| **Seats** | INT |  |  | 核定座位数 |
| **DriverName** | NVARCHAR(50) |  |  | 默认驾驶员姓名 |
| **Status** | VARCHAR(20) | ✅ | DEFAULT 'idle' | 车辆状态 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 启用/停用 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记（车辆报废/出售时标记） |

---

#### 4.4.8 SysCustomer（客户基础资料表）

**业务含义**：企业客户池主数据，为外勤日志模块提供客户名称联想匹配。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 客户唯一标识 |
| **CustomerName** | NVARCHAR(200) | ✅ |  | 客户公司全称 |
| **ShortName** | NVARCHAR(100) |  |  | 客户简称（快速检索用） |
| **Address** | NVARCHAR(500) |  |  | 客户办公/注册地址 |
| **Longitude** | DECIMAL(10,6) |  |  | 客户地址经度（用于外勤距离偏差计算） |
| **Latitude** | DECIMAL(10,6) |  |  | 客户地址纬度 |
| **SalespersonId** | BIGINT |  | FK → SysUser.Id | 默认负责业务员 |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 启用/停用 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Customer_Name_Search` | NONCLUSTERED | (IsActive, CustomerName) INCLUDE (ShortName, SalespersonId) | 客户名称模糊搜索——外勤日志选客户时的即时联想 |
| `IX_Customer_Salesperson` | NONCLUSTERED | (SalespersonId, IsActive) INCLUDE (CustomerName, ShortName) | 按业务员筛选客户池 |

---

#### 4.4.9 SysCustomerContact（客户联系人表）

**业务含义**：客户下的联系人明细，支持一个客户多个联系人。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 联系人唯一标识 |
| **CustomerId** | BIGINT | ✅ | FK → SysCustomer.Id | 关联客户 |
| **ContactName** | NVARCHAR(50) | ✅ |  | 联系人姓名 |
| **Position** | NVARCHAR(50) |  |  | 联系人职务 |
| **Phone** | VARCHAR(20) |  |  | 联系电话 |
| **Email** | VARCHAR(100) |  |  | 电子邮箱 |
| **SortOrder** | INT |  | DEFAULT 0 | 排序权重 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记 |

---

### 4.5 行政通告、已读审计与 DING 催办模块

#### 4.5.1 Announcement（行政通告公告主表）

**业务含义**：行政公告/红头文件的发布、置顶、定时下架管理。支持按全员/部门/指定用户三种可见范围发布。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 公告唯一标识 |
| **Title** | NVARCHAR(200) | ✅ |  | 行政公告标题 |
| **Content** | NVARCHAR(MAX) | ✅ |  | 公告正文（支持 HTML / Markdown） |
| **Type** | VARCHAR(20) | ✅ |  | 分类 |
| **Status** | VARCHAR(20) | ✅ | DEFAULT 'draft' | 发布状态（`draft` 仅创建者和管理员可见 / `published` 已发布） |
| **PublisherId** | BIGINT | ✅ | FK → SysUser.Id | 发布管理员 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 首次创建时间（含草稿） |
| **PublishTime** | DATETIME |  |  | 设定发布生效时间（草稿时为 NULL） |
| **IsTop** | BIT | ✅ | DEFAULT 0 | 是否全局置顶（1=置顶，0=普通） |
| **PrivateLevel** | INT | ✅ | DEFAULT 0 | 可见域范围级别（0=全员，1=按部门，2=按指定用户） |
| **ExpiryDate** | DATETIME |  |  | 自动下架失效截止时间（不填则永不过期） |
| **UpdateTime** | DATETIME |  |  | 最后修改/下架时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 逻辑删除标记 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Announcement_List` | NONCLUSTERED | (Type, IsTop DESC, PublishTime DESC) INCLUDE (Title, PublisherId, Status, ExpiryDate) WHERE IsDeleted = 0 AND Status = 'published' | 公告列表——分类筛选 + 置顶优先 + 时间倒序 |

---

#### 4.5.2 AnnouncementTarget（公告可见权限发布范围表）

**业务含义**：当公告按部门或按指定用户发布时，记录目标范围。多态外键设计——SQL Server 不支持多态 FK 约束，由应用层校验引用完整性。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 范围标识 |
| **AnnouncementId** | BIGINT | ✅ | FK → Announcement.Id | 关联公告主表 |
| **TargetType** | VARCHAR(10) | ✅ |  | 范围实体类别划分 |
| **TargetId** | BIGINT | ✅ |  | 多态外键：根据 TargetType 指向 SysDepartment.Id 或 SysUser.Id |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |

---

#### 4.5.3 AnnouncementReadLog（全员行政触达率审计与一键 DING 记录表）

**业务含义**：记录每个员工对每条公告的已读/未读状态，以及管理员"一键 DING 强力催办"的操作历史。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 审计行唯一标识 |
| **AnnouncementId** | BIGINT | ✅ | FK → Announcement.Id | 关联公告 |
| **UserId** | BIGINT | ✅ | FK → SysUser.Id | 被下发触达的员工 |
| **IsRead** | BIT | ✅ | DEFAULT 0 | 已读标记（停留 2 秒后异步置换为 1） |
| **ReadTime** | DATETIME |  |  | 员工实际查阅阅读时间 |
| **IsUrged** | BIT | ✅ | DEFAULT 0 | 是否被管理员一键 DING 催办过 |
| **LastUrgeTime** | DATETIME |  |  | 最后一次触发高优先级 Push 或短信的时间 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 触达记录创建时间 |
| **UpdateTime** | DATETIME |  |  | 已读/催办更新时间 |

> **约束**：`UNIQUE (AnnouncementId, UserId)`，每个员工对每条公告仅有一条触达记录。

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Announcement_Audit_Ding` | NONCLUSTERED | (AnnouncementId, IsRead, IsUrged) INCLUDE (UserId, ReadTime) | 管理员一键 DING 时瞬间抓取未读员工列表 |

---

#### 4.5.4 AnnouncementAttachment（公告公文红头附件链接表）

**业务含义**：公告关联的附件文件（红头文件 PDF、图片等）。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 附件标识 |
| **AnnouncementId** | BIGINT | ✅ | FK → Announcement.Id | 关联公告 |
| **FileName** | NVARCHAR(200) | ✅ |  | 附件名称 |
| **FileUrl** | VARCHAR(500) | ✅ |  | 绝对存储 URL 下载地址 |
| **FileType** | VARCHAR(20) | ✅ |  | 格式划分 |
| **FileSize** | BIGINT |  |  | 文件体积（字节） |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 上传时间 |
| **UpdateTime** | DATETIME |  |  | 替换/删除时间 |

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_AnnouncementAttachment_AnnId` | NONCLUSTERED | (AnnouncementId) INCLUDE (FileName, FileUrl, FileType, FileSize) | 子表 FK 查询 |

---

#### 4.5.5 Message（消息通知流水与动态路由寻址表）

**业务含义**：框架外壳左侧首 Tab 的消息通知中心。支持待办提醒、审批结果通知、公告推送三种消息类型，配合前端 GoRouter 实现点击穿透跳转。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 消息行唯一标识 |
| **UserId** | BIGINT | ✅ | FK → SysUser.Id | 接收消息通知的员工 |
| **SenderId** | BIGINT | ✅ | FK → SysUser.Id | 消息发送人（消息创建时固化，不随源数据变更） |
| **Title** | NVARCHAR(200) | ✅ |  | 消息通知卡片大标题 |
| **Content** | NVARCHAR(500) |  |  | 卡片摘要文本 |
| **MsgType** | VARCHAR(20) | ✅ |  | 消息分类 |
| **BizType** | VARCHAR(30) | ✅ |  | 精准寻址路由标记（配合前端 GoRouter） |
| **BizId** | BIGINT | ✅ |  | 关联单据的具体主键 ID（点击穿透跳转详情页） |
| **IsRead** | BIT | ✅ | DEFAULT 0 | 未读已读状态（决定 Tab 上 TDBadge 红点） |
| **ReadTime** | DATETIME |  |  | 点击标记阅读时间 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 消息推送产生时间 |
| **UpdateTime** | DATETIME |  |  | 已读/删除时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记（左滑删除时标记） |

> **多态外键说明**：`BizId` 与 `BizType` 组成多态关联，根据 `BizType` 的值指向不同的业务主表。约束策略与 `ApprovalRecord` 一致——业务表一律软删除，查询时通过 `BizType` 路由 + `IsDeleted` 过滤。

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_Message_List_Active` | NONCLUSTERED | (UserId, IsDeleted, IsRead, CreateTime DESC) INCLUDE (Title, MsgType, BizType, BizId, SenderId) | 消息列表查询——排除已软删除的消息 |

---

### 4.6 工作流引擎与审批记录

#### 4.6.1 ApprovalChain（企业业务审批链流程配置表）

**业务含义**：多级审批链的配置中心。支持按部门/全局配置、固定审批人/动态角色审批、以及基于条件（超预算、金额门槛）的动态审批层级插入。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 配置项唯一标识 |
| **BizType** | VARCHAR(30) | ✅ |  | 业务单据类型标识 |
| **DeptId** | BIGINT |  | FK → SysDepartment.Id | 可空。绑定特定部门，NULL 代表全局通用流 |
| **LevelNo** | INT | ✅ |  | 审批层级序号（从 1 开始） |
| **ApproverId** | BIGINT |  | FK → SysUser.Id | 固定指定特定审批人 |
| **ApproverRole** | VARCHAR(20) |  |  | 按岗位角色动态寻人 |
| **ConditionType** | VARCHAR(20) |  |  | 条件触发类型（`budget_exceed` 超预算 / `amount_threshold` 金额门槛 / NULL 无条件） |
| **ConditionThreshold** | DECIMAL(18,2) |  |  | 条件阈值 |
| **ConditionConfig** | NVARCHAR(500) |  |  | 条件扩展配置 JSON |
| **IsActive** | BIT | ✅ | DEFAULT 1 | 流程是否生效 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 创建时间 |
| **UpdateTime** | DATETIME |  |  | 修改时间 |
| **IsDeleted** | BIT | ✅ | DEFAULT 0 | 软删除标记（审批链废弃时标记） |

**约束**：
- `CHECK (ApproverId IS NOT NULL OR ApproverRole IS NOT NULL)`：`ApproverId` 与 `ApproverRole` 至少填写一项
- `UNIQUE (BizType, DeptId, LevelNo)` 结合 `IsDeleted = 0` 过滤

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `UX_ApprovalChain_Global` | UNIQUE NONCLUSTERED | (BizType, LevelNo) WHERE DeptId IS NULL AND IsDeleted = 0 | 全局配置唯一约束 |
| `UX_ApprovalChain_Dept` | UNIQUE NONCLUSTERED | (BizType, DeptId, LevelNo) WHERE DeptId IS NOT NULL AND IsDeleted = 0 | 部门级配置唯一约束 |

---

#### 4.6.2 ApprovalRecord（审批流全历史节点决策运行流转表）

**业务含义**：每条审批动作（同意/拒绝/转交）的完整历史记录。支持审批链失效与重建（驳回后重新编辑再次发起）。

| 字段名 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- |
| **Id** | BIGINT | ✅ | PK, IDENTITY(1,1) | 审批记录节点唯一标识 |
| **BizId** | BIGINT | ✅ |  | 关联的具体业务单据主键 ID（多态外键，与 BizType 联合路由） |
| **BizType** | VARCHAR(30) | ✅ |  | 业务单据类型映射标志 |
| **ApproverId** | BIGINT | ✅ | FK → SysUser.Id | 执行审批动作的领导/财务人员 |
| **ApprovalLevel** | INT | ✅ |  | 当前所处审批绝对层级（1, 2, 3...） |
| **Action** | VARCHAR(20) | ✅ |  | 三元决策动作代码 |
| **TransferToUserId** | BIGINT |  | FK → SysUser.Id | 转交接管的新审批人（仅 Action='transfer' 时有值） |
| **Opinion** | NVARCHAR(500) |  |  | 审批意见备注（Action='reject' 时强制 ≥5 个汉字） |
| **IsValid** | BIT | ✅ | DEFAULT 1 | 有效标记（1=有效，0=已失效）。重新发起时旧记录置为 0 |
| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() | 单据到达该层级节点的时间 |
| **ApprovalTime** | DATETIME |  |  | 领导实际点击提交的执行时间 |
| **UpdateTime** | DATETIME |  |  | 审批记录变更时间 |

> **多态外键说明**：`BizId` 与 `BizType` 组成多态关联——根据 `BizType` 的值指向不同的业务主表（`expense_apply` → `ExpenseApplication.Id`，`expense` → `Expense.Id`，`overtime` → `Overtime.Id`，`vehicle` → `Vehicle.Id`）。SQL Server 不支持多态外键约束，无法在数据库层保证引用完整性。
>
> **补偿措施**：
> 1. 业务表删除单据时执行**软删除**（`IsDeleted=1`），不物理删除行，确保审批记录的 `BizId` 始终有效
> 2. 后端查询时始终通过 `BizType` 路由到正确的表，JOIN 时追加 `AND target.IsDeleted = 0`
> 3. 定时 Job 扫描 `ApprovalRecord` 中 `BizId` 在对应业务表中不存在的记录，标记为异常并告警

**索引**：

| 索引 | 类型 | 列 | 说明 |
| --- | --- | --- | --- |
| `IX_ApprovalRecord_Approver_Todo` | NONCLUSTERED | (ApproverId, Action, BizType, IsValid) INCLUDE (BizId, ApprovalLevel, CreateTime) WHERE IsValid = 1 | 经理端待办列表"待我审批的单据" |
| `IX_ApprovalRecord_Biz_Valid` | NONCLUSTERED | (BizId, BizType, IsValid, ApprovalLevel) INCLUDE (ApproverId, Action, Opinion, ApprovalTime) | 前端时间线组件——按 BizId+BizType 过滤有效审批链 |

---

## 5. 表关系与业务逻辑与约束 (Business Logic & Constraints)

### 5.1 级联删除策略

| 主表 | 子表 | 删除策略 | 说明 |
| --- | --- | --- | --- |
| `ExpenseApplication` | `ExpenseAppDetail` | 级联删除 | 删除事前申请时同时删除其明细行 |
| `ExpenseApplication` | `ExpenseApplicationAttachment` | 级联删除 | 删除事前申请时同时删除其附件 |
| `Expense` | `ExpenseDetail` | 级联删除 | 删除报销单时同时删除其明细行 |
| `OutingLog` | `OutingLogComment` | 级联删除 | 删除外勤日志时同时删除其全部点评 |
| `OutingLog` | `OutingLogAttachment` | 级联删除 | 删除外勤日志时同时删除其附件 |

> **重要**：所有业务主表统一使用**软删除**（`IsDeleted = 1`）。以上"级联删除"指在应用层执行主表软删除时，同步对子表执行软删除。**绝不物理删除业务数据行**，以确保审批记录（`ApprovalRecord`）中的多态外键始终有效。

### 5.2 软删除统一规范

- **适用表**：除 `SysRoleChangeLog`（审计日志不可删除）、`SysProjectBudget`（预算记录不可删除）、`ApprovalRecord`（通过 `IsValid` 控制有效性）外的所有业务表与基础数据表
- **AI 强制规则**：编写所有 SELECT / JOIN 查询语句时，**务必在 WHERE 子句中添加 `table.IsDeleted = 0`**（如果是软删除适用表）
- **删除操作实现**：永远使用 `UPDATE table SET IsDeleted = 1, UpdateTime = GETDATE() WHERE Id = @id`，严禁使用 `DELETE FROM`

### 5.3 事务边界

#### 5.3.1 事前申请提交事务

提交申请必须在**同一数据库事务**内原子完成以下操作：

1. 更新业务主表 `Status = 'pending'`、`CurrentApproverId`、`ApplicationDate = GETDATE()`（若提交时为空则写入当日日期）
2. 冻结预算：`SysProjectBudget.FrozenAmount += 申请金额`（携带 `Version` 乐观锁）
3. 写入首级 `ApprovalRecord`（`Action = 'pending'`）
4. 发送 `Message` 通知至首级审批人（`MsgType = 'approval_notice'`）

> **注意**：以上适用于 `ExpenseApplication` 和 `Expense` 两张表——两表均有 `ApplicationDate` 字段，提交逻辑一致。

#### 5.3.2 审批动作事务（同意/拒绝/转交）

每次审批动作必须在**同一数据库事务**内原子完成以下操作：

1. 更新业务主表 `Status` 和 `CurrentApproverId`
2. 写入/更新 `ApprovalRecord`（`Action` + `Opinion` + `ApprovalTime`）
3. 若审批通过：`SysProjectBudget.FrozenAmount -= 申请金额` **且** `AvailableAmount -= 申请金额` + 累加 `SysProject.SpentBudget`（携带 `Version` 乐观锁）
4. 若审批拒绝：`SysProjectBudget.FrozenAmount -= 申请金额`（释放冻结，不扣减 `AvailableAmount`）
5. 发送 `Message` 通知至申请人（`MsgType = 'approval_result'`）
6. 若当前层级为最后一级：更新 `SysVehicle.Status`（仅用车单据）

#### 5.3.3 撤回事务

申请人撤回必须在**同一数据库事务**内原子完成：

1. 更新业务主表 `Status = 'withdrawn'`，`CurrentApproverId = NULL`
2. 释放预算冻结：`SysProjectBudget.FrozenAmount -= 申请金额`
3. 失效审批记录：`ApprovalRecord.IsValid = 0`
4. 发送 `Message` 通知至当前审批人（告知申请已撤回）

> 任一步骤失败则整体回滚，确保不会出现"状态已改但预算未动"或"预算已改但状态未变"的不一致。

### 5.4 预算控制策略（冻结 → 扣减 → 释放）

**适用表**：`SysProjectBudget`

**核心公式**：实际可用余额 = `AvailableAmount - FrozenAmount`

**状态流转**：

| 环节 | FrozenAmount | AvailableAmount | 说明 |
| --- | --- | --- | --- |
| 事前申请存草稿 | 不变 | 不变 | 草稿不参与预算控制 |
| 事前申请提交 | **+申请金额** | 不变 | 冻结预算，该额度不可被其他申请使用 |
| 审批通过 | **-申请金额** | **-申请金额** | 冻结转正式扣减 |
| 审批拒绝 | **-申请金额** | 不变 | 释放冻结，额度恢复可用 |
| 申请人撤回 | **-申请金额** | 不变 | 释放冻结 |
| 申请过期（ValidUntil 到期） | **-申请金额** | 不变 | 定时 Job 扫描释放 |

**并发控制**：
- 所有 UPDATE `AvailableAmount` 或 `FrozenAmount` 的语句必须携带 `WHERE Version = @oldVersion` 做乐观锁校验
- 若 `@@ROWCOUNT = 0`（版本冲突），应用层重试最多 3 次
- 禁止在事务外进行"先读后写"操作

**约束**：
- `CHECK (FrozenAmount >= 0)`
- `CHECK (AvailableAmount >= FrozenAmount)`：扣减后的可用余额不允许为负，但允许冻结金额等于可用余额（余额用完）
- 前端校验时使用 `AvailableAmount - FrozenAmount` 作为员工可见余额
- 若申请金额 > 可见余额，前端红色警告并触发超预算特批流程（`ApprovalChain.ConditionType = 'budget_exceed'`）

### 5.5 事前申请-报销剩余额度追踪

一张已审批通过的事前申请可被多张报销单分批引用。`UsageStatus` 由后端在报销单状态变更时自动重算：

| 触发事件 | 计算逻辑 |
| --- | --- |
| 报销单提交（关联事前申请） | `已报总额 = SUM(Expense.TotalAmount WHERE SourceApplicationId = @appId AND IsDeleted = 0)` |
|  | 若 `已报总额 = 0` → `UsageStatus = 'unused'` |
|  | 若 `0 < 已报总额 < EstimatedAmount` → `UsageStatus = 'partially_used'` |
|  | 若 `已报总额 >= EstimatedAmount` → `UsageStatus = 'fully_used'` |
| 报销单撤回/删除（关联事前申请） | 重新计算，`fully_used` 可降级回 `partially_used` 或 `unused` |
| 事前申请被拒/撤回 | `UsageStatus` 不适用（申请未通过，不存在报销引用） |

> **注意**：`UsageStatus` 联动更新**不在审批事务边界内**——报销单提交的审批事务只负责报销单自身的状态变更 + 预算扣减，`UsageStatus` 的更新在事务提交后异步触发，允许短暂不一致。

### 5.6 事前申请类型专用字段校验规则

`ExpenseType` 决定提交时哪些字段为必填。以下规则在**应用层**执行，数据库层不设 NOT NULL 约束：

| ExpenseType | 必填字段 | 校验关系 |
| --- | --- | --- |
| `travel` | `EstimatedStartDate`, `EstimatedEndDate`, `IsOvernight`, `TransportType` | `EstimatedEndDate >= EstimatedStartDate`；`IsOvernight=1` 时前端自动计算住宿天数 |
| `entertainment` | `EntertainmentTarget`, `EntertainmentLevel`, `GuestCount`, `CompanionCount`, `Venue`, `EstimatedStartDate` | `CompanionCount <= GuestCount`（陪同 ≤ 外部人数，八项规定红线） |
| `meeting` | `EstimatedStartDate`, `EstimatedEndDate`, `Venue` | `EstimatedEndDate >= EstimatedStartDate`；外部/内部参会人数在明细行中按 `Quantity` 体现 |
| `procurement` | —（`ReferenceNo` 建议填写但非必填） | 附件上传报价单/合同（`ExpenseApplicationAttachment`） |
| `office` / `activity` / `training` | — | 通用字段已覆盖，无额外专用字段 |

### 5.7 SysProjectBudget.ExpenseType 匹配规则

同一 `(ProjectId, SubjectId)` 下：
- `ExpenseType = NULL` 的行作为"通用预算"（fallback）
- `ExpenseType` 为具体值的行作为"特定费用类型预算"
- 后端查询时**优先精确匹配** `ExpenseType` 值，匹配不到时 fallback 到 `ExpenseType IS NULL` 的行
- 应用层禁止对同一 `(ProjectId, SubjectId)` 同时创建 NULL 行和多个特定类型行（只能选其一：全类型通用预算 OR 按类型分预算）

### 5.8 还车登记与车辆状态同步

- 还车登记在 `Status = 'approved'` 状态下均可触发（不限制当前时间必须超过 `EndTime`），提前还车时前端标注提示文案
- `SysVehicle.Status` 由后端在审批动作时自动维护：
  - 用车申请审批通过 → `SysVehicle.Status` 置为 `in_use`
  - 用车单据变为 `rejected` / `withdrawn` / `returned` → 若无其他重叠时段的活跃申请，`SysVehicle.Status` 恢复为 `idle`
  - 车辆维修登记 → 人工置为 `maintenance`，维修期间所有申请校验拒绝

### 5.9 双状态机转换规则（报销单）

`Status`（审批状态）与 `PaymentStatus`（付款状态）相互独立但存在依赖：

```
Status:   draft → pending → approved → (财务打款后) → 归档
                       ↘ rejected
                       ↘ withdrawn

PaymentStatus: unpaid → paying → paid
               (仅 Status='approved' 时允许流转)
```

- 审批通过（`approved`）后 `PaymentStatus` 才解锁，允许财务操作 `unpaid → paying → paid`
- 审批被拒绝或撤回时，若 `PaymentStatus` 已进入 `paying` / `paid`，系统禁止撤回并告警

### 5.10 审批链失效与重建规则

当员工对 `rejected` 单据执行"重新编辑并发起"时：
1. 该 `BizId` 下所有 `ApprovalRecord` 的 `IsValid` 置为 0（标记旧审批链失效）
2. 新的审批流程从 `ApprovalLevel = 1` 开始，重新匹配 `ApprovalChain` 配置写入新记录
3. 前端时间线组件渲染时增加 `WHERE IsValid = 1` 过滤条件

### 5.11 公告触达初始化策略

当管理员将公告 `Status` 从 `draft` 变为 `published`（发布动作）时，后台通过异步 Job 根据 `PrivateLevel` 写入触达记录：
- `PrivateLevel = 0`（全员）：为所有在职员工（`SysUser.IsActive = 1 AND IsDeleted = 0`）批量插入 `IsRead = 0` 记录
- `PrivateLevel = 1`（按部门）/ `PrivateLevel = 2`（按用户）：依据 `AnnouncementTarget` 表的覆盖范围写入

草稿状态（`Status = 'draft'`）不触发任何触达记录写入。新员工入职时不回溯历史公告。管理员 DING 催办仅作用于已存在记录的未读人员（`IsRead = 0`）。

### 5.12 发票查验与 InvoiceNo 为空的处理规则

当明细行 `InvoiceType = 'none'`（无发票场景，如小额零星采购、交通卡充值等），`InvoiceNo` / `InvoiceCode` 允许为 NULL。财务核销时三项合规自查（`IsInvoiceVerified` / `IsTaxIdMatched` / `IsCategoryCompliant`）的判定逻辑为：
- 若该报销单全部明细行均为 `InvoiceType = 'none'`，三项自查标记自动置 1，跳过发票查验流程，财务仅审核费用合理性
- 若存在非 `none` 明细行但对应的 `InvoiceNo` 为空，前端发票查验区展示警告"明细行 X 缺少发票号码，请补充后重新查验"，财务核销按钮保持锁定

### 5.13 收款银行默认值回写

当报销单首次提交审批通过（`Status` 变为 `approved`）时，后端自动将 `Expense.BankName`、`Expense.AccountName`、`Expense.BankAccount` 回写到 `SysUser` 表的 `DefaultBankName`、`DefaultAccountName`、`DefaultBankAccount` 字段，供下次发起报销时自动填充。

### 5.14 外勤日志新客户动态创建

当业务员输入不存在的客户名称时，提交时后端在同一事务内：
1. `INSERT INTO SysCustomer (CustomerName, ...) VALUES (@customerName, ...)` 获取新 `Id`
2. `UPDATE OutingLog SET CustomerId = @newId WHERE Id = @logId`

### 5.15 外勤日志未读点评红点逻辑

列表页"新点评"红点显示条件：`OutingLogComment.CreateTime > COALESCE(LastViewedTime, '1900-01-01')` 且评论者 `CommenterId != 当前员工 Id`。

---

## 6. 枚举字段的完整取值说明

> **AI 注意**：以下所有枚举取值均为数据库存储值（英文标识字符串）。编写业务代码时不得臆造新的取值，必须严格使用下表中定义的值。

### 6.1 通用状态机

#### Status（审批状态）— 多表共用

| 值 | 含义 | 适用表 |
| --- | --- | --- |
| `draft` | 草稿，仅创建者可见 | ExpenseApplication, Expense, Overtime, Vehicle, Announcement |
| `pending` | 待审批，已提交进入审批链 | ExpenseApplication, Expense, Overtime, Vehicle |
| `approved` | 审批通过 | ExpenseApplication, Expense, Overtime, Vehicle |
| `rejected` | 审批驳回，可重新编辑发起 | ExpenseApplication, Expense, Overtime, Vehicle |
| `withdrawn` | 申请人主动撤回 | ExpenseApplication, Expense, Overtime, Vehicle |
| `returned` | 已还车归档 | Vehicle（仅此表特有） |
| `completed` | 已提交（外勤日志不走审批链） | OutingLog |
| `published` | 已发布 | Announcement |

#### PaymentStatus（付款状态）— 仅 Expense

| 值 | 含义 |
| --- | --- |
| `unpaid` | 未付款 |
| `paying` | 付款中（财务已发起银行转账） |
| `paid` | 已付款到账 |

### 6.2 单据类型

#### BizType（业务单据类型）— ApprovalRecord, ApprovalChain, Message

| 值 | 含义 | 对应主表 |
| --- | --- | --- |
| `expense_apply` | 事前申请 | ExpenseApplication |
| `expense` | 费用报销 | Expense |
| `overtime` | 加班申请 | Overtime |
| `vehicle` | 用车申请 | Vehicle |
| `announcement` | 行政公告 | Announcement（仅 Message.BizType） |
| `outing_log` | 外勤日志 | OutingLog（仅 Message.BizType） |

### 6.3 消息类型

#### MsgType（消息分类）— Message

| 值 | 含义 |
| --- | --- |
| `approval_notice` | 待办提醒（有单据待审批） |
| `approval_result` | 审批结果通知（通过/拒绝/转交） |
| `announcement` | 公告推送通知 |

### 6.4 角色与权限

#### RoleCode（角色编码）— SysRole

| 值 | 含义 |
| --- | --- |
| `employee` | 员工（基础权限） |
| `approver` | 经理（审批权限） |
| `finance` | 财务（核销权限） |
| `admin` | 系统管理员（全权限） |

#### ChangeType（角色变更类型）— SysRoleChangeLog

| 值 | 含义 |
| --- | --- |
| `assign_role` | 角色变更（增/删角色） |
| `toggle_active` | 启用/禁用账号 |
| `batch` | 批量操作 |

### 6.5 审批相关

#### Action（审批动作）— ApprovalRecord

| 值 | 含义 | 约束 |
| --- | --- | --- |
| `pending` | 处理中（单据刚到达审批节点） | |
| `approve` | 同意 | |
| `reject` | 拒绝 | 强制填写 ≥5 个汉字的 `Opinion` |
| `transfer` | 转交 | 必须填写 `TransferToUserId` |

#### ApproverRole（审批人角色）— ApprovalChain

| 值 | 含义 |
| --- | --- |
| `dept_manager` | 部门主管（动态取 SysDepartment.ManagerId） |
| `finance` | 财务专岗（动态取角色为 finance 的用户） |

#### ConditionType（审批条件触发类型）— ApprovalChain

| 值 | 含义 |
| --- | --- |
| `budget_exceed` | 超预算高管特批（申请金额 > 可用余额时触发） |
| `amount_threshold` | 金额门槛（申请金额 ≥ ConditionThreshold 时触发） |
| NULL | 无条件（始终进入此审批层级） |

### 6.6 费用相关

#### ExpenseType（费用类型）— ExpenseApplication, ExpenseDetail, SysProjectBudget

| 值 | 含义 | 适用业务 |
| --- | --- | --- |
| `travel` | 差旅费 | 出差行程，含交通/住宿/补助 |
| `entertainment` | 业务招待费 | 宴请客户、赠送礼品、外部人员接待 |
| `procurement` | 日常采购 | 含报价单/合同的大额采购 |
| `activity` | 活动经费 | 团建、庆典等内部活动 |
| `office` | 办公费 | 文具、耗材、快递、饮用水等零散开销 |
| `meeting` | 会议费 | 内/外部会议，含场地/设备/餐饮/物料 |
| `training` | 培训费 | 员工外部培训、认证考试、教材 |

> **注意**：`ExpenseType` 为系统编码层面的固定枚举，不开放管理员自行新增。新增类型需发版支持，因为每种类型对应不同的表单 UI、审批链和费用标准校验逻辑。

#### InvoiceType（发票类型）— ExpenseDetail

| 值 | 含义 | InvoiceNo/InvoiceCode |
| --- | --- | --- |
| `special` | 增值税专用发票 | 必填 |
| `general` | 增值税普通发票 | 必填 |
| `none` | 无发票 | 允许 NULL |

#### Urgency（紧急程度）— ExpenseApplication

| 值 | 含义 |
| --- | --- |
| `normal` | 普通 |
| `urgent` | 紧急 |
| `critical` | 特急 |

#### TransportType（交通工具）— ExpenseApplication

| 值 | 含义 |
| --- | --- |
| `plane` | 飞机 |
| `high_speed_rail` | 高铁/动车 |
| `train` | 火车（普速） |
| `self_drive` | 自驾 |

#### EntertainmentLevel（招待对象层级）— ExpenseApplication

| 值 | 含义 | 说明 |
| --- | --- | --- |
| `normal` | 普通人员 | 一般业务对接人，人均标准最低 |
| `important` | 重要人员 | 客户中层/项目负责人 |
| `vip` | VIP | 客户高管/政府领导，人均标准最高 |

#### UsageStatus（申请使用状态）— ExpenseApplication

| 值 | 含义 | 说明 |
| --- | --- | --- |
| `unused` | 未被报销引用 | 申请审批通过后初始状态 |
| `partially_used` | 部分已报销 | 被一张或多张报销单分批引用，尚有剩余额度 |
| `fully_used` | 已全部报销 | 报销总金额已达申请预估金额 |

#### BizScope（字典适用范围）— SysCostCategory

| 值 | 含义 |
| --- | --- |
| `expense_apply` | 仅事前申请端使用 |
| `expense` | 仅费用报销端使用 |
| `both` | 两端通用 |

### 6.7 考勤与用车

#### OtType（加班类型）— Overtime

| 值 | 含义 |
| --- | --- |
| `workday` | 工作日加班 |
| `weekend` | 休息日加班 |
| `holiday` | 法定节假日加班 |

#### CompensationType（补偿形式）— Overtime

| 值 | 含义 |
| --- | --- |
| `overtime_pay` | 全部结算加班费 |
| `comp_leave` | 全部转为调休时数 |
| `mixed` | 混合模式（按 CompLeaveRatio 比例分配） |

#### Purpose（用车目的类别）— Vehicle

| 值 | 含义 |
| --- | --- |
| `reception` | 客户接待 |
| `business` | 商务出行 |
| `official` | 公务 |

#### VehicleType（车辆类型）— SysVehicle

| 值 | 含义 |
| --- | --- |
| `sedan` | 轿车 |
| `suv` | SUV |
| `mpv` | 商务车 |
| `van` | 面包车 |

#### 车辆状态（SysVehicle.Status）— SysVehicle

| 值 | 含义 |
| --- | --- |
| `idle` | 空闲可用 |
| `in_use` | 使用中（有活跃用车申请） |
| `maintenance` | 维修中（不可申请） |

### 6.8 公告与附件

#### Announcement.Type（公告分类）

| 值 | 含义 |
| --- | --- |
| `notice` | 通知公告 |
| `policy` | 人事与制度 |
| `activity` | 放假与活动安排 |

#### PrivateLevel（公告可见范围级别）— Announcement

| 值 | 含义 |
| --- | --- |
| `0` | 全员发布 |
| `1` | 按特定部门树发布 |
| `2` | 按指定特定用户发布 |

#### TargetType（公告范围实体类别）— AnnouncementTarget

| 值 | 含义 | TargetId 指向 |
| --- | --- | --- |
| `dept` | 按部门 | SysDepartment.Id |
| `user` | 按指定个人 | SysUser.Id |

### 6.9 附件文件类型

#### FileType（文件格式）— 多表共用

| 值 | 含义 |
| --- | --- |
| `image` | 图片（JPG/PNG/WebP 等） |
| `pdf` | PDF 文档 |
| `doc` | Word 文档 |
| `xls` | Excel 表格 |

#### OutingLogAttachment.FileType（外勤照片用途分类）— 附加取值

| 值 | 含义 |
| --- | --- |
| `sign_in_photo` | 签到照（水印含服务器授时 + GPS） |
| `visit_photo` | 拜访现场照 |
| `other` | 其他附件 |

### 6.10 BOOLEAN 语义字段

| 字段 | 0 的含义 | 1 的含义 |
| --- | --- | --- |
| `IsDeleted` | 正常记录（未删除） | 已软删除 |
| `IsActive` | 停用/禁用 | 启用 |
| `IsRead` | 未读 | 已读 |
| `IsValid` | 已失效 | 有效 |
| `IsTop` | 普通 | 置顶 |
| `IsUrged` | 未催办 | 已催办 |
| `IsInvoiceVerified` | 未查验 | 已查验 |
| `IsTaxIdMatched` | 未匹配 | 税号已匹配 |
| `IsCategoryCompliant` | 不合规 | 合规 |

---

## 7. 生产级高性能企业级索引布局规范

以下索引需在 SQL Server 部署阶段直接建立，为 Flutter 前端应用提供极致流畅的下拉刷新、防抖模糊联想以及大数据穿透下钻多维分析能力。

```sql
-- ==========================================
-- 1. 费用报销列表：五状态 Chip 切换 + 时间倒序
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Expense_List_Path 
ON Expense (ApplicantId, Status, CreateTime DESC) 
INCLUDE (TotalAmount, ReportNo, ApplicationDate, PaymentStatus);

-- ==========================================
-- 2. 事前申请一键导入：过滤已通过且尚有剩余额度的单据
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ExpenseApp_Import_Drawer 
ON ExpenseApplication (ApplicantId, Status, UsageStatus) 
WHERE Status = 'approved' AND UsageStatus IN ('unused', 'partially_used');

-- ==========================================
-- 3. 经理端待办列表："待我审批的单据"及批量同意
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ApprovalRecord_Approver_Todo 
ON ApprovalRecord (ApproverId, Action, BizType, IsValid) 
INCLUDE (BizId, ApprovalLevel, CreateTime)
WHERE IsValid = 1;

-- ==========================================
-- 4. 预算可用余额校验：高并发唯一索引
-- ==========================================
CREATE UNIQUE NONCLUSTERED INDEX UX_ProjectBudget_Concurrency_Control 
ON SysProjectBudget (ProjectId, SubjectId, ExpenseType) 
INCLUDE (AvailableAmount, FrozenAmount, AllocatedAmount);

-- 4.1 NULL ExpenseType 过滤唯一索引
CREATE UNIQUE NONCLUSTERED INDEX UX_ProjectBudget_NULL_Type 
ON SysProjectBudget (ProjectId, SubjectId) 
WHERE ExpenseType IS NULL;

-- ==========================================
-- 5. 公车排期冲突检测：多条件防卫索引
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Vehicle_Schedule_Collision_Defense 
ON Vehicle (VehicleId, StartTime, EndTime) 
WHERE Status IN ('pending', 'approved');

-- ==========================================
-- 6. 公告 DING 催办：瞬间抓取未读员工列表
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Announcement_Audit_Ding 
ON AnnouncementReadLog (AnnouncementId, IsRead, IsUrged) 
INCLUDE (UserId, ReadTime);

-- ==========================================
-- 7. 外勤日志列表：按业务员时间倒序
-- ==========================================
CREATE NONCLUSTERED INDEX IX_OutingLog_Sales_Timeline 
ON OutingLog (SalespersonId, CreateTime DESC) 
INCLUDE (CustomerId, CustomerName, CheckInAddress);

-- ==========================================
-- 8. 权限管理抽屉：按目标用户查询角色变更审计历史
-- ==========================================
CREATE NONCLUSTERED INDEX IX_RoleChangeLog_TargetUser 
ON SysRoleChangeLog (TargetUserId, CreateTime DESC) 
INCLUDE (OperatorId, ChangeType, BeforeSnapshot, AfterSnapshot);

-- ==========================================
-- 9. 消息列表：排除已软删除的消息
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Message_List_Active 
ON Message (UserId, IsDeleted, IsRead, CreateTime DESC) 
INCLUDE (Title, MsgType, BizType, BizId, SenderId);

-- ==========================================
-- 10. 费用类别字典：级联下拉子类别加载
-- ==========================================
CREATE NONCLUSTERED INDEX IX_CostCategory_Parent 
ON SysCostCategory (ParentId, SortOrder) 
INCLUDE (CategoryName, CategoryCode)
WHERE IsActive = 1;

-- ==========================================
-- 11. 银行字典：下拉列表按排序加载
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Bank_List 
ON SysBank (SortOrder, IsActive) 
INCLUDE (BankName, BankCode);

-- ==========================================
-- 12. 客户名称模糊搜索：外勤日志选客户时的即时联想
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Customer_Name_Search 
ON SysCustomer (IsActive, CustomerName) 
INCLUDE (ShortName, SalespersonId);

-- ==========================================
-- 13. 按业务员筛选客户池
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Customer_Salesperson 
ON SysCustomer (SalespersonId, IsActive) 
INCLUDE (CustomerName, ShortName);

-- ==========================================
-- 14. 经理端按部门查看下属外勤日志时间线
-- ==========================================
CREATE NONCLUSTERED INDEX IX_OutingLog_Dept_Timeline 
ON OutingLog (DeptId, CreateTime DESC) 
INCLUDE (SalespersonId, CustomerId, CustomerName, CheckInAddress, Status);

-- ==========================================
-- 15. 审批记录有效链查询：前端时间线组件
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ApprovalRecord_Biz_Valid 
ON ApprovalRecord (BizId, BizType, IsValid, ApprovalLevel) 
INCLUDE (ApproverId, Action, Opinion, ApprovalTime);

-- ==========================================
-- 16. 事前申请列表：五状态 Chip 切换 + 时间倒序
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ExpenseApp_List_Path 
ON ExpenseApplication (ApplicantId, Status, CreateTime DESC) 
INCLUDE (ApplicationNo, ApplicationDate, EstimatedAmount, Purpose, ExpenseType, UsageStatus, ValidUntil);

-- ==========================================
-- 17. 加班记录列表：五状态 Chip 切换 + 时间倒序
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Overtime_List_Path 
ON Overtime (ApplicantId, Status, CreateTime DESC) 
INCLUDE (ApplicationNo, OtType, NetOtHours, CompensationType);

-- ==========================================
-- 18. 用车记录列表：状态 Chip 切换 + 时间倒序
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Vehicle_List_Path 
ON Vehicle (ApplicantId, Status, CreateTime DESC) 
INCLUDE (ApplicationNo, VehicleId, Purpose, Origin, Destination, StartTime);

-- ==========================================
-- 19. 按角色查用户：权限管理页 + 审批链动态寻人
-- ==========================================
CREATE NONCLUSTERED INDEX IX_UserRole_Role_Reverse 
ON SysUserRole (RoleId, IsDeleted) 
INCLUDE (UserId);

-- ==========================================
-- 20. 用户登录/搜索：用户名 + 姓名
-- ==========================================
CREATE NONCLUSTERED INDEX IX_User_Search 
ON SysUser (UserName, RealName) 
INCLUDE (DeptId, IsActive, IsDeleted);

-- ==========================================
-- 21. 用户角色映射：防止同一角色重复授权
-- ==========================================
CREATE UNIQUE NONCLUSTERED INDEX UX_UserRole_Active 
ON SysUserRole (UserId, RoleId) 
WHERE IsDeleted = 0;

-- ==========================================
-- 22. 审批链全局配置唯一约束
-- ==========================================
CREATE UNIQUE NONCLUSTERED INDEX UX_ApprovalChain_Global 
ON ApprovalChain (BizType, LevelNo) 
WHERE DeptId IS NULL AND IsDeleted = 0;

-- ==========================================
-- 23. 审批链部门级配置唯一约束
-- ==========================================
CREATE UNIQUE NONCLUSTERED INDEX UX_ApprovalChain_Dept 
ON ApprovalChain (BizType, DeptId, LevelNo) 
WHERE DeptId IS NOT NULL AND IsDeleted = 0;

-- ==========================================
-- 24. 公告列表：分类筛选 + 置顶优先 + 时间倒序
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Announcement_List 
ON Announcement (Type, IsTop DESC, PublishTime DESC) 
INCLUDE (Title, PublisherId, Status, ExpiryDate)
WHERE IsDeleted = 0 AND Status = 'published';

-- ==========================================
-- 25. 部门树形架构：按上级部门加载子节点
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Department_Tree 
ON SysDepartment (ParentId, SortOrder) 
INCLUDE (DeptName, ManagerId, IsActive)
WHERE IsDeleted = 0;

-- ==========================================
-- 26. 工作台轮播图：按排序加载启用项
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Banner_Active 
ON SysBanner (IsActive, SortOrder) 
INCLUDE (ImageUrl, Title, LinkUrl)
WHERE IsDeleted = 0;

-- ==========================================
-- 27. 报销单关联事前申请：一键导入时排除已被引用的申请
-- ==========================================
CREATE NONCLUSTERED INDEX IX_Expense_SourceApp 
ON Expense (SourceApplicationId) 
WHERE SourceApplicationId IS NOT NULL;

-- ==========================================
-- 28. 外勤日志点评列表
-- ==========================================
CREATE NONCLUSTERED INDEX IX_OutingLogComment_LogId 
ON OutingLogComment (LogId, IsDeleted) 
INCLUDE (CommenterId, RatingStars, CommentText, CreateTime);

-- ==========================================
-- 29. 费用报销明细行
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ExpenseDetail_ExpenseId 
ON ExpenseDetail (ExpenseId, SortOrder) 
INCLUDE (ExpenseDate, ExpenseType, ExpenseDesc, Amount, TotalAmount, InvoiceType);

-- ==========================================
-- 30. 事前申请明细行
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ExpenseAppDetail_AppId 
ON ExpenseAppDetail (ApplicationId, SortOrder) 
INCLUDE (ExpenseCategory, EstimatedAmount, Remark);

-- ==========================================
-- 31. 报销发票附件
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ExpenseAttachment_ExpenseId 
ON ExpenseAttachment (ExpenseId) 
INCLUDE (FileName, FileUrl, FileType, FileSize);

-- ==========================================
-- 32. 事前申请支撑材料附件
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ExpenseAppAttachment_AppId 
ON ExpenseApplicationAttachment (ApplicationId) 
INCLUDE (FileName, FileUrl, FileType, FileSize);

-- ==========================================
-- 33. 公告附件
-- ==========================================
CREATE NONCLUSTERED INDEX IX_AnnouncementAttachment_AnnId 
ON AnnouncementAttachment (AnnouncementId) 
INCLUDE (FileName, FileUrl, FileType, FileSize);

-- ==========================================
-- 34. 外勤日志照片墙
-- ==========================================
CREATE NONCLUSTERED INDEX IX_OutingLogAttachment_LogId 
ON OutingLogAttachment (LogId, SortOrder) 
INCLUDE (FileName, FileUrl, FileType);

-- ==========================================
-- 35. 用车同行人列表
-- ==========================================
CREATE NONCLUSTERED INDEX IX_VehiclePassenger_AppId 
ON VehiclePassenger (ApplicationId, IsDeleted) 
INCLUDE (UserId, PassengerName);

-- ==========================================
-- 36. 费用类别字典：按业务范围 + 费用大类过滤
-- ==========================================
CREATE NONCLUSTERED INDEX IX_CostCategory_Scope_Type
ON SysCostCategory (BizScope, ExpenseType, ParentId, SortOrder)
INCLUDE (CategoryName, CategoryCode)
WHERE IsActive = 1 AND IsDeleted = 0;

-- ==========================================
-- 37. 预算冻结查询：按项目+科目查冻结额度
-- ==========================================
CREATE NONCLUSTERED INDEX IX_ProjectBudget_Frozen 
ON SysProjectBudget (ProjectId, SubjectId, ExpenseType)
INCLUDE (AvailableAmount, FrozenAmount, AllocatedAmount);
```

---

## 8. 常用查询/变更示例 (Code Snippets / Migration Examples)

> 以下示例基于 **Dapper + ADO.NET** 风格编写，展示本文档约定的正确用法。AI 在生成新业务代码时应模仿这些示例的命名、事务边界、乐观锁和软删除过滤模式。

### 8.1 报销单详情多表联查

一次性加载报销单的全部信息（主表 + 明细行 + 附件列表 + 申请人信息 + 审批历史），用于详情页渲染。

```csharp
// 报销单详情多表联查
const string sql = @"
SELECT 
    -- 主表
    e.Id, e.ReportNo, e.TotalAmount, e.Purpose,
    e.BankName, e.AccountName, e.BankAccount,
    e.IsInvoiceVerified, e.IsTaxIdMatched, e.IsCategoryCompliant,
    e.BankTransferNo, e.VoucherNo,
    e.Status, e.PaymentStatus, e.ApplicationDate, e.CreateTime,
    -- 申请人
    u.RealName AS ApplicantName,
    u.DeptId,
    -- 部门
    d.DeptName,
    -- 关联事前申请
    ea.ApplicationNo AS SourceApplicationNo,
    -- 项目
    p.ProjectName
FROM Expense e
INNER JOIN SysUser u ON u.Id = e.ApplicantId AND u.IsDeleted = 0
INNER JOIN SysDepartment d ON d.Id = e.DeptId AND d.IsDeleted = 0
LEFT JOIN ExpenseApplication ea ON ea.Id = e.SourceApplicationId AND ea.IsDeleted = 0
LEFT JOIN SysProject p ON p.Id = e.ProjectId AND p.IsDeleted = 0
WHERE e.Id = @expenseId AND e.IsDeleted = 0;

-- 明细行（含发票信息）
SELECT 
    ed.Id, ed.ExpenseDate, ed.ExpenseType, ed.ExpenseDesc,
    ed.Amount, ed.TaxAmount, ed.TotalAmount,
    ed.InvoiceNo, ed.InvoiceCode, ed.InvoiceType, ed.TaxRate,
    ed.SortOrder,
    cc.CategoryName AS ExpenseTypeName
FROM ExpenseDetail ed
LEFT JOIN SysCostCategory cc ON cc.CategoryCode = ed.ExpenseType AND cc.IsDeleted = 0
WHERE ed.ExpenseId = @expenseId
ORDER BY ed.SortOrder;

-- 附件列表
SELECT Id, FileName, FileUrl, FileType, FileSize, DetailId
FROM ExpenseAttachment
WHERE ExpenseId = @expenseId
ORDER BY CreateTime;

-- 审批历史
SELECT 
    ar.ApprovalLevel, ar.Action, ar.Opinion, ar.ApprovalTime,
    ar.IsValid,
    u.RealName AS ApproverName,
    tu.RealName AS TransferToUserName
FROM ApprovalRecord ar
INNER JOIN SysUser u ON u.Id = ar.ApproverId
LEFT JOIN SysUser tu ON tu.Id = ar.TransferToUserId
WHERE ar.BizId = @expenseId 
  AND ar.BizType = 'expense'
  AND ar.IsValid = 1
ORDER BY ar.ApprovalLevel;
";
```

### 8.2 预算余额查询与可用余额校验

查询某项目+科目的当前可用余额（含冻结额度），供前端展示和提交校验时使用。

```csharp
public async Task<BudgetBalance> GetAvailableBalanceAsync(
    IDbConnection conn,
    long projectId, long subjectId, string expenseType)
{
    const string sql = @"
SELECT 
    AllocatedAmount,
    AvailableAmount,
    FrozenAmount,
    (AvailableAmount - FrozenAmount) AS ActualAvailable
FROM SysProjectBudget
WHERE ProjectId = @projectId 
  AND SubjectId = @subjectId 
  AND (ExpenseType = @expenseType OR ExpenseType IS NULL)
ORDER BY CASE WHEN ExpenseType = @expenseType THEN 0 ELSE 1 END;";

    var budget = await conn.QueryFirstOrDefaultAsync<BudgetBalance>(sql, new
    {
        projectId, subjectId, expenseType
    });

    return budget ?? throw new BusinessException("该项目+科目未配置预算");
}

// BudgetBalance 返回结构：
// AllocatedAmount  — 初始分配总额
// AvailableAmount  — 已扣减审批通过的额度后余额
// FrozenAmount     — 当前冻结中的额度（已提交但未审批通过）
// ActualAvailable  — 前端展示的实际可用余额 = AvailableAmount - FrozenAmount
```

### 8.3 事前申请提交 — 预算冻结（乐观锁 + 事务）

事前申请提交时冻结预算额度，携带乐观锁版本号防止并发超冻。

```csharp
public async Task<bool> FreezeBudgetOnSubmitAsync(
    IDbConnection conn, IDbTransaction tx,
    long projectId, long subjectId, string expenseType, decimal amount)
{
    // 1. 查询预算记录、可用余额及当前版本号
    const string querySql = @"
SELECT Id, AvailableAmount, FrozenAmount, AllocatedAmount, Version
FROM SysProjectBudget
WHERE ProjectId = @projectId 
  AND SubjectId = @subjectId 
  AND (ExpenseType = @expenseType OR ExpenseType IS NULL)
ORDER BY CASE WHEN ExpenseType = @expenseType THEN 0 ELSE 1 END;";

    var budgets = (await conn.QueryAsync<ProjectBudget>(querySql, new
    {
        projectId, subjectId, expenseType
    }, tx)).ToList();

    var budget = budgets.FirstOrDefault();
    if (budget == null)
        throw new BusinessException("该项目+科目未配置预算");

    // 可用余额 = AvailableAmount - FrozenAmount
    var availableBalance = budget.AvailableAmount - budget.FrozenAmount;
    if (availableBalance < amount)
        throw new BusinessException($"预算余额不足，可用 {availableBalance:F2}，申请 {amount:F2}");

    // 2. 乐观锁冻结（WHERE Version = @oldVersion）
    const string freezeSql = @"
UPDATE SysProjectBudget
SET FrozenAmount = FrozenAmount + @amount,
    UpdateTime = GETDATE()
WHERE Id = @id AND Version = @oldVersion;";

    var affected = await conn.ExecuteAsync(freezeSql, new
    {
        id = budget.Id,
        amount,
        oldVersion = budget.Version
    }, tx);

    if (affected == 0)
        throw new ConcurrencyException("预算版本冲突，冻结失败，请重试"); // 应用层重试最多 3 次

    return true;
}
```

### 8.4 审批通过 — 冻结转扣减 + 释放剩余冻结

审批通过时：释放冻结额度，同时扣减可用余额。两者在同一事务内执行。

```csharp
public async Task ConvertFreezeToDeductionAsync(
    IDbConnection conn, IDbTransaction tx,
    long projectId, long subjectId, string expenseType, decimal amount)
{
    const string sql = @"
UPDATE SysProjectBudget
SET FrozenAmount = FrozenAmount - @amount,
    AvailableAmount = AvailableAmount - @amount,
    UpdateTime = GETDATE()
WHERE ProjectId = @projectId 
  AND SubjectId = @subjectId 
  AND (ExpenseType = @expenseType OR ExpenseType IS NULL)
  AND Version = @oldVersion;";

    // ... 乐观锁校验逻辑同 8.3
}
```

### 8.5 拒绝/撤回 — 释放冻结

审批拒绝或申请人撤回时：仅释放冻结额度，可用余额不扣减。

```csharp
public async Task ReleaseFreezeAsync(
    IDbConnection conn, IDbTransaction tx,
    long projectId, long subjectId, string expenseType, decimal amount)
{
    const string sql = @"
UPDATE SysProjectBudget
SET FrozenAmount = FrozenAmount - @amount,
    UpdateTime = GETDATE()
WHERE ProjectId = @projectId 
  AND SubjectId = @subjectId 
  AND (ExpenseType = @expenseType OR ExpenseType IS NULL)
  AND Version = @oldVersion;";

    // ... 乐观锁校验逻辑同 8.3
}
```

### 8.6 事前申请 UsageStatus 联动更新

报销单提交/撤回/删除后，触发事前申请的 `UsageStatus` 重算。

```csharp
public async Task UpdateUsageStatusAsync(
    IDbConnection conn, IDbTransaction tx, long applicationId)
{
    const string sql = @"
UPDATE ea
SET ea.UsageStatus = 
    CASE 
        WHEN ISNULL(SUM(e.TotalAmount), 0) = 0 THEN 'unused'
        WHEN ISNULL(SUM(e.TotalAmount), 0) >= ea.EstimatedAmount THEN 'fully_used'
        ELSE 'partially_used'
    END,
    ea.UpdateTime = GETDATE()
FROM ExpenseApplication ea
LEFT JOIN Expense e 
    ON e.SourceApplicationId = ea.Id 
    AND e.IsDeleted = 0
WHERE ea.Id = @applicationId
GROUP BY ea.Id, ea.EstimatedAmount;";

    await conn.ExecuteAsync(sql, new { applicationId }, tx);
}
```

### 8.7 待审批列表查询（多态外键 JOIN）

经理端"待我审批"列表需跨四种业务类型查询，每种类型 JOIN 对应的业务表和申请人信息。

```csharp
// 方案：按 BizType 分别查询后 UNION ALL，或应用层分四次查询合并
const string sql = @"
-- 待审批的报销单
SELECT 
    'expense' AS BizType,
    e.Id AS BizId,
    e.ReportNo AS BizNo,
    e.TotalAmount AS Amount,
    e.Purpose AS Summary,
    e.CreateTime AS SubmitTime,
    u.RealName AS ApplicantName,
    d.DeptName,
    ar.Id AS RecordId,
    ar.ApprovalLevel
FROM ApprovalRecord ar
INNER JOIN Expense e ON e.Id = ar.BizId AND e.IsDeleted = 0
INNER JOIN SysUser u ON u.Id = e.ApplicantId
INNER JOIN SysDepartment d ON d.Id = e.DeptId
WHERE ar.ApproverId = @approverId
  AND ar.Action = 'pending'
  AND ar.IsValid = 1

UNION ALL

-- 待审批的加班申请
SELECT 
    'overtime' AS BizType,
    o.Id AS BizId,
    o.ApplicationNo AS BizNo,
    o.NetOtHours AS Amount,
    o.Reason AS Summary,
    o.CreateTime AS SubmitTime,
    u.RealName AS ApplicantName,
    d.DeptName,
    ar.Id AS RecordId,
    ar.ApprovalLevel
FROM ApprovalRecord ar
INNER JOIN Overtime o ON o.Id = ar.BizId AND o.IsDeleted = 0
INNER JOIN SysUser u ON u.Id = o.ApplicantId
INNER JOIN SysDepartment d ON d.Id = o.DeptId
WHERE ar.ApproverId = @approverId
  AND ar.Action = 'pending'
  AND ar.IsValid = 1

UNION ALL

-- 待审批的用车申请
SELECT 
    'vehicle' AS BizType,
    v.Id AS BizId,
    v.ApplicationNo AS BizNo,
    0 AS Amount,  -- 用车无金额字段
    v.Reason AS Summary,
    v.CreateTime AS SubmitTime,
    u.RealName AS ApplicantName,
    d.DeptName,
    ar.Id AS RecordId,
    ar.ApprovalLevel
FROM ApprovalRecord ar
INNER JOIN Vehicle v ON v.Id = ar.BizId AND v.IsDeleted = 0
INNER JOIN SysUser u ON u.Id = v.ApplicantId
INNER JOIN SysDepartment d ON d.Id = v.DeptId
WHERE ar.ApproverId = @approverId
  AND ar.Action = 'pending'
  AND ar.IsValid = 1

UNION ALL

-- 待审批的事前申请
SELECT 
    'expense_apply' AS BizType,
    ea.Id AS BizId,
    ea.ApplicationNo AS BizNo,
    ea.EstimatedAmount AS Amount,
    ea.Purpose AS Summary,
    ea.CreateTime AS SubmitTime,
    u.RealName AS ApplicantName,
    d.DeptName,
    ar.Id AS RecordId,
    ar.ApprovalLevel
FROM ApprovalRecord ar
INNER JOIN ExpenseApplication ea ON ea.Id = ar.BizId AND ea.IsDeleted = 0
INNER JOIN SysUser u ON u.Id = ea.ApplicantId
INNER JOIN SysDepartment d ON d.Id = ea.DeptId
WHERE ar.ApproverId = @approverId
  AND ar.Action = 'pending'
  AND ar.IsValid = 1

ORDER BY SubmitTime DESC;
";
```

### 8.4 单据编号原子生成（sp_getapplock）

高并发下安全生成按天重置的单据编号。

```csharp
public async Task<string> GenerateApplicationNoAsync(
    IDbConnection conn, IDbTransaction tx, string prefix, DateTime date)
{
    var dateStr = date.ToString("yyyyMMdd");
    var pattern = $"{prefix}-{dateStr}-%";
    var lockKey = $"AppNo_{prefix}_{dateStr}";

    // 获取应用程序锁（串行化编号生成）
    await conn.ExecuteAsync("sp_getapplock @Resource, @LockMode, @LockOwner", new
    {
        Resource = lockKey,
        LockMode = "Exclusive",
        LockOwner = "Transaction"
    }, tx);

    // 查询当日最大序号
    const string sql = @"
SELECT MAX(ApplicationNo) 
FROM ExpenseApplication WITH (UPDLOCK, ROWLOCK)
WHERE ApplicationNo LIKE @pattern";

    var maxNo = await conn.QuerySingleOrDefaultAsync<string>(sql, new { pattern }, tx);

    int seq = 1;
    if (maxNo != null)
    {
        // 提取序号部分（后 3 位）
        var seqStr = maxNo.Substring(maxNo.Length - 3);
        seq = int.Parse(seqStr) + 1;
    }

    return $"{prefix}-{dateStr}-{seq:D3}";
}
```

> **注意**：以上编号生成逻辑适用于所有单据类型，仅需替换表名和前缀参数。

---

> **文档版本**：v2.0（主键策略变更：VARCHAR(36) GUID → BIGINT IDENTITY(1,1)；文档结构重组为 8 节标准格式）
