# 软件月度账单运维手册

> 适用范围：软件坐席的月度账单快照 —— 何时关账、如何修正、和实时费用视图的关系
> 最近更新：2026-04-24（首版 — migration 063 上线 `software_monthly_bills` 表 + 关账 / 重新打开 / 实时预览）

---

## 零、为什么需要月度账单

在 migration 063 之前，坐席费用页完全是**实时算**的：

```
总费用 = Σ (当前 plan.price × billing_cycle 系数)
```

两个问题：

1. **改价追溯** —— 把 Zoom Professional 从 $100 改成 $120，整个历史都变成 $120。三月份实际支付的是 $100，但报表里看到的是 $120。
2. **人员变动无法归档** —— 4 月份某人 15 号加入、25 号离职，这个月的"真实占用"是什么？实时算只会根据"现在还活着吗"判断。

月度账单解决这两个问题：**每个月可以被"关账"一次**，关账时对当时所有有效的 allocation 拍快照，包括价格、cycle、软件名、plan 名、用户名、部门。一旦关账，这些字段固化，再改 plan 不影响已关账的月份。

---

## 一、核心概念

### 1.1 "期"（period）

字符串格式 `YYYY-MM`，例如 `2026-03`。页面月份选择器的每一项对应一个期。

### 1.2 一条账单行（bill line）

一期 × 一条 allocation = 一条账单行。字段见 `software_monthly_bills` 表。特殊字段：

| 字段 | 含义 |
|------|------|
| `amount` | 本期对应金额：monthly → `price`；yearly → `price / 12`；one_time → 分配当月算 `price`，其他月 $0 |
| `allocated_at` / `revoked_at` | 快照时的 allocation 状态 |
| `price` / `billing_cycle` | 快照 plan 字段 |
| `software_name` / `plan_name` / `user_name` / `department` / `reason` | 防止后续改名/改 reason 影响历史 |
| `finalized_by` | 关账的操作者 UUID；自动关账时为 NULL（审计 `actor_type='system'`）|

### 1.3 期的三种状态

| 状态 | 定义 |
|------|------|
| `finalized` | 已关账 —— 表里有记录，数据是冻结快照 |
| `draft` | 当月（还没过完） —— 没有记录，页面读 `/cost-stats/*` 实时算 |
| `available` | 过去某月但没关账 —— 没有记录，页面按"overlap"口径实时算，管理员可以点"关账"|

---

## 二、计费规则

### 2.1 哪些 allocation 会进某一期

**判断条件**：`allocated_at < 期末+1日` 且 `(revoked_at IS NULL 或 revoked_at >= 期初)`。

即：本月任何一天活过的坐席，都进本月账单。

### 2.2 金额怎么算

| `billing_cycle` | `amount` 计算 |
|-----------------|---------------|
| `monthly` | `plan.price`（整月计费，即使只用了 1 天也算全月）|
| `yearly` | `plan.price / 12`（按月均摊到每一个活跃月）|
| `one_time` | 分配当月 = `plan.price`；其他月 = $0（零金额行不入库，保持表精简）|

**注意：** 年付的 12 等分取两位小数四舍五入，所以 `12 × (price/12) ≈ price` 有 1-2 分的舍入差；这是设计允许的，无需修正。

### 2.3 例子

| 场景 | 本期 `amount` |
|------|--------------|
| Alice 4 月 1 日分配 $100/月 Zoom Pro，5 月仍在用 | 4 月、5 月各 $100 |
| Bob 4 月 15 日分配 $100/月 Zoom Pro，4 月 20 日撤销 | 4 月 $100（整月计费） |
| Carol 3 月 1 日分配 $1200/年 Slack，一直在用 | 每月 $100（$1200/12） |
| Dave 3 月分配 $500 一次性采购 | 3 月 $500，之后月份无记录 |

---

## 三、操作流程

### 3.1 自动关账（默认行为）

**定时任务**：`software_billing.auto_close`，migration 064 种下。
- 时间：每月 1 号凌晨 3:03（Asia/Shanghai）
- 做什么：自动关上月（例如 5 月 1 号关 4 月）
- 审计：`audit_logs.action = 'software_billing.close'`，`actor_type='system'`
- 幂等：如果上月已被管理员手动关账，任务检测到 `PERIOD_ALREADY_CLOSED` 自动跳过，不报错

> **一般情况你什么都不用做** —— 系统会在月初自动帮你关账。只有想在 1 号之前提前关账或需要修正的情况才手动操作。

定时任务可在「管理后台 → 定时任务」里看到最后一次运行的时间和摘要，失败会在同页面显示原因。

### 3.2 管理员 —— 手动关账一个月

**前提**：只能关 `已结束的月`。当月（还没过完）不允许关账。

**路径**：
1. 进入「软件费用」页
2. 月份选择器选某个 **"未关账 · 可关账"** 的月（例如 `2026-03`）
3. 页面顶部黄色横幅提示"已结束但未关账"
4. 点击右上角紫色"关账"按钮 → 二次确认 → 完成

**后端处理**（`billing_service.close_month`）：
- 遍历该期所有 overlap 的 allocation
- 为每条计算 `amount`，零金额行跳过
- 批量 INSERT 到 `software_monthly_bills`，`finalized_at` 记录时间；`finalized_by` 是用户 UUID（如果是自动关账则为 NULL，审计表标 `actor_type='system'`）
- 同步写一条 `software_billing.close` 审计日志（`audit_logs`）

**失败情况**：
- 期还没结束 → `400 PERIOD_NOT_ENDED`
- 已关账 → `409 PERIOD_ALREADY_CLOSED`（需要先重新打开）
- 非 asset_admin / it_admin → `403`

### 3.3 管理员 —— 重新打开一个月（修正历史）

**典型场景**：发现某月遗漏了一条 allocation、或 plan 价格被误改后重新设置、或某员工漏统计等。

**路径**：
1. 月份选择器选已关账的月（状态 `Closed`）
2. 顶部绿色横幅显示"xx 已关账"
3. 点击"重新打开"按钮 → 二次确认 → 完成
4. 此时该期变回 `available` —— 页面重新按当前 plan 价格实时算
5. 修正 allocation 或价格后，再次点"关账"生成新的快照

**后端处理**：
- DELETE 该期所有 `software_monthly_bills` 记录
- 写 `software_billing.reopen` 审计日志

**注意：重新打开不是撤销操作**。旧的快照行被物理删除；如果想保留历史对比，关账前先导出。

### 3.4 员工 —— 查看自己的月度费用

- 月份选择器选"当月（实时）"或某个过去月
- 视图下拉默认"我的费用"
- 表格层级：软件 → 展开看自己该软件的所有 plan 汇总行 → 再展开看每条 allocation 明细

### 3.5 管理员 —— 当月实时 vs 历史月

**经验法则：**

- 看本月到目前为止的费用 → 月份选"当月（实时）"
- 看 3 月份结算时的费用 → 月份选 `2026-03`（已关账 = 快照；未关账 = 实时算）
- 改了价格后想让报表留旧价 → **关账要在改价之前**

### 3.6 财务 —— 一个时间段内某些软件分别花了多少（区间汇总）

单月看不出「这几个软件这半年分别花了多少」。软件费用页的**「区间汇总」tab**回答这个问题(2026-07-30):

- tab 内自带月份区间选择器(默认最近 6 个月,**最宽 24 个月**,更宽返回 `RANGE_TOO_WIDE`)
- 想只看某几个软件 / 某个部门 → 用页面**上方**共享的「软件」多选和「部门」树(区间 tab 复用它们);状态 / 计费周期 / 关键词 / 授予日期这些**行级**筛选在按月汇总下不适用,tab 里会明确提示已忽略
- 表格:一行一个软件 → 区间合计(**按币种分行,不折汇率**)+ 计费人数(跨月去重)+ 每月一列;表头带该月状态标签
- 「导出」下载 xlsx:一行 = 软件 × 币种,一列 = 一个月,月份表头带「已关账 / 实时 / 未纳入」
- 对应 API `GET /assets/software-billing/range-summary`(MCP 工具 `software_cost_range_summary`)

**口径与单月完全一致**:已关账的月份读该月冻结快照(改价不倒推),未关账的月份实时算。

**⚠️ 出现「币种标签与现行计划不一致」黄条时**:某个已关账月份的冻结行币种与该软件**现在**的计划币种对不上。已关账月份保留当时的标签是设计如此(改价/改币种都不倒推),但如果是历史错标(2026-05-05 迁移 117 一刀切回填 CNY 就制造过一批:Claude / OPEN AI 的 2026-04/05),同一批坐席会在区间表里被拆成两栏。核对后用 `server/scripts/fix_ai_seat_bill_currency_2026_07_30.py` 修正(默认 dry-run,`--commit` 生效,回滚语句在脚本 docstring);它只改「现行计划单一币种 且 冻结行与之不符」的行。

**未来月份**:区间选到还没到的月份时,那些月份标灰「未来」、不做任何预测、不计入合计(前端也不允许选)—— 蓝色提示条就是这个意思。

**⚠️ 出现黄色告警条「以下月份未关账且超出实时计算上限,未计入合计」时**:实时重算每个月都是一次完整坐席扫描,所以最多只实时算 **3 个**未关账月份(`RANGE_MAX_LIVE_PERIODS`),更早的未关账月份**不计入合计**且被点名列出——**合计偏小,不是数据丢了**。处理:按 §3.2 把那些月份关账(自动关账 cron 正常工作时只有当月未关账,不会看到这条告警),或把区间收窄到最近几个月。

---

## 四、页面显示 —— 三级折叠

目的：解决"一个软件多个坐席铺几十行"的问题。

```
软件行：Zoom              · 15 生效坐席 · $1,700.00
└─ 人员行：Alice · Eng    · [Professional] [Basic]   · 2 生效 · $250.00
    └─ 坐席行：Professional · $100.00/月 · $100.00   · 生效 · 2026-03-15
    └─ 坐席行：Basic       · $50.00/月  · $50.00    · 生效 · 2026-03-15
└─ 人员行：Bob · Eng       · [Professional]            · 1 生效 · $100.00
    └─ 坐席行：Professional · $100.00/月 · $100.00   · 生效 · 2026-04-01
```

**同一个软件 × 同一个人的多条 allocation 在 L2 人员行合并**（坐席数累加、金额累加、plan 用 tag 展示）。L3 才看到具体每条 allocation。撤销按钮只在 L3 出现，且只在"当月实时"视图可用 —— 历史月是只读快照，不能撤销。

---

## 五、数据表

### 5.1 `software_monthly_bills`

迁移：`server/alembic/versions/063_software_monthly_bills.py`
模型：`server/app/domains/asset/models.py` → `SoftwareMonthlyBill`

唯一键：`(period, allocation_id)` —— 保证关账幂等，同一期同一 allocation 不会重复。

### 5.2 关键索引

- `ix_software_monthly_bills_period` —— 按期查询（最热）
- `ix_software_monthly_bills_user_id` —— "我的月度账单"
- `ix_software_monthly_bills_software_id` —— "某个软件本期总消费"

---

## 六、API

基础路径：`/api/v1/assets/software-billing`

| 方法 | 路径 | 权限 | 作用 |
|------|------|------|------|
| GET | `/periods?months=12` | `asset_admin` | 列最近 N 个月，每月含关账状态 |
| GET | `/{period}/my` | 登录用户 | 本人该期账单 |
| GET | `/{period}/subordinates` | 登录用户 | 自己 + 下属（递归）|
| GET | `/{period}/department?department=X` | 登录用户 | 部门**子树**（例如 `Business Operations` 自动包含 `IT Services & Security` 等所有后代；非管理员受自己所在链限制）|
| GET | `/{period}/all?software_id=X` | `asset_admin` | 全公司 |
| POST | `/close` body `{period}` | `asset_admin` | 关账，期未结束 → 400 |
| POST | `/reopen` body `{period}` | `asset_admin` | 删期，允许重新关账 |

**返回格式**：

```json
{
  "period": "2026-03",
  "status": "finalized",      // or "draft"
  "total_amount": "12345.67",
  "active_seats": 42,
  "finalized_at": "2026-04-01T02:00:00Z",
  "finalized_by": "uuid",
  "items": [ { … 单行 BillLine … } ]
}
```

如果期未关账（`available`），走 `preview_current_or_draft` 实时算并以 `status: "draft"` 返回 —— 同样的表结构可以渲染。

---

## 七、审计 / 可追溯

关账和重新打开都会写 `audit_logs` 一条：

```sql
SELECT * FROM audit_logs
WHERE action LIKE 'software_billing.%'
ORDER BY created_at DESC LIMIT 20;
```

字段：`actor_user_id`、`resource_id`（期字符串）、`detail` JSON 包含 `line_count` / `total_amount` / `deleted`。

---

## 八、常见问题

### Q1：我修改了 Zoom 价格但实时页没变
检查浏览器缓存 / 前端 Zustand 缓存。刷新页面即可。本质上只要没关账，实时页就会读当前 plan 价格。

### Q2：3 月份报表上的金额和我记忆中不一致
可能 3 月关账前 plan 价格被改过。查 audit：`action = 'software_billing.close' AND resource_id = '2026-03'` 的 `finalized_at` 时间点，对比 `software_license_plans` 的历史（目前 plan 表无 audit，只能靠 git blame 或你自己的改价记录）。

### Q3：某员工月初请假但月底才离职，当月算不算钱？
**整月计费**。只要本期内任何一天有 active allocation，这条 allocation 计整月费用。

### Q4：什么时候应该关账？
**默认系统每月 1 号自动关上月**（`software_billing.auto_close` 定时任务）。如果自动关的时点不合适，可以在 1 号之前提前手动关账；也可以在关账后 reopen 修正。

### Q5：没关账的月份为什么数字和以前看的不一样？
因为那些月还在"实时算"状态，plan 价格变了它们就跟着变。关账就是在说"从这一刻起，这个月的数据别再变了"。

---

## 九、开发者注记

### 9.1 测试关账

本地跑（需要本地 DB 中有一些 allocation）：

```bash
curl -X POST http://localhost:8000/api/v1/assets/software-billing/close \
  -H 'Content-Type: application/json' -H "Authorization: Bearer $TOKEN" \
  -d '{"period": "2026-03"}'
```

查看：

```bash
curl -s http://localhost:8000/api/v1/assets/software-billing/2026-03/all \
  -H "Authorization: Bearer $TOKEN" | jq .data.total_amount
```

### 9.2 计算函数

所有公式集中在 `server/app/domains/asset/billing_service.py::_compute_amount`。新增 `billing_cycle` 只改这一处。

### 9.3 时区

服务端统一用 UTC。`one_time` 的"哪个月"判断把 `allocated_at` 归一化到 UTC 再取日期。如果未来接入非 UTC 数据源，需要在入库时就归一化，避免 `period` 判定错位。
