diff --git a/docs/mcp.md b/docs/mcp.md index 1427a95..1968d56 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -179,7 +179,10 @@ the 待開發票 list. **Client ops** — projects/subscriptions/contracts each have `list_*` + `create_*`/`update_*`/`delete_*` (`list_customers` is a convenience filter of `list_parties`). A contract carries a `fileUrl` — the link to the signed contract -file (e.g. a Google Drive link). Contract status is stored, not derived: linking +file (e.g. a Google Drive link). A subscription carries an optional `contractId` +(`create_subscription` / `update_subscription`; pass `null` on update to unlink) +so a recurring fee quoted in a contract stays traceable — `list_subscriptions` +returns it as `contractId` / `contractTitle`. Contract status is stored, not derived: linking an income transaction that fills the contract does **not** move it to `completed` on its own. So `create_transaction` / `bulk_create_transactions` / `update_transaction` return `contractProgress` (amount / received / remaining / diff --git a/migrations/0022_subscription_contract.sql b/migrations/0022_subscription_contract.sql new file mode 100644 index 0000000..37b3c95 --- /dev/null +++ b/migrations/0022_subscription_contract.sql @@ -0,0 +1,18 @@ +-- 0022: subscriptions.contract_id(訂閱可綁定合約)。 +-- +-- 為什麼需要:訂閱(週期性月費)與合約是兩張各自獨立的表,訂閱只能掛專案 +-- (project_id),掛不到合約。但週期性費用多半是合約裡談好的條件 —— 合約寫「每月 +-- 維運費 30,000」,系統裡卻只有一張孤兒訂閱,回頭要問「這筆月費是哪張合約來的」 +-- 沒有任何欄位答得出來,只能靠方案名稱猜。請款看板上的訂閱期別也因此永遠是 +-- contract_id = NULL,跟 billing_items 那側的合約欄位對不起來。 +-- +-- 一次性分期走 billing_items.contract_id,週期性月費走本欄位,兩邊語意一致。 +-- 刻意維持選填:既有訂閱不見得對得到合約,也有純口頭約定的月費。 +-- +-- Forward-only, additive. 既有資料一律 NULL,不需要 backfill。 + +ALTER TABLE subscriptions + ADD COLUMN contract_id bigint REFERENCES contracts(id); + +-- 合約詳情頁要反查「這張合約綁了哪些訂閱」,比照 idx_billing_item_contract。 +CREATE INDEX idx_subscriptions_contract ON subscriptions(contract_id); diff --git a/src/app/dashboard/contracts/contract-subscriptions.tsx b/src/app/dashboard/contracts/contract-subscriptions.tsx new file mode 100644 index 0000000..002eaa8 --- /dev/null +++ b/src/app/dashboard/contracts/contract-subscriptions.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { Badge } from "@/components/ui/badge"; +import { formatCurrency } from "@/lib/format"; +import type { ContractSubscription } from "@/db/queries"; + +const statusVariant: Record = { + active: "default", + paused: "secondary", + ended: "outline", +}; + +function intervalLabel( + t: Awaited>>, + months: number, +) { + if (months === 1) return t("linkedSubscriptions.interval.monthly"); + if (months === 3) return t("linkedSubscriptions.interval.quarterly"); + if (months === 12) return t("linkedSubscriptions.interval.yearly"); + return t("linkedSubscriptions.interval.everyNMonths", { months }); +} + +/** + * 合約編輯視窗裡的「綁定的訂閱」區塊:這張合約談好的週期性費用。 + * 沒有綁任何訂閱就整塊不出現 —— 大多數合約都沒有,留一行空狀態只是噪音。 + */ +export async function ContractSubscriptions({ + rows, +}: Readonly<{ rows: ContractSubscription[] }>) { + if (rows.length === 0) return null; + const t = await getTranslations("contracts"); + const statusLabels: Record = { + active: t("linkedSubscriptions.status.active"), + paused: t("linkedSubscriptions.status.paused"), + ended: t("linkedSubscriptions.status.ended"), + }; + return ( +
+
{t("linkedSubscriptions.heading")}
+
    + {rows.map((s) => ( +
  • +
    +
    {s.name}
    +
    + {formatCurrency(s.amount, s.currency)} · {intervalLabel(t, s.intervalMonths)} +
    +
    + + {statusLabels[s.status] ?? s.status} + +
  • + ))} +
+ + {t("linkedSubscriptions.manageLink")} + +
+ ); +} diff --git a/src/app/dashboard/contracts/page.tsx b/src/app/dashboard/contracts/page.tsx index 4abe279..d5f975d 100644 --- a/src/app/dashboard/contracts/page.tsx +++ b/src/app/dashboard/contracts/page.tsx @@ -21,6 +21,7 @@ import { listContracts, listParties, listProjects, + listSubscriptionsByContract, } from "@/db/queries"; import { formatCurrency, formatDate } from "@/lib/format"; import { cn } from "@/lib/utils"; @@ -28,6 +29,7 @@ import { NewContractDialog } from "./new-contract-dialog"; import { ContractFileLink } from "./contract-file-link"; import { requireOrg } from "@/lib/session"; import { ContractBillingItems } from "./contract-billing-items"; +import { ContractSubscriptions } from "./contract-subscriptions"; export const dynamic = "force-dynamic"; @@ -101,11 +103,12 @@ function CollectionCell({ export default async function ContractsPage() { const { orgId } = await requireOrg(); const t = await getTranslations("contracts"); - const [rows, parties, projects, board] = await Promise.all([ + const [rows, parties, projects, board, subsByContract] = await Promise.all([ listContracts(orgId), listParties(orgId), listProjects(orgId), listBillingBoard(orgId, { includeAllHistory: true }), + listSubscriptionsByContract(orgId), ]); const partyOptions = parties.map((p) => ({ id: p.id, name: p.name })); const projectOptions = projects.map((p) => ({ id: p.id, name: p.name })); @@ -221,12 +224,15 @@ export default async function ContractsPage() { (billingByContract.get(c.id) ?? []).filter(isLockedScheduleRow).length } extra={ - + <> + + + } footer={} /> diff --git a/src/app/dashboard/subscriptions/edit-subscription-form.tsx b/src/app/dashboard/subscriptions/edit-subscription-form.tsx index 6d26c0a..899568c 100644 --- a/src/app/dashboard/subscriptions/edit-subscription-form.tsx +++ b/src/app/dashboard/subscriptions/edit-subscription-form.tsx @@ -15,11 +15,13 @@ export function EditSubscriptionForm({ subscription, parties, projects, + contracts, footer, }: Readonly<{ subscription: Subscription; parties: Option[]; projects: Option[]; + contracts: Option[]; footer?: React.ReactNode; }>) { const t = useTranslations("subscriptions"); @@ -34,7 +36,12 @@ export function EditSubscriptionForm({ footer={footer} > - + ); } diff --git a/src/app/dashboard/subscriptions/new-subscription-dialog.tsx b/src/app/dashboard/subscriptions/new-subscription-dialog.tsx index 86fe1d8..0ba0d40 100644 --- a/src/app/dashboard/subscriptions/new-subscription-dialog.tsx +++ b/src/app/dashboard/subscriptions/new-subscription-dialog.tsx @@ -8,9 +8,11 @@ import { SubscriptionFields, type Option } from "./subscription-fields"; export function NewSubscriptionDialog({ parties, projects, + contracts, }: Readonly<{ parties: Option[]; projects: Option[]; + contracts: Option[]; }>) { const t = useTranslations("subscriptions"); @@ -25,7 +27,7 @@ export function NewSubscriptionDialog({ submittingLabel={t("newDialog.submitting")} className="sm:max-w-lg max-h-[85vh] overflow-y-auto" > - + ); } diff --git a/src/app/dashboard/subscriptions/page.tsx b/src/app/dashboard/subscriptions/page.tsx index 0a66660..9400871 100644 --- a/src/app/dashboard/subscriptions/page.tsx +++ b/src/app/dashboard/subscriptions/page.tsx @@ -19,6 +19,7 @@ import { listSubscriptions, listParties, listProjects, + listContracts, getSubscriptionSchedule, type SubscriptionSchedule, } from "@/db/queries"; @@ -117,13 +118,19 @@ function intervalLabel(t: Translator, months: number) { export default async function SubscriptionsPage() { const { orgId } = await requireOrg(); const t = await getTranslations("subscriptions"); - const [rows, parties, projects] = await Promise.all([ + const [rows, parties, projects, contracts] = await Promise.all([ listSubscriptions(orgId), listParties(orgId), listProjects(orgId), + listContracts(orgId), ]); const partyOptions = parties.map((p) => ({ id: p.id, name: p.name })); const projectOptions = projects.map((p) => ({ id: p.id, name: p.name })); + // 合約標題常常重複(同一客戶多張約),帶上客戶名才分得出是誰的合約。 + const contractOptions = contracts.map((c) => ({ + id: c.id, + name: c.customerName ? `${c.title} — ${c.customerName}` : c.title, + })); // 訂閱不多,逐筆抓各期收款狀態即可(一次平行抓完)。 const schedules = await Promise.all(rows.map((s) => getSubscriptionSchedule(orgId, s.id))); const scheduleById = new Map(schedules.filter((s) => s != null).map((s) => [s.id, s])); @@ -142,7 +149,11 @@ export default async function SubscriptionsPage() { return ( <> - + @@ -152,6 +163,7 @@ export default async function SubscriptionsPage() { {t("list.columns.plan")} {t("list.columns.customer")} {t("list.columns.project")} + {t("list.columns.contract")} {t("list.columns.amount")} {t("list.columns.frequency")} {t("list.columns.status")} @@ -159,7 +171,7 @@ export default async function SubscriptionsPage() { {rows.length === 0 ? ( - + ) : ( rows.map((s) => ( {s.name} {s.customerName ?? "—"} {s.projectName ?? "—"} + + {s.contractTitle ?? "—"} + {formatCurrency(s.amount, s.currency)} @@ -191,6 +206,7 @@ export default async function SubscriptionsPage() { customerPartyId: s.customerPartyId, customerName: s.customerName, projectId: s.projectId, + contractId: s.contractId, name: s.name, amount: s.amount, currency: s.currency, @@ -202,6 +218,7 @@ export default async function SubscriptionsPage() { }} parties={partyOptions} projects={projectOptions} + contracts={contractOptions} footer={} /> ) { const t = useTranslations("subscriptions"); @@ -61,6 +64,20 @@ export function SubscriptionFields({ ))} + + {/* 選填:Radix Select 不接受空字串,「不綁定」用 "none"(mutations 那側轉回 null)。 */} + {t("newDialog.contract.placeholder")} + {contracts.map((c) => ( + + {c.name} + + ))} + > { + const db = getDb(); + const rows = await db + .select({ + contractId: subscriptions.contractId, + id: subscriptions.id, + name: subscriptions.name, + amount: subscriptions.amount, + currency: subscriptions.currency, + intervalMonths: subscriptions.intervalMonths, + status: subscriptions.status, + }) + .from(subscriptions) + .where( + and( + eq(subscriptions.organizationId, orgId), + isNotNull(subscriptions.contractId), + isNull(subscriptions.deletedAt), + ), + ) + .orderBy(desc(subscriptions.createdAt)); + const out = new Map(); + for (const { contractId, ...sub } of rows) { + if (contractId == null) continue; + const list = out.get(contractId) ?? []; + list.push(sub); + out.set(contractId, list); + } + return out; +} + export async function getSubscription(orgId: string, id: number) { const db = getDb(); const [row] = await db @@ -953,6 +1002,8 @@ export async function getSubscription(orgId: string, id: number) { customerPartyId: subscriptions.customerPartyId, customerName: parties.name, projectId: subscriptions.projectId, + contractId: subscriptions.contractId, + contractTitle: contracts.title, name: subscriptions.name, amount: subscriptions.amount, currency: subscriptions.currency, @@ -963,6 +1014,7 @@ export async function getSubscription(orgId: string, id: number) { }) .from(subscriptions) .leftJoin(parties, eq(parties.id, subscriptions.customerPartyId)) + .leftJoin(contracts, eq(contracts.id, subscriptions.contractId)) .where(and(eq(subscriptions.organizationId, orgId), eq(subscriptions.id, id), isNull(subscriptions.deletedAt))) .limit(1); return row ?? null; @@ -1592,6 +1644,8 @@ export async function listBillingBoard( customerTaxId: parties.taxId, projectId: subscriptions.projectId, projectName: projects.name, + contractId: subscriptions.contractId, + contractTitle: contracts.title, amount: subscriptions.amount, currency: subscriptions.currency, intervalMonths: subscriptions.intervalMonths, @@ -1602,6 +1656,7 @@ export async function listBillingBoard( .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))), subscriptionPaidByPeriodAll(orgId), subscriptionPeriodsAll(orgId), @@ -1687,8 +1742,8 @@ export async function listBillingBoard( customerName: sub.customerName, customerTaxId: sub.customerTaxId, title: `${sub.name} (${p.periodLabel})`, - contractId: null, - contractTitle: null, + contractId: sub.contractId, + contractTitle: sub.contractTitle, projectId: sub.projectId, projectName: sub.projectName, dueDate, diff --git a/src/db/schema.ts b/src/db/schema.ts index fb1d7e2..2fcb818 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -382,6 +382,10 @@ export const subscriptions = pgTable("subscriptions", { organizationId: text("organization_id"), customerPartyId: bigint("customer_party_id", { mode: "number" }).notNull(), projectId: bigint("project_id", { mode: "number" }), + // 合約綁定(選填):合約裡談好的週期性費用要能追回是哪張合約。一次性分期走 + // billing_items.contract_id,週期性月費走這裡。 + // FK 在 DB 端(migrations/0022)建立,這裡只放欄位避免與 contracts 的宣告順序衝突。 + contractId: bigint("contract_id", { mode: "number" }), name: text().notNull(), amount: numeric({ precision: 18, scale: 2 }).notNull(), currency: char({ length: 3 }).default('TWD').notNull(), diff --git a/src/i18n/messages/contracts.ts b/src/i18n/messages/contracts.ts index 8ce4f70..9c0182e 100644 --- a/src/i18n/messages/contracts.ts +++ b/src/i18n/messages/contracts.ts @@ -101,6 +101,21 @@ const contracts = { noDueDate: { "zh-TW": "未設應請款日", en: "No due date set" }, manageLink: { "zh-TW": "到請款看板管理", en: "Manage on the billing board" }, }, + linkedSubscriptions: { + heading: { "zh-TW": "綁定的訂閱", en: "Linked subscriptions" }, + manageLink: { "zh-TW": "到訂閱 / 月費管理", en: "Manage on the subscriptions page" }, + interval: { + monthly: { "zh-TW": "每月", en: "Monthly" }, + quarterly: { "zh-TW": "每季", en: "Quarterly" }, + yearly: { "zh-TW": "每年", en: "Yearly" }, + everyNMonths: { "zh-TW": "每 {months} 個月", en: "Every {months} months" }, + }, + status: { + active: { "zh-TW": "進行中", en: "Active" }, + paused: { "zh-TW": "暫停", en: "Paused" }, + ended: { "zh-TW": "結束", en: "Ended" }, + }, + }, billingPlan: { plan: { single: { "zh-TW": "一次付清", en: "Single payment" }, diff --git a/src/i18n/messages/subscriptions.ts b/src/i18n/messages/subscriptions.ts index 750e0f9..6d8a0b9 100644 --- a/src/i18n/messages/subscriptions.ts +++ b/src/i18n/messages/subscriptions.ts @@ -27,6 +27,7 @@ const subscriptions = { plan: { "zh-TW": "方案", en: "Plan" }, customer: { "zh-TW": "客戶", en: "Client" }, project: { "zh-TW": "專案", en: "Project" }, + contract: { "zh-TW": "合約", en: "Contract" }, amount: { "zh-TW": "金額", en: "Amount" }, frequency: { "zh-TW": "頻率", en: "Frequency" }, status: { "zh-TW": "狀態", en: "Status" }, @@ -52,6 +53,10 @@ const subscriptions = { label: { "zh-TW": "專案", en: "Project" }, placeholder: { "zh-TW": "— 未指定 —", en: "— None —" }, }, + contract: { + label: { "zh-TW": "合約", en: "Contract" }, + placeholder: { "zh-TW": "不綁定合約", en: "No contract" }, + }, name: { label: { "zh-TW": "方案名稱", en: "Plan name" }, placeholder: { "zh-TW": "例:基礎月費、維運合約", en: "e.g. Basic plan, maintenance contract" }, diff --git a/src/lib/mcp/tools-client.ts b/src/lib/mcp/tools-client.ts index 52668a8..0b485db 100644 --- a/src/lib/mcp/tools-client.ts +++ b/src/lib/mcp/tools-client.ts @@ -60,6 +60,7 @@ 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." }, @@ -231,6 +232,7 @@ export const clientTools: Record = { properties: { customerPartyId: { type: "number", description: "See list_parties (label=customer)." }, projectId: { type: "number" }, + contractId: { type: "number", description: "See list_contracts." }, name: { type: "string" }, amount: { type: "number" }, currency: { type: "string", description: "3-letter; default TWD." }, @@ -252,6 +254,8 @@ export const clientTools: Record = { 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"); checkEnum(optString(args, "status"), SUB_STATUS, "status"); const [row] = await db .insert(subscriptions) @@ -259,6 +263,7 @@ export const clientTools: Record = { organizationId: orgId, customerPartyId, projectId: projectId ?? null, + contractId: contractId ?? null, name: requireString(args, "name"), amount: requireAmount(args, "amount"), currency: normalizeCurrency(args, "currency"), @@ -281,6 +286,10 @@ export const clientTools: Record = { id: { type: "number" }, customerPartyId: { type: "number" }, projectId: { type: "number" }, + contractId: { + type: ["number", "null"], + description: "See list_contracts. Pass null to unlink.", + }, name: { type: "string" }, amount: { type: "number" }, currency: { type: "string" }, @@ -303,10 +312,17 @@ export const clientTools: Record = { 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"); 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"); diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 75fded7..bd3d119 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -47,6 +47,8 @@ const SUBSCRIPTION_LIST_ROW: JsonSchemaObject = rowSchema({ endDate: { type: ["string", "null"], description: "YYYY-MM-DD." }, status: { type: "string", enum: ["active", "paused", "ended"] }, customer: { type: ["string", "null"], description: "Customer party name." }, + contractId: { type: ["number", "null"], description: "Linked contract; see list_contracts." }, + contractTitle: { type: ["string", "null"] }, nextChargeDate: { type: ["string", "null"], description: "YYYY-MM-DD; null unless the subscription is active and still running.", @@ -345,9 +347,12 @@ const billingTools: Record = { endDate: subscriptions.endDate, status: subscriptions.status, customer: parties.name, + contractId: subscriptions.contractId, + contractTitle: contracts.title, }) .from(subscriptions) .leftJoin(parties, eq(subscriptions.customerPartyId, parties.id)) + .leftJoin(contracts, eq(subscriptions.contractId, contracts.id)) .where(where) .orderBy(asc(subscriptions.startDate)); return listResult(