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')}` +}