Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 16 additions & 46 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getDb>) {
return db
.select({
id: subscriptions.id,
customerPartyId: subscriptions.customerPartyId,
customerName: parties.name,
/** 開發票草稿要預填的統編 */
customerTaxId: parties.taxId,
projectId: subscriptions.projectId,
projectName: projects.name,
contractId: subscriptions.contractId,
Expand All @@ -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));
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
]);
Expand Down
90 changes: 59 additions & 31 deletions src/lib/mcp/tools-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand All @@ -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;

Expand Down Expand Up @@ -124,6 +124,55 @@ function checkEnum(v: string | undefined, allowed: readonly string[], field: str
}
}

/**
* 訂閱/合約共用的外鍵歸屬檢查。四支 create_/update_ 原本各自抄同一組
* 「有帶才驗」的判斷;別的 org 的 id 一樣是合法的 FK 值,這裡是唯一的攔截點。
*/
async function assertClientRefs(
db: ReturnType<typeof getDb>,
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<string, unknown>): Record<string, unknown> {
const patch: Record<string, unknown> = {};
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<string, ToolDef> = {
// ---- projects ----
create_project: {
Expand Down Expand Up @@ -251,11 +300,9 @@ export const clientTools: Record<string, ToolDef> = {
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)
Expand Down Expand Up @@ -308,30 +355,13 @@ export const clientTools: Record<string, ToolDef> = {
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<string, unknown> = {};
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)
Expand Down Expand Up @@ -396,9 +426,8 @@ export const clientTools: Record<string, ToolDef> = {
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)
Expand Down Expand Up @@ -452,9 +481,8 @@ export const clientTools: Record<string, ToolDef> = {
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<string, unknown> = {};
if (customerPartyId !== undefined) patch.customerPartyId = customerPartyId;
Expand Down