From e70b9885115300bcb81620775f9ba255f296c728 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 28 Sep 2026 18:43:01 +0800 Subject: [PATCH 1/3] =?UTF-8?q?[feature]=20Simpany=20=E8=96=AA=E8=B3=87?= =?UTF-8?q?=E7=94=B3=E5=A0=B1=EF=BC=9A=E5=94=AF=E8=AE=80=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=20+=20=E6=AC=A0=E8=96=AA=E5=B0=8D=E5=B8=B3=EF=BC=88MCP=20?= =?UTF-8?q?=E8=88=87=E8=96=AA=E8=B3=87=E9=A0=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SimpanyClient.listSalaryMonthlyForms / getSalaryForm,assertSalaryReadOnly(GET + 路徑/query 白名單) - 解析時丟掉身分證字號 / 地址 / 國籍;薪資錯誤訊息不附 body 片段 - migration 0027:simpany_salary_forms(月份狀態)+ simpany_salary_declarations(員工 × 月) - src/lib/simpany-salary.ts:冪等同步、salaryReconciliation(payslip 期別優先 + FIFO,未申報月份估計另計) - MCP:simpany_list_salary_declarations、simpany_sync_salary_declarations、salary_arrears;SERVER_VERSION 1.7.0 - 薪資頁 Simpany 薪資申報區塊:月份格、欠薪表、明細 Sheet、未指定員工的薪資支出;zh-TW / en - docs:integrations.md、mcp.md --- docs/integrations.md | 67 +- docs/mcp.md | 18 +- .../0027_simpany_salary_declarations.sql | 85 ++ src/app/dashboard/payroll/page.tsx | 35 +- .../payroll/simpany-salary-actions.ts | 42 + .../payroll/simpany-salary-client.tsx | 212 ++++ .../payroll/simpany-salary-section.tsx | 242 +++++ src/db/schema.ts | 56 + src/i18n/messages/payroll.ts | 118 +++ src/lib/integrations/simpany.ts | 249 ++++- src/lib/mcp/handler.ts | 10 +- src/lib/mcp/tools-simpany.ts | 132 +++ src/lib/simpany-salary.ts | 962 ++++++++++++++++++ 13 files changed, 2207 insertions(+), 21 deletions(-) create mode 100644 migrations/0027_simpany_salary_declarations.sql create mode 100644 src/app/dashboard/payroll/simpany-salary-actions.ts create mode 100644 src/app/dashboard/payroll/simpany-salary-client.tsx create mode 100644 src/app/dashboard/payroll/simpany-salary-section.tsx create mode 100644 src/lib/simpany-salary.ts diff --git a/docs/integrations.md b/docs/integrations.md index cbd1a05..090afa3 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -234,6 +234,7 @@ Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就 | `src/lib/integrations/simpany.ts` | `simpanyProvider`(連接測試)與 `SimpanyClient` / `getSimpanyClient(orgId)` | | `src/lib/simpany-sync.ts` | Simpany → `invoices` 同步、自動綁定、作廢清理 | | `src/lib/simpany-issue.ts` | 預覽(`invoice_drafts`)→ 開立、作廢;MCP 與 web 共用 | +| `src/lib/simpany-salary.ts` | 薪資申報唯讀同步與欠薪對帳(見下方「薪資申報」) | | `src/lib/mcp/tools-simpany.ts` | MCP 工具(見 [mcp.md](mcp.md)) | | `src/app/dashboard/invoices/simpany-*.ts(x)` | 發票頁「從 Simpany 同步」、看板「在 Simpany 開立」、server actions | | `migrations/0025_invoice_simpany_sync.sql` | invoices 的課稅別 / 零稅率原因 / 外幣匯率 / B2B-B2C / `external_id` / 作廢欄位,`invoice_drafts` 表 | @@ -241,7 +242,7 @@ Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就 **Config**:`companyId` + `companyName`(非機密)。帳號底下只有一家公司時連接時自動選; 多家就要在連接 Sheet 填「公司 ID」(失敗訊息會列出可選的 ID)。 -**用到的端點**(其他一概不碰): +**用到的端點**(其他一概不碰;薪資申報的唯讀端點另見下方「薪資申報」): | Host | Endpoint | 用途 | | --- | --- | --- | @@ -276,6 +277,70 @@ Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就 **xlsx 對帳**(`src/lib/simpany-export.ts`、發票 › Simpany 對帳)保留,給沒開整合的組織用; API 同步取代它。 +### 薪資申報(唯讀)+ 欠薪對帳 + +Simpany 也是公司申報薪資(扣繳、勞健保)的地方。這裡**只讀**它的薪資申報,存到本地,再跟 +本系統實際記錄的發薪對帳,算出每位員工每月的欠薪。 + +| Where | What | +| --- | --- | +| `src/lib/integrations/simpany.ts` | `SimpanyClient.listSalaryMonthlyForms(year)`、`getSalaryForm(year, month)`、`assertSalaryReadOnly()` | +| `src/lib/simpany-salary.ts` | `syncSalaryDeclarations(orgId, year)`、`salaryReconciliation(orgId, opts)`、`listSalaryDeclarationsLive()`、`allocatePayments()` | +| `migrations/0027_simpany_salary_declarations.sql` | `simpany_salary_forms`(一個月一列)、`simpany_salary_declarations`(員工 × 月一列) | +| `src/app/dashboard/payroll/simpany-salary-*.ts(x)` | 薪資頁「Simpany 薪資申報」區塊、同步 server action | + +**端點**(不同 host / path:沒有 `c/`,在 `api.simpany.co`;header 與 JWT 同上;回應 `{status, code, data, meta}`): + +| Endpoint | 用途 | +| --- | --- | +| `GET api.simpany.co/v1/{companyId}/salary-declaration/form/monthly-forms/{year}` | 一年 12 格 `{id\|null, year, rocYear, month, employees[{id, name}]}`;`id` null = 那個月沒建表單 | +| `GET api.simpany.co/v1/{companyId}/salary-declaration/form?year=YYYY&month=M` | 那個月的表單:`payday`、`isSettled`、每位員工的 `salaryDeclaration.salaryDeclarationItems[{name, type, amount}]` | + +`month` 是**薪資所屬月份**(`yearMonth`),發薪日通常是次月 5 日(`payday`)。 + +**唯讀護欄**:薪資請求一律走 `SimpanyClient` 的私有 `salaryGet()` → `assertSalaryReadOnly()`: +method 必須是 GET、路徑必須符合白名單(`form/monthly-forms/{yyyy}`、`form`)、query key 只能是 +`year` / `month`,否則**不發請求**直接丟錯。結算、複製、建立、寄薪資單等端點沒有任何程式碼路徑; +要加端點只能加唯讀的 GET 到白名單。 + +**個資**:Simpany 的回應含身分證字號(`personalId`)、戶籍地址(`address`)、國籍(`nationality`)。 +`parseSalaryForm` 只挑白名單欄位組新物件,這三個欄位**從來不會被讀進記憶體裡的結構**,所以不會進 +DB、log、錯誤訊息、server action 或 MCP 結果。薪資請求的錯誤訊息也不附 body 片段(`salaryErrorMessage`)。 +本地只存姓名、Simpany 員工 id、金額、日期、旗標;`items` 只有 `{name, type, amount}`(不存 Simpany 的 `note`)。 + +**資料表**(兩張而不是一張加哨兵列:「沒建表單 / 表單空白 / 已申報」是月份層級的事實,跟員工無關): + +- `simpany_salary_forms`:`(org, year, month)` 唯一。`simpany_form_id` NULL = 未建立; + `filed_count = 0` = 表單空白;`is_settled` = 已申報。沒有列 = 沒同步過。 +- `simpany_salary_declarations`:`(org, year, month, simpany_employee_id)` 唯一。從明細抽出 + 本薪、非經常性獎金、實際申報薪資(`gross_declared`)、實際發薪(`net_pay`)、勞健保個人 / 公司負擔、 + 就業保險;`filed` = 有申報明細。`employee_id` = 同組織姓名完全相同的員工(重名不綁),否則 NULL。 + +月份狀態:`missing`(未建立)、`empty`(表單空白)、`draft`(有明細但沒結算)、`settled`(已申報)、 +`not_synced`。 + +**同步**(`syncSalaryDeclarations`,owner / admin):1 + (有表單的月數) 個 GET。以唯一鍵 upsert; +Simpany 上已不存在的表單 / 員工會從本地刪掉,所以重跑是冪等的。只寫本組織的這兩張表。 + +**對帳**(`salaryReconciliation`,只讀本地表): + +- 應發 = 已申報月份的 `net_pay`。未申報的月份(沒表單、表單空白、表單上沒有這個人)在任職期間內 + (`employees.start_date`,沒有就從第一個有申報的月份起;到 `end_date`)且發薪日已過時,用 + `expectedMonthlyNet` 估:預設 = 最近一次申報的實發 − 非經常性獎金,可用姓名覆寫;這些月份標 + `estimated: true`,另計在 `estimatedArrears`,不混進 `arrears`。 +- 已發 = (1) `payslips`(有 `paid_transaction_id` 的以那筆交易為準;沒有交易但批次 `status = paid` 的以 + `net_pay` 計)+ (2) `type = expense`、分類「薪資費用」、`txn_date` 在 `paidFrom ~ paidTo` 的交易, + 對象是員工(`settle_employee_id`,或對象 party 名稱 = 員工姓名)。屬於別年 payslip 的交易不算。 +- 分配(`allocatePayments`):payslip 有期別的先補那個月;其餘(含超付部分)依付款日期先進先出, + 從最舊的欠款月份補起;再有剩 = `credit`。 +- 發薪日(表單 `payday`,沒有就假設次月 5 日)還沒到的月份,未付金額算 `notYetDue`,不算欠薪。 +- 分類是「薪資費用」卻對不到任何員工的交易 → `unallocatedPayments`,讓 owner 去交易頁指派。 +- 預設 `throughMonth` = 今年的本月 / 過去年份 12;`paidFrom` = 1/1;`paidTo` = 今天 / 過去年份為隔年 1/31。 + +入口:薪資頁(`/dashboard/payroll?year=YYYY`)的「Simpany 薪資申報」區塊(月份狀態格、欠薪表、明細 Sheet、 +未指定員工的薪資支出);MCP `simpany_list_salary_declarations`、`simpany_sync_salary_declarations`、 +`salary_arrears`(見 [mcp.md](mcp.md))。 + ## Security rules - Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see diff --git a/docs/mcp.md b/docs/mcp.md index 7230437..9e3c9d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -104,10 +104,11 @@ the `tools-*.ts` modules): derived from the verb, plus `openWorldHint`, which is `false` for everything except the tools that reach a third-party system: `sync_billing_calendar` (writes to Google Calendar), the three `wise_*` tools (read-only GETs to - Wise) and the `simpany_*` tools. The overrides that correct the verb heuristic: + Wise) and the `simpany_*` tools (`salary_arrears` reads only our own tables + and stays closed-world). The overrides that correct the verb heuristic: `sync_billing_calendar`, every `wise_*` and every `simpany_*` tool get `openWorldHint: true`; `simpany_void_invoice` gets `destructiveHint: true` (voiding a legal e-invoice - cannot be undone); `simpany_list_*` / `simpany_get_invoice` declare + cannot be undone); `simpany_list_*` / `simpany_get_invoice` / `salary_arrears` declare `readOnlyHint: true` themselves (their names don't start with `list_`/`get_`); `pay_employee_salary` gets `destructiveHint: true` — it writes the payslip plus the salary-expense ledger entry, the month can't @@ -122,8 +123,8 @@ the `tools-*.ts` modules): - `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT shows while a call is in flight. -**Output schemas.** Every tool declares an `outputSchema` — all 85 of them, as of -server version 1.6.0 (the Simpany tools whose result shape comes from Simpany's +**Output schemas.** Every tool declares an `outputSchema` — all 88 of them, as of +server version 1.7.0 (the Simpany tools whose result shape comes from Simpany's unofficial API declare an open object schema). When a tool declares one the handler additionally returns the result as MCP `structuredContent` (the JSON text block stays, per MCP's back-compat recommendation), which is what ChatGPT and Codex prefer over parsing @@ -276,6 +277,13 @@ integrations.md): | `simpany_issue_invoice` | `draftId`, `notifyEmails?` | Owner/admin. Issues the previewed draft verbatim — a legal e-invoice uploaded to the MOF and emailed to the buyer. Only after the user approved the preview. Saves + links the invoice. | | `simpany_void_invoice` | `invoice`, `reason` (≤ 20 chars) | Owner/admin, destructive. Voids in Simpany, re-syncs, clears 開發票日 / transaction links. | | `simpany_list_zero_rate_reasons` | — | Simpany's reason codes (71 外銷貨物, 72 外銷勞務, …). | +| `simpany_list_salary_declarations` | `year?` (default this year), `month?` (1-12, salary month) | Read live from Simpany (GET only, `assertSalaryReadOnly`). Per month: status (`missing`/`empty`/`draft`/`settled`), payday, and per employee name, Simpany id, owner flag, filed flag, base / bonus / declared gross / net paid / insurance amounts and `{name,type,amount}` items. **No national id, address or nationality.** | +| `simpany_sync_salary_declarations` | `year?` | Owner/admin. GETs the year from Simpany and upserts `simpany_salary_forms` / `simpany_salary_declarations` (idempotent; removes forms/employees gone from Simpany). Links employees by exact name; returns `unmatchedNames`. Writes nothing to Simpany. | +| `salary_arrears` | `year?`, `throughMonth?`, `paidFrom?`, `paidTo?`, `estimateUnfiled?` (default true), `expectedMonthlyNet?` (`{name: amount}`) | Reads **only our tables** (closed world). Per employee: `totalDeclaredNet`, `totalPaid`, `arrears`, `estimatedArrears` (unfiled months, `estimated: true`), `notYetDue`, `credit`, monthly rows and payments with allocations; plus the month grid and `unallocatedPayments` (薪資費用 outflows with no employee). Payslip periods first, then FIFO by date. | + +Salary declarations are read-only end to end: the salary endpoints are GET-only and +path-whitelisted in `src/lib/integrations/simpany.ts`, and the only writes are to +this organization's own `simpany_salary_*` tables. **Not exposed (do in the app):** creating an organization, uploading invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google @@ -342,7 +350,7 @@ things that don't live in this repo: Both directories ask for the same thing in different words — OpenAI wants "test credentials for a fully populated account", Anthropic wants a "fully featured demo account with sample data". An empty workspace fails review: most -of the 85 tools would answer with an empty array and the reviewer has no way to +of the 88 tools would answer with an empty array and the reviewer has no way to tell what the connector does. Two commands produce that account. Run them against the environment you are diff --git a/migrations/0027_simpany_salary_declarations.sql b/migrations/0027_simpany_salary_declarations.sql new file mode 100644 index 0000000..6745ad0 --- /dev/null +++ b/migrations/0027_simpany_salary_declarations.sql @@ -0,0 +1,85 @@ +-- 0027: Simpany 薪資申報(唯讀同步)+ 欠薪對帳。 +-- +-- Simpany 除了電子發票,也是公司申報薪資(扣繳、勞健保)的地方。這裡把它的「薪資申報」 +-- 表單**唯讀**拉回來(src/lib/integrations/simpany.ts 的 listSalaryMonthlyForms / +-- getSalaryForm,只允許 GET + 路徑白名單),存成兩張表,再拿去和本系統實際發出的薪資 +-- (payslips + 薪資費用交易)對帳,算出每位員工每個月還欠多少(src/lib/simpany-salary.ts)。 +-- +-- 為什麼是兩張表而不是在明細表塞「這個月沒有表單」的哨兵列: +-- 「某月沒建表單」「表單建了但沒人申報」「已申報」是**月份層級**的事實,和員工無關; +-- 硬塞進 (org, year, month, simpany_employee_id) 唯一鍵的表裡就得用 NULL 員工當哨兵, +-- 唯一索引、查詢、FK 都會變醜。所以: +-- simpany_salary_forms 一個月一列(Simpany 的 monthly-forms 12 格都存), +-- simpany_form_id NULL = 那個月沒建表單(UI 顯示「未建立」)。 +-- simpany_salary_declarations 一位員工一個月一列(只有表單存在的月份才有)。 +-- 沒有 forms 列的月份 = 從來沒同步過。 +-- +-- ⚠️ 個資:Simpany 的回應裡有身分證字號、戶籍地址、國籍 —— **一律不存**(解析時就丟掉, +-- 不進 DB、不進 log、不進任何回傳值)。這裡只有姓名、Simpany 員工 id、金額、日期、旗標。 +-- items 只存 { name, type, amount },不存 Simpany 的 note。 +-- +-- Forward-only,全部 additive。Run AFTER 0026。 + +CREATE TABLE simpany_salary_forms ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE, + year integer NOT NULL, + month integer NOT NULL, + -- NULL = Simpany 那個月沒有建立薪資申報表單 + simpany_form_id bigint, + payday date, + is_settled boolean NOT NULL DEFAULT false, + -- 表單上的員工數 / 其中真的有申報明細的人數(0 = 表單空白) + employee_count integer NOT NULL DEFAULT 0, + filed_count integer NOT NULL DEFAULT 0, + synced_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_simpany_salary_form UNIQUE (organization_id, year, month), + CONSTRAINT chk_simpany_salary_form_month CHECK (month BETWEEN 1 AND 12) +); + +COMMENT ON TABLE simpany_salary_forms IS 'Simpany 薪資申報的月份狀態(唯讀同步);simpany_form_id NULL = 那個月沒建表單'; +COMMENT ON COLUMN simpany_salary_forms.month IS '薪資所屬月份(Simpany yearMonth),不是發薪日的月份'; +COMMENT ON COLUMN simpany_salary_forms.payday IS 'Simpany 表單上的發薪日(通常是次月 5 日)'; +COMMENT ON COLUMN simpany_salary_forms.is_settled IS 'Simpany isSettled:已結算 / 已申報'; +COMMENT ON COLUMN simpany_salary_forms.filed_count IS '表單上有申報明細(salaryDeclarationItems)的員工數;0 且 employee_count > 0 = 表單空白'; + +CREATE TABLE simpany_salary_declarations ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE, + year integer NOT NULL, + month integer NOT NULL, + simpany_form_id bigint, + payday date, + is_settled boolean NOT NULL DEFAULT false, + simpany_employee_id bigint NOT NULL, + employee_name text NOT NULL, + -- 本系統的員工:同組織、姓名完全相同才綁;對不到就 NULL + employee_id bigint REFERENCES employees(id) ON DELETE SET NULL, + is_company_owner boolean NOT NULL DEFAULT false, + base_salary numeric(18,2), + bonus numeric(18,2), + gross_declared numeric(18,2), + net_pay numeric(18,2), + labor_ins_personal numeric(18,2), + health_ins_personal numeric(18,2), + labor_ins_company numeric(18,2), + health_ins_company numeric(18,2), + employment_ins_company numeric(18,2), + items jsonb NOT NULL DEFAULT '[]'::jsonb, + filed boolean NOT NULL DEFAULT false, + synced_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_simpany_salary_decl UNIQUE (organization_id, year, month, simpany_employee_id), + CONSTRAINT chk_simpany_salary_decl_month CHECK (month BETWEEN 1 AND 12) +); + +CREATE INDEX idx_simpany_salary_decl_employee ON simpany_salary_declarations (employee_id) + WHERE employee_id IS NOT NULL; + +COMMENT ON TABLE simpany_salary_declarations IS 'Simpany 薪資申報明細(唯讀同步),一位員工一個月一列;不含身分證字號 / 地址 / 國籍'; +COMMENT ON COLUMN simpany_salary_declarations.employee_id IS '本系統 employees.id;同組織姓名完全相同才綁,否則 NULL'; +COMMENT ON COLUMN simpany_salary_declarations.base_salary IS '本薪'; +COMMENT ON COLUMN simpany_salary_declarations.bonus IS '非經常性薪資(獎金)'; +COMMENT ON COLUMN simpany_salary_declarations.gross_declared IS '實際申報薪資(應發總額)'; +COMMENT ON COLUMN simpany_salary_declarations.net_pay IS '實際發薪(扣除個人負擔勞健保、扣繳後的實發)'; +COMMENT ON COLUMN simpany_salary_declarations.items IS 'Simpany salaryDeclarationItems 去識別化:[{ name, type, amount }]'; +COMMENT ON COLUMN simpany_salary_declarations.filed IS 'true = 這位員工這個月有申報明細;false = 表單上有人但沒申報'; diff --git a/src/app/dashboard/payroll/page.tsx b/src/app/dashboard/payroll/page.tsx index d0c5bbc..ac171dd 100644 --- a/src/app/dashboard/payroll/page.tsx +++ b/src/app/dashboard/payroll/page.tsx @@ -17,15 +17,34 @@ import { listPayslipRecords } from "@/db/queries"; import { formatCurrency, formatYearMonth } from "@/lib/format"; import { DeleteButton } from "@/components/delete-button"; import { deletePayslip } from "@/db/mutations"; -import { requireOrg } from "@/lib/session"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; import { formatAccountShort } from "@/lib/employee-accounts"; +import { getIntegration } from "@/lib/integrations/store"; +import { salaryReconciliation } from "@/lib/simpany-salary"; +import { taipeiDate } from "@/lib/simpany-sync"; +import { SimpanySalarySection } from "./simpany-salary-section"; export const dynamic = "force-dynamic"; -export default async function PayrollPage() { +export default async function PayrollPage({ + searchParams, +}: Readonly<{ searchParams: Promise<{ year?: string }> }>) { const t = await getTranslations("payroll"); - const { orgId } = await requireOrg(); - const rows = await listPayslipRecords(orgId); + const { orgId, role } = await requireOrgWithRole(); + const { year: yearParam } = await searchParams; + const thisYear = Number(taipeiDate().slice(0, 4)); + const parsedYear = Number(yearParam); + const year = + Number.isInteger(parsedYear) && parsedYear >= 2000 && parsedYear <= 2100 ? parsedYear : thisYear; + const [rows, integration, recon] = await Promise.all([ + listPayslipRecords(orgId), + getIntegration(orgId, "simpany"), + // 對帳失敗(例如 migration 0027 還沒跑)不該讓整個薪資頁掛掉:記 log、不顯示該區塊。 + salaryReconciliation(orgId, { year }).catch((e: unknown) => { + console.error("salaryReconciliation failed", e); + return null; + }), + ]); return ( <> @@ -108,6 +127,14 @@ export default async function PayrollPage() { + + {recon && (integration || recon.lastSyncedAt) ? ( + + ) : null} ); } diff --git a/src/app/dashboard/payroll/simpany-salary-actions.ts b/src/app/dashboard/payroll/simpany-salary-actions.ts new file mode 100644 index 0000000..1fbacaa --- /dev/null +++ b/src/app/dashboard/payroll/simpany-salary-actions.ts @@ -0,0 +1,42 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; +import { logWeb } from "@/db/activity"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { IntegrationUnavailableError } from "@/lib/integrations/store"; +import { SimpanyError } from "@/lib/integrations/simpany"; +import { syncSalaryDeclarations, type SalarySyncResult } from "@/lib/simpany-salary"; + +/** + * 薪資頁「從 Simpany 同步薪資申報」。限 owner / admin(按鈕只對他們顯示,這裡再擋一次)。 + * 對 Simpany 只發 GET;只寫本組織的 simpany_salary_* 表。回傳值不含個資。 + */ +export async function syncSalaryDeclarationsAction( + year: number, +): Promise<{ ok: true; data: SalarySyncResult } | { ok: false; error: string }> { + const t = await getTranslations("integrations"); + const { orgId, role } = await requireOrgWithRole(); + if (!canManageOrg(role)) return { ok: false, error: t("errors.notAllowed") }; + if (!Number.isInteger(year) || year < 2000 || year > 2100) { + return { ok: false, error: `不合法的年份:${year}` }; + } + try { + const res = await syncSalaryDeclarations(orgId, year); + const filed = res.months.filter((m) => m.filedCount > 0).length; + await logWeb( + orgId, + "update", + "integration", + null, + `simpany: salary sync ${year}: ${filed} filed months, ${res.declarationsUpserted} rows, -${res.declarationsRemoved}`, + ); + revalidatePath("/dashboard/payroll"); + return { ok: true, data: res }; + } catch (e) { + if (e instanceof IntegrationUnavailableError || e instanceof SimpanyError) { + return { ok: false, error: e.message }; + } + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} diff --git a/src/app/dashboard/payroll/simpany-salary-client.tsx b/src/app/dashboard/payroll/simpany-salary-client.tsx new file mode 100644 index 0000000..64aae66 --- /dev/null +++ b/src/app/dashboard/payroll/simpany-salary-client.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { RefreshCw } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatCurrency } from "@/lib/format"; +import type { ReconEmployee } from "@/lib/simpany-salary"; +import { syncSalaryDeclarationsAction } from "./simpany-salary-actions"; + +/** owner / admin 才看得到:同步這一年的 Simpany 薪資申報(對 Simpany 只發 GET)。 */ +export function SalarySyncButton({ year }: Readonly<{ year: number }>) { + const t = useTranslations("payroll.simpany.sync"); + const router = useRouter(); + const [pending, run] = useTransition(); + const [unmatched, setUnmatched] = useState([]); + + function submit() { + run(async () => { + const res = await syncSalaryDeclarationsAction(year); + if (!res.ok) { + toast.error(res.error); + return; + } + toast.success( + t("done", { + year, + filed: res.data.months.filter((m) => m.filedCount > 0).length, + rows: res.data.declarationsUpserted, + }), + ); + setUnmatched(res.data.unmatchedNames); + router.refresh(); + }); + } + + return ( +
+ + {unmatched.length > 0 ? ( +

+ {t("unmatched", { names: unmatched.join("、") })} +

+ ) : null} +
+ ); +} + +/** 一位員工的逐月對帳與付款分配。 */ +export function ArrearsDetailSheet({ employee }: Readonly<{ employee: ReconEmployee }>) { + const t = useTranslations("payroll.simpany.detail"); + const [open, setOpen] = useState(false); + const e = employee; + + return ( + + + + + + + {t("title", { name: e.name })} + {t("description")} + +
+ {e.expectedMonthlyNet != null && e.expectedSource ? ( +

+ {t("expected", { + amount: formatCurrency(e.expectedMonthlyNet), + source: t(`expectedSource.${e.expectedSource}`), + })} +

+ ) : null} + +
+

{t("monthsTitle")}

+
+ + + + {t("columns.month")} + {t("columns.declared")} + {t("columns.allocated")} + {t("columns.outstanding")} + + + + {e.months.map((m) => ( + + + {t("monthShort", { month: m.month })} + + {m.filed ? null : ( + + {t("unfiled")} + + )} + {m.estimated ? ( + + {t("estimated")} + + ) : null} + {m.due ? null : ( + + {t("notDue")} + + )} + + + + {m.owed > 0 ? formatCurrency(m.owed) : "—"} + + + {m.allocatedPaid > 0 ? formatCurrency(m.allocatedPaid) : "—"} + + 0 + ? "pr-3 text-right font-medium tabular-nums text-expense" + : "pr-3 text-right tabular-nums text-muted-foreground" + } + > + {m.outstanding > 0 ? formatCurrency(m.outstanding) : "—"} + + + ))} + +
+
+
+ +
+

{t("paymentsTitle")}

+ {e.payments.length === 0 ? ( +

{t("noPayments")}

+ ) : ( +
+ + + + {t("columns.date")} + {t("columns.amount")} + {t("columns.appliedTo")} + + + + {e.payments.map((p) => ( + + +
{p.date}
+
+ {p.source === "payslip" ? t("payslip") : (p.accountName ?? p.description ?? "")} +
+
+ {formatCurrency(p.amount)} + + {p.allocations.map((a) => ( +
+ {t("monthShort", { month: a.month })} {formatCurrency(a.amount)} +
+ ))} + {p.unapplied > 0 ? ( +
+ {t("unapplied", { amount: formatCurrency(p.unapplied) })} +
+ ) : null} +
+
+ ))} +
+
+
+ )} +
+
+ + + +
+
+ ); +} diff --git a/src/app/dashboard/payroll/simpany-salary-section.tsx b/src/app/dashboard/payroll/simpany-salary-section.tsx new file mode 100644 index 0000000..7dcecc5 --- /dev/null +++ b/src/app/dashboard/payroll/simpany-salary-section.tsx @@ -0,0 +1,242 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { TableCard } from "@/components/table-card"; +import { EmptyRow } from "@/components/empty-state"; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatCurrency, formatDateTime } from "@/lib/format"; +import type { IntegrationSummary } from "@/lib/integrations/types"; +import type { SalaryMonthStatus, SalaryReconciliation } from "@/lib/simpany-salary"; +import { cn } from "@/lib/utils"; +import { ArrearsDetailSheet, SalarySyncButton } from "./simpany-salary-client"; + +const statusClass: Record = { + settled: "border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400", + draft: "border-sky-500/40 bg-sky-500/5 text-sky-700 dark:text-sky-400", + empty: "border-amber-500/40 bg-amber-500/5 text-amber-700 dark:text-amber-400", + missing: "border-dashed text-muted-foreground", + not_synced: "border-dashed text-muted-foreground/70", +}; + +/** + * 薪資頁的「Simpany 薪資申報」區塊:月份狀態格 + 每位員工的欠薪對帳 + 未指定員工的薪資支出。 + * 資料只讀本地表(simpany_salary_* / payslips / transactions);同步按鈕限 owner / admin。 + */ +export async function SimpanySalarySection({ + recon, + integration, + canManage, +}: Readonly<{ + recon: SalaryReconciliation; + integration: IntegrationSummary | null; + canManage: boolean; +}>) { + const t = await getTranslations("payroll.simpany"); + const usable = integration?.status === "connected" && integration.enabled; + const { year } = recon; + + return ( +
+
+
+

{t("title")}

+

{t("description")}

+

+ {recon.lastSyncedAt + ? t("lastSynced", { date: formatDateTime(recon.lastSyncedAt) }) + : t("neverSynced")} + {usable ? null : ( + <> + {" · "} + {t("notConnected")}{" "} + + {t("settingsLink")} + + + )} +

+
+
+
+ + {year} + +
+ {canManage && usable ? : null} +
+
+ + +
{t("months.title")}
+
+ {recon.months.map((m) => ( +
+ + {t("months.month", { month: m.month })} + + {t(`months.status.${m.status}`)} + {m.employeeCount > 0 && m.status !== "missing" ? ( + + {t("months.filedOf", { filed: m.filedCount, total: m.employeeCount })} + + ) : null} +
+ ))} +
+
+ + + + + + {t("arrears.columns.employee")} + {t("arrears.columns.declared")} + {t("arrears.columns.paid")} + {t("arrears.columns.arrears")} + {t("arrears.columns.detail")} + + + + {recon.employees.length === 0 ? ( + + ) : ( + recon.employees.map((e) => ( + + +
+ {e.name} + {e.isCompanyOwner ? {t("arrears.owner")} : null} + {e.employeeId == null ? ( + + {t("arrears.unlinked")} + + ) : null} +
+
+ +
{formatCurrency(e.totalDeclaredNet)}
+ {e.totalEstimatedNet > 0 ? ( +
+ {t("arrears.estimatedExtra", { amount: formatCurrency(e.totalEstimatedNet) })} +
+ ) : null} +
+ +
{formatCurrency(e.totalPaid)}
+ {e.credit > 0 ? ( +
{t("arrears.credit", { amount: formatCurrency(e.credit) })}
+ ) : null} +
+ +
0 ? "font-semibold text-expense" : "text-muted-foreground"}> + {formatCurrency(e.arrears)} +
+ {e.estimatedArrears > 0 ? ( +
+ {t("arrears.estimatedExtra", { amount: formatCurrency(e.estimatedArrears) })} +
+ ) : null} + {e.notYetDue > 0 ? ( +
+ {t("arrears.notYetDue", { amount: formatCurrency(e.notYetDue) })} +
+ ) : null} +
+ + + +
+ )) + )} +
+ {recon.employees.length > 1 ? ( + + + {t("arrears.total")} + {formatCurrency(recon.totals.declaredNet)} + {formatCurrency(recon.totals.paid)} + + {formatCurrency(recon.totals.arrears)} + {recon.totals.estimatedArrears > 0 ? ( +
+ {t("arrears.estimatedExtra", { amount: formatCurrency(recon.totals.estimatedArrears) })} +
+ ) : null} +
+ +
+
+ ) : null} +
+
+ {recon.totals.estimatedNet > 0 ? ( +

{t("arrears.estimateNote")}

+ ) : null} + + {recon.unallocatedPayments.length > 0 ? ( + +

{t("unallocated.description")}

+ + + + {t("unallocated.columns.date")} + {t("unallocated.columns.description")} + {t("unallocated.columns.party")} + {t("unallocated.columns.account")} + {t("unallocated.columns.amount")} + + + + {recon.unallocatedPayments.map((p) => ( + + + + {p.date} + + + {p.description ?? "—"} + {p.partyName ?? "—"} + {p.accountName ?? "—"} + {formatCurrency(p.amount, p.currency)} + + ))} + +
+
+ ) : null} +
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index b314538..359fb9c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -733,3 +733,59 @@ export const invoiceDrafts = pgTable("invoice_drafts", { }), check("chk_invoice_draft_status", sql`status = ANY (ARRAY['pending'::text, 'issued'::text, 'cancelled'::text, 'expired'::text])`), ]); + +// ---- Simpany 薪資申報(migrations/0027,唯讀同步 → 欠薪對帳,src/lib/simpany-salary.ts)。 +// 月份層級狀態一張、員工明細一張。⚠️ 不存身分證字號 / 地址 / 國籍。---- +export const simpanySalaryForms = pgTable("simpany_salary_forms", { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "simpany_salary_forms_id_seq", startWith: 1, increment: 1, minValue: 1, cache: 1 }), + organizationId: text("organization_id").notNull(), + year: integer().notNull(), + month: integer().notNull(), + // NULL = Simpany 那個月沒建表單 + simpanyFormId: bigint("simpany_form_id", { mode: "number" }), + payday: date(), + isSettled: boolean("is_settled").default(false).notNull(), + employeeCount: integer("employee_count").default(0).notNull(), + filedCount: integer("filed_count").default(0).notNull(), + syncedAt: timestamp("synced_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + unique("uq_simpany_salary_form").on(table.organizationId, table.year, table.month), + check("chk_simpany_salary_form_month", sql`(month >= 1) AND (month <= 12)`), +]); + +export type SimpanySalaryItem = { name: string; type: string; amount: number }; + +export const simpanySalaryDeclarations = pgTable("simpany_salary_declarations", { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "simpany_salary_declarations_id_seq", startWith: 1, increment: 1, minValue: 1, cache: 1 }), + organizationId: text("organization_id").notNull(), + year: integer().notNull(), + month: integer().notNull(), + simpanyFormId: bigint("simpany_form_id", { mode: "number" }), + payday: date(), + isSettled: boolean("is_settled").default(false).notNull(), + simpanyEmployeeId: bigint("simpany_employee_id", { mode: "number" }).notNull(), + employeeName: text("employee_name").notNull(), + employeeId: bigint("employee_id", { mode: "number" }), + isCompanyOwner: boolean("is_company_owner").default(false).notNull(), + baseSalary: numeric("base_salary", { precision: 18, scale: 2 }), + bonus: numeric({ precision: 18, scale: 2 }), + grossDeclared: numeric("gross_declared", { precision: 18, scale: 2 }), + netPay: numeric("net_pay", { precision: 18, scale: 2 }), + laborInsPersonal: numeric("labor_ins_personal", { precision: 18, scale: 2 }), + healthInsPersonal: numeric("health_ins_personal", { precision: 18, scale: 2 }), + laborInsCompany: numeric("labor_ins_company", { precision: 18, scale: 2 }), + healthInsCompany: numeric("health_ins_company", { precision: 18, scale: 2 }), + employmentInsCompany: numeric("employment_ins_company", { precision: 18, scale: 2 }), + items: jsonb().$type().default([]).notNull(), + filed: boolean().default(false).notNull(), + syncedAt: timestamp("synced_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + unique("uq_simpany_salary_decl").on(table.organizationId, table.year, table.month, table.simpanyEmployeeId), + index("idx_simpany_salary_decl_employee").using("btree", table.employeeId.asc().nullsLast().op("int8_ops")).where(sql`employee_id IS NOT NULL`), + foreignKey({ + columns: [table.employeeId], + foreignColumns: [employees.id], + name: "simpany_salary_declarations_employee_id_fkey" + }).onDelete("set null"), + check("chk_simpany_salary_decl_month", sql`(month >= 1) AND (month <= 12)`), +]); diff --git a/src/i18n/messages/payroll.ts b/src/i18n/messages/payroll.ts index 9cde007..2c61023 100644 --- a/src/i18n/messages/payroll.ts +++ b/src/i18n/messages/payroll.ts @@ -18,6 +18,124 @@ const payroll = { empty: { "zh-TW": "尚無發放紀錄", en: "No payment history yet" }, posted: { "zh-TW": "已入帳", en: "Posted" }, }, + simpany: { + title: { "zh-TW": "Simpany 薪資申報", en: "Simpany salary declarations" }, + description: { + "zh-TW": "從 Simpany 唯讀同步每月薪資申報,對照實際發出的薪資,算出每位員工的欠薪。", + en: "Read-only sync of monthly salary declarations from Simpany, reconciled against what was actually paid to show each employee's arrears.", + }, + prevYear: { "zh-TW": "上一年", en: "Previous year" }, + nextYear: { "zh-TW": "下一年", en: "Next year" }, + lastSynced: { "zh-TW": "上次同步 {date}", en: "Last synced {date}" }, + neverSynced: { "zh-TW": "這一年還沒同步過", en: "This year hasn't been synced yet" }, + notConnected: { + "zh-TW": "Simpany 整合尚未連接或未開啟,請 owner 或 admin 到", + en: "The Simpany integration isn't connected or switched on. An owner or admin can fix it in", + }, + settingsLink: { "zh-TW": "設定 › 整合", en: "Settings › Integrations" }, + sync: { + button: { "zh-TW": "從 Simpany 同步", en: "Sync from Simpany" }, + pending: { "zh-TW": "同步中…", en: "Syncing…" }, + done: { + "zh-TW": "已同步 {year}:{filed} 個月有申報、{rows} 筆明細", + en: "Synced {year}: {filed} months declared, {rows} rows", + }, + unmatched: { + "zh-TW": "這些姓名在員工名冊找不到(仍會以姓名比對付款對象):{names}", + en: "Not found in the employee list (payments are still matched by name): {names}", + }, + }, + months: { + title: { "zh-TW": "每月申報狀態", en: "Monthly status" }, + month: { "zh-TW": "{month} 月", en: "M{month}" }, + status: { + not_synced: { "zh-TW": "未同步", en: "Not synced" }, + missing: { "zh-TW": "未建立", en: "No form" }, + empty: { "zh-TW": "表單空白", en: "Empty form" }, + draft: { "zh-TW": "已填未結算", en: "Not settled" }, + settled: { "zh-TW": "已申報", en: "Filed" }, + }, + filedOf: { "zh-TW": "{filed}/{total} 人", en: "{filed}/{total}" }, + payday: { "zh-TW": "發薪日 {date}", en: "Payday {date}" }, + }, + arrears: { + title: { "zh-TW": "欠薪對帳", en: "Salary arrears" }, + window: { + "zh-TW": "對到 {month} 月 · 付款期間 {from} ~ {to}", + en: "Through month {month} · payments {from} – {to}", + }, + columns: { + employee: { "zh-TW": "員工", en: "Employee" }, + declared: { "zh-TW": "應發(申報)", en: "Due (declared)" }, + paid: { "zh-TW": "已發", en: "Paid" }, + arrears: { "zh-TW": "欠", en: "Owed" }, + detail: { "zh-TW": "明細", en: "Detail" }, + }, + owner: { "zh-TW": "負責人", en: "Owner" }, + unlinked: { "zh-TW": "未對應員工", en: "Not linked" }, + estimatedExtra: { "zh-TW": "估計未申報 +{amount}", en: "Est. unfiled +{amount}" }, + notYetDue: { "zh-TW": "未到期 {amount}", en: "Not yet due {amount}" }, + credit: { "zh-TW": "溢付 {amount}", en: "Overpaid {amount}" }, + total: { "zh-TW": "合計", en: "Total" }, + empty: { + "zh-TW": "還沒有申報資料。按「從 Simpany 同步」把這一年的申報拉進來。", + en: "No declarations yet. Use “Sync from Simpany” to pull this year in.", + }, + estimateNote: { + "zh-TW": "「估」= Simpany 沒申報的月份,以最近一次申報的實發(扣掉非經常性獎金)估計。", + en: "“Est.” = months not declared in Simpany, estimated from the latest declared net pay (minus one-off bonus).", + }, + }, + detail: { + button: { "zh-TW": "明細", en: "Detail" }, + title: { "zh-TW": "{name} 的薪資對帳", en: "Salary reconciliation: {name}" }, + description: { + "zh-TW": "付款先對到 payslip 的期別,其餘依日期先進先出補最舊的欠款。", + en: "Payments go to their payslip period first; the rest is applied to the oldest unpaid month first.", + }, + expected: { + "zh-TW": "每月預估實發 {amount}({source})", + en: "Expected monthly net {amount} ({source})", + }, + expectedSource: { + override: { "zh-TW": "手動指定", en: "override" }, + latest_filed: { "zh-TW": "最近一次申報", en: "latest declaration" }, + }, + monthsTitle: { "zh-TW": "逐月", en: "By month" }, + paymentsTitle: { "zh-TW": "付款紀錄", en: "Payments" }, + columns: { + month: { "zh-TW": "月份", en: "Month" }, + declared: { "zh-TW": "應發", en: "Due" }, + allocated: { "zh-TW": "已對到", en: "Applied" }, + outstanding: { "zh-TW": "欠", en: "Owed" }, + date: { "zh-TW": "日期", en: "Date" }, + amount: { "zh-TW": "金額", en: "Amount" }, + appliedTo: { "zh-TW": "對到", en: "Applied to" }, + }, + unfiled: { "zh-TW": "未申報", en: "Not declared" }, + estimated: { "zh-TW": "估", en: "Est." }, + notDue: { "zh-TW": "未到期", en: "Not due" }, + noPayments: { "zh-TW": "沒有付款紀錄", en: "No payments recorded" }, + unapplied: { "zh-TW": "未對到 {amount}", en: "Unapplied {amount}" }, + payslip: { "zh-TW": "薪資單", en: "Payslip" }, + monthShort: { "zh-TW": "{month} 月", en: "M{month}" }, + close: { "zh-TW": "關閉", en: "Close" }, + }, + unallocated: { + title: { "zh-TW": "未指定員工的薪資支出", en: "Salary payments not linked to an employee" }, + description: { + "zh-TW": "分類是「薪資費用」但沒有綁員工、對象也對不到員工姓名。到交易頁把對象或撥款員工改好,就會算進對帳。", + en: "Categorised as 薪資費用 but not linked to an employee and the party name doesn't match one. Set the party or employee on the transaction to include it.", + }, + columns: { + date: { "zh-TW": "日期", en: "Date" }, + description: { "zh-TW": "說明", en: "Description" }, + party: { "zh-TW": "對象", en: "Party" }, + account: { "zh-TW": "帳戶", en: "Account" }, + amount: { "zh-TW": "金額", en: "Amount" }, + }, + }, + }, delete: { title: { "zh-TW": "撤銷這筆發放?", en: "Reverse this payment?" }, description: { "zh-TW": "會一併刪除它產生的薪資支出交易。", en: "This also deletes the salary expense transaction it created." }, diff --git a/src/lib/integrations/simpany.ts b/src/lib/integrations/simpany.ts index c429200..f831d21 100644 --- a/src/lib/integrations/simpany.ts +++ b/src/lib/integrations/simpany.ts @@ -25,13 +25,45 @@ import type { * - 帳密(account / password)與 JWT 只存在 server 記憶體,不寫 log、不進錯誤訊息、 * 不進任何回傳值。 * - * 兩個 host: + * 三個 base: * - api.simpany.co/v1 登入、/me(使用者與公司清單) * - member2.simpany.co/api/v1/c/{companyId}/ 電子發票(receipts) + * - api.simpany.co/v1/{companyId}/salary-declaration/ 薪資申報(**只讀**:GET + 路徑白名單, + * 見 assertSalaryReadOnly)。回應含身分證字號 / 地址 / 國籍,解析時一律丟掉。 */ const AUTH_BASE = "https://api.simpany.co/v1"; const EINVOICE_BASE = "https://member2.simpany.co/api/v1/c"; +const SALARY_BASE = "https://api.simpany.co/v1"; + +/** + * 薪資申報只允許這些路徑(相對於 {companyId}/salary-declaration/),而且只能 GET。 + * 結算、複製、建立、寄薪資單等端點一律不在清單內 —— 要加端點只能加唯讀的 GET。 + */ +const SALARY_READ_ONLY_PATHS: readonly RegExp[] = [ + /^form\/monthly-forms\/\d{4}$/, + /^form$/, +]; +const SALARY_QUERY_KEYS: ReadonlySet = new Set(["year", "month"]); + +/** 薪資申報的唯讀護欄:非 GET、路徑不在白名單、或帶了白名單外的 query key,一律不發請求直接丟錯。 */ +export function assertSalaryReadOnly( + method: string, + path: string, + query?: Record, +): void { + if (method.toUpperCase() !== "GET") { + throw new SimpanyError("config", `Simpany 薪資申報整合是唯讀的,拒絕送出 ${method} ${path}`); + } + if (!SALARY_READ_ONLY_PATHS.some((re) => re.test(path))) { + throw new SimpanyError("config", `Simpany 薪資申報不允許呼叫 ${path}(不在唯讀端點白名單內)`); + } + for (const k of Object.keys(query ?? {})) { + if (!SALARY_QUERY_KEYS.has(k)) { + throw new SimpanyError("config", `Simpany 薪資申報不允許 query 參數 ${k}`); + } + } +} /** 取不到 JWT exp 時的保守效期。 */ const FALLBACK_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; @@ -125,6 +157,51 @@ export type SimpanyCreateBody = { zeroTaxRateReasonCode: string | null; }; +/** 薪資申報:monthly-forms 的一格。id null = 那個月沒建表單。 */ +export type SimpanySalaryMonthlyForm = { + id: number | null; + year: number; + month: number; + employees: { id: number; name: string }[]; +}; + +/** 薪資申報明細的一個項目(去識別化:只有名稱、類型、金額)。 */ +export type SimpanySalaryDeclarationItem = { name: string; type: string; amount: number }; + +/** + * 表單上的一位員工。**刻意不含** personalId / address / nationality —— 解析時就丟掉。 + * declaration null = 表單上有這個人但沒有申報明細。 + */ +export type SimpanySalaryFormEmployee = { + id: number; + name: string; + employeeType: string | null; + payslipSentAt: string | null; + hasMissingEmployeeData: boolean | null; + hasMissingSalaryData: boolean | null; + declaration: { + id: number | null; + isCompanyOwner: boolean; + payday: string | null; + yearMonth: string | null; + payStartDate: string | null; + payEndDate: string | null; + laborInsuranceStartDate: string | null; + laborInsuranceEndDate: string | null; + items: SimpanySalaryDeclarationItem[]; + } | null; +}; + +export type SimpanySalaryForm = { + id: number | null; + year: number; + month: number; + payday: string | null; + isSettled: boolean; + canSettle: boolean | null; + employees: SimpanySalaryFormEmployee[]; +}; + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -222,6 +299,20 @@ export function simpanyErrorMessage(body: unknown): string | null { return snippet(body); } +/** 薪資申報用:只取結構化的錯誤訊息(validation / business / message),絕不回 body 片段。 */ +function salaryErrorMessage(body: unknown): string | null { + if (!isObj(body)) return null; + if (isObj(body.errors)) { + const validation = validationErrorsMessage(body.errors); + if (validation) return validation.slice(0, 400); + } + if (isObj(body.error)) { + const business = businessErrorMessage(body.error); + if (business) return business.slice(0, 400); + } + return str(body.message)?.slice(0, 400) ?? null; +} + /** JWT 的 exp(秒)→ Date;解不出來回 null。只讀 payload,不驗簽(那是 Simpany 的事)。 */ export function jwtExpiry(token: string): Date | null { const parts = token.split("."); @@ -306,6 +397,88 @@ export function parseDetail(v: unknown): SimpanyReceiptDetail | null { }; } +/** YYYY-MM-DD(或 ISO 字串的日期部分);其他格式回 null。 */ +function dateStr(v: unknown): string | null { + const s = str(v); + if (!s) return null; + const m = /^(\d{4}-\d{2}-\d{2})/.exec(s); + return m ? m[1] : null; +} + +export function parseSalaryMonthlyForms(data: unknown, year: number): SimpanySalaryMonthlyForm[] { + const list = Array.isArray(data) ? data : []; + return list + .filter(isObj) + .map((f): SimpanySalaryMonthlyForm | null => { + const month = numOrNull(f.month); + if (month == null || month < 1 || month > 12) return null; + const employees = Array.isArray(f.employees) + ? f.employees + .filter(isObj) + .map((e) => ({ id: numOrNull(e.id), name: str(e.name)?.trim() ?? "" })) + .filter((e): e is { id: number; name: string } => e.id != null && e.name !== "") + : []; + return { id: numOrNull(f.id), year: numOrNull(f.year) ?? year, month, employees }; + }) + .filter((f): f is SimpanySalaryMonthlyForm => f !== null); +} + +function parseSalaryItems(v: unknown): SimpanySalaryDeclarationItem[] { + if (!Array.isArray(v)) return []; + return v + .filter(isObj) + .map((it) => ({ name: str(it.name)?.trim() ?? "", type: str(it.type) ?? "", amount: num(it.amount) })) + .filter((it) => it.name !== ""); +} + +/** + * 解析 GET form?year&month。只挑白名單欄位組新物件 —— personalId / address / nationality + * 永遠不會被讀進來。 + */ +export function parseSalaryForm(data: unknown, year: number, month: number): SimpanySalaryForm | null { + if (!isObj(data)) return null; + const employees: SimpanySalaryFormEmployee[] = Array.isArray(data.employees) + ? data.employees.filter(isObj).flatMap((e): SimpanySalaryFormEmployee[] => { + const id = numOrNull(e.id); + const name = str(e.name)?.trim() ?? ""; + if (id == null || !name) return []; + const d = isObj(e.salaryDeclaration) ? e.salaryDeclaration : null; + return [ + { + id, + name, + employeeType: str(e.employeeType), + payslipSentAt: str(e.payslipSentAt), + hasMissingEmployeeData: bool(e.hasMissingEmployeeData), + hasMissingSalaryData: bool(e.hasMissingSalaryData), + declaration: d + ? { + id: numOrNull(d.id), + isCompanyOwner: d.isCompanyOwner === true, + payday: dateStr(d.payday), + yearMonth: str(d.yearMonth), + payStartDate: dateStr(d.payStartDate), + payEndDate: dateStr(d.payEndDate), + laborInsuranceStartDate: dateStr(d.laborInsuranceStartDate), + laborInsuranceEndDate: dateStr(d.laborInsuranceEndDate), + items: parseSalaryItems(d.salaryDeclarationItems), + } + : null, + }, + ]; + }) + : []; + return { + id: numOrNull(data.id), + year, + month, + payday: dateStr(data.payday), + isSettled: data.isSettled === true, + canSettle: bool(data.canSettle), + employees, + }; +} + /** `{ data: {...} }` 或直接是物件,兩種都接受。 */ function unwrapData(body: unknown): unknown { return isObj(body) && "data" in body ? body.data : body; @@ -470,6 +643,9 @@ type RequestOptions = { body?: unknown; }; +/** einvoice = member2 的電子發票;salary = api 的薪資申報(唯讀)。 */ +type ApiBase = "einvoice" | "salary"; + /** * 已登入、已選定公司的 Simpany client。用 getSimpanyClient(orgId) 取得。 * @@ -511,18 +687,28 @@ export class SimpanyClient { // ---- HTTP ---- - private url(path: string, query?: RequestOptions["query"]): string { - const u = new URL(`${EINVOICE_BASE}/${this.companyId}/${path}`); + private url(base: ApiBase, path: string, query?: RequestOptions["query"]): string { + const u = new URL( + base === "salary" + ? `${SALARY_BASE}/${this.companyId}/salary-declaration/${path}` + : `${EINVOICE_BASE}/${this.companyId}/${path}`, + ); for (const [k, v] of Object.entries(query ?? {})) { if (v !== undefined && v !== "") u.searchParams.set(k, String(v)); } return u.toString(); } - private async send(token: string, path: string, opts: RequestOptions): Promise { + private async send( + token: string, + base: ApiBase, + path: string, + opts: RequestOptions, + ): Promise { + if (base === "salary") assertSalaryReadOnly(opts.method ?? "GET", path, opts.query); const headers: Record = { ...BASE_HEADERS, Authorization: `Bearer ${token}` }; if (opts.body !== undefined) headers["Content-Type"] = "application/json"; - return safeFetch(this.url(path, opts.query), { + return safeFetch(this.url(base, path, opts.query), { method: opts.method ?? "GET", headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), @@ -531,16 +717,26 @@ export class SimpanyClient { /** 發一個 e-invoice API 請求,回傳解析後的 body。錯誤一律丟 SimpanyError。 */ async request(path: string, opts: RequestOptions = {}): Promise { + return this.requestAt("einvoice", path, opts); + } + + /** 薪資申報(唯讀):method 寫死 GET,路徑 / query 過 assertSalaryReadOnly 白名單。 */ + private async salaryGet(path: string, query?: RequestOptions["query"]): Promise { + assertSalaryReadOnly("GET", path, query); + return this.requestAt("salary", path, { method: "GET", query }); + } + + private async requestAt(base: ApiBase, path: string, opts: RequestOptions): Promise { let res: Response; try { const token = this.token ?? (await this.relogin()); - res = await this.send(token, path, opts); + res = await this.send(token, base, path, opts); if (res.status === 401) { // 快取的 token 過期或被撤銷:重新登入、重試一次。 await clearTokenCache(this.orgId, "simpany"); this.token = null; const fresh = await this.relogin(); - res = await this.send(fresh, path, opts); + res = await this.send(fresh, base, path, opts); if (res.status === 401) { await markNeedsReauth(this.orgId, "simpany", "Simpany 拒絕了新登入的 token"); throw new SimpanyError( @@ -556,12 +752,15 @@ export class SimpanyClient { } const body = await readBody(res); + // 薪資申報的回應可能含個資:錯誤訊息只取結構化的 message,不附 body 片段。 + const errorText = (b: unknown) => + base === "salary" ? salaryErrorMessage(b) : simpanyErrorMessage(b); if (res.ok) { // 業務錯誤有時仍是 2xx:{ status: "error", error: {...} } if (isObj(body) && body.status === "error") { throw new SimpanyError( "business", - `Simpany 回應錯誤:${simpanyErrorMessage(body) ?? "未知錯誤"}`, + `Simpany 回應錯誤:${errorText(body) ?? "未知錯誤"}`, res.status, ); } @@ -571,7 +770,7 @@ export class SimpanyClient { if (res.status >= 500 || res.status === 429) { const err = new SimpanyError( "http", - `Simpany 暫時無法處理(HTTP ${res.status}):${simpanyErrorMessage(body) ?? "無訊息"}`, + `Simpany 暫時無法處理(HTTP ${res.status}):${errorText(body) ?? "無訊息"}`, res.status, ); await this.failure(err); @@ -582,11 +781,41 @@ export class SimpanyClient { : "business"; throw new SimpanyError( kind, - `Simpany 拒絕了這個請求(HTTP ${res.status}):${simpanyErrorMessage(body) ?? "無訊息"}`, + `Simpany 拒絕了這個請求(HTTP ${res.status}):${errorText(body) ?? "無訊息"}`, res.status, ); } + // ---- salary declarations(唯讀)---- + + /** GET form/monthly-forms/{year}:一年 12 格,id null = 那個月沒建表單。 */ + async listSalaryMonthlyForms(year: number): Promise { + if (!Number.isInteger(year) || year < 2000 || year > 2100) { + throw new SimpanyError("config", `不合法的年份:${year}`); + } + const body = await this.salaryGet(`form/monthly-forms/${year}`); + const data = unwrapData(body); + if (!Array.isArray(data)) { + // 不附 body 片段:薪資回應可能含個資。 + throw new SimpanyError("business", "Simpany 回傳的薪資申報月份清單格式無法辨識"); + } + return parseSalaryMonthlyForms(data, year); + } + + /** GET form?year&month:那個月的表單與每位員工的申報明細(已去識別化)。 */ + async getSalaryForm(year: number, month: number): Promise { + if (!Number.isInteger(year) || year < 2000 || year > 2100) { + throw new SimpanyError("config", `不合法的年份:${year}`); + } + if (!Number.isInteger(month) || month < 1 || month > 12) { + throw new SimpanyError("config", `不合法的月份:${month}`); + } + const body = await this.salaryGet("form", { year, month }); + const form = parseSalaryForm(unwrapData(body), year, month); + if (!form) throw new SimpanyError("business", "Simpany 回傳的薪資申報表單格式無法辨識"); + return form; + } + // ---- receipts ---- async listReceipts(params: SimpanyListParams): Promise> { diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index 984cfaf..8a98a5b 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -75,7 +75,7 @@ function deriveMcpAudit(name: string, out: unknown): McpAudit | null { /** Bump on every published change to tools, schemas or instructions. Clients * (and OpenAI's plugin "Scan Tools") key their cached snapshot off this. */ -export const SERVER_VERSION = "1.6.0"; +export const SERVER_VERSION = "1.7.0"; /** Public base URL of this deployment; doubles as the OAuth issuer. * Keep in sync with the `resource` passed to `mcp()` in src/lib/auth.ts. */ @@ -276,6 +276,11 @@ const OPENWORLD_OVERRIDES: Record> = { simpany_issue_invoice: { openWorldHint: true }, simpany_void_invoice: { openWorldHint: true }, simpany_list_zero_rate_reasons: { openWorldHint: true }, + // Salary declarations: GET-only reads from Simpany (assertSalaryReadOnly); the + // sync writes only our own simpany_salary_* tables. salary_arrears reads only + // our tables, so it keeps the closed-world default. + simpany_list_salary_declarations: { openWorldHint: true }, + simpany_sync_salary_declarations: { openWorldHint: true }, }; // Writes that are irreversible from MCP even though the verb isn't "delete". @@ -323,14 +328,17 @@ const TITLE_OVERRIDES: Record = { list_salary_status: "Salary status by month", list_upcoming_billing: "Upcoming billing", mark_accountant_notified: "Mark as sent to the accountant", + salary_arrears: "Salary arrears by employee", pay_employee_salary: "Record a salary payslip", sync_billing_calendar: "Sync the billing calendar", simpany_get_invoice: "Simpany e-invoice detail", simpany_issue_invoice: "Issue a Simpany e-invoice", simpany_list_invoices: "List Simpany e-invoices", + simpany_list_salary_declarations: "List Simpany salary declarations", simpany_list_zero_rate_reasons: "Simpany zero-rate reasons", simpany_preview_invoice: "Preview a Simpany e-invoice", simpany_sync_invoices: "Sync invoices from Simpany", + simpany_sync_salary_declarations: "Sync salary declarations from Simpany", simpany_void_invoice: "Void a Simpany e-invoice", unmark_accountant_notified: "Unmark as sent to the accountant", }; diff --git a/src/lib/mcp/tools-simpany.ts b/src/lib/mcp/tools-simpany.ts index a266592..8521fdd 100644 --- a/src/lib/mcp/tools-simpany.ts +++ b/src/lib/mcp/tools-simpany.ts @@ -16,6 +16,11 @@ import { type TaxTreatment, } from "@/lib/simpany-sync"; import { getSimpanyClient } from "@/lib/integrations/simpany"; +import { + listSalaryDeclarationsLive, + salaryReconciliation, + syncSalaryDeclarations, +} from "@/lib/simpany-salary"; import { auditIntegrationCall, requireIntegrationForTool } from "./tools-integrations"; import { listResult, @@ -117,6 +122,31 @@ const LIST_ROW: JsonSchemaObject = rowSchema({ const LOOSE_OBJECT: JsonSchemaObject = { type: "object", additionalProperties: true }; +function yearArg(args: Record, fallback?: number): number { + const year = optNumber(args, "year") ?? fallback ?? Number(taipeiDate().slice(0, 4)); + if (!Number.isInteger(year) || year < 2000 || year > 2100) throw new Error('"year" must be a 4-digit year.'); + return year; +} + +function monthArg(args: Record, key: string): number | undefined { + const m = optNumber(args, key); + if (m === undefined) return undefined; + if (!Number.isInteger(m) || m < 0 || m > 12) throw new Error(`"${key}" must be 1-12.`); + return m; +} + +function optNumberMap(v: unknown, key: string): Record | undefined { + if (v === undefined || v === null) return undefined; + if (typeof v !== "object" || Array.isArray(v)) throw new Error(`"${key}" must be an object of name → number.`); + const out: Record = {}; + for (const [k, raw] of Object.entries(v as Record)) { + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n) || n < 0) throw new Error(`"${key}.${k}" must be a non-negative number.`); + out[k.trim()] = n; + } + return out; +} + export const simpanyTools: Record = { simpany_list_invoices: { description: @@ -450,4 +480,106 @@ export const simpanyTools: Record = { return listResult(await listZeroTaxReasons(orgId)); }, }, + + // ---- 薪資申報(唯讀:只對 Simpany 發 GET,見 assertSalaryReadOnly)---- + // 回應在解析時就丟掉身分證字號 / 地址 / 國籍;工具結果只有姓名、Simpany 員工 id、金額、日期、旗標。 + + simpany_list_salary_declarations: { + description: + "[read] Salary declarations (薪資申報) filed in Simpany for a year (or one month), read live from Simpany — GET only, nothing is written anywhere. Per month: status (missing = no form that month, empty = form exists but nobody's salary was declared, draft = declared but not settled, settled = filed/closed), payday, and per employee: name, Simpany employee id, company-owner flag, filed flag, base salary, non-recurring bonus, declared gross (實際申報薪資), net paid (實際發薪), personal/company labour & health insurance, employment insurance, and the item list (name/type/amount). Contains no national id, address or nationality. Requires the Simpany integration (設定 › 整合).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: "object", + properties: { + year: { type: "number", description: "Western year, e.g. 2026. Default: this year (Taipei)." }, + month: { type: "number", description: "1-12: only this salary month (the month the salary is for, not the payday month)." }, + ...ORG_ARG, + }, + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + const year = yearArg(args); + const month = monthArg(args, "month"); + if (month === 0) throw new Error('"month" must be 1-12.'); + const res = await listSalaryDeclarationsLive(orgId, year, month); + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "read", + `salary declarations ${year}${month ? `-${String(month).padStart(2, "0")}` : ""}`, + ); + return res; + }, + }, + + simpany_sync_salary_declarations: { + description: + "[write] Pull a year's Simpany salary declarations (薪資申報) into this organization's own tables (simpany_salary_forms / simpany_salary_declarations) so salary_arrears can reconcile them. Only GET requests go to Simpany; nothing is written to Simpany. Idempotent: re-running replaces the year's rows, and forms/employees removed in Simpany are removed here. Each Simpany employee is linked to an employee record by exact name (unmatched names are returned). Owner/admin only.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: "object", + properties: { + year: { type: "number", description: "Western year, e.g. 2026. Default: this year (Taipei)." }, + ...ORG_ARG, + }, + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "同步 Simpany 薪資申報"); + const year = yearArg(args); + const res = await syncSalaryDeclarations(orgId, year); + const filed = res.months.filter((m) => m.filedCount > 0).length; + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "update", + `salary sync ${year}: ${filed} filed months, ${res.declarationsUpserted} rows, -${res.declarationsRemoved}`, + ); + return res; + }, + }, + + salary_arrears: { + description: + "[read] Salary arrears (欠薪) per employee: what was declared in Simpany (net paid 實際發薪, per month) versus what this organization actually recorded as paid (payslips + expense transactions in the 薪資費用 category linked to the employee by settle-employee or by party name). Reads only this organization's tables — run simpany_sync_salary_declarations first to refresh. Payments tied to a payslip period go to that month first; everything else is applied oldest-month-first (FIFO). Returns per employee: totalDeclaredNet, totalPaid, arrears (due, declared months still unpaid), estimatedArrears (months not declared in Simpany, estimated from the latest declared month's net minus its one-off bonus, flagged estimated: true), notYetDue, credit (paid with nothing to apply to), monthly rows and payments with their allocation; plus the month grid (missing / empty / draft / settled / not_synced) and unallocatedPayments — 薪資費用 outflows not linked to any employee, for the owner to assign.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: "object", + properties: { + year: { type: "number", description: "Default: this year (Taipei)." }, + throughMonth: { type: "number", description: "Last salary month to include (1-12). Default: current month for this year, 12 for past years." }, + paidFrom: { type: "string", description: "YYYY-MM-DD: earliest date of 薪資費用 transactions counted as payments (payslip-linked ones count by period instead). Default: Jan 1 of year." }, + paidTo: { type: "string", description: "YYYY-MM-DD: latest payment date counted, also the as-of date for 'due'. Default: today for this year, Jan 31 of the next year for past years." }, + estimateUnfiled: { type: "boolean", description: "Estimate months with no Simpany declaration (within employment) from expectedMonthlyNet. Default true." }, + expectedMonthlyNet: { + type: "object", + additionalProperties: { type: "number" }, + description: "Override the monthly net used for estimates, keyed by employee name, e.g. {\"呂安\": 38376}.", + }, + ...ORG_ARG, + }, + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + const year = yearArg(args); + return salaryReconciliation(orgId, { + year, + throughMonth: monthArg(args, "throughMonth"), + paidFrom: optDate(args, "paidFrom"), + paidTo: optDate(args, "paidTo"), + estimateUnfiled: optBoolean(args, "estimateUnfiled"), + expectedMonthlyNet: optNumberMap(args.expectedMonthlyNet, "expectedMonthlyNet"), + }); + }, + }, }; diff --git a/src/lib/simpany-salary.ts b/src/lib/simpany-salary.ts new file mode 100644 index 0000000..b9787ef --- /dev/null +++ b/src/lib/simpany-salary.ts @@ -0,0 +1,962 @@ +import { and, eq, gte, inArray, isNull, lte, notInArray, or, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { + bankAccounts, + categories, + employees, + parties, + payrollRuns, + payslips, + simpanySalaryDeclarations, + simpanySalaryForms, + transactions, + type SimpanySalaryItem, +} from "@/db/schema"; +import { + getSimpanyClient, + type SimpanySalaryDeclarationItem, + type SimpanySalaryForm, + type SimpanySalaryMonthlyForm, +} from "@/lib/integrations/simpany"; +import { taipeiDate } from "@/lib/simpany-sync"; + +/** + * Simpany 薪資申報(migrations/0027):唯讀同步 + 欠薪對帳。 + * + * - 同步:GET monthly-forms/{year} 拿 12 格,有表單的月份再 GET form?year&month, + * 寫進 simpany_salary_forms(月份層級)與 simpany_salary_declarations(員工 × 月)。 + * 冪等:以唯一鍵 upsert,Simpany 上已不存在的員工 / 表單會從本地刪掉。 + * - 對帳:申報的「實際發薪」= 應發;本系統記的薪資(payslips + 薪資費用交易)= 已發; + * 已發先對到它明確所屬的月份(payslip 的期別),其餘依日期先進先出補最舊的欠款。 + * + * ⚠️ 個資:Simpany 的回應在 src/lib/integrations/simpany.ts 解析時就丟掉身分證字號、地址、國籍, + * 這裡碰得到的只有姓名、Simpany 員工 id、金額、日期、旗標。 + */ + +export const SALARY_CATEGORY_NAME = "薪資費用"; + +// --------------------------------------------------------------------------- +// Declaration items → fields +// --------------------------------------------------------------------------- + +export type DeclarationSummary = { + baseSalary: number | null; + bonus: number | null; + grossDeclared: number | null; + netPay: number | null; + laborInsPersonal: number | null; + healthInsPersonal: number | null; + laborInsCompany: number | null; + healthInsCompany: number | null; + employmentInsCompany: number | null; +}; + +const norm = (s: string) => s.replaceAll(/\s+/g, ""); + +function pick( + items: SimpanySalaryDeclarationItem[], + pred: (name: string, type: string) => boolean, +): number | null { + const hit = items.find((it) => pred(norm(it.name), it.type.toUpperCase())); + return hit ? hit.amount : null; +} + +/** 從 Simpany 的 salaryDeclarationItems 抽出我們要的金額;找不到的欄位是 null。 */ +export function summarizeDeclaration(items: SimpanySalaryDeclarationItem[]): DeclarationSummary { + const notRange = (type: string) => type !== "INSURANCE_RANGE" && type !== "PAYSLIP_SUMMARY"; + const bonusSummary = pick(items, (n, t) => t === "PAYSLIP_SUMMARY" && n === "非經常性薪資給付總額"); + const bonusItems = items.filter( + (it) => it.type.toUpperCase() === "ALLOWANCE" && /非經常性|獎金/.test(norm(it.name)), + ); + return { + baseSalary: + pick(items, (n, t) => t === "ALLOWANCE" && n === "本薪") ?? + pick(items, (n, t) => t === "PAYSLIP_SUMMARY" && n === "本薪小計"), + bonus: + bonusSummary ?? + (bonusItems.length ? bonusItems.reduce((s, it) => s + it.amount, 0) : null), + grossDeclared: pick(items, (n, t) => t === "PAYSLIP_SUMMARY" && n === "實際申報薪資"), + netPay: pick(items, (n, t) => t === "PAYSLIP_SUMMARY" && n === "實際發薪"), + laborInsPersonal: pick(items, (n, t) => notRange(t) && n.includes("勞保") && n.includes("個人")), + healthInsPersonal: pick(items, (n, t) => notRange(t) && n.includes("健保") && n.includes("個人")), + laborInsCompany: pick( + items, + (n, t) => notRange(t) && n.includes("勞保") && n.includes("公司") && !n.includes("就業"), + ), + healthInsCompany: pick(items, (n, t) => notRange(t) && n.includes("健保") && n.includes("公司")), + employmentInsCompany: pick( + items, + (n, t) => notRange(t) && n.includes("就業保險") && n.includes("公司"), + ), + }; +} + +export type SalaryMonthStatus = "not_synced" | "missing" | "empty" | "draft" | "settled"; + +function monthStatus(formId: number | null, filedCount: number, isSettled: boolean): SalaryMonthStatus { + if (formId == null) return "missing"; + if (filedCount === 0) return "empty"; + return isSettled ? "settled" : "draft"; +} + +// --------------------------------------------------------------------------- +// Live read (MCP simpany_list_salary_declarations) +// --------------------------------------------------------------------------- + +export type LiveSalaryEmployee = { + simpanyEmployeeId: number; + name: string; + isCompanyOwner: boolean; + filed: boolean; + payslipSentAt: string | null; + hasMissingSalaryData: boolean | null; + payStartDate: string | null; + payEndDate: string | null; + laborInsuranceStartDate: string | null; + laborInsuranceEndDate: string | null; + items: SimpanySalaryItem[]; +} & DeclarationSummary; + +export type LiveSalaryMonth = { + month: number; + status: SalaryMonthStatus; + formId: number | null; + payday: string | null; + isSettled: boolean | null; + employeeCount: number; + employees: LiveSalaryEmployee[] | null; + employeeNames: string[]; +}; + +function liveEmployees(form: SimpanySalaryForm): LiveSalaryEmployee[] { + return form.employees.map((e) => { + const d = e.declaration; + const items = d?.items ?? []; + return { + simpanyEmployeeId: e.id, + name: e.name, + isCompanyOwner: d?.isCompanyOwner ?? false, + filed: items.length > 0, + payslipSentAt: e.payslipSentAt, + hasMissingSalaryData: e.hasMissingSalaryData, + payStartDate: d?.payStartDate ?? null, + payEndDate: d?.payEndDate ?? null, + laborInsuranceStartDate: d?.laborInsuranceStartDate ?? null, + laborInsuranceEndDate: d?.laborInsuranceEndDate ?? null, + items: items.map((it) => ({ name: it.name, type: it.type, amount: it.amount })), + ...summarizeDeclaration(items), + }; + }); +} + +/** + * 直接向 Simpany 讀(不寫 DB)。沒給 month:整年 12 格,有表單的月份附明細; + * 給 month:只讀那個月。明細都已去識別化(沒有身分證字號 / 地址 / 國籍)。 + */ +export async function listSalaryDeclarationsLive( + orgId: string, + year: number, + month?: number, +): Promise<{ year: number; months: LiveSalaryMonth[] }> { + const client = await getSimpanyClient(orgId); + const monthly = await client.listSalaryMonthlyForms(year); + const targets = month ? monthly.filter((m) => m.month === month) : monthly; + const months: LiveSalaryMonth[] = []; + for (const m of targets) { + if (m.id == null) { + months.push({ + month: m.month, + status: "missing", + formId: null, + payday: null, + isSettled: null, + employeeCount: m.employees.length, + employees: null, + employeeNames: m.employees.map((e) => e.name), + }); + continue; + } + const form = await client.getSalaryForm(year, m.month); + const emps = liveEmployees(form); + months.push({ + month: m.month, + status: monthStatus(form.id ?? m.id, emps.filter((e) => e.filed).length, form.isSettled), + formId: form.id ?? m.id, + payday: form.payday, + isSettled: form.isSettled, + employeeCount: emps.length, + employees: emps, + employeeNames: emps.map((e) => e.name), + }); + } + if (month && months.length === 0) { + months.push({ + month, + status: "missing", + formId: null, + payday: null, + isSettled: null, + employeeCount: 0, + employees: null, + employeeNames: [], + }); + } + return { year, months }; +} + +// --------------------------------------------------------------------------- +// Sync +// --------------------------------------------------------------------------- + +export type SalarySyncResult = { + year: number; + months: { + month: number; + status: SalaryMonthStatus; + formId: number | null; + payday: string | null; + employeeCount: number; + filedCount: number; + }[]; + declarationsUpserted: number; + declarationsRemoved: number; + /** Simpany 上的員工姓名在本系統員工名冊裡找不到(或重名)的 —— 對帳仍會用姓名比對對象。 */ + unmatchedNames: string[]; +}; + +const money = (n: number | null) => (n == null ? null : String(n)); + +/** + * 把一整年的 Simpany 薪資申報拉進本地表。冪等:同一年跑幾次結果都一樣;Simpany 上刪掉的 + * 表單 / 員工會在本地一併刪掉。只寫本組織的 simpany_salary_* 表,不會寫 Simpany。 + */ +export async function syncSalaryDeclarations(orgId: string, year: number): Promise { + if (!Number.isInteger(year) || year < 2000 || year > 2100) throw new Error(`不合法的年份:${year}`); + const db = getDb(); + const client = await getSimpanyClient(orgId); + const monthly = await client.listSalaryMonthlyForms(year); + const byMonth = new Map(monthly.map((m) => [m.month, m])); + + const forms = new Map(); + for (const m of monthly) { + if (m.id != null) forms.set(m.month, await client.getSalaryForm(year, m.month)); + } + + // 本系統員工:同組織、姓名完全相同才綁;重名就不綁。 + const emps = await db + .select({ id: employees.id, name: employees.name }) + .from(employees) + .where(and(eq(employees.organizationId, orgId), isNull(employees.deletedAt))); + const nameToId = new Map(); + for (const e of emps) { + const k = e.name.trim(); + nameToId.set(k, nameToId.has(k) ? null : e.id); + } + + const now = new Date().toISOString(); + const result: SalarySyncResult = { + year, + months: [], + declarationsUpserted: 0, + declarationsRemoved: 0, + unmatchedNames: [], + }; + const unmatched = new Set(); + + for (let month = 1; month <= 12; month++) { + const entry = byMonth.get(month); + const form = forms.get(month); + const formId = form?.id ?? entry?.id ?? null; + const rows = (form?.employees ?? []).map((e) => { + const items = e.declaration?.items ?? []; + const s = summarizeDeclaration(items); + const employeeId = nameToId.get(e.name) ?? null; + if (employeeId == null) unmatched.add(e.name); + return { + organizationId: orgId, + year, + month, + simpanyFormId: formId, + payday: form?.payday ?? e.declaration?.payday ?? null, + isSettled: form?.isSettled ?? false, + simpanyEmployeeId: e.id, + employeeName: e.name, + employeeId, + isCompanyOwner: e.declaration?.isCompanyOwner ?? false, + baseSalary: money(s.baseSalary), + bonus: money(s.bonus), + grossDeclared: money(s.grossDeclared), + netPay: money(s.netPay), + laborInsPersonal: money(s.laborInsPersonal), + healthInsPersonal: money(s.healthInsPersonal), + laborInsCompany: money(s.laborInsCompany), + healthInsCompany: money(s.healthInsCompany), + employmentInsCompany: money(s.employmentInsCompany), + items: items.map((it) => ({ name: it.name, type: it.type, amount: it.amount })), + filed: items.length > 0, + syncedAt: now, + }; + }); + const filedCount = rows.filter((r) => r.filed).length; + const employeeCount = form ? rows.length : (entry?.employees.length ?? 0); + const isSettled = form?.isSettled ?? false; + const payday = form?.payday ?? null; + + await db + .insert(simpanySalaryForms) + .values({ + organizationId: orgId, + year, + month, + simpanyFormId: formId, + payday, + isSettled, + employeeCount, + filedCount, + syncedAt: now, + }) + .onConflictDoUpdate({ + target: [simpanySalaryForms.organizationId, simpanySalaryForms.year, simpanySalaryForms.month], + set: { simpanyFormId: formId, payday, isSettled, employeeCount, filedCount, syncedAt: now }, + }); + + if (rows.length > 0) { + await db + .insert(simpanySalaryDeclarations) + .values(rows) + .onConflictDoUpdate({ + target: [ + simpanySalaryDeclarations.organizationId, + simpanySalaryDeclarations.year, + simpanySalaryDeclarations.month, + simpanySalaryDeclarations.simpanyEmployeeId, + ], + set: { + simpanyFormId: sql`excluded.simpany_form_id`, + payday: sql`excluded.payday`, + isSettled: sql`excluded.is_settled`, + employeeName: sql`excluded.employee_name`, + employeeId: sql`excluded.employee_id`, + isCompanyOwner: sql`excluded.is_company_owner`, + baseSalary: sql`excluded.base_salary`, + bonus: sql`excluded.bonus`, + grossDeclared: sql`excluded.gross_declared`, + netPay: sql`excluded.net_pay`, + laborInsPersonal: sql`excluded.labor_ins_personal`, + healthInsPersonal: sql`excluded.health_ins_personal`, + laborInsCompany: sql`excluded.labor_ins_company`, + healthInsCompany: sql`excluded.health_ins_company`, + employmentInsCompany: sql`excluded.employment_ins_company`, + items: sql`excluded.items`, + filed: sql`excluded.filed`, + syncedAt: sql`excluded.synced_at`, + }, + }); + result.declarationsUpserted += rows.length; + } + + // Simpany 上已不存在的(整張表單沒了、或員工被移出表單)→ 本地刪掉。 + const keep = rows.map((r) => r.simpanyEmployeeId); + const removed = await db + .delete(simpanySalaryDeclarations) + .where( + and( + eq(simpanySalaryDeclarations.organizationId, orgId), + eq(simpanySalaryDeclarations.year, year), + eq(simpanySalaryDeclarations.month, month), + keep.length ? notInArray(simpanySalaryDeclarations.simpanyEmployeeId, keep) : undefined, + ), + ) + .returning({ id: simpanySalaryDeclarations.id }); + result.declarationsRemoved += removed.length; + + result.months.push({ + month, + status: monthStatus(formId, filedCount, isSettled), + formId, + payday, + employeeCount, + filedCount, + }); + } + + result.unmatchedNames = [...unmatched].sort((a, b) => a.localeCompare(b, "zh-Hant")); + return result; +} + +// --------------------------------------------------------------------------- +// Reconciliation (reads our tables only) +// --------------------------------------------------------------------------- + +export type ReconMonth = { + month: number; + /** 這個月 Simpany 表單的狀態(整家公司的,不是這位員工的)。 */ + formStatus: SalaryMonthStatus; + /** 這位員工這個月有申報明細。 */ + filed: boolean; + /** 申報的實際發薪;未申報為 null。 */ + declaredNet: number | null; + /** true = 沒申報,應發是用 expectedMonthlyNet 估的。 */ + estimated: boolean; + /** 應發(申報值或估計值;兩者皆無為 0)。 */ + owed: number; + allocatedPaid: number; + outstanding: number; + /** 發薪日(表單的 payday,沒有就假設次月 5 日)是否已過 asOf。 */ + due: boolean; + payday: string; +}; + +export type ReconPayment = { + source: "transaction" | "payslip"; + transactionId: number | null; + payslipId: number | null; + date: string; + amount: number; + /** payslip 的期別月份(明確指定屬於哪個月);一般交易為 null,走先進先出。 */ + periodMonth: number | null; + accountName: string | null; + description: string | null; + allocations: { month: number; amount: number }[]; + /** 沒有可對的欠款,剩下的金額(預付 / 溢付)。 */ + unapplied: number; +}; + +export type ReconEmployee = { + key: string; + employeeId: number | null; + simpanyEmployeeId: number | null; + name: string; + isCompanyOwner: boolean; + expectedMonthlyNet: number | null; + expectedSource: "override" | "latest_filed" | null; + totalDeclaredNet: number; + totalEstimatedNet: number; + totalPaid: number; + /** 已到期、已申報月份還沒付的(真正的欠薪)。 */ + arrears: number; + /** 已到期、未申報(估計)月份還沒付的。 */ + estimatedArrears: number; + /** 還沒到發薪日的月份未付金額。 */ + notYetDue: number; + /** 付了但沒有欠款可對的金額。 */ + credit: number; + months: ReconMonth[]; + payments: ReconPayment[]; +}; + +export type UnallocatedPayment = { + transactionId: number; + date: string; + amount: number; + currency: string; + description: string | null; + partyName: string | null; + accountName: string | null; +}; + +export type SalaryReconciliation = { + year: number; + throughMonth: number; + asOf: string; + paidFrom: string; + paidTo: string; + lastSyncedAt: string | null; + months: { + month: number; + status: SalaryMonthStatus; + payday: string | null; + employeeCount: number; + filedCount: number; + }[]; + employees: ReconEmployee[]; + unallocatedPayments: UnallocatedPayment[]; + totals: { + declaredNet: number; + estimatedNet: number; + paid: number; + arrears: number; + estimatedArrears: number; + notYetDue: number; + credit: number; + unallocated: number; + }; +}; + +export type ReconOptions = { + year: number; + /** 對到哪個月(含)。預設:今年 = 本月;過去的年份 = 12。 */ + throughMonth?: number; + /** 一般(沒指定期別的)薪資交易的日期範圍。預設 year-01-01 起。 */ + paidFrom?: string; + /** 預設:今年 = 今天;過去的年份 = 隔年 1/31(涵蓋 12 月薪次月 5 日發)。 */ + paidTo?: string; + /** 未申報的月份用 expectedMonthlyNet 估應發(標 estimated)。預設 true。 */ + estimateUnfiled?: boolean; + /** 覆寫某位員工的每月預估實發,key = 員工姓名。 */ + expectedMonthlyNet?: Record; +}; + +const round2 = (n: number) => Math.round(n * 100) / 100; +const pad2 = (n: number) => String(n).padStart(2, "0"); + +function defaultPayday(year: number, month: number): string { + return month === 12 ? `${year + 1}-01-05` : `${year}-${pad2(month + 1)}-05`; +} + +/** + * 把付款分配到各月(就地更新 months 的 allocatedPaid / outstanding 與 payments 的 + * allocations / unapplied),回傳依日期排序後的付款。 + * 1) 有明確期別(payslip)的先補那個月;2) 其餘金額(含 1 超付的部分)依付款日期先進先出, + * 從最舊的欠款月份開始補;3) 再有剩就是 unapplied。 + */ +export function allocatePayments(months: ReconMonth[], input: ReconPayment[]): ReconPayment[] { + const payments = [...input].sort( + (a, b) => a.date.localeCompare(b.date) || (a.transactionId ?? 0) - (b.transactionId ?? 0), + ); + const rows = [...months].sort((a, b) => a.month - b.month); + const remaining = new Map(payments.map((x) => [x, x.amount])); + const apply = (pay: ReconPayment, row: ReconMonth) => { + const left = remaining.get(pay) ?? 0; + const amt = round2(Math.min(left, row.outstanding)); + if (amt <= 0) return; + row.allocatedPaid = round2(row.allocatedPaid + amt); + row.outstanding = round2(row.outstanding - amt); + remaining.set(pay, round2(left - amt)); + const existing = pay.allocations.find((a) => a.month === row.month); + if (existing) existing.amount = round2(existing.amount + amt); + else pay.allocations.push({ month: row.month, amount: amt }); + }; + for (const pay of payments) { + if (pay.periodMonth == null) continue; + const row = rows.find((r) => r.month === pay.periodMonth); + if (row) apply(pay, row); + } + for (const pay of payments) { + for (const row of rows) { + if ((remaining.get(pay) ?? 0) <= 0) break; + apply(pay, row); + } + } + for (const pay of payments) { + pay.unapplied = round2(remaining.get(pay) ?? 0); + pay.allocations.sort((a, b) => a.month - b.month); + } + return payments; +} + +type DeclRow = typeof simpanySalaryDeclarations.$inferSelect; + +type Person = { + key: string; + employeeId: number | null; + simpanyEmployeeId: number | null; + name: string; + isCompanyOwner: boolean; + startDate: string | null; + endDate: string | null; + decl: Map; + payments: ReconPayment[]; +}; + +/** + * 每位員工:申報的應發(實際發薪)vs 本系統記錄的已發,按月列出欠多少。 + * + * 已發 = (1) payslips(有 paid_transaction_id 的以那筆交易為準;沒有交易、但所屬薪資批次 + * status = paid 的以 net_pay 計)+ (2) 薪資費用分類的支出交易,對象是這位員工 + * (settle_employee_id = 員工,或對象 party 名稱 = 員工姓名)。 + * 分配:payslip 有明確期別 → 先補那個月;其餘(含超付的部分)依日期先進先出補最舊的欠款; + * 再有剩就是 credit。薪資費用交易對不到任何員工的 → unallocatedPayments 讓人指派。 + */ +export async function salaryReconciliation( + orgId: string, + opts: ReconOptions, +): Promise { + const db = getDb(); + const { year } = opts; + const today = taipeiDate(); + const thisYear = Number(today.slice(0, 4)); + const thisMonth = Number(today.slice(5, 7)); + let defaultThrough = 12; + if (year === thisYear) defaultThrough = thisMonth; + else if (year > thisYear) defaultThrough = 0; + const throughMonth = Math.max(0, Math.min(12, opts.throughMonth ?? defaultThrough)); + const paidFrom = opts.paidFrom ?? `${year}-01-01`; + const paidTo = opts.paidTo ?? (year < thisYear ? `${year + 1}-01-31` : today); + const asOf = paidTo < today ? paidTo : today; + const estimateUnfiled = opts.estimateUnfiled ?? true; + + // ---- load ---- + const [formRows, declRows, empRows, salaryCats] = await Promise.all([ + db + .select() + .from(simpanySalaryForms) + .where(and(eq(simpanySalaryForms.organizationId, orgId), eq(simpanySalaryForms.year, year))), + db + .select() + .from(simpanySalaryDeclarations) + .where( + and( + eq(simpanySalaryDeclarations.organizationId, orgId), + eq(simpanySalaryDeclarations.year, year), + ), + ), + db + .select({ + id: employees.id, + name: employees.name, + startDate: employees.startDate, + endDate: employees.endDate, + isActive: employees.isActive, + }) + .from(employees) + .where(and(eq(employees.organizationId, orgId), isNull(employees.deletedAt))), + db + .select({ id: categories.id }) + .from(categories) + .where( + and( + eq(categories.organizationId, orgId), + eq(categories.name, SALARY_CATEGORY_NAME), + isNull(categories.deletedAt), + ), + ), + ]); + const salaryCatIds = salaryCats.map((c) => c.id); + + // 所有 payslip(跨年份,才能把「屬於別年」的交易排除掉) + const slipRows = await db + .select({ + id: payslips.id, + employeeId: payslips.employeeId, + netPay: payslips.netPay, + paidTransactionId: payslips.paidTransactionId, + periodYear: payrollRuns.periodYear, + periodMonth: payrollRuns.periodMonth, + payDate: payrollRuns.payDate, + runStatus: payrollRuns.status, + }) + .from(payslips) + .innerJoin(payrollRuns, eq(payslips.payrollRunId, payrollRuns.id)) + .where( + and( + eq(payrollRuns.organizationId, orgId), + isNull(payrollRuns.deletedAt), + isNull(payslips.deletedAt), + ), + ); + const slipByTxn = new Map(); + for (const s of slipRows) if (s.paidTransactionId != null) slipByTxn.set(s.paidTransactionId, s); + const thisYearSlipTxnIds = slipRows + .filter((s) => s.periodYear === year && s.paidTransactionId != null) + .map((s) => s.paidTransactionId as number); + + const txnSelect = { + id: transactions.id, + txnDate: transactions.txnDate, + amount: transactions.amount, + amountTwd: transactions.amountTwd, + currency: transactions.currency, + description: transactions.description, + categoryId: transactions.categoryId, + settleEmployeeId: transactions.settleEmployeeId, + partyName: parties.name, + accountName: bankAccounts.name, + }; + const txnConds = []; + if (salaryCatIds.length) { + txnConds.push( + and( + inArray(transactions.categoryId, salaryCatIds), + gte(transactions.txnDate, paidFrom), + lte(transactions.txnDate, paidTo), + ), + ); + } + if (thisYearSlipTxnIds.length) txnConds.push(inArray(transactions.id, thisYearSlipTxnIds)); + const txnRows = txnConds.length + ? await db + .select(txnSelect) + .from(transactions) + .leftJoin(parties, eq(transactions.partyId, parties.id)) + .leftJoin(bankAccounts, eq(transactions.fromAccountId, bankAccounts.id)) + .where( + and( + eq(transactions.organizationId, orgId), + isNull(transactions.deletedAt), + eq(transactions.type, "expense"), + or(...txnConds), + ), + ) + .orderBy(transactions.txnDate, transactions.id) + : []; + + // ---- people ---- + const people = new Map(); + const nameToKey = new Map(); + const empById = new Map(empRows.map((e) => [e.id, e])); + const personForEmployee = (id: number): Person => { + const key = `e:${id}`; + let p = people.get(key); + if (!p) { + const e = empById.get(id); + p = { + key, + employeeId: id, + simpanyEmployeeId: null, + name: e?.name ?? `#${id}`, + isCompanyOwner: false, + startDate: e?.startDate ?? null, + endDate: e?.endDate ?? null, + decl: new Map(), + payments: [], + }; + people.set(key, p); + if (e) nameToKey.set(e.name.trim(), key); + } + return p; + }; + + // 同步之後才建的員工:這裡再用姓名補綁一次(重名不綁)。 + const empNameToId = new Map(); + for (const e of empRows) { + const k = e.name.trim(); + empNameToId.set(k, empNameToId.has(k) ? null : e.id); + } + for (const d of declRows) { + let p: Person; + const employeeId = d.employeeId ?? empNameToId.get(d.employeeName.trim()) ?? null; + if (employeeId != null) { + p = personForEmployee(employeeId); + } else { + const key = `s:${d.simpanyEmployeeId}`; + p = people.get(key) ?? { + key, + employeeId: null, + simpanyEmployeeId: d.simpanyEmployeeId, + name: d.employeeName, + isCompanyOwner: false, + startDate: null, + endDate: null, + decl: new Map(), + payments: [], + }; + people.set(key, p); + } + p.simpanyEmployeeId ??= d.simpanyEmployeeId; + if (d.isCompanyOwner) p.isCompanyOwner = true; + p.decl.set(d.month, d); + if (!nameToKey.has(d.employeeName.trim())) nameToKey.set(d.employeeName.trim(), p.key); + } + // 本系統員工的姓名也能對上 party 名稱(即使他沒有申報) + for (const e of empRows) { + const k = e.name.trim(); + if (!nameToKey.has(k)) nameToKey.set(k, `e:${e.id}`); + } + const personByKey = (key: string): Person => { + if (key.startsWith("e:")) return personForEmployee(Number(key.slice(2))); + const p = people.get(key); + if (!p) throw new Error(`unknown person ${key}`); + return p; + }; + + // ---- payments ---- + const unallocatedPayments: UnallocatedPayment[] = []; + for (const t of txnRows) { + const amount = Number(t.amountTwd ?? t.amount); + if (!Number.isFinite(amount) || amount <= 0) continue; + const slip = slipByTxn.get(t.id); + if (slip && slip.periodYear !== year) continue; // 別年的薪資 + let person: Person | null = null; + if (slip) person = personForEmployee(slip.employeeId); + else if (t.settleEmployeeId != null) person = personForEmployee(t.settleEmployeeId); + else if (t.partyName && nameToKey.has(t.partyName.trim())) { + person = personByKey(nameToKey.get(t.partyName.trim()) as string); + } + if (!person) { + unallocatedPayments.push({ + transactionId: t.id, + date: t.txnDate, + amount: round2(amount), + currency: t.amountTwd != null ? "TWD" : t.currency, + description: t.description, + partyName: t.partyName, + accountName: t.accountName, + }); + continue; + } + person.payments.push({ + source: "transaction", + transactionId: t.id, + payslipId: slip?.id ?? null, + date: t.txnDate, + amount: round2(amount), + periodMonth: slip ? slip.periodMonth : null, + accountName: t.accountName, + description: t.description, + allocations: [], + unapplied: 0, + }); + } + // 沒有交易、但批次已標為 paid 的 payslip + for (const s of slipRows) { + if (s.periodYear !== year || s.paidTransactionId != null || s.runStatus !== "paid") continue; + const amount = Number(s.netPay); + if (!Number.isFinite(amount) || amount <= 0) continue; + personForEmployee(s.employeeId).payments.push({ + source: "payslip", + transactionId: null, + payslipId: s.id, + date: s.payDate ?? defaultPayday(year, s.periodMonth), + amount: round2(amount), + periodMonth: s.periodMonth, + accountName: null, + description: null, + allocations: [], + unapplied: 0, + }); + } + + // ---- months grid ---- + const formByMonth = new Map(formRows.map((f) => [f.month, f])); + const monthsGrid = Array.from({ length: 12 }, (_, i) => { + const f = formByMonth.get(i + 1); + return { + month: i + 1, + status: f ? monthStatus(f.simpanyFormId, f.filedCount, f.isSettled) : ("not_synced" as const), + payday: f?.payday ?? null, + employeeCount: f?.employeeCount ?? 0, + filedCount: f?.filedCount ?? 0, + }; + }); + const lastSyncedAt = formRows.reduce( + (acc, f) => (acc == null || f.syncedAt > acc ? f.syncedAt : acc), + null, + ); + + // ---- per person ---- + const result: ReconEmployee[] = []; + for (const p of people.values()) { + const filedMonths = [...p.decl.values()] + .filter((d) => d.filed && d.netPay != null) + .sort((a, b) => a.month - b.month); + + // 預估每月實發 + let expected: number | null = null; + let expectedSource: ReconEmployee["expectedSource"] = null; + const override = opts.expectedMonthlyNet?.[p.name]; + if (override != null && Number.isFinite(override)) { + expected = override; + expectedSource = "override"; + } else { + const within = filedMonths.filter((d) => d.month <= Math.max(throughMonth, 1)); + const latest = (within.length ? within : filedMonths).at(-1); + if (latest) { + const net = Number(latest.netPay); + const bonus = Number(latest.bonus ?? 0); + expected = bonus > 0 && net - bonus > 0 ? net - bonus : net; + expectedSource = "latest_filed"; + } + } + + // 估計的範圍:到職月(或第一個有申報的月份)起,到離職月為止 + let firstMonth: number | null = filedMonths[0]?.month ?? null; + if (p.startDate) { + const sy = Number(p.startDate.slice(0, 4)); + const sm = Number(p.startDate.slice(5, 7)); + if (sy < year) firstMonth = 1; + else if (sy === year) firstMonth = sm; + else firstMonth = null; + } + let lastMonth = 12; + if (p.endDate) { + const ey = Number(p.endDate.slice(0, 4)); + if (ey < year) lastMonth = 0; + else if (ey === year) lastMonth = Number(p.endDate.slice(5, 7)); + } + + const months: ReconMonth[] = []; + for (let m = 1; m <= throughMonth; m++) { + const d = p.decl.get(m); + const form = formByMonth.get(m); + const filed = Boolean(d?.filed && d.netPay != null); + const payday = form?.payday ?? d?.payday ?? defaultPayday(year, m); + const due = payday <= asOf; + const declaredNet = filed ? Number(d?.netPay) : null; + const inRange = firstMonth != null && m >= firstMonth && m <= lastMonth; + const estimated = + !filed && estimateUnfiled && expected != null && inRange && due; + const owed = declaredNet ?? (estimated ? (expected as number) : 0); + // 沒申報、也不估計,而且不在任職期間的月份就不列出來(避免一整排 0) + if (!filed && !estimated && !inRange) continue; + months.push({ + month: m, + formStatus: form + ? monthStatus(form.simpanyFormId, form.filedCount, form.isSettled) + : "not_synced", + filed, + declaredNet, + estimated, + owed: round2(owed), + allocatedPaid: 0, + outstanding: round2(owed), + due, + payday, + }); + } + + const payments = allocatePayments(months, p.payments); + + const sum = (xs: number[]) => round2(xs.reduce((s, x) => s + x, 0)); + const emp: ReconEmployee = { + key: p.key, + employeeId: p.employeeId, + simpanyEmployeeId: p.simpanyEmployeeId, + name: p.name, + isCompanyOwner: p.isCompanyOwner, + expectedMonthlyNet: expected, + expectedSource, + totalDeclaredNet: sum(months.map((r) => r.declaredNet ?? 0)), + totalEstimatedNet: sum(months.filter((r) => r.estimated).map((r) => r.owed)), + totalPaid: sum(payments.map((x) => x.amount)), + arrears: sum(months.filter((r) => r.due && !r.estimated).map((r) => r.outstanding)), + estimatedArrears: sum(months.filter((r) => r.estimated).map((r) => r.outstanding)), + notYetDue: sum(months.filter((r) => !r.due).map((r) => r.outstanding)), + credit: sum(payments.map((x) => x.unapplied)), + months, + payments, + }; + // 沒有申報、沒估計、也沒有任何付款的員工不列(例如名冊上的外包) + if (emp.months.length === 0 && emp.payments.length === 0) continue; + result.push(emp); + } + result.sort( + (a, b) => + b.arrears + b.estimatedArrears - (a.arrears + a.estimatedArrears) || + a.name.localeCompare(b.name, "zh-Hant"), + ); + + const total = (f: (e: ReconEmployee) => number) => + round2(result.reduce((s, e) => s + f(e), 0)); + return { + year, + throughMonth, + asOf, + paidFrom, + paidTo, + lastSyncedAt, + months: monthsGrid, + employees: result, + unallocatedPayments, + totals: { + declaredNet: total((e) => e.totalDeclaredNet), + estimatedNet: total((e) => e.totalEstimatedNet), + paid: total((e) => e.totalPaid), + arrears: total((e) => e.arrears), + estimatedArrears: total((e) => e.estimatedArrears), + notYetDue: total((e) => e.notYetDue), + credit: total((e) => e.credit), + unallocated: round2(unallocatedPayments.reduce((s, x) => s + x.amount, 0)), + }, + }; +} From a24bf8f783a117256dc819fa612f6a630c1d4d5b Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 28 Sep 2026 18:51:17 +0800 Subject: [PATCH 2/3] =?UTF-8?q?[refactor]=20=E6=B8=85=20Sonar=EF=BC=9A?= =?UTF-8?q?=E6=8B=86=20salary=5Farrears=20=E5=B0=8D=E5=B8=B3=E5=87=BD?= =?UTF-8?q?=E5=BC=8F=EF=BC=88=E8=AA=8D=E7=9F=A5=E8=A4=87=E9=9B=9C=E5=BA=A6?= =?UTF-8?q?=EF=BC=89=E7=AD=89=205=20=E6=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/mcp/tools-simpany.ts | 3 +- src/lib/simpany-salary.ts | 618 ++++++++++++++++++++++------------- 2 files changed, 387 insertions(+), 234 deletions(-) diff --git a/src/lib/mcp/tools-simpany.ts b/src/lib/mcp/tools-simpany.ts index 8521fdd..043275f 100644 --- a/src/lib/mcp/tools-simpany.ts +++ b/src/lib/mcp/tools-simpany.ts @@ -505,12 +505,13 @@ export const simpanyTools: Record = { const month = monthArg(args, "month"); if (month === 0) throw new Error('"month" must be 1-12.'); const res = await listSalaryDeclarationsLive(orgId, year, month); + const monthSuffix = month ? `-${String(month).padStart(2, "0")}` : ""; await auditIntegrationCall( ctx, orgId, "simpany", "read", - `salary declarations ${year}${month ? `-${String(month).padStart(2, "0")}` : ""}`, + `salary declarations ${year}${monthSuffix}`, ); return res; }, diff --git a/src/lib/simpany-salary.ts b/src/lib/simpany-salary.ts index b9787ef..b208756 100644 --- a/src/lib/simpany-salary.ts +++ b/src/lib/simpany-salary.ts @@ -546,6 +546,7 @@ export function allocatePayments(months: ReconMonth[], input: ReconPayment[]): R } type DeclRow = typeof simpanySalaryDeclarations.$inferSelect; +type FormRow = typeof simpanySalaryForms.$inferSelect; type Person = { key: string; @@ -559,34 +560,50 @@ type Person = { payments: ReconPayment[]; }; -/** - * 每位員工:申報的應發(實際發薪)vs 本系統記錄的已發,按月列出欠多少。 - * - * 已發 = (1) payslips(有 paid_transaction_id 的以那筆交易為準;沒有交易、但所屬薪資批次 - * status = paid 的以 net_pay 計)+ (2) 薪資費用分類的支出交易,對象是這位員工 - * (settle_employee_id = 員工,或對象 party 名稱 = 員工姓名)。 - * 分配:payslip 有明確期別 → 先補那個月;其餘(含超付的部分)依日期先進先出補最舊的欠款; - * 再有剩就是 credit。薪資費用交易對不到任何員工的 → unallocatedPayments 讓人指派。 - */ -export async function salaryReconciliation( - orgId: string, - opts: ReconOptions, -): Promise { - const db = getDb(); +type ReconWindow = { + year: number; + throughMonth: number; + paidFrom: string; + paidTo: string; + asOf: string; + estimateUnfiled: boolean; +}; + +function defaultThroughMonth(year: number, thisYear: number, thisMonth: number): number { + if (year === thisYear) return thisMonth; + if (year > thisYear) return 0; + return 12; +} + +/** 兩個 YYYY-MM-DD 取較早的(字串比較,所以不能用 Math.min)。 */ +function earlierDate(a: string, b: string): string { + if (a < b) return a; + return b; +} + +/** 對帳的期間:對到哪個月、付款日期範圍、以哪天為準判斷到期。 */ +function reconWindow(opts: ReconOptions, today: string): ReconWindow { const { year } = opts; - const today = taipeiDate(); const thisYear = Number(today.slice(0, 4)); const thisMonth = Number(today.slice(5, 7)); - let defaultThrough = 12; - if (year === thisYear) defaultThrough = thisMonth; - else if (year > thisYear) defaultThrough = 0; + const defaultThrough = defaultThroughMonth(year, thisYear, thisMonth); const throughMonth = Math.max(0, Math.min(12, opts.throughMonth ?? defaultThrough)); const paidFrom = opts.paidFrom ?? `${year}-01-01`; const paidTo = opts.paidTo ?? (year < thisYear ? `${year + 1}-01-31` : today); - const asOf = paidTo < today ? paidTo : today; - const estimateUnfiled = opts.estimateUnfiled ?? true; + return { + year, + throughMonth, + paidFrom, + paidTo, + asOf: earlierDate(paidTo, today), + estimateUnfiled: opts.estimateUnfiled ?? true, + }; +} - // ---- load ---- +/** 讀對帳需要的所有資料(只讀本系統的表)。 */ +async function loadReconData(orgId: string, win: ReconWindow) { + const db = getDb(); + const { year, paidFrom, paidTo } = win; const [formRows, declRows, empRows, salaryCats] = await Promise.all([ db .select() @@ -645,8 +662,6 @@ export async function salaryReconciliation( isNull(payslips.deletedAt), ), ); - const slipByTxn = new Map(); - for (const s of slipRows) if (s.paidTransactionId != null) slipByTxn.set(s.paidTransactionId, s); const thisYearSlipTxnIds = slipRows .filter((s) => s.periodYear === year && s.paidTransactionId != null) .map((s) => s.paidTransactionId as number); @@ -691,119 +706,170 @@ export async function salaryReconciliation( .orderBy(transactions.txnDate, transactions.id) : []; - // ---- people ---- - const people = new Map(); - const nameToKey = new Map(); - const empById = new Map(empRows.map((e) => [e.id, e])); - const personForEmployee = (id: number): Person => { + return { formRows, declRows, empRows, slipRows, txnRows }; +} + +type ReconData = Awaited>; +type EmpRow = ReconData["empRows"][number]; +type SlipRow = ReconData["slipRows"][number]; +type TxnRow = ReconData["txnRows"][number]; + +/** 對帳的「人」:本系統員工(e:id)或只在 Simpany 申報裡出現的人(s:simpanyId)。 */ +class PeopleRegistry { + readonly people = new Map(); + /** 姓名(trim 後)→ person key,用來把 party 名稱對到人。 */ + readonly nameToKey = new Map(); + private readonly empById: Map; + + constructor(empRows: EmpRow[]) { + this.empById = new Map(empRows.map((e) => [e.id, e])); + } + + forEmployee(id: number): Person { const key = `e:${id}`; - let p = people.get(key); - if (!p) { - const e = empById.get(id); - p = { - key, - employeeId: id, - simpanyEmployeeId: null, - name: e?.name ?? `#${id}`, - isCompanyOwner: false, - startDate: e?.startDate ?? null, - endDate: e?.endDate ?? null, - decl: new Map(), - payments: [], - }; - people.set(key, p); - if (e) nameToKey.set(e.name.trim(), key); - } + const existing = this.people.get(key); + if (existing) return existing; + const e = this.empById.get(id); + const p: Person = { + key, + employeeId: id, + simpanyEmployeeId: null, + name: e?.name ?? `#${id}`, + isCompanyOwner: false, + startDate: e?.startDate ?? null, + endDate: e?.endDate ?? null, + decl: new Map(), + payments: [], + }; + this.people.set(key, p); + if (e) this.nameToKey.set(e.name.trim(), key); return p; - }; + } + + forSimpanyEmployee(d: DeclRow): Person { + const key = `s:${d.simpanyEmployeeId}`; + const p = this.people.get(key) ?? { + key, + employeeId: null, + simpanyEmployeeId: d.simpanyEmployeeId, + name: d.employeeName, + isCompanyOwner: false, + startDate: null, + endDate: null, + decl: new Map(), + payments: [], + }; + this.people.set(key, p); + return p; + } + + byKey(key: string): Person { + if (key.startsWith("e:")) return this.forEmployee(Number(key.slice(2))); + const p = this.people.get(key); + if (!p) throw new Error(`unknown person ${key}`); + return p; + } + + /** 交易算給誰:payslip 的員工 > settle_employee_id > party 名稱對到的人;都沒有 = null。 */ + forTransaction(t: TxnRow, slip: SlipRow | undefined): Person | null { + if (slip) return this.forEmployee(slip.employeeId); + if (t.settleEmployeeId != null) return this.forEmployee(t.settleEmployeeId); + if (t.partyName && this.nameToKey.has(t.partyName.trim())) { + return this.byKey(this.nameToKey.get(t.partyName.trim()) as string); + } + return null; + } - // 同步之後才建的員工:這裡再用姓名補綁一次(重名不綁)。 + setNameIfAbsent(name: string, key: string): void { + if (!this.nameToKey.has(name)) this.nameToKey.set(name, key); + } +} + +/** 申報明細掛到人身上;同步之後才建的員工,這裡再用姓名補綁一次(重名不綁)。 */ +function attachDeclarations(reg: PeopleRegistry, declRows: DeclRow[], empRows: EmpRow[]): void { const empNameToId = new Map(); for (const e of empRows) { const k = e.name.trim(); empNameToId.set(k, empNameToId.has(k) ? null : e.id); } for (const d of declRows) { - let p: Person; const employeeId = d.employeeId ?? empNameToId.get(d.employeeName.trim()) ?? null; - if (employeeId != null) { - p = personForEmployee(employeeId); - } else { - const key = `s:${d.simpanyEmployeeId}`; - p = people.get(key) ?? { - key, - employeeId: null, - simpanyEmployeeId: d.simpanyEmployeeId, - name: d.employeeName, - isCompanyOwner: false, - startDate: null, - endDate: null, - decl: new Map(), - payments: [], - }; - people.set(key, p); - } + const p = employeeId == null ? reg.forSimpanyEmployee(d) : reg.forEmployee(employeeId); p.simpanyEmployeeId ??= d.simpanyEmployeeId; if (d.isCompanyOwner) p.isCompanyOwner = true; p.decl.set(d.month, d); - if (!nameToKey.has(d.employeeName.trim())) nameToKey.set(d.employeeName.trim(), p.key); + reg.setNameIfAbsent(d.employeeName.trim(), p.key); } // 本系統員工的姓名也能對上 party 名稱(即使他沒有申報) - for (const e of empRows) { - const k = e.name.trim(); - if (!nameToKey.has(k)) nameToKey.set(k, `e:${e.id}`); - } - const personByKey = (key: string): Person => { - if (key.startsWith("e:")) return personForEmployee(Number(key.slice(2))); - const p = people.get(key); - if (!p) throw new Error(`unknown person ${key}`); - return p; + for (const e of empRows) reg.setNameIfAbsent(e.name.trim(), `e:${e.id}`); +} + +/** 正的有限金額;否則 null(該筆不列入)。 */ +function positiveAmount(raw: unknown): number | null { + const amount = Number(raw); + return Number.isFinite(amount) && amount > 0 ? amount : null; +} + +function unallocatedPayment(t: TxnRow, amount: number): UnallocatedPayment { + return { + transactionId: t.id, + date: t.txnDate, + amount: round2(amount), + currency: t.amountTwd == null ? t.currency : "TWD", + description: t.description, + partyName: t.partyName, + accountName: t.accountName, + }; +} + +function transactionPayment(t: TxnRow, slip: SlipRow | undefined, amount: number): ReconPayment { + return { + source: "transaction", + transactionId: t.id, + payslipId: slip?.id ?? null, + date: t.txnDate, + amount: round2(amount), + periodMonth: slip ? slip.periodMonth : null, + accountName: t.accountName, + description: t.description, + allocations: [], + unapplied: 0, }; +} + +/** + * 薪資費用交易(與本年度 payslip 對到的交易)掛到人身上,回傳對不到任何人的交易。 + * 屬於別年 payslip 的交易跳過。 + */ +function attachTransactionPayments( + reg: PeopleRegistry, + txnRows: TxnRow[], + slipRows: SlipRow[], + year: number, +): UnallocatedPayment[] { + const slipByTxn = new Map(); + for (const s of slipRows) if (s.paidTransactionId != null) slipByTxn.set(s.paidTransactionId, s); - // ---- payments ---- - const unallocatedPayments: UnallocatedPayment[] = []; + const unallocated: UnallocatedPayment[] = []; for (const t of txnRows) { - const amount = Number(t.amountTwd ?? t.amount); - if (!Number.isFinite(amount) || amount <= 0) continue; + const amount = positiveAmount(t.amountTwd ?? t.amount); + if (amount == null) continue; const slip = slipByTxn.get(t.id); if (slip && slip.periodYear !== year) continue; // 別年的薪資 - let person: Person | null = null; - if (slip) person = personForEmployee(slip.employeeId); - else if (t.settleEmployeeId != null) person = personForEmployee(t.settleEmployeeId); - else if (t.partyName && nameToKey.has(t.partyName.trim())) { - person = personByKey(nameToKey.get(t.partyName.trim()) as string); - } - if (!person) { - unallocatedPayments.push({ - transactionId: t.id, - date: t.txnDate, - amount: round2(amount), - currency: t.amountTwd != null ? "TWD" : t.currency, - description: t.description, - partyName: t.partyName, - accountName: t.accountName, - }); - continue; - } - person.payments.push({ - source: "transaction", - transactionId: t.id, - payslipId: slip?.id ?? null, - date: t.txnDate, - amount: round2(amount), - periodMonth: slip ? slip.periodMonth : null, - accountName: t.accountName, - description: t.description, - allocations: [], - unapplied: 0, - }); + const person = reg.forTransaction(t, slip); + if (person) person.payments.push(transactionPayment(t, slip, amount)); + else unallocated.push(unallocatedPayment(t, amount)); } - // 沒有交易、但批次已標為 paid 的 payslip + return unallocated; +} + +/** 沒有交易、但批次已標為 paid 的 payslip,以 net_pay 當作已發。 */ +function attachPaidPayslips(reg: PeopleRegistry, slipRows: SlipRow[], year: number): void { for (const s of slipRows) { if (s.periodYear !== year || s.paidTransactionId != null || s.runStatus !== "paid") continue; - const amount = Number(s.netPay); - if (!Number.isFinite(amount) || amount <= 0) continue; - personForEmployee(s.employeeId).payments.push({ + const amount = positiveAmount(s.netPay); + if (amount == null) continue; + reg.forEmployee(s.employeeId).payments.push({ source: "payslip", transactionId: null, payslipId: s.id, @@ -816,147 +882,233 @@ export async function salaryReconciliation( unapplied: 0, }); } +} - // ---- months grid ---- - const formByMonth = new Map(formRows.map((f) => [f.month, f])); - const monthsGrid = Array.from({ length: 12 }, (_, i) => { +function formStatusOf(form: FormRow | undefined): SalaryMonthStatus { + return form ? monthStatus(form.simpanyFormId, form.filedCount, form.isSettled) : "not_synced"; +} + +function buildMonthsGrid(formByMonth: Map): SalaryReconciliation["months"] { + return Array.from({ length: 12 }, (_, i) => { const f = formByMonth.get(i + 1); return { month: i + 1, - status: f ? monthStatus(f.simpanyFormId, f.filedCount, f.isSettled) : ("not_synced" as const), + status: formStatusOf(f), payday: f?.payday ?? null, employeeCount: f?.employeeCount ?? 0, filedCount: f?.filedCount ?? 0, }; }); - const lastSyncedAt = formRows.reduce( +} + +function latestSyncedAt(formRows: FormRow[]): string | null { + return formRows.reduce( (acc, f) => (acc == null || f.syncedAt > acc ? f.syncedAt : acc), null, ); +} - // ---- per person ---- - const result: ReconEmployee[] = []; - for (const p of people.values()) { - const filedMonths = [...p.decl.values()] - .filter((d) => d.filed && d.netPay != null) - .sort((a, b) => a.month - b.month); - - // 預估每月實發 - let expected: number | null = null; - let expectedSource: ReconEmployee["expectedSource"] = null; - const override = opts.expectedMonthlyNet?.[p.name]; - if (override != null && Number.isFinite(override)) { - expected = override; - expectedSource = "override"; - } else { - const within = filedMonths.filter((d) => d.month <= Math.max(throughMonth, 1)); - const latest = (within.length ? within : filedMonths).at(-1); - if (latest) { - const net = Number(latest.netPay); - const bonus = Number(latest.bonus ?? 0); - expected = bonus > 0 && net - bonus > 0 ? net - bonus : net; - expectedSource = "latest_filed"; - } - } +type Expectation = Pick; - // 估計的範圍:到職月(或第一個有申報的月份)起,到離職月為止 - let firstMonth: number | null = filedMonths[0]?.month ?? null; - if (p.startDate) { - const sy = Number(p.startDate.slice(0, 4)); - const sm = Number(p.startDate.slice(5, 7)); - if (sy < year) firstMonth = 1; - else if (sy === year) firstMonth = sm; - else firstMonth = null; - } - let lastMonth = 12; - if (p.endDate) { - const ey = Number(p.endDate.slice(0, 4)); - if (ey < year) lastMonth = 0; - else if (ey === year) lastMonth = Number(p.endDate.slice(5, 7)); - } +/** 預估每月實發:有覆寫用覆寫,否則用對帳範圍內(沒有就全年)最後一個申報月扣掉獎金。 */ +function expectedMonthly( + name: string, + filedMonths: DeclRow[], + throughMonth: number, + overrides: ReconOptions["expectedMonthlyNet"], +): Expectation { + const override = overrides?.[name]; + if (override != null && Number.isFinite(override)) { + return { expectedMonthlyNet: override, expectedSource: "override" }; + } + const within = filedMonths.filter((d) => d.month <= Math.max(throughMonth, 1)); + const latest = (within.length ? within : filedMonths).at(-1); + if (!latest) return { expectedMonthlyNet: null, expectedSource: null }; + const net = Number(latest.netPay); + const bonus = Number(latest.bonus ?? 0); + return { + expectedMonthlyNet: bonus > 0 && net - bonus > 0 ? net - bonus : net, + expectedSource: "latest_filed", + }; +} - const months: ReconMonth[] = []; - for (let m = 1; m <= throughMonth; m++) { - const d = p.decl.get(m); - const form = formByMonth.get(m); - const filed = Boolean(d?.filed && d.netPay != null); - const payday = form?.payday ?? d?.payday ?? defaultPayday(year, m); - const due = payday <= asOf; - const declaredNet = filed ? Number(d?.netPay) : null; - const inRange = firstMonth != null && m >= firstMonth && m <= lastMonth; - const estimated = - !filed && estimateUnfiled && expected != null && inRange && due; - const owed = declaredNet ?? (estimated ? (expected as number) : 0); - // 沒申報、也不估計,而且不在任職期間的月份就不列出來(避免一整排 0) - if (!filed && !estimated && !inRange) continue; - months.push({ - month: m, - formStatus: form - ? monthStatus(form.simpanyFormId, form.filedCount, form.isSettled) - : "not_synced", - filed, - declaredNet, - estimated, - owed: round2(owed), - allocatedPaid: 0, - outstanding: round2(owed), - due, - payday, - }); - } +/** 估計範圍的起點:到職月(或第一個有申報的月份)。今年之後才到職 = null。 */ +function firstEmployedMonth(p: Person, filedMonths: DeclRow[], year: number): number | null { + if (!p.startDate) return filedMonths[0]?.month ?? null; + const sy = Number(p.startDate.slice(0, 4)); + if (sy < year) return 1; + if (sy === year) return Number(p.startDate.slice(5, 7)); + return null; +} - const payments = allocatePayments(months, p.payments); - - const sum = (xs: number[]) => round2(xs.reduce((s, x) => s + x, 0)); - const emp: ReconEmployee = { - key: p.key, - employeeId: p.employeeId, - simpanyEmployeeId: p.simpanyEmployeeId, - name: p.name, - isCompanyOwner: p.isCompanyOwner, - expectedMonthlyNet: expected, - expectedSource, - totalDeclaredNet: sum(months.map((r) => r.declaredNet ?? 0)), - totalEstimatedNet: sum(months.filter((r) => r.estimated).map((r) => r.owed)), - totalPaid: sum(payments.map((x) => x.amount)), - arrears: sum(months.filter((r) => r.due && !r.estimated).map((r) => r.outstanding)), - estimatedArrears: sum(months.filter((r) => r.estimated).map((r) => r.outstanding)), - notYetDue: sum(months.filter((r) => !r.due).map((r) => r.outstanding)), - credit: sum(payments.map((x) => x.unapplied)), - months, - payments, - }; - // 沒有申報、沒估計、也沒有任何付款的員工不列(例如名冊上的外包) - if (emp.months.length === 0 && emp.payments.length === 0) continue; - result.push(emp); +/** 估計範圍的終點:離職月。今年之前就離職 = 0。 */ +function lastEmployedMonth(p: Person, year: number): number { + if (!p.endDate) return 12; + const ey = Number(p.endDate.slice(0, 4)); + if (ey < year) return 0; + if (ey === year) return Number(p.endDate.slice(5, 7)); + return 12; +} + +type MonthCtx = { + win: ReconWindow; + formByMonth: Map; + expected: number | null; + firstMonth: number | null; + lastMonth: number; +}; + +/** 這個人這個月的應發;沒申報、也不估計、又不在任職期間的月份回傳 null(不列出)。 */ +function owedMonth(p: Person, m: number, ctx: MonthCtx): ReconMonth | null { + const { win, expected } = ctx; + const d = p.decl.get(m); + const form = ctx.formByMonth.get(m); + const filed = Boolean(d?.filed && d.netPay != null); + const payday = form?.payday ?? d?.payday ?? defaultPayday(win.year, m); + const due = payday <= win.asOf; + const declaredNet = filed ? Number(d?.netPay) : null; + const inRange = ctx.firstMonth != null && m >= ctx.firstMonth && m <= ctx.lastMonth; + const estimated = !filed && win.estimateUnfiled && expected != null && inRange && due; + // 沒申報、也不估計,而且不在任職期間的月份就不列出來(避免一整排 0) + if (!filed && !estimated && !inRange) return null; + const owed = declaredNet ?? (estimated ? (expected as number) : 0); + return { + month: m, + formStatus: formStatusOf(form), + filed, + declaredNet, + estimated, + owed: round2(owed), + allocatedPaid: 0, + outstanding: round2(owed), + due, + payday, + }; +} + +const sumRounded = (xs: number[]) => round2(xs.reduce((s, x) => s + x, 0)); + +function summarizeEmployee( + p: Person, + expectation: Expectation, + months: ReconMonth[], + payments: ReconPayment[], +): ReconEmployee { + const outstandingWhere = (pred: (r: ReconMonth) => boolean) => + sumRounded(months.filter(pred).map((r) => r.outstanding)); + return { + key: p.key, + employeeId: p.employeeId, + simpanyEmployeeId: p.simpanyEmployeeId, + name: p.name, + isCompanyOwner: p.isCompanyOwner, + ...expectation, + totalDeclaredNet: sumRounded(months.map((r) => r.declaredNet ?? 0)), + totalEstimatedNet: sumRounded(months.filter((r) => r.estimated).map((r) => r.owed)), + totalPaid: sumRounded(payments.map((x) => x.amount)), + arrears: outstandingWhere((r) => r.due && !r.estimated), + estimatedArrears: outstandingWhere((r) => r.estimated), + notYetDue: outstandingWhere((r) => !r.due), + credit: sumRounded(payments.map((x) => x.unapplied)), + months, + payments, + }; +} + +/** 一個人的逐月對帳;沒有申報、沒估計、也沒有任何付款的人(例如名冊上的外包)回傳 null。 */ +function reconcilePerson( + p: Person, + win: ReconWindow, + formByMonth: Map, + overrides: ReconOptions["expectedMonthlyNet"], +): ReconEmployee | null { + const filedMonths = [...p.decl.values()] + .filter((d) => d.filed && d.netPay != null) + .sort((a, b) => a.month - b.month); + const expectation = expectedMonthly(p.name, filedMonths, win.throughMonth, overrides); + const ctx: MonthCtx = { + win, + formByMonth, + expected: expectation.expectedMonthlyNet, + firstMonth: firstEmployedMonth(p, filedMonths, win.year), + lastMonth: lastEmployedMonth(p, win.year), + }; + const months: ReconMonth[] = []; + for (let m = 1; m <= win.throughMonth; m++) { + const row = owedMonth(p, m, ctx); + if (row) months.push(row); } - result.sort( - (a, b) => - b.arrears + b.estimatedArrears - (a.arrears + a.estimatedArrears) || - a.name.localeCompare(b.name, "zh-Hant"), + const payments = allocatePayments(months, p.payments); + if (months.length === 0 && payments.length === 0) return null; + return summarizeEmployee(p, expectation, months, payments); +} + +/** 欠款(含估計)多的在前,同額依姓名。 */ +function byArrearsDesc(a: ReconEmployee, b: ReconEmployee): number { + return ( + b.arrears + b.estimatedArrears - (a.arrears + a.estimatedArrears) || + a.name.localeCompare(b.name, "zh-Hant") ); +} - const total = (f: (e: ReconEmployee) => number) => - round2(result.reduce((s, e) => s + f(e), 0)); +function reconTotals( + result: ReconEmployee[], + unallocatedPayments: UnallocatedPayment[], +): SalaryReconciliation["totals"] { + const total = (f: (e: ReconEmployee) => number) => round2(result.reduce((s, e) => s + f(e), 0)); return { - year, - throughMonth, - asOf, - paidFrom, - paidTo, - lastSyncedAt, - months: monthsGrid, + declaredNet: total((e) => e.totalDeclaredNet), + estimatedNet: total((e) => e.totalEstimatedNet), + paid: total((e) => e.totalPaid), + arrears: total((e) => e.arrears), + estimatedArrears: total((e) => e.estimatedArrears), + notYetDue: total((e) => e.notYetDue), + credit: total((e) => e.credit), + unallocated: round2(unallocatedPayments.reduce((s, x) => s + x.amount, 0)), + }; +} + +/** + * 每位員工:申報的應發(實際發薪)vs 本系統記錄的已發,按月列出欠多少。 + * + * 已發 = (1) payslips(有 paid_transaction_id 的以那筆交易為準;沒有交易、但所屬薪資批次 + * status = paid 的以 net_pay 計)+ (2) 薪資費用分類的支出交易,對象是這位員工 + * (settle_employee_id = 員工,或對象 party 名稱 = 員工姓名)。 + * 分配:payslip 有明確期別 → 先補那個月;其餘(含超付的部分)依日期先進先出補最舊的欠款; + * 再有剩就是 credit。薪資費用交易對不到任何員工的 → unallocatedPayments 讓人指派。 + */ +export async function salaryReconciliation( + orgId: string, + opts: ReconOptions, +): Promise { + const win = reconWindow(opts, taipeiDate()); + const { formRows, declRows, empRows, slipRows, txnRows } = await loadReconData(orgId, win); + + // ---- people & payments ---- + const reg = new PeopleRegistry(empRows); + attachDeclarations(reg, declRows, empRows); + const unallocatedPayments = attachTransactionPayments(reg, txnRows, slipRows, win.year); + attachPaidPayslips(reg, slipRows, win.year); + + // ---- per person ---- + const formByMonth = new Map(formRows.map((f) => [f.month, f])); + const result: ReconEmployee[] = []; + for (const p of reg.people.values()) { + const emp = reconcilePerson(p, win, formByMonth, opts.expectedMonthlyNet); + if (emp) result.push(emp); + } + result.sort(byArrearsDesc); + + return { + year: win.year, + throughMonth: win.throughMonth, + asOf: win.asOf, + paidFrom: win.paidFrom, + paidTo: win.paidTo, + lastSyncedAt: latestSyncedAt(formRows), + months: buildMonthsGrid(formByMonth), employees: result, unallocatedPayments, - totals: { - declaredNet: total((e) => e.totalDeclaredNet), - estimatedNet: total((e) => e.totalEstimatedNet), - paid: total((e) => e.totalPaid), - arrears: total((e) => e.arrears), - estimatedArrears: total((e) => e.estimatedArrears), - notYetDue: total((e) => e.notYetDue), - credit: total((e) => e.credit), - unallocated: round2(unallocatedPayments.reduce((s, x) => s + x.amount, 0)), - }, + totals: reconTotals(result, unallocatedPayments), }; } From af6ca88a1b3739c535dda5e8216e66aebe75d2bb Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 28 Sep 2026 18:54:42 +0800 Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20=E7=A7=BB=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E9=A4=98=E7=9A=84=E5=9E=8B=E5=88=A5=E6=96=B7=E8=A8=80=EF=BC=88?= =?UTF-8?q?Sonar=20S4325=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/simpany-salary.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/simpany-salary.ts b/src/lib/simpany-salary.ts index b208756..88b505a 100644 --- a/src/lib/simpany-salary.ts +++ b/src/lib/simpany-salary.ts @@ -971,7 +971,7 @@ function owedMonth(p: Person, m: number, ctx: MonthCtx): ReconMonth | null { const estimated = !filed && win.estimateUnfiled && expected != null && inRange && due; // 沒申報、也不估計,而且不在任職期間的月份就不列出來(避免一整排 0) if (!filed && !estimated && !inRange) return null; - const owed = declaredNet ?? (estimated ? (expected as number) : 0); + const owed = declaredNet ?? (estimated ? expected : 0); return { month: m, formStatus: formStatusOf(form),