Skip to content

[feature] 交易對象別名與合併 - #87

Merged
YJack0000 merged 2 commits into
mainfrom
feat/party-aliases-merge
Oct 12, 2026
Merged

YJack0000 merged 2 commits into
mainfrom
feat/party-aliases-merge

Conversation

@YJack0000

@YJack0000 YJack0000 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

問題

  1. Wise 匯入只用「名稱完全相同」找交易對象(src/lib/wise-sync.ts 的 resolveParties),查無就自動新建。
  2. 那個查詢沒有過濾 deleted_at IS NULL:被軟刪除的交易對象還是會吃到新匯入的交易,結果那些交易掛在看不到的對象底下,畫面上等於消失。
  3. 同一家廠商被拆成很多個對象:信用卡帳單的商家字串每筆都不同(Facebk *26qx2aj6e4、Grab* A-9p4ckuhgwjnpav),或同一家有多種寫法(ElevenLabs / Elevenlabs.io、GitHub / Github, Inc.)。之前沒有辦法把它們併起來,也沒有辦法讓之後的匯入自動歸到同一個。

設計

別名 parties.aliases(migration 0033)

  • text[] NOT NULL DEFAULT '{}'。一般別名 = 不分大小寫、名稱完全相同才算;以 * 結尾 = 前綴比對(Facebk *、Grab*)。
  • 比對是一個純函式 matchParty(src/lib/party-aliases.ts):名稱完全相同 > 別名完全相同 > 最長的前綴別名,同分取 id 最小的。前後空白不算;只有 * 的別名會被丟掉(會吃掉所有名稱)。
  • 撈候選的查詢(src/lib/party-resolve.ts)一律帶 organization_id 與 deleted_at IS NULL,只撈「名稱(lower/btrim)在清單裡」或「有設別名」的列,然後交給 matchParty。
  • 同一批裡只差大小寫的新名稱只建一筆。

共用同一套比對的地方

grep 過所有 insert(parties) 與 parties.name 的名稱查詢,換成共用函式:

  • Wise 同步(wise-sync.ts,原本的 resolveParties 刪掉)
  • MCP create_transaction(partyName)與 bulk_create_transactions(tools-transactions.ts,原本的 getOrCreateParty / resolvePartyNames 也有同樣「沒過濾已刪除」的問題)
  • 網頁表單的客戶欄 / 交易對象欄(mutations.ts 的 getOrCreateParty / resolvePartyName,同樣沒過濾已刪除)
  • Simpany 發票同步 matchPartyId:先比統編(唯一才算),名稱那步改用 matchParty(原本只認唯一的完全相同名稱)

simpany-issue / simpany-salary / vendor-billing 用到 parties.name 的地方都是「依 id 取名稱顯示」,不是名稱比對,沒動。

合併 mergeParties(orgId, targetId, sourceIds[])

  • src/lib/party-merge.ts:驗證與編排(純邏輯,DB 由 deps 注入);src/lib/party-merge-deps.ts:Neon 實作。
  • 驗證:來源至少一個、id 都是正整數、目標不能在來源裡;目標與來源都必須同組織、未刪除(查詢帶條件,planPartyMerge 再比一次 row 的組織,第二道防線)。
  • 單一 transaction(neon-http 沒有互動式 transaction,用 db.batch)。依序:
    1. 守門:目標 + 來源在同組織且未刪除的筆數不對就除以零,整批 rollback(驗證是先讀的,讀跟寫之間有空檔)
    2. 改指目標,每句都帶 organization_id,含已軟刪除的列:transactions.party_id、projects.client_party_id、subscriptions.customer_party_id、contracts.customer_party_id、billing_items.customer_party_id、invoices.party_id、vendor_billing_accounts.party_id、recurring_expenses.party_id
    3. vendor_billing_accounts.meta.matchPartyIds(jsonb 裡的 party id,不是 FK 但一樣會留著指向已刪除對象):來源換成目標、去重,只動這個鍵
    4. 目標的 aliases += 每個來源的名稱與別名(不分大小寫去重、跳過跟目標名稱相同的)
    5. 來源軟刪除
  • FK 清單是對過 migrations 裡所有 REFERENCES parties(id) 的:links(0007)與 receivables(0003)已在 0008 / 0010 刪表;invoice_drafts.payload 是 2 小時過期的 jsonb 草稿,沒動。
  • 頭像不動(不搬 R2 物件)。
  • 回傳每張表搬了幾列。

MCP

  • 新工具 merge_parties:{ targetId: number, sourceIds: number[], organizationId? } → { id, name, mergedIds, mergedNames, aliases, moved: { transactions, projects, subscriptions, contracts, billingItems, invoices, vendorBillingAccounts, vendorBillingMatchParties, recurringExpenses } }。標 destructiveHint: true(沒有 unmerge),描述裡要求先跟使用者確認。activity log 走 handler 的集中紀錄(update / party / 目標 id)。
  • create_party / update_party 可帶 aliases: string[](update 是整串取代、[] 清空);list_parties / get_party 回傳 aliases。
  • 權限跟現有交易對象工具一樣(create_party / delete_party 都沒有 owner/admin 限制),沒另加。
  • SERVER_VERSION 1.11.0 → 1.12.0;docs/mcp.md 補上別名與合併。

網頁

  • 交易對象編輯(與新增)視窗多一個「別名」textarea,一行一個,下面一行提示「結尾加 * 代表開頭相同就算」。
  • updateParty 只在表單有帶 aliases 欄位時才寫,避免舊表單一存就清空。
  • 合併目前只有 MCP,沒有做 UI。

Migration 0033 ⚠️

migrations/0033_party_aliases.sql:ALTER TABLE parties ADD COLUMN aliases text[] NOT NULL DEFAULT '{}'。Forward-only、additive,Run AFTER 0032。
必須在部署前跑:listParties / getParty / 所有名稱比對都會 select 這一欄,欄位不存在時交易對象頁、MCP 與 Wise 同步都會壞。
沒有加索引:比對要做前綴 + 不分大小寫,GIN(text[]) 幫不上忙;候選查詢走既有的 idx_parties_org,每個組織頂多數百筆。docs/deployment.md 已補說明。

測試

  • bun run test:220 pass / 0 fail(新增 party-aliases.test.ts、party-merge.test.ts、party-merge-deps.test.ts)
    • 比對:優先順序、不分大小寫、前綴(含最長前綴、同分取最小 id)、已刪除的由呼叫端排除、只有 * 的別名被忽略
    • 合併(假 deps,同 party-avatar-store 的測法):別名合併與去重、matchPartyIds 只改同組織、目標在來源裡 / 空清單 / 非整數 / 已刪除 / 別組織都擋、查詢租戶條件被改壞時第二道還擋得住
    • SQL 組裝:8 張表都有、每一句寫入都帶 organization_id、目標 / 來源都要求 deleted_at IS NULL
  • tsc --noEmit、bun run lint、bun run build 全過
  • 拋棄式 Postgres(本機 initdb,非任何正式 DB):0000 → 0033 依序全部套用無錯誤;再用 drizzle bun-sql 跑同一組合併 / 比對 SQL 驗證:
    • 已軟刪除的「Notion」不再吃到新匯入,新建一筆;facebk *zzz 經前綴別名 Facebk * 對到既有對象;New Co / NEW CO 同批只建一筆
    • 合併:orgA 的 3 筆交易與固定支出改指目標、orgB 同名對象與交易不動;meta.matchPartyIds 換成目標且 billingAccountId 保留;來源軟刪除、目標別名正確
    • 拿過期的計畫(來源已刪除)再跑一次:守門語句 division by zero 中止,目標別名沒被改

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_internal

0 open issues on this PR.

@YJack0000

Copy link
Copy Markdown
Contributor Author

@yui AI check 沒問題,可以 review/merge

@YJack0000
YJack0000 merged commit 4dfd04a into main Oct 12, 2026
1 check passed
@YJack0000
YJack0000 deleted the feat/party-aliases-merge branch October 12, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants