diff --git a/.infisical.json b/.infisical.json new file mode 100644 index 0000000..bfbdfaf --- /dev/null +++ b/.infisical.json @@ -0,0 +1,5 @@ +{ + "workspaceId": "3dbddeb7-9e1b-444c-be5a-1dedc702351c", + "defaultEnvironment": "dev", + "gitBranchToEnvironmentMapping": null +} diff --git a/deployment-architectures-screenshot.png b/deployment-architectures-screenshot.png new file mode 100644 index 0000000..9c30de4 Binary files /dev/null and b/deployment-architectures-screenshot.png differ diff --git a/sonar-project.properties b/sonar-project.properties index 47cd9ae..601aaa9 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,3 +1,4 @@ sonar.projectKey=pathorsAI_internal -# 排除產生 / 第三方腳手架的檔案(shadcn UI、drizzle introspect schema、wrangler 型別) -sonar.exclusions=src/components/ui/**,src/db/schema.ts,cloudflare-env.d.ts,.open-next/**,.next/**,.next-prod/** +# 排除產生 / 第三方腳手架的檔案(shadcn UI、drizzle introspect schema、wrangler 型別、 +# 已套用且不可改的 forward-only SQL migrations) +sonar.exclusions=src/components/ui/**,src/db/schema.ts,cloudflare-env.d.ts,.open-next/**,.next/**,.next-prod/**,migrations/** diff --git a/src/app/(dashboard)/activity/page.tsx b/src/app/(dashboard)/activity/page.tsx index 5af24f8..1378f7f 100644 --- a/src/app/(dashboard)/activity/page.tsx +++ b/src/app/(dashboard)/activity/page.tsx @@ -90,7 +90,7 @@ export default async function ActivityPage() { {entityLabel[r.entityType] ?? r.entityType} - {r.entityId != null ? ` #${r.entityId}` : ""} + {r.entityId == null ? "" : ` #${r.entityId}`} {r.summary ?? "—"} diff --git a/src/app/(dashboard)/contracts/page.tsx b/src/app/(dashboard)/contracts/page.tsx index 4d34272..58a57d8 100644 --- a/src/app/(dashboard)/contracts/page.tsx +++ b/src/app/(dashboard)/contracts/page.tsx @@ -54,6 +54,14 @@ function CollectionCell({ // 進度條填色依收款狀態:溢收→支出色,已收齊→收入色,進行中→主色;無金額時留一條空軌維持等高。 const overCollected = total != null && received > total; const fullyCollected = total != null && received >= total; + let barColor = "bg-primary"; + if (overCollected) barColor = "bg-expense"; + else if (fullyCollected) barColor = "bg-income"; + let remainingLabel: string; + if (remaining == null) remainingLabel = "—"; + else if (remaining > 0) remainingLabel = `未收 ${formatCurrency(remaining, currency)}`; + else if (remaining < 0) remainingLabel = `溢收 ${formatCurrency(-remaining, currency)}`; + else remainingLabel = "已收齊"; return (
@@ -65,28 +73,13 @@ function CollectionCell({
{pct != null && (
)}
- - {remaining == null - ? "—" - : remaining > 0 - ? `未收 ${formatCurrency(remaining, currency)}` - : remaining < 0 - ? `溢收 ${formatCurrency(-remaining, currency)}` - : "已收齊"} - + {remainingLabel} {cost > 0 && 成本 {formatCurrency(cost, currency)}}
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index dc3e70d..3490125 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -9,9 +9,9 @@ import { export default function DashboardLayout({ children, -}: { +}: Readonly<{ children: React.ReactNode; -}) { +}>) { return ( diff --git a/src/app/(dashboard)/reconciliation/new-reconciliation-dialog.tsx b/src/app/(dashboard)/reconciliation/new-reconciliation-dialog.tsx index bd9cff4..1544bd2 100644 --- a/src/app/(dashboard)/reconciliation/new-reconciliation-dialog.tsx +++ b/src/app/(dashboard)/reconciliation/new-reconciliation-dialog.tsx @@ -30,9 +30,9 @@ const initial: ActionState = { ok: false }; export function NewReconciliationDialog({ accounts, -}: { +}: Readonly<{ accounts: { id: number; name: string; currency: string }[]; -}) { +}>) { const [open, setOpen] = useState(false); const [state, action, pending] = useActionState(createReconciliation, initial); const today = new Date().toISOString().slice(0, 10); diff --git a/src/app/(dashboard)/subscriptions/page.tsx b/src/app/(dashboard)/subscriptions/page.tsx index 03b3373..84a8dc3 100644 --- a/src/app/(dashboard)/subscriptions/page.tsx +++ b/src/app/(dashboard)/subscriptions/page.tsx @@ -48,7 +48,7 @@ const periodStatusVariant: Record) { if (!schedule || schedule.periods.length === 0) { return

尚無收款期別

; } @@ -91,8 +91,7 @@ function PeriodScheduleTable({ schedule }: { schedule: SubscriptionSchedule | nu

- 未收合計: - + 未收合計: {formatCurrency(schedule.totalOutstanding, schedule.currency)}

diff --git a/src/app/(dashboard)/transactions/page.tsx b/src/app/(dashboard)/transactions/page.tsx index d2d00a7..4014032 100644 --- a/src/app/(dashboard)/transactions/page.tsx +++ b/src/app/(dashboard)/transactions/page.tsx @@ -183,7 +183,7 @@ const PAGE_SIZE = 50; export default async function TransactionsPage({ searchParams, -}: { +}: Readonly<{ searchParams: Promise<{ book?: string; category?: string; @@ -191,7 +191,7 @@ export default async function TransactionsPage({ period?: string; page?: string; }>; -}) { +}>) { const { orgId } = await requireOrg(); const { book, category, account, period, page: pageParam } = await searchParams; const active = (["internal", "external", "both"].includes(book ?? "") ? book : undefined) as diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 4b61d9d..f8b7198 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -21,7 +21,7 @@ function slugify(name: string) { .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); + .replace(/^-|-$/g, ""); } type Invite = { @@ -82,7 +82,8 @@ export default function OnboardingPage() { async function onCreate(e: React.FormEvent) { e.preventDefault(); const form = new FormData(e.currentTarget); - const name = String(form.get("name")).trim(); + const nameField = form.get("name"); + const name = (typeof nameField === "string" ? nameField : "").trim(); const slug = slugify(name) || `org-${Date.now()}`; setCreating(true); const { data: org, error } = await authClient.organization.create({ name, slug }); diff --git a/src/components/book-badge.tsx b/src/components/book-badge.tsx index 712566a..711398c 100644 --- a/src/components/book-badge.tsx +++ b/src/components/book-badge.tsx @@ -6,7 +6,7 @@ const map: Record) { const cfg = map[book] ?? { label: book, variant: "outline" as const }; return {cfg.label}; } diff --git a/src/components/combobox.tsx b/src/components/combobox.tsx index 6ae11d9..34e22b2 100644 --- a/src/components/combobox.tsx +++ b/src/components/combobox.tsx @@ -122,6 +122,7 @@ export function Combobox({
) { return ( ) { return (
diff --git a/src/components/page-header.tsx b/src/components/page-header.tsx index d1aa185..910bb69 100644 --- a/src/components/page-header.tsx +++ b/src/components/page-header.tsx @@ -2,11 +2,11 @@ export function PageHeader({ title, description, children, -}: { +}: Readonly<{ title: React.ReactNode; description?: string; children?: React.ReactNode; -}) { +}>) { return (
diff --git a/src/components/row-dialog.tsx b/src/components/row-dialog.tsx index 5de9d8b..71aaab6 100644 --- a/src/components/row-dialog.tsx +++ b/src/components/row-dialog.tsx @@ -18,6 +18,11 @@ import { } from "@/components/ui/sheet"; const CloseCtx = React.createContext<() => void>(() => {}); + +// 點到列裡的按鈕 / 連結 / 輸入等互動元素 → 交給它們自己處理,不開啟編輯 +function hitInteractive(target: EventTarget | null) { + return Boolean((target as HTMLElement | null)?.closest("button, a, input, label")); +} /** 在 RowDialog 內的表單可呼叫此 hook,存檔成功後關閉對話框 */ export function useRowDialogClose() { return React.useContext(CloseCtx); @@ -42,11 +47,6 @@ export function RowDialog({ const [open, setOpen] = React.useState(false); const close = React.useCallback(() => setOpen(false), []); - // 點到列裡的按鈕 / 連結 / 輸入等互動元素 → 交給它們自己處理,不開啟編輯 - function hitInteractive(target: EventTarget | null) { - return Boolean((target as HTMLElement | null)?.closest("button, a, input, label")); - } - function shouldOpen(target: EventTarget | null) { // 有對話框正開著 / 正在關閉時(例如點灰色背景關閉發放薪資 dialog, // 那個點擊會「穿透」到下面的列)→ 不要開啟這一列的編輯 diff --git a/src/components/table-skeleton.tsx b/src/components/table-skeleton.tsx index 326729e..3b9bcfc 100644 --- a/src/components/table-skeleton.tsx +++ b/src/components/table-skeleton.tsx @@ -6,17 +6,19 @@ export function TableSkeleton({ rows = 8, cols = 5, }: Readonly<{ rows?: number; cols?: number }>) { + const rowKeys = Array.from({ length: rows }, (_, i) => `row-${i}`); + const colKeys = Array.from({ length: cols }, (_, i) => `col-${i}`); return (
- {Array.from({ length: rows }).map((_, r) => ( -
- {Array.from({ length: cols }).map((_, c) => ( + {rowKeys.map((rowKey) => ( +
+ {colKeys.map((colKey, c) => ( a.localeCompare(b)); let totalOutstanding = 0; const periods: SubscriptionPeriod[] = sorted.map((periodStart) => { const override = overrideByStart.get(periodStart); diff --git a/src/hooks/use-mobile.ts b/src/hooks/use-mobile.ts index 2b0fe1d..eeb2b6e 100644 --- a/src/hooks/use-mobile.ts +++ b/src/hooks/use-mobile.ts @@ -6,12 +6,12 @@ export function useIsMobile() { const [isMobile, setIsMobile] = React.useState(undefined) React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const mql = globalThis.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + setIsMobile(globalThis.innerWidth < MOBILE_BREAKPOINT) } mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + setIsMobile(globalThis.innerWidth < MOBILE_BREAKPOINT) return () => mql.removeEventListener("change", onChange) }, []) diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index d10819a..e8335d2 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -81,6 +81,48 @@ function categoryTag(a: ToolAnnotations): string { return "[write]"; } +async function handleToolCall( + id: JsonRpcId, + params: Record | undefined, + ctx: ToolContext, +): Promise { + const name = params?.name as string | undefined; + const args = (params?.arguments as Record) ?? {}; + const tool = name ? tools[name] : undefined; + if (!tool) { + return ok(id, { + content: [{ type: "text", text: `Unknown tool: ${name}` }], + isError: true, + }); + } + try { + const out = await tool.execute(args, ctx); + // Best-effort audit log for MCP writes (never affects the response). + const audit = name ? deriveMcpAudit(name, out) : null; + if (audit) { + try { + const orgId = await resolveOrg(args, ctx); + await logMcp(orgId, ctx.userId, audit.action, audit.entityType, audit.entityId, name); + } catch { + // ignore audit failures + } + } + return ok(id, { + content: [{ type: "text", text: JSON.stringify(out, null, 2) }], + }); + } catch (err) { + return ok(id, { + content: [ + { + type: "text", + text: `Error: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }); + } +} + async function handleMessage( msg: JsonRpcMessage, ctx: ToolContext, @@ -118,43 +160,8 @@ async function handleMessage( }), }); - case "tools/call": { - const name = msg.params?.name as string | undefined; - const args = (msg.params?.arguments as Record) ?? {}; - const tool = name ? tools[name] : undefined; - if (!tool) { - return ok(id, { - content: [{ type: "text", text: `Unknown tool: ${name}` }], - isError: true, - }); - } - try { - const out = await tool.execute(args, ctx); - // Best-effort audit log for MCP writes (never affects the response). - const audit = name ? deriveMcpAudit(name, out) : null; - if (audit) { - try { - const orgId = await resolveOrg(args, ctx); - await logMcp(orgId, ctx.userId, audit.action, audit.entityType, audit.entityId, name); - } catch { - // ignore audit failures - } - } - return ok(id, { - content: [{ type: "text", text: JSON.stringify(out, null, 2) }], - }); - } catch (err) { - return ok(id, { - content: [ - { - type: "text", - text: `Error: ${err instanceof Error ? err.message : String(err)}`, - }, - ], - isError: true, - }); - } - } + case "tools/call": + return handleToolCall(id, msg.params, ctx); default: if (isNotification) return null; diff --git a/src/lib/mcp/tools-hr.ts b/src/lib/mcp/tools-hr.ts index b0a219b..0fe8d8c 100644 --- a/src/lib/mcp/tools-hr.ts +++ b/src/lib/mcp/tools-hr.ts @@ -44,6 +44,43 @@ function checkEmploymentType(v: string | undefined) { } } +type SalaryItem = { + itemTypeId: number | null; + name: string; + direction: "earning" | "deduction"; + isTaxable: boolean; + amount: number; +}; + +// Validate + normalize the raw salary line items into typed, non-zero rows. +function parseSalaryItems(raw: unknown): SalaryItem[] { + if (!Array.isArray(raw) || raw.length === 0) { + throw new Error('"items" must be a non-empty array of salary line items.'); + } + return raw + .map((r, i) => { + const o = (r ?? {}) as Record; + const name = typeof o.name === "string" ? o.name.trim() : ""; + const amount = typeof o.amount === "number" ? o.amount : Number(o.amount); + if (!name) throw new Error(`items[${i}].name is required.`); + if (!Number.isFinite(amount)) throw new Error(`items[${i}].amount must be a number.`); + const direction: "earning" | "deduction" = + o.direction === "deduction" ? "deduction" : "earning"; + let isTaxable: boolean; + if (direction === "deduction") isTaxable = false; + else if (o.isTaxable === undefined) isTaxable = true; + else isTaxable = Boolean(o.isTaxable); + return { + itemTypeId: typeof o.itemTypeId === "number" ? o.itemTypeId : null, + name, + direction, + isTaxable, + amount, + }; + }) + .filter((r) => r.amount !== 0); +} + export const hrTools: Record = { // ---- employees ---- list_employees: { @@ -346,29 +383,7 @@ export const hrTools: Record = { const bookRaw = optString(args, "book") ?? "both"; const book = ["internal", "external", "both"].includes(bookRaw) ? bookRaw : "both"; - const raw = args.items; - if (!Array.isArray(raw) || raw.length === 0) { - throw new Error('"items" must be a non-empty array of salary line items.'); - } - const items = raw - .map((r, i) => { - const o = (r ?? {}) as Record; - const name = typeof o.name === "string" ? o.name.trim() : ""; - const amount = typeof o.amount === "number" ? o.amount : Number(o.amount); - if (!name) throw new Error(`items[${i}].name is required.`); - if (!Number.isFinite(amount)) throw new Error(`items[${i}].amount must be a number.`); - const direction = o.direction === "deduction" ? "deduction" : "earning"; - const isTaxable = - direction === "deduction" ? false : o.isTaxable === undefined ? true : !!o.isTaxable; - return { - itemTypeId: typeof o.itemTypeId === "number" ? o.itemTypeId : null, - name, - direction, - isTaxable, - amount, - }; - }) - .filter((r) => r.amount !== 0); + const items = parseSalaryItems(args.items); if (items.length === 0) throw new Error("No non-zero salary items provided."); let taxable = 0; diff --git a/src/lib/mcp/tools-transactions.ts b/src/lib/mcp/tools-transactions.ts index 47167eb..7b1e797 100644 --- a/src/lib/mcp/tools-transactions.ts +++ b/src/lib/mcp/tools-transactions.ts @@ -221,6 +221,229 @@ async function optSubscriptionId( return subscriptionId; } +// ---- bulk_create_transactions helpers ---- + +type Norm = { + type: string; + txnDate: string; + amount: string; + currency: string; + description: string | null; + reported: boolean; + projectId: number | null; + contractId: number | null; + subscriptionId: number | null; + subscriptionPeriod: string | null; + categoryId: number | null; + fromAccountId: number | null; + toAccountId: number | null; + partyName: string | null; + settleEmployeeName: string | null; + billedToCompanyTaxId: boolean; +}; + +type BulkTxnSets = { + acctSet: Set; + catSet: Set; + projSet: Set; + contractSet: Set; + subscriptionSet: Set; +}; + +// A nullable FK, when present, must belong to the org's known ids. +function assertOrgRef(id: number | null, set: Set, at: string, field: string) { + if (id !== null && !set.has(id)) { + throw new Error(`${at}.${field} ${id} not found in your organization.`); + } +} + +function fillIncomeExpense( + n: Norm, + it: Record, + at: string, + acctSet: Set, + catSet: Set, + partyLabelByName: Map, +) { + const isIncome = n.type === "income"; + const accountId = requireNumber(it, "accountId"); + if (!acctSet.has(accountId)) { + throw new Error(`${at}.accountId ${accountId} not found in your organization.`); + } + const categoryId = optNumber(it, "categoryId") ?? null; // 可省略=未分類 + if (categoryId !== null && !catSet.has(categoryId)) { + throw new Error(`${at}.categoryId ${categoryId} not found in your organization.`); + } + const partyName = requireString(it, "partyName"); + if (!partyLabelByName.has(partyName)) { + partyLabelByName.set(partyName, isIncome ? "customer" : "vendor"); + } + n.categoryId = categoryId; + n.partyName = partyName; + n.fromAccountId = isIncome ? null : accountId; + n.toAccountId = isIncome ? accountId : null; +} + +function fillAdvance( + n: Norm, + it: Record, + at: string, + catSet: Set, + partyLabelByName: Map, + employeeNames: Set, +) { + const categoryId = optNumber(it, "categoryId") ?? null; // 可省略=未分類 + if (categoryId !== null && !catSet.has(categoryId)) { + throw new Error(`${at}.categoryId ${categoryId} not found in your organization.`); + } + const partyName = requireString(it, "partyName"); + if (!partyLabelByName.has(partyName)) partyLabelByName.set(partyName, "vendor"); + const emp = requireString(it, "settleEmployeeName"); + employeeNames.add(emp); + n.categoryId = categoryId; + n.partyName = partyName; + n.settleEmployeeName = emp; +} + +function fillTransfer(n: Norm, it: Record, at: string, acctSet: Set) { + const fromAccountId = requireNumber(it, "fromAccountId"); + const toAccountId = requireNumber(it, "toAccountId"); + if (!acctSet.has(fromAccountId)) { + throw new Error(`${at}.fromAccountId ${fromAccountId} not found in your organization.`); + } + if (!acctSet.has(toAccountId)) { + throw new Error(`${at}.toAccountId ${toAccountId} not found in your organization.`); + } + n.fromAccountId = fromAccountId; + n.toAccountId = toAccountId; +} + +// Validate one raw bulk item and normalize it; accumulates party/employee names +// so they can be resolved to ids in a single round trip afterwards. +function normalizeBulkTxnItem( + raw: unknown, + i: number, + sets: BulkTxnSets, + partyLabelByName: Map, + employeeNames: Set, +): Norm { + if (typeof raw !== "object" || raw === null) { + throw new Error(`items[${i}] must be an object.`); + } + const it = raw as Record; + const at = `items[${i}]`; + const type = requireString(it, "type"); + if (!TXN_TYPES.includes(type as (typeof TXN_TYPES)[number])) { + throw new Error(`${at}.type must be one of: ${TXN_TYPES.join(", ")}.`); + } + const projectId = optNumber(it, "projectId") ?? null; + assertOrgRef(projectId, sets.projSet, at, "projectId"); + const contractId = optNumber(it, "contractId") ?? null; + assertOrgRef(contractId, sets.contractSet, at, "contractId"); + const subscriptionId = optNumber(it, "subscriptionId") ?? null; + assertOrgRef(subscriptionId, sets.subscriptionSet, at, "subscriptionId"); + const subscriptionPeriod = optDate(it, "subscriptionPeriod") ?? null; + if (subscriptionId !== null && subscriptionPeriod === null) { + throw new Error(`${at}.subscriptionPeriod (YYYY-MM-DD) is required when subscriptionId is set.`); + } + const n: Norm = { + type, + txnDate: requireDate(it, "txnDate"), + amount: requireDecimal(it, "amount"), + currency: normalizeCurrency(it, "currency"), + description: optString(it, "description") ?? null, + reported: type === "transfer" || optBoolean(it, "reported") === true, + projectId, + contractId, + subscriptionId, + subscriptionPeriod, + categoryId: null, + fromAccountId: null, + toAccountId: null, + partyName: null, + settleEmployeeName: null, + billedToCompanyTaxId: optBoolean(it, "billedToCompanyTaxId") === true, + }; + if (type === "expense" || type === "income") { + fillIncomeExpense(n, it, at, sets.acctSet, sets.catSet, partyLabelByName); + } else if (type === "advance") { + fillAdvance(n, it, at, sets.catSet, partyLabelByName, employeeNames); + } else if (type === "transfer") { + fillTransfer(n, it, at, sets.acctSet); + } + return n; +} + +// ---- update_transaction helpers ---- + +// Apply the relation fields (category / project / contract / subscription) to +// the patch. Each is only touched when its arg was provided; 0 clears the link. +async function applyTxnRelationPatch( + patch: Record, + db: Db, + args: Record, + orgId: string, +) { + if (optNumber(args, "categoryId") !== undefined) { + const categoryId = requireNumber(args, "categoryId"); + if (categoryId === 0) { + patch.categoryId = null; // 0=清除分類(未分類) + } else { + await assertInOrg(db, categories, categoryId, orgId, "Category"); + patch.categoryId = categoryId; + } + } + if (optNumber(args, "projectId") !== undefined) { + patch.projectId = await optProjectId(db, args, orgId); + } + if (optNumber(args, "contractId") !== undefined) { + const contractId = requireNumber(args, "contractId"); + if (contractId === 0) { + patch.contractId = null; + } else { + await assertInOrg(db, contracts, contractId, orgId, "Contract"); + patch.contractId = contractId; + } + } + if (optNumber(args, "subscriptionId") !== undefined) { + const subscriptionId = requireNumber(args, "subscriptionId"); + if (subscriptionId === 0) { + // 解除訂閱綁定時,期別一併清空。 + patch.subscriptionId = null; + patch.subscriptionPeriod = null; + } else { + await assertInOrg(db, subscriptions, subscriptionId, orgId, "Subscription"); + patch.subscriptionId = subscriptionId; + } + } + // 期別:空字串代表清除;未提供則不動。只有在沒有因 subscriptionId=0 清空時才處理。 + if (args.subscriptionPeriod !== undefined && patch.subscriptionPeriod === undefined) { + patch.subscriptionPeriod = optDate(args, "subscriptionPeriod") ?? null; + } +} + +// Apply amount/currency and the reported→book flag to the patch, falling back to +// the existing row's values when only one of amount/currency is supplied. +function applyTxnAmountPatch( + patch: Record, + args: Record, + existing: { type: string; amount: string; currency: string }, +) { + const amountProvided = optNumber(args, "amount") !== undefined; + const currencyProvided = optString(args, "currency") !== undefined; + if (amountProvided || currencyProvided) { + const amount = amountProvided ? requireDecimal(args, "amount") : existing.amount; + const currency = currencyProvided ? normalizeCurrency(args, "currency") : existing.currency; + patch.amount = amount; + patch.currency = currency; + patch.amountTwd = currency === "TWD" ? amount : null; + } + if (optBoolean(args, "reported") !== undefined) { + patch.book = + existing.type === "transfer" || optBoolean(args, "reported") ? "both" : "internal"; + } +} + export const transactionTools: Record = { list_transactions: { description: @@ -452,116 +675,12 @@ export const transactionTools: Record = { const contractSet = new Set(contractRows.map((r) => r.id)); const subscriptionSet = new Set(subscriptionRows.map((r) => r.id)); - type Norm = { - type: string; - txnDate: string; - amount: string; - currency: string; - description: string | null; - reported: boolean; - projectId: number | null; - contractId: number | null; - subscriptionId: number | null; - subscriptionPeriod: string | null; - categoryId: number | null; - fromAccountId: number | null; - toAccountId: number | null; - partyName: string | null; - settleEmployeeName: string | null; - billedToCompanyTaxId: boolean; - }; - const norms: Norm[] = []; + const sets: BulkTxnSets = { acctSet, catSet, projSet, contractSet, subscriptionSet }; const partyLabelByName = new Map(); const employeeNames = new Set(); - - items.forEach((raw, i) => { - if (typeof raw !== "object" || raw === null) { - throw new Error(`items[${i}] must be an object.`); - } - const it = raw as Record; - const at = `items[${i}]`; - const type = requireString(it, "type"); - if (!TXN_TYPES.includes(type as (typeof TXN_TYPES)[number])) { - throw new Error(`${at}.type must be one of: ${TXN_TYPES.join(", ")}.`); - } - const projectId = optNumber(it, "projectId") ?? null; - if (projectId !== null && !projSet.has(projectId)) { - throw new Error(`${at}.projectId ${projectId} not found in your organization.`); - } - const contractId = optNumber(it, "contractId") ?? null; - if (contractId !== null && !contractSet.has(contractId)) { - throw new Error(`${at}.contractId ${contractId} not found in your organization.`); - } - const subscriptionId = optNumber(it, "subscriptionId") ?? null; - if (subscriptionId !== null && !subscriptionSet.has(subscriptionId)) { - throw new Error(`${at}.subscriptionId ${subscriptionId} not found in your organization.`); - } - const subscriptionPeriod = optDate(it, "subscriptionPeriod") ?? null; - if (subscriptionId !== null && subscriptionPeriod === null) { - throw new Error(`${at}.subscriptionPeriod (YYYY-MM-DD) is required when subscriptionId is set.`); - } - const n: Norm = { - type, - txnDate: requireDate(it, "txnDate"), - amount: requireDecimal(it, "amount"), - currency: normalizeCurrency(it, "currency"), - description: optString(it, "description") ?? null, - reported: type === "transfer" || optBoolean(it, "reported") === true, - projectId, - contractId, - subscriptionId, - subscriptionPeriod, - categoryId: null, - fromAccountId: null, - toAccountId: null, - partyName: null, - settleEmployeeName: null, - billedToCompanyTaxId: optBoolean(it, "billedToCompanyTaxId") === true, - }; - if (type === "expense" || type === "income") { - const isIncome = type === "income"; - const accountId = requireNumber(it, "accountId"); - if (!acctSet.has(accountId)) { - throw new Error(`${at}.accountId ${accountId} not found in your organization.`); - } - const categoryId = optNumber(it, "categoryId") ?? null; // 可省略=未分類 - if (categoryId !== null && !catSet.has(categoryId)) { - throw new Error(`${at}.categoryId ${categoryId} not found in your organization.`); - } - const partyName = requireString(it, "partyName"); - if (!partyLabelByName.has(partyName)) { - partyLabelByName.set(partyName, isIncome ? "customer" : "vendor"); - } - n.categoryId = categoryId; - n.partyName = partyName; - n.fromAccountId = isIncome ? null : accountId; - n.toAccountId = isIncome ? accountId : null; - } else if (type === "advance") { - const categoryId = optNumber(it, "categoryId") ?? null; // 可省略=未分類 - if (categoryId !== null && !catSet.has(categoryId)) { - throw new Error(`${at}.categoryId ${categoryId} not found in your organization.`); - } - const partyName = requireString(it, "partyName"); - if (!partyLabelByName.has(partyName)) partyLabelByName.set(partyName, "vendor"); - const emp = requireString(it, "settleEmployeeName"); - employeeNames.add(emp); - n.categoryId = categoryId; - n.partyName = partyName; - n.settleEmployeeName = emp; - } else if (type === "transfer") { - const fromAccountId = requireNumber(it, "fromAccountId"); - const toAccountId = requireNumber(it, "toAccountId"); - if (!acctSet.has(fromAccountId)) { - throw new Error(`${at}.fromAccountId ${fromAccountId} not found in your organization.`); - } - if (!acctSet.has(toAccountId)) { - throw new Error(`${at}.toAccountId ${toAccountId} not found in your organization.`); - } - n.fromAccountId = fromAccountId; - n.toAccountId = toAccountId; - } - norms.push(n); - }); + const norms: Norm[] = items.map((raw, i) => + normalizeBulkTxnItem(raw, i, sets, partyLabelByName, employeeNames), + ); const partyId = await resolvePartyNames(db, orgId, partyLabelByName); const employeeId = await resolveEmployeeNames(db, orgId, employeeNames); @@ -636,58 +755,11 @@ export const transactionTools: Record = { if (optDate(args, "txnDate") !== undefined) patch.txnDate = optDate(args, "txnDate"); if (optString(args, "description") !== undefined) patch.description = optString(args, "description"); - if (optNumber(args, "categoryId") !== undefined) { - const categoryId = requireNumber(args, "categoryId"); - if (categoryId === 0) { - patch.categoryId = null; // 0=清除分類(未分類) - } else { - await assertInOrg(db, categories, categoryId, orgId, "Category"); - patch.categoryId = categoryId; - } - } if (optBoolean(args, "billedToCompanyTaxId") !== undefined) { patch.billedToCompanyTaxId = optBoolean(args, "billedToCompanyTaxId"); } - if (optNumber(args, "projectId") !== undefined) { - patch.projectId = await optProjectId(db, args, orgId); - } - if (optNumber(args, "contractId") !== undefined) { - const contractId = requireNumber(args, "contractId"); - if (contractId === 0) { - patch.contractId = null; - } else { - await assertInOrg(db, contracts, contractId, orgId, "Contract"); - patch.contractId = contractId; - } - } - if (optNumber(args, "subscriptionId") !== undefined) { - const subscriptionId = requireNumber(args, "subscriptionId"); - if (subscriptionId === 0) { - // 解除訂閱綁定時,期別一併清空。 - patch.subscriptionId = null; - patch.subscriptionPeriod = null; - } else { - await assertInOrg(db, subscriptions, subscriptionId, orgId, "Subscription"); - patch.subscriptionId = subscriptionId; - } - } - // 期別:空字串代表清除;未提供則不動。只有在沒有因 subscriptionId=0 清空時才處理。 - if (args.subscriptionPeriod !== undefined && patch.subscriptionPeriod === undefined) { - patch.subscriptionPeriod = optDate(args, "subscriptionPeriod") ?? null; - } - const amountProvided = optNumber(args, "amount") !== undefined; - const currencyProvided = optString(args, "currency") !== undefined; - if (amountProvided || currencyProvided) { - const amount = amountProvided ? requireDecimal(args, "amount") : existing.amount; - const currency = currencyProvided ? normalizeCurrency(args, "currency") : existing.currency; - patch.amount = amount; - patch.currency = currency; - patch.amountTwd = currency === "TWD" ? amount : null; - } - if (optBoolean(args, "reported") !== undefined) { - patch.book = - existing.type === "transfer" || optBoolean(args, "reported") ? "both" : "internal"; - } + await applyTxnRelationPatch(patch, db, args, orgId); + applyTxnAmountPatch(patch, args, existing); const [row] = await db .update(transactions) .set(patch) diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 9165951..5820577 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -19,7 +19,6 @@ import { requireNumber, resolveOrg, todayStr, - type ToolContext, type ToolDef, } from "./shared"; import { accountingTools } from "./tools-accounting"; @@ -27,7 +26,7 @@ import { transactionTools } from "./tools-transactions"; import { clientTools } from "./tools-client"; import { hrTools } from "./tools-hr"; -export type { ToolContext, ToolDef }; +export type { ToolContext, ToolDef } from "./shared"; // Discovery + the recurring-billing tools. Domain CRUD lives in the tools-*.ts // modules and is merged into `tools` at the bottom.