diff --git a/src/db/queries.ts b/src/db/queries.ts index 8593396..463e018 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -920,13 +920,18 @@ export async function getProject(orgId: string, id: number) { return row ?? null; } -export async function listSubscriptions(orgId: string) { - const db = getDb(); +/** + * 訂閱的讀取路徑有三條(清單 / 單筆 / 請款看板),欄位與 join 一模一樣。 + * 收成一個 builder,往後加欄位只要改這裡,三邊不會漂移。 + */ +function subscriptionRows(db: ReturnType) { return db .select({ id: subscriptions.id, customerPartyId: subscriptions.customerPartyId, customerName: parties.name, + /** 開發票草稿要預填的統編 */ + customerTaxId: parties.taxId, projectId: subscriptions.projectId, projectName: projects.name, contractId: subscriptions.contractId, @@ -943,7 +948,11 @@ export async function listSubscriptions(orgId: string) { .from(subscriptions) .leftJoin(parties, eq(parties.id, subscriptions.customerPartyId)) .leftJoin(projects, eq(projects.id, subscriptions.projectId)) - .leftJoin(contracts, eq(contracts.id, subscriptions.contractId)) + .leftJoin(contracts, eq(contracts.id, subscriptions.contractId)); +} + +export async function listSubscriptions(orgId: string) { + return subscriptionRows(getDb()) .where(and(eq(subscriptions.organizationId, orgId), isNull(subscriptions.deletedAt))) .orderBy(desc(subscriptions.createdAt)); } @@ -995,26 +1004,7 @@ export async function listSubscriptionsByContract( } export async function getSubscription(orgId: string, id: number) { - const db = getDb(); - const [row] = await db - .select({ - id: subscriptions.id, - customerPartyId: subscriptions.customerPartyId, - customerName: parties.name, - projectId: subscriptions.projectId, - contractId: subscriptions.contractId, - contractTitle: contracts.title, - name: subscriptions.name, - amount: subscriptions.amount, - currency: subscriptions.currency, - intervalMonths: subscriptions.intervalMonths, - startDate: subscriptions.startDate, - endDate: subscriptions.endDate, - status: subscriptions.status, - }) - .from(subscriptions) - .leftJoin(parties, eq(parties.id, subscriptions.customerPartyId)) - .leftJoin(contracts, eq(contracts.id, subscriptions.contractId)) + const [row] = await subscriptionRows(getDb()) .where(and(eq(subscriptions.organizationId, orgId), eq(subscriptions.id, id), isNull(subscriptions.deletedAt))) .limit(1); return row ?? null; @@ -1635,29 +1625,9 @@ export async function listBillingBoard( .where(and(eq(billingItems.organizationId, orgId), isNull(billingItems.deletedAt))) .orderBy(billingItems.dueDate), billingItemPaidById(orgId), - db - .select({ - id: subscriptions.id, - name: subscriptions.name, - customerPartyId: subscriptions.customerPartyId, - customerName: parties.name, - customerTaxId: parties.taxId, - projectId: subscriptions.projectId, - projectName: projects.name, - contractId: subscriptions.contractId, - contractTitle: contracts.title, - amount: subscriptions.amount, - currency: subscriptions.currency, - intervalMonths: subscriptions.intervalMonths, - startDate: subscriptions.startDate, - endDate: subscriptions.endDate, - status: subscriptions.status, - }) - .from(subscriptions) - .leftJoin(parties, eq(parties.id, subscriptions.customerPartyId)) - .leftJoin(projects, eq(projects.id, subscriptions.projectId)) - .leftJoin(contracts, eq(contracts.id, subscriptions.contractId)) - .where(and(eq(subscriptions.organizationId, orgId), isNull(subscriptions.deletedAt))), + subscriptionRows(db).where( + and(eq(subscriptions.organizationId, orgId), isNull(subscriptions.deletedAt)), + ), subscriptionPaidByPeriodAll(orgId), subscriptionPeriodsAll(orgId), ]); diff --git a/src/lib/mcp/tools-client.ts b/src/lib/mcp/tools-client.ts index 0b485db..fc76339 100644 --- a/src/lib/mcp/tools-client.ts +++ b/src/lib/mcp/tools-client.ts @@ -60,7 +60,6 @@ const SUBSCRIPTION_ROW_PROPS = { organizationId: { type: ["string", "null"] }, customerPartyId: { type: "number" }, projectId: { type: ["number", "null"] }, - contractId: { type: ["number", "null"] }, name: { type: "string" }, amount: { type: "string", description: "Decimal as a string." }, currency: { type: "string", description: "3-letter code." }, @@ -69,6 +68,7 @@ const SUBSCRIPTION_ROW_PROPS = { endDate: { type: ["string", "null"], description: "YYYY-MM-DD." }, status: { type: "string", enum: [...SUB_STATUS] }, note: { type: ["string", "null"] }, + contractId: { type: ["number", "null"] }, createdAt: { type: "string" }, } as const; @@ -124,6 +124,55 @@ function checkEnum(v: string | undefined, allowed: readonly string[], field: str } } +/** + * 訂閱/合約共用的外鍵歸屬檢查。四支 create_/update_ 原本各自抄同一組 + * 「有帶才驗」的判斷;別的 org 的 id 一樣是合法的 FK 值,這裡是唯一的攔截點。 + */ +async function assertClientRefs( + db: ReturnType, + orgId: string, + refs: Readonly<{ customerPartyId?: number; projectId?: number; contractId?: number }>, +) { + if (refs.customerPartyId !== undefined) + await assertInOrg(db, parties, refs.customerPartyId, orgId, "Customer"); + if (refs.projectId !== undefined) + await assertInOrg(db, projects, refs.projectId, orgId, "Project"); + if (refs.contractId !== undefined) + await assertInOrg(db, contracts, refs.contractId, orgId, "Contract"); +} + +/** + * update_subscription 的欄位組裝:只把「有帶」的欄位寫進 patch。 + * + * 抽成獨立的純函式而不是留在 execute 裡:十幾個 if 疊在同一個函式本體, + * 認知複雜度會超過門檻(S3776)。判斷「有沒有帶」的邏輯收在 put 裡只寫一次。 + */ +function subscriptionPatch(args: Record): Record { + const patch: Record = {}; + const put = (key: string, provided: unknown, value: () => unknown) => { + if (provided !== undefined) patch[key] = value(); + }; + put("customerPartyId", optNumber(args, "customerPartyId"), () => + requireNumber(args, "customerPartyId"), + ); + put("projectId", optNumber(args, "projectId"), () => requireNumber(args, "projectId")); + // 明確傳 null = 解除綁定;完全沒帶這個 key = 不動它。optNumber 會把兩者都收斂成 + // undefined,分不開,所以 null 要自己先攔一次。 + if (args.contractId === null) patch.contractId = null; + else put("contractId", optNumber(args, "contractId"), () => requireNumber(args, "contractId")); + put("name", optString(args, "name"), () => requireString(args, "name")); + put("amount", optNumber(args, "amount"), () => requireAmount(args, "amount")); + put("currency", optString(args, "currency"), () => normalizeCurrency(args, "currency")); + put("intervalMonths", optNumber(args, "intervalMonths"), () => + requireNumber(args, "intervalMonths"), + ); + put("startDate", optString(args, "startDate"), () => requireDate(args, "startDate")); + put("endDate", optString(args, "endDate"), () => optString(args, "endDate")); + put("status", optString(args, "status"), () => optString(args, "status")); + put("note", optString(args, "note"), () => optString(args, "note")); + return patch; +} + export const clientTools: Record = { // ---- projects ---- create_project: { @@ -251,11 +300,9 @@ export const clientTools: Record = { const orgId = await resolveOrg(args, ctx); const db = getDb(); const customerPartyId = requireNumber(args, "customerPartyId"); - await assertInOrg(db, parties, customerPartyId, orgId, "Customer"); const projectId = optNumber(args, "projectId"); - if (projectId !== undefined) await assertInOrg(db, projects, projectId, orgId, "Project"); const contractId = optNumber(args, "contractId"); - if (contractId !== undefined) await assertInOrg(db, contracts, contractId, orgId, "Contract"); + await assertClientRefs(db, orgId, { customerPartyId, projectId, contractId }); checkEnum(optString(args, "status"), SUB_STATUS, "status"); const [row] = await db .insert(subscriptions) @@ -308,30 +355,13 @@ export const clientTools: Record = { const id = requireNumber(args, "id"); const orgId = await resolveOrg(args, ctx); const db = getDb(); - const customerPartyId = optNumber(args, "customerPartyId"); - if (customerPartyId !== undefined) await assertInOrg(db, parties, customerPartyId, orgId, "Customer"); - const projectId = optNumber(args, "projectId"); - if (projectId !== undefined) await assertInOrg(db, projects, projectId, orgId, "Project"); - // 解除綁定要能表達,所以明確傳 null 與「沒帶這個欄位」必須分得開 - // (optNumber 兩者都回 undefined)。 - const unlinkContract = args.contractId === null; - const contractId = optNumber(args, "contractId"); - if (contractId !== undefined) await assertInOrg(db, contracts, contractId, orgId, "Contract"); + await assertClientRefs(db, orgId, { + customerPartyId: optNumber(args, "customerPartyId"), + projectId: optNumber(args, "projectId"), + contractId: optNumber(args, "contractId"), + }); checkEnum(optString(args, "status"), SUB_STATUS, "status"); - const patch: Record = {}; - if (customerPartyId !== undefined) patch.customerPartyId = customerPartyId; - if (projectId !== undefined) patch.projectId = projectId; - if (unlinkContract) patch.contractId = null; - else if (contractId !== undefined) patch.contractId = contractId; - if (optString(args, "name") !== undefined) patch.name = requireString(args, "name"); - if (optNumber(args, "amount") !== undefined) patch.amount = requireAmount(args, "amount"); - if (optString(args, "currency") !== undefined) patch.currency = normalizeCurrency(args, "currency"); - if (optNumber(args, "intervalMonths") !== undefined) - patch.intervalMonths = requireNumber(args, "intervalMonths"); - if (optString(args, "startDate") !== undefined) patch.startDate = requireDate(args, "startDate"); - if (optString(args, "endDate") !== undefined) patch.endDate = optString(args, "endDate"); - if (optString(args, "status") !== undefined) patch.status = optString(args, "status"); - if (optString(args, "note") !== undefined) patch.note = optString(args, "note"); + const patch = subscriptionPatch(args); if (Object.keys(patch).length === 0) throw new Error("Nothing to update."); const [row] = await db .update(subscriptions) @@ -396,9 +426,8 @@ export const clientTools: Record = { const orgId = await resolveOrg(args, ctx); const db = getDb(); const customerPartyId = requireNumber(args, "customerPartyId"); - await assertInOrg(db, parties, customerPartyId, orgId, "Customer"); const projectId = optNumber(args, "projectId"); - if (projectId !== undefined) await assertInOrg(db, projects, projectId, orgId, "Project"); + await assertClientRefs(db, orgId, { customerPartyId, projectId }); checkEnum(optString(args, "status"), CONTRACT_STATUS, "status"); const [row] = await db .insert(contracts) @@ -452,9 +481,8 @@ export const clientTools: Record = { const orgId = await resolveOrg(args, ctx); const db = getDb(); const customerPartyId = optNumber(args, "customerPartyId"); - if (customerPartyId !== undefined) await assertInOrg(db, parties, customerPartyId, orgId, "Customer"); const projectId = optNumber(args, "projectId"); - if (projectId !== undefined) await assertInOrg(db, projects, projectId, orgId, "Project"); + await assertClientRefs(db, orgId, { customerPartyId, projectId }); checkEnum(optString(args, "status"), CONTRACT_STATUS, "status"); const patch: Record = {}; if (customerPartyId !== undefined) patch.customerPartyId = customerPartyId;