diff --git a/backend/app/services/oa_confirmation_date.py b/backend/app/services/oa_confirmation_date.py
new file mode 100644
index 00000000..b30795a1
--- /dev/null
+++ b/backend/app/services/oa_confirmation_date.py
@@ -0,0 +1,29 @@
+"""转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。"""
+from __future__ import annotations
+
+import calendar
+import re
+from datetime import date
+from typing import Optional
+
+_YMD = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
+
+
+def add_months_ymd(hire_ymd: str, months: int) -> Optional[str]:
+ s = (hire_ymd or "").strip()
+ m = _YMD.match(s)
+ if not m:
+ return None
+ if not isinstance(months, int) or isinstance(months, bool) or months < 0:
+ return None
+ y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
+ try:
+ date(y, mo, d)
+ except ValueError:
+ return None
+ total = y * 12 + (mo - 1) + months
+ ny, nm = divmod(total, 12)
+ nm += 1
+ last = calendar.monthrange(ny, nm)[1]
+ nd = min(d, last)
+ return f"{ny:04d}-{nm:02d}-{nd:02d}"
diff --git a/backend/tests/test_oa_confirmation_date.py b/backend/tests/test_oa_confirmation_date.py
new file mode 100644
index 00000000..5fc606b8
--- /dev/null
+++ b/backend/tests/test_oa_confirmation_date.py
@@ -0,0 +1,20 @@
+from app.services.oa_confirmation_date import add_months_ymd
+
+
+def test_simple_month_add():
+ assert add_months_ymd("2026-01-15", 3) == "2026-04-15"
+
+
+def test_clamp_end_of_month():
+ assert add_months_ymd("2026-01-31", 1) == "2026-02-28"
+
+
+def test_zero_months_returns_same_day():
+ assert add_months_ymd("2026-07-30", 0) == "2026-07-30"
+
+
+def test_invalid_returns_none():
+ assert add_months_ymd("", 3) is None
+ assert add_months_ymd("2026-01-15", -1) is None
+ assert add_months_ymd("2026-01-15", True) is None # type: ignore[arg-type]
+ assert add_months_ymd("2026-02-30", 1) is None
diff --git a/docs/superpowers/plans/2026-07-30-oa-confirmation-kit.md b/docs/superpowers/plans/2026-07-30-oa-confirmation-kit.md
new file mode 100644
index 00000000..6923a7bc
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-30-oa-confirmation-kit.md
@@ -0,0 +1,513 @@
+# OA Confirmation Kit(转正套件)Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 补齐用户入职日期/试用期主数据;在审批套件 Tab 增加转正套件(9 子字段、可显隐、发起默认、转正日联动);审批全部通过后回写发起人六项主数据。
+
+**Architecture:** 与请假套件相同:FormCreate `confirmationKit` 布局容器 + 子字段拼装;语义用 `props._fc_kit_field`。转正日用纯函数 `addMonthsYmd`(TS/Python 对齐),手改标记 `formData['__manual_' + confirmationDateField]`。实例 `status` 变为 `completed` 时调用 `apply_confirmation_kit_user_sync`。
+
+**Tech Stack:** Vue 3 · `@form-create/designer` · Element Plus · FastAPI · SQLAlchemy · pytest · Node `node:test`
+
+**Spec:** `docs/superpowers/specs/2026-07-30-oa-confirmation-kit-design.md`
+**参考:** `docs/superpowers/plans/2026-07-30-oa-leave-kit.md`、`oa-owner-fields`
+
+---
+
+## File map
+
+| Path | Responsibility |
+|------|----------------|
+| Create: `docs/sql/sys_user_hire_probation.sql` | `ALTER TABLE` 增列 |
+| Modify: `backend/app/models/models.py` | `SysUser.hire_date` / `probation_months` |
+| Modify: `backend/app/crud/user.py` | `_row_to_dict` / create / update |
+| Modify: `backend/app/api/admin.py` | `UserCreateBody` / `UserUpdateBody` + create/update 传参 |
+| Modify: `backend/app/api/public.py` | `_build_user_info_payload` 带出 `hireDate` / `probationMonths` |
+| Create: `web-admin/src/utils/oa-confirmation-date.ts` | `addMonthsYmd` |
+| Create: `web-admin/src/utils/oa-confirmation-date.test.ts` | 前端算法单测 |
+| Create: `backend/app/services/oa_confirmation_date.py` | 同算法 |
+| Create: `backend/tests/test_oa_confirmation_date.py` | pytest |
+| Create: `backend/app/services/oa_confirmation_kit_sync.py` | 通过后回写 |
+| Create: `backend/tests/test_oa_confirmation_kit_sync.py` | 回写单测 |
+| Modify: `backend/app/services/oa_publish.py` | `LAYOUT_TYPES` + `confirmationKit` |
+| Modify: `backend/app/services/oa_engine.py` | `completed` 时调用 sync |
+| Create: `web-admin/src/components/oa-approval/OaConfirmationKit.vue` | 容器(设计器虚线框;运行时无框) |
+| Create: `web-admin/src/utils/oa-confirmation-kit-rule.ts` | DragRule + 9 子字段 |
+| Create: `web-admin/src/utils/oa-form-create-confirmation-kit.ts` | 注册(kit menu 已存在则勿重复 addMenu) |
+| Create: `web-admin/src/utils/oa-confirmation-kit-runtime.ts` | 默认值、联动、显隐归一、manual 标记 |
+| Modify: `web-admin/src/main.ts` / `oa-fc-host/main.ts` | `setupOaConfirmationKit` |
+| Modify: `web-admin/src/views/oa-approval/start/index.vue` | 默认 + 联动 |
+| Modify: `web-admin/src/views/system/user/index.vue` | 编辑入职/试用期 |
+| Modify: Spec 状态 → 实现中/已实现 |
+
+---
+
+### Task 1: `addMonthsYmd` 算法(TDD,前后端)
+
+**Files:**
+- Create: `web-admin/src/utils/oa-confirmation-date.ts`
+- Create: `web-admin/src/utils/oa-confirmation-date.test.ts`
+- Create: `backend/app/services/oa_confirmation_date.py`
+- Create: `backend/tests/test_oa_confirmation_date.py`
+
+- [ ] **Step 1: 写前端失败测试**
+
+```ts
+// web-admin/src/utils/oa-confirmation-date.test.ts
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { addMonthsYmd } from './oa-confirmation-date'
+
+describe('addMonthsYmd', () => {
+ it('simple month add', () => {
+ assert.equal(addMonthsYmd('2026-01-15', 3), '2026-04-15')
+ })
+
+ it('clamp end of month', () => {
+ assert.equal(addMonthsYmd('2026-01-31', 1), '2026-02-28')
+ })
+
+ it('zero months returns same day', () => {
+ assert.equal(addMonthsYmd('2026-07-30', 0), '2026-07-30')
+ })
+
+ it('invalid => null', () => {
+ assert.equal(addMonthsYmd('', 3), null)
+ assert.equal(addMonthsYmd('2026-01-15', -1), null)
+ })
+})
+```
+
+- [ ] **Step 2: 运行 — expect FAIL**
+
+```bash
+cd web-admin && pnpm exec tsx --test src/utils/oa-confirmation-date.test.ts
+```
+
+- [ ] **Step 3: 实现前端**
+
+```ts
+// web-admin/src/utils/oa-confirmation-date.ts
+/** 入职日 + 试用期月数 → YYYY-MM-DD;非法返回 null。按自然月加,日溢出钳到目标月最后一天。 */
+export function addMonthsYmd(hireYmd: string, months: number): string | null {
+ const s = String(hireYmd || '').trim()
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null
+ if (!Number.isInteger(months) || months < 0) return null
+ const [y, m, d] = s.split('-').map((x) => Number(x))
+ if (!y || !m || !d) return null
+ const total = y * 12 + (m - 1) + months
+ const ny = Math.floor(total / 12)
+ const nm = (total % 12) + 1
+ const lastDay = new Date(ny, nm, 0).getDate()
+ const nd = Math.min(d, lastDay)
+ return `${ny}-${String(nm).padStart(2, '0')}-${String(nd).padStart(2, '0')}`
+}
+```
+
+- [ ] **Step 4: 前端测试 PASS**
+
+- [ ] **Step 5: 后端同测 + 实现**
+
+```python
+# backend/app/services/oa_confirmation_date.py
+from __future__ import annotations
+import calendar
+import re
+from datetime import date
+from typing import Optional
+
+_YMD = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
+
+def add_months_ymd(hire_ymd: str, months: int) -> Optional[str]:
+ s = (hire_ymd or "").strip()
+ m = _YMD.match(s)
+ if not m:
+ return None
+ if not isinstance(months, int) or isinstance(months, bool) or months < 0:
+ return None
+ y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
+ try:
+ date(y, mo, d)
+ except ValueError:
+ return None
+ total = y * 12 + (mo - 1) + months
+ ny, nm = divmod(total, 12)
+ nm += 1
+ last = calendar.monthrange(ny, nm)[1]
+ nd = min(d, last)
+ return f"{ny:04d}-{nm:02d}-{nd:02d}"
+```
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_confirmation_date.py -v
+```
+
+- [ ] **Step 6: Commit**(仅当用户要求时)
+
+```bash
+git add web-admin/src/utils/oa-confirmation-date.ts \
+ web-admin/src/utils/oa-confirmation-date.test.ts \
+ backend/app/services/oa_confirmation_date.py \
+ backend/tests/test_oa_confirmation_date.py
+git commit -m "feat(oa): add confirmation date month-add helper for 转正套件"
+```
+
+---
+
+### Task 2: `sys_user` 入职日期 / 试用期 + 用户管理 + userInfo
+
+**Files:**
+- Create: `docs/sql/sys_user_hire_probation.sql`
+- Modify: `backend/app/models/models.py`(`SysUser` 在 `phone_no` 后增列)
+- Modify: `backend/app/crud/user.py`
+- Modify: `backend/app/api/admin.py`
+- Modify: `backend/app/api/public.py`
+- Modify: `web-admin/src/views/system/user/index.vue`
+
+- [ ] **Step 1: SQL 增量**
+
+```sql
+-- docs/sql/sys_user_hire_probation.sql
+ALTER TABLE `sys_user`
+ ADD COLUMN `hire_date` date DEFAULT NULL COMMENT '入职日期' AFTER `phone_no`,
+ ADD COLUMN `probation_months` int(11) DEFAULT NULL COMMENT '试用期月数' AFTER `hire_date`;
+```
+
+在本地/测试库执行该脚本后再跑后端。
+
+- [ ] **Step 2: Model**
+
+在 `SysUser` 增加:
+
+```python
+hire_date = Column(Date, nullable=True, comment="入职日期")
+probation_months = Column(Integer, nullable=True, comment="试用期月数")
+```
+
+(确认 `Date` / `Integer` 已从 sqlalchemy 导入。)
+
+- [ ] **Step 3: `_row_to_dict` + create/update**
+
+`_row_to_dict` 增加:
+
+```python
+"hireDate": u.hire_date.strftime("%Y-%m-%d") if getattr(u, "hire_date", None) else "",
+"probationMonths": u.probation_months if getattr(u, "probation_months", None) is not None else None,
+```
+
+`create_user` / `update_user` 增加可选参数 `hire_date`、`probation_months` 并写入列。
+`update_user` 约定:**未传不改;传入 `null`/空字符串清空;传入整数更新**(含 `0`)。
+
+- [ ] **Step 4: API Body**
+
+`UserCreateBody` / `UserUpdateBody`:
+
+```python
+hire_date: Optional[str] = Field(None, alias="hireDate") # YYYY-MM-DD
+probation_months: Optional[int] = Field(None, alias="probationMonths")
+```
+
+create/update 路由把字段传入 crud;解析日期字符串为 `date` 或 `None`。
+
+- [ ] **Step 5: userInfo**
+
+`_build_user_info_payload` 至少增加:
+
+```python
+"hireDate": user.hire_date.strftime("%Y-%m-%d") if getattr(user, "hire_date", None) else None,
+"probationMonths": getattr(user, "probation_months", None),
+```
+
+**Runtime 映射约定(锁定):**
+
+| kit 语义 | userInfo 字段 |
+|----------|----------------|
+| name | `realName` |
+| email | `email` |
+| phone | `phone` |
+| dept | `regionId` + 部门树全路径(复用 `resolveCurrentOwnerDept`) |
+| hireDate | `hireDate` |
+| probationMonths | `probationMonths` |
+
+- [ ] **Step 6: 用户管理 UI**
+
+在 `web-admin/src/views/system/user/index.vue` 新增/编辑弹窗(`form-grid-2`)增加:
+
+- 入职日期:`el-date-picker`,`value-format="YYYY-MM-DD"`,`v-model="formData.hireDate"`
+- 试用期(月):`el-input-number`,`:min="0"`,`:precision="0"`,`v-model="formData.probationMonths"`
+
+`formData` 初始化 / reset / 打开编辑赋值 / submit payload 带上两字段。
+查看详情增加两项(试用期展示为 `N 个月` 或 `-`)。
+
+**列表列:首期不加**(符合 spec §4.2 最小范围)。
+
+- [ ] **Step 7: 手测** 用户管理保存后再登录,确认 userInfo 有新字段
+
+- [ ] **Step 8: Commit**(仅当用户要求时)
+
+```bash
+git commit -m "feat(user): add hire_date and probation_months to sys_user and user admin"
+```
+
+---
+
+### Task 3: FormCreate 转正套件(容器 + DragRule + 注册 + 发布白名单)
+
+**Files:**
+- Create: `web-admin/src/components/oa-approval/OaConfirmationKit.vue`
+- Create: `web-admin/src/utils/oa-confirmation-kit-rule.ts`
+- Create: `web-admin/src/utils/oa-form-create-confirmation-kit.ts`
+- Modify: `web-admin/src/main.ts`、`web-admin/src/oa-fc-host/main.ts`
+- Modify: `backend/app/services/oa_publish.py`
+
+- [ ] **Step 1: 容器组件**
+
+对齐 `OaLeaveKit.vue`:设计器显示虚线框 +「转正套件」标题 + ``;运行时(非 designer)仅渲染 slot、无边框。
+
+- [ ] **Step 2: DragRule**
+
+`oa-confirmation-kit-rule.ts` 要点:
+
+- `menu: 'kit'`,`label: '转正'`,`name: 'confirmationKit'`,`input: false`,`inside: true`
+- `uniqueId()` 与请假相同
+- 每个 child:`props._fc_kit_field` 为语义 key
+- 容器 `props` 含 `visibleFields`(九项默认 `true`)以及各 `*Field` id 便于 runtime
+
+子字段类型:
+
+| `_fc_kit_field` | type | 关键 props |
+|-----------------|------|-----------|
+| hireDate | datePicker | type=date, valueFormat=YYYY-MM-DD, $required |
+| probationMonths | inputNumber | min=0, precision=0, $required |
+| confirmationDate | datePicker | type=date, valueFormat=YYYY-MM-DD |
+| post | post | multiple=true |
+| jobLevel | input | |
+| name | input | $required |
+| email | input | |
+| dept | dept | multiple=false, $required |
+| phone | input | |
+
+设计器容器属性:9 个「显示:xxx」开关;文案提示:
+
+> 隐藏字段仍会写入默认值;审批通过后可能回写用户主数据。
+
+**显隐 source of truth:** `props.visibleFields`;runtime `ensureConfirmationKitVisibility` 同步到各 child `hidden`。
+
+- [ ] **Step 3: setup**
+
+```ts
+// oa-form-create-confirmation-kit.ts
+export function setupOaConfirmationKit(formCreate?: { component?: (n: string, c: unknown) => void }) {
+ try {
+ FcDesigner.addMenu({ name: 'kit', title: '套件', list: [] })
+ } catch {
+ /* kit menu may already exist from leave kit */
+ }
+ FcDesigner.component('confirmationKit', OaConfirmationKit)
+ formCreate?.component?.('confirmationKit', OaConfirmationKit)
+ FcDesigner.addDragRule(confirmationKitDragRule)
+}
+```
+
+在 `main.ts` / `oa-fc-host/main.ts` 于 `setupOaLeaveKit` 后调用 `setupOaConfirmationKit(...)`。
+
+- [ ] **Step 4: 发布白名单**
+
+`oa_publish.py` 的 `LAYOUT_TYPES` 增加 `"confirmationKit"`。
+
+- [ ] **Step 5: 手测** 设计器套件 Tab 出现「转正」,拖入可见 9 字段;切换显隐后 schema 中 `visibleFields` / child `hidden` 正确。
+
+- [ ] **Step 6: Commit**(仅当用户要求时)
+
+---
+
+### Task 4: 发起页默认值 + 转正日联动
+
+**Files:**
+- Create: `web-admin/src/utils/oa-confirmation-kit-runtime.ts`
+- Modify: `web-admin/src/views/oa-approval/start/index.vue`
+- Modify: `web-admin/src/views/oa-approval/instances/detail.vue`(仅 `ensureConfirmationKitVisibility`,不写默认)
+
+- [ ] **Step 1: Runtime API**
+
+导出:
+
+```ts
+export const CONFIRMATION_MANUAL_PREFIX = '__manual_'
+
+export type ConfirmationKitBinding = {
+ fields: Record // semantic -> field id
+ visibleFields: Record
+}
+
+export function listConfirmationKitBindings(schema: unknown): ConfirmationKitBinding[]
+export function ensureConfirmationKitVisibility(schema: unknown): void
+export function applyConfirmationKitDefaults(
+ schema: unknown,
+ formData: Record,
+ user: Record,
+ deptValue: { id: string; name: string } | null
+): Record
+export function applyConfirmationKitDateLinkage(
+ schema: unknown,
+ formData: Record
+): Record
+export function markConfirmationDateManual(
+ formData: Record,
+ confirmationDateField: string
+): void
+export function clearConfirmationDateManualAndRelink(
+ schema: unknown,
+ formData: Record,
+ confirmationDateField: string
+): Record
+```
+
+行为要点:
+
+- 默认仅填空;`post` / `jobLevel` 不填;隐藏字段也填默认
+- 若 `confirmationDate` 空且可算则写入 `addMonthsYmd`
+- 若 `formData[CONFIRMATION_MANUAL_PREFIX + confirmationDateField]` 为真则不联动
+- 手改转正日:值与当前自动结果不同时打 manual 标记(对齐请假 `__manual_`)
+
+部门默认:复用 `resolveCurrentOwnerDept(user)`(`oa-owner-field-defaults.ts`)。
+
+- [ ] **Step 2: 接入 start/index.vue**
+
+在 `applyOwnerFieldDefaults` 之后:
+
+1. `ensureConfirmationKitVisibility(schema)`
+2. `formData = applyConfirmationKitDefaults(...)`
+3. `formData = applyConfirmationKitDateLinkage(...)`
+
+watch formData:hire/probation 变化 → linkage;confirmationDate 手改 → mark manual。
+
+「恢复自动计算」最小实现:发起页检测到任一 `__manual_` 时,在表单上方显示链接「恢复转正日期自动计算」。
+
+- [ ] **Step 3: detail.vue** 加载 schema 后调用 `ensureConfirmationKitVisibility`;不调用 defaults/linkage。
+
+- [ ] **Step 4: 手测**
+
+1. 有入职/试用期 → 自动填六项 + 建议转正日;职位空
+2. 改试用期 → 转正日变;手改转正日 → 再改试用期不变
+3. 恢复自动 → 再联动
+
+- [ ] **Step 5: Commit**(仅当用户要求时)
+
+---
+
+### Task 5: 审批通过后回写发起人
+
+**Files:**
+- Create: `backend/app/services/oa_confirmation_kit_sync.py`
+- Create: `backend/tests/test_oa_confirmation_kit_sync.py`
+- Modify: `backend/app/services/oa_engine.py`
+
+- [ ] **Step 1: 写失败测试**
+
+覆盖:有值更新六列;空跳过;非法手机号 skip+不抛;无 kit noop。
+
+- [ ] **Step 2: 实现 sync**
+
+```python
+# backend/app/services/oa_confirmation_kit_sync.py
+WRITEBACK_KEYS = {
+ "hireDate": "hire_date",
+ "probationMonths": "probation_months",
+ "name": "nick_name",
+ "email": "email",
+ "dept": "dept_id",
+ "phone": "phone_no",
+}
+
+def apply_confirmation_kit_user_sync(db: Session, inst) -> None:
+ """扫描 confirmationKit,有值则更新发起人 sys_user;异常只打日志。"""
+ # 1. schema + form_data(对照 OaInstance / OaAppVersion 实际字段名)
+ # 2. walk type==confirmationKit,children props._fc_kit_field → field
+ # 3. 发起人:tenant_no + initiator id,del_flag=0
+ # 4. dept 取 dict['id'];hire_date 解析为 date;probation_months int
+ # 5. phone: ^1[3-9]\\d{9}$;email 含 @;失败 skip 该列 + logger.warning
+ # 6. db.add(user);不自行 commit
+ # 7. try/except 整函数,不向上抛
+```
+
+实现前用模型核对:`OaInstance` 的发起人字段、`form_data`、关联 version schema 取法(可对照 `start_instance` / 详情序列化已有代码)。
+
+- [ ] **Step 3: 挂钩**
+
+```bash
+rg -n 'status = "completed"' backend/app/services/oa_engine.py
+```
+
+每一处赋值 `completed` 后:
+
+```python
+try:
+ from app.services.oa_confirmation_kit_sync import apply_confirmation_kit_user_sync
+ apply_confirmation_kit_user_sync(db, inst)
+except Exception:
+ logger.exception("confirmation kit user sync failed instance=%s", getattr(inst, "id", None))
+```
+
+- [ ] **Step 4: pytest PASS**
+
+- [ ] **Step 5: 手测** 发起→通过→用户管理六项已更新;驳回不更新。
+
+- [ ] **Step 6: Commit**(仅当用户要求时)
+
+```bash
+git commit -m "feat(oa): sync initiator user from confirmation kit on approval complete"
+```
+
+---
+
+### Task 6: 收尾(打印、图谱、spec)
+
+- [ ] **Step 1:** 打印路径核对:若 `confirmationKit` 被跳过导致子字段丢失,对照 leaveKit 打印 unwrap 补一层。
+
+- [ ] **Step 2:** Spec 状态改为「已实现」,链接本 plan。
+
+- [ ] **Step 3:**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 4:** 按 spec §9 验收清单手测勾选。
+
+---
+
+## Spec coverage
+
+| Spec 项 | Task |
+|---------|------|
+| hire_date / probation_months + 用户管理 | T2 |
+| userInfo 带出 | T2 |
+| confirmationKit + 9 子字段 | T3 |
+| 显示/隐藏 + 隐藏仍默认/回写 | T3 + T4 + T5 |
+| 发起默认(职位不默认) | T4 |
+| 转正日自动建议 + 手改断开 + 恢复 | T1 + T4 |
+| 通过后回写六项、空跳过、失败不回滚 | T5 |
+| 职位多选 / 部门 {id,name} | T3 |
+| 发布白名单 | T3 |
+| 列表不加列 | T2 明确不做 |
+
+---
+
+## Self-review
+
+- Spec 各节均有对应 Task;无 TBD/“类似 Task N”。
+- 手改标记前缀与请假套件一致:`__manual_`。
+- Commit 步骤默认「用户要求时」执行。
+
+---
+
+## Execution
+
+Plan complete and saved to `docs/superpowers/plans/2026-07-30-oa-confirmation-kit.md`.
+
+**Two execution options:**
+
+1. **Subagent-Driven (recommended)** — 每任务新开子代理,任务间审查
+2. **Inline Execution** — 本会话按 executing-plans 连续做,带检查点
+
+Which approach?
diff --git a/web-admin/src/utils/oa-confirmation-date.test.ts b/web-admin/src/utils/oa-confirmation-date.test.ts
new file mode 100644
index 00000000..3fe78961
--- /dev/null
+++ b/web-admin/src/utils/oa-confirmation-date.test.ts
@@ -0,0 +1,22 @@
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { addMonthsYmd } from './oa-confirmation-date'
+
+describe('addMonthsYmd', () => {
+ it('simple month add', () => {
+ assert.equal(addMonthsYmd('2026-01-15', 3), '2026-04-15')
+ })
+
+ it('clamp end of month', () => {
+ assert.equal(addMonthsYmd('2026-01-31', 1), '2026-02-28')
+ })
+
+ it('zero months returns same day', () => {
+ assert.equal(addMonthsYmd('2026-07-30', 0), '2026-07-30')
+ })
+
+ it('invalid => null', () => {
+ assert.equal(addMonthsYmd('', 3), null)
+ assert.equal(addMonthsYmd('2026-01-15', -1), null)
+ })
+})
diff --git a/web-admin/src/utils/oa-confirmation-date.ts b/web-admin/src/utils/oa-confirmation-date.ts
new file mode 100644
index 00000000..e5a25a86
--- /dev/null
+++ b/web-admin/src/utils/oa-confirmation-date.ts
@@ -0,0 +1,14 @@
+/** 入职日 + 试用期月数 → YYYY-MM-DD;非法返回 null。按自然月加,日溢出钳到目标月最后一天。 */
+export function addMonthsYmd(hireYmd: string, months: number): string | null {
+ const s = String(hireYmd || '').trim()
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null
+ if (!Number.isInteger(months) || months < 0) return null
+ const [y, m, d] = s.split('-').map((x) => Number(x))
+ if (!y || !m || !d) return null
+ const total = y * 12 + (m - 1) + months
+ const ny = Math.floor(total / 12)
+ const nm = (total % 12) + 1
+ const lastDay = new Date(ny, nm, 0).getDate()
+ const nd = Math.min(d, lastDay)
+ return `${ny}-${String(nm).padStart(2, '0')}-${String(nd).padStart(2, '0')}`
+}