diff --git a/backend/app/main.py b/backend/app/main.py index 3aab2b7..0161aee 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -64,9 +64,34 @@ from app.modules.opening_balances.lock_router import router as opening_balance_l app.include_router(opening_balance_lock_router) from app.routers import journal_entries +from app.routers import cash +from app.routers import accounts + app.include_router(journal_entries.router) +app.include_router(cash.router) +app.include_router(accounts.router) + from app.routers import month_locks app.include_router(month_locks.router) +from app.routers import year_locks +app.include_router(year_locks.router) + +from app.routers import cash +app.include_router(cash.router) + + +from fastapi.staticfiles import StaticFiles + +app.mount( + "/", + StaticFiles(directory="app/static", html=True), + name="static", +) + + + + + diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py index e69de29..af2698d 100644 --- a/backend/app/routers/accounts.py +++ b/backend/app/routers/accounts.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter +from app.core.database import get_connection + +router = APIRouter(prefix="/accounts", tags=["科目"]) + +@router.get("", summary="科目一覧取得") +def get_accounts(): + """ + 科目マスタ一覧を返す + """ + with get_connection() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT + account_id, + account_code, + account_name, + account_type + FROM accounts + ORDER BY account_code + """) + rows = cur.fetchall() + + return rows diff --git a/backend/app/routers/cash.py b/backend/app/routers/cash.py new file mode 100644 index 0000000..8fbb315 --- /dev/null +++ b/backend/app/routers/cash.py @@ -0,0 +1,105 @@ +from fastapi import APIRouter, Query +from datetime import date +from app.core.database import get_connection + +router = APIRouter(prefix="/cash", tags=["资金"]) + + +# ※ 临时方案:资金系科目(现金 / 银行) +CASH_ACCOUNT_IDS = [101, 102] + + +# ------------------------- +# 资金余额 +# ------------------------- +@router.get("/balance", summary="资金余额取得") +def get_cash_balance(): + """ + 取得当前资金余额(现金・银行) + """ + with get_connection() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT + a.account_id, + a.account_name, + COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) AS balance + FROM accounts a + LEFT JOIN journal_lines l + ON l.account_id = a.account_id + LEFT JOIN journal_entries e + ON e.journal_id = l.journal_id + AND e.is_deleted = false + WHERE a.account_id = ANY(%s) + GROUP BY a.account_id, a.account_name + ORDER BY a.account_id + """, (CASH_ACCOUNT_IDS,)) + + rows = cur.fetchall() + + total = sum(r["balance"] for r in rows) + + return { + "items": rows, + "total_balance": total + } + + +# ------------------------- +# 资金流水 +# ------------------------- +@router.get("/transactions", summary="资金流水取得") +def get_cash_transactions( + account_id: int = Query(..., description="资金账户ID"), + from_date: date | None = Query(None, alias="from"), + to_date: date | None = Query(None, alias="to"), +): + """ + 指定资金账户的资金流水 + """ + with get_connection() as conn, conn.cursor() as cur: + sql = """ + SELECT + e.journal_date, + e.description, + l.debit, + l.credit, + COALESCE(a2.account_name, '(未指定)') AS counter_account + FROM journal_lines l + JOIN journal_entries e + ON e.journal_id = l.journal_id + LEFT JOIN journal_lines l2 + ON l2.journal_id = l.journal_id + AND l2.account_id <> l.account_id + LEFT JOIN accounts a2 + ON a2.account_id = l2.account_id + WHERE l.account_id = %s + AND e.is_deleted = false + """ + + params = [account_id] + + if from_date: + sql += " AND e.journal_date >= %s" + params.append(from_date) + + if to_date: + sql += " AND e.journal_date <= %s" + params.append(to_date) + + sql += " ORDER BY e.journal_date, e.journal_id" + + cur.execute(sql, params) + rows = cur.fetchall() + + result = [] + for r in rows: + amount = r["debit"] - r["credit"] + result.append({ + "journal_date": r["journal_date"], + "description": r["description"], + "counter_account": r["counter_account"], + "amount": amount, + "direction": "in" if amount > 0 else "out" if amount < 0 else "none", + }) + + return result diff --git a/backend/app/routers/year_locks.py b/backend/app/routers/year_locks.py new file mode 100644 index 0000000..7477682 --- /dev/null +++ b/backend/app/routers/year_locks.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter, HTTPException +from app.core.database import get_connection + +router = APIRouter(prefix="/year-locks", tags=["年度锁定"]) + + +@router.get("", summary="年度锁定一覧") +def get_year_locks(): + with get_connection() as conn, conn.cursor() as cur: + cur.execute(""" + SELECT fiscal_year, is_locked, locked_at, locked_by + FROM year_locks + ORDER BY fiscal_year + """) + return cur.fetchall() + + +@router.post("/lock", summary="年度锁定") +def lock_year(fiscal_year: int): + with get_connection() as conn, conn.cursor() as cur: + cur.execute(""" + INSERT INTO year_locks (fiscal_year, is_locked, locked_at, locked_by) + VALUES (%s, true, NOW(), %s) + ON CONFLICT (fiscal_year) + DO UPDATE + SET is_locked = true, + locked_at = NOW(), + locked_by = EXCLUDED.locked_by + """, (fiscal_year, "system")) + conn.commit() + + return {"status": "locked", "fiscal_year": fiscal_year} + + +@router.post("/unlock", summary="年度解锁") +def unlock_year(fiscal_year: int): + with get_connection() as conn, conn.cursor() as cur: + cur.execute(""" + UPDATE year_locks + SET is_locked = false + WHERE fiscal_year = %s + """, (fiscal_year,)) + + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="指定年度不存在") + + conn.commit() + + return {"status": "unlocked", "fiscal_year": fiscal_year} diff --git a/backend/app/static/cash-transactions.html b/backend/app/static/cash-transactions.html new file mode 100644 index 0000000..fe187d4 --- /dev/null +++ b/backend/app/static/cash-transactions.html @@ -0,0 +1,96 @@ + + + + + 资金流水 + + +

📄 资金流水

+ +
+ + + + + + + +
+ + + + + + + + + + + + + + +
日期摘要金额
请输入条件后查询
+ + + + diff --git a/backend/app/static/cash.html b/backend/app/static/cash.html new file mode 100644 index 0000000..a3e055e --- /dev/null +++ b/backend/app/static/cash.html @@ -0,0 +1,76 @@ + + + + + 资金余额 + + +

💰 当前资金状况

+ + + + + + + + + + + + + + + + + + + +
账户余额
读取中...
合计-
+ + + + diff --git a/frontend/index.html b/frontend/index.html index 8880463..5b78c0b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -30,7 +30,67 @@ +

💰 当前资金状况

+ + + + + + + + + + + + + + + + + + + +
账户余额
读取中...
合计-
+ + diff --git a/frontend/journal-entry.html b/frontend/journal-entry.html index 40ee21e..e20148a 100644 --- a/frontend/journal-entry.html +++ b/frontend/journal-entry.html @@ -1,185 +1,280 @@ - - -仕訳入力 - - - + + + 仕訳入力 + + + +

仕訳入力

-

仕訳入力

+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + -
- - - - - -
-
- - -
- - - - - - - - - - - - - -
科目借方貸方税区分税方向操作
- -
- - -
- -

月次锁定管理

- -
- - - - - -
- - - - - - - -
- -
- -

-
- - - - - - + tbody.appendChild(tr); + + // 税行の場合,填值 + if (isTaxRow && taxInfo) { + const acc = tr.querySelector(".account"); + acc.value = taxInfo.accountId || ""; + if (taxInfo.side === "debit") + tr.querySelector(".debit").value = taxInfo.amount; + if (taxInfo.side === "credit") + tr.querySelector(".credit").value = taxInfo.amount; + } + + // 普通行:绑定事件 + if (!isTaxRow) { + const recalc = () => handleTax(tr); + tr.querySelector(".debit").addEventListener("input", recalc); + tr.querySelector(".credit").addEventListener("input", recalc); + tr.querySelector(".taxType").addEventListener("change", recalc); + tr.querySelector(".taxDirection").addEventListener("change", recalc); + } + + return tr; + } + + // 删除该行的所有税行 + function removeTaxRowsOf(row) { + const id = row.dataset.rowId; + if (!id) return; + document + .querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`) + .forEach((r) => r.remove()); + } + + function removeRow(btn) { + const row = btn.closest("tr"); + removeTaxRowsOf(row); + row.remove(); + } + + // ----------------------------- + // 税行 自動生成/更新(10%/8%のみ、四捨五入) + // ----------------------------- + function handleTax(row) { + // 清理旧税行 + removeTaxRowsOf(row); + + const taxType = row.querySelector(".taxType").value; + const taxDir = row.querySelector(".taxDirection").value; + const debit = Number(row.querySelector(".debit").value || 0); + const credit = Number(row.querySelector(".credit").value || 0); + + if (taxType === "none" || taxDir === "none") return; + + const totalAmount = debit || credit; + if (!totalAmount) return; + + const rate = taxType === "8" ? 0.08 : 0.1; + const roundingMode = document.getElementById("roundingMode").value; + + // 税込 → 税額逆算 + const rawTax = (totalAmount * rate) / (1 + rate); + let taxAmount; + switch (roundingMode) { + case "floor": + taxAmount = Math.floor(rawTax); + break; + case "ceil": + taxAmount = Math.ceil(rawTax); + break; + default: + taxAmount = Math.round(rawTax); + } + + const parentId = row.dataset.rowId; + + if (taxDir === "paid") { + const taxAcc = document.getElementById("taxPaidSelect").value; + if (!taxAcc) return alert("仮払消費税等 勘定を選択してください"); + addRow( + true, + { accountId: taxAcc, amount: taxAmount, side: "debit" }, + parentId + ); + } + if (taxDir === "received") { + const taxAcc = document.getElementById("taxReceivedSelect").value; + if (!taxAcc) return alert("仮受消費税等 勘定を選択してください"); + addRow( + true, + { accountId: taxAcc, amount: taxAmount, side: "credit" }, + parentId + ); + } + } + + // 税行は常に「直後の1行」を使う + function removeTaxRow(row) { + const id = row.dataset.rowId; + if (!id) return; + + document + .querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`) + .forEach((tr) => tr.remove()); + } + + function removeRow(btn) { + const row = btn.closest("tr"); + removeTaxRow(row); + row.remove(); + } + + // ----------------------------- + // 登録(最小可用版) + // ----------------------------- + async function submitJournal() { + const entryDate = document.getElementById("entryDate").value; + const description = document.getElementById("description").value.trim(); + + if (!entryDate) { + alert("仕訳日を入力してください"); + return; + } + + // 明細収集 + const rows = document.querySelectorAll("#linesTable tbody tr"); + const lines = []; + let totalDebit = 0, + totalCredit = 0; + + rows.forEach((row) => { + if (row.classList.contains("tax-row")) return; + const accountId = Number(row.querySelector(".account").value); + const debit = Number(row.querySelector(".debit").value || 0); + const credit = Number(row.querySelector(".credit").value || 0); + if (debit > 0 && credit > 0) { + alert("同一行不能同时填写借方和贷方"); + throw new Error("invalid line"); + } + if (debit === 0 && credit === 0) return; + + lines.push({ account_id: accountId, debit: debit, credit: credit }); + totalDebit += debit; + totalCredit += credit; + }); + + if (lines.length === 0) { + alert("仕訳明細がありません"); + return; + } + if (lines.length < 2) { + alert("至少需要借方和贷方各一行"); + return; + } + // 借貸一致チェック(最小) + if (totalDebit !== totalCredit) { + alert( + `借方(${totalDebit.toLocaleString()})と貸方(${totalCredit.toLocaleString()})が一致しません` + ); + return; + } + + const payload = { entry_date: entryDate, description, lines }; + + const res = await fetch(`${API}/journal-entries`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + alert("登録に失敗しました"); + return; + } + + const result = await res.json(); + alert(`登録完了(ID=${result.journal_entry_id})`); + location.reload(); + } + + const API_BASE = "http://127.0.0.1:18080"; + + async function callApi(path) { + try { + const res = await fetch(`${API_BASE}${path}`, { method: "POST" }); + const text = await res.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { detail: text }; + } + + if (!res.ok) { + throw new Error(data.detail || `HTTP ${res.status}`); + } + return data; + } catch (e) { + throw new Error(e.message || "请求失败"); + } + } + + async function lockMonth() { + const year = document.getElementById("lockYear").value; + const month = document.getElementById("lockMonth").value; + + const el = document.getElementById("lockResult"); + el.innerText = "处理中..."; + + try { + await callApi(`/month-locks/lock?fiscal_year=${year}&month=${month}`); + el.innerText = `✅ ${year}年${month}月 已锁定`; + await refreshMonthLocks(); // 可选:自动刷新列表 + } catch (e) { + el.innerText = `❌ 错误:${e.message}`; + } + } + + async function unlockMonth() { + const year = document.getElementById("lockYear").value; + const month = document.getElementById("lockMonth").value; + + const el = document.getElementById("lockResult"); + el.innerText = "处理中..."; + + try { + await callApi( + `/month-locks/unlock?fiscal_year=${year}&month=${month}` + ); + el.innerText = `✅ ${year}年${month}月 已解锁`; + await refreshMonthLocks(); // 可选:自动刷新列表 + } catch (e) { + el.innerText = `❌ 错误:${e.message}`; + } + } + + async function refreshMonthLocks() { + const el = document.getElementById("lockList"); + el.textContent = "读取中..."; + + try { + const res = await fetch(`${API_BASE}/month-locks`); + const data = await res.json(); + + // 只显示 locked 的,避免太长 + const locked = (data || []).filter((x) => x.is_locked); + + if (locked.length === 0) { + el.textContent = "(当前没有锁定月份)"; + return; + } + + el.textContent = locked + .map( + (x) => + `${x.fiscal_year}-${String(x.month).padStart( + 2, + "0" + )} locked_by=${x.locked_by ?? ""}` + ) + .join("\n"); + } catch (e) { + el.textContent = `读取失败:${e.message || e}`; + } + } + + async function checkMonthLockByDate(dateStr) { + if (!dateStr) return false; + + const d = new Date(dateStr); + const year = d.getFullYear(); + const month = d.getMonth() + 1; + + const res = await fetch(`${API_BASE}/month-locks`); + const data = await res.json(); + + return (data || []).some( + (x) => x.fiscal_year === year && x.month === month && x.is_locked + ); + } + + async function onEntryDateChanged() { + const dateStr = document.getElementById("entryDate").value; + const locked = await checkMonthLockByDate(dateStr); + + const banner = document.getElementById("monthLockBanner"); + + if (locked) { + banner.style.display = "block"; + setJournalReadonly(true); + } else { + banner.style.display = "none"; + setJournalReadonly(false); + } + } + + function setJournalReadonly(readonly) { + // 保存按钮 + const saveBtn = document.getElementById("saveButton"); + if (saveBtn) saveBtn.disabled = readonly; + + // 行追加按钮 + const addRowBtn = document.getElementById("addRowButton"); + if (addRowBtn) addRowBtn.disabled = readonly; + + // 所有“删除行”按钮 + document.querySelectorAll(".delete-row-btn").forEach((btn) => { + btn.disabled = readonly; + }); + + // 所有仕訳输入项(金额 / 科目等) + document.querySelectorAll(".journal-input").forEach((input) => { + input.disabled = readonly; + }); + } + + function buildAccountOptions() { + let html = ""; + + // 已分组的科目 + ACCOUNT_GROUPS.forEach((group) => { + html += ``; + accounts + .filter((a) => group.codes.includes(a.account_code)) + .forEach((a) => { + html += ``; + }); + html += ``; + }); + + // 未分组的其他科目 + const groupedCodes = ACCOUNT_GROUPS.flatMap((g) => g.codes); + const others = accounts.filter( + (a) => !groupedCodes.includes(a.account_code) + ); + + if (others.length > 0) { + html += ``; + others.forEach((a) => { + html += ``; + }); + html += ``; + } + + return html; + } + + diff --git a/frontend/pages/cash-transactions.html b/frontend/pages/cash-transactions.html new file mode 100644 index 0000000..e69de29 diff --git a/frontend/pages/cash.html b/frontend/pages/cash.html new file mode 100644 index 0000000..deba512 --- /dev/null +++ b/frontend/pages/cash.html @@ -0,0 +1,202 @@ + + + + + 資金状況 + + + + +

💰 現在の資金状況

+ + + + + + + + + + + + + + + + +
口座残高
合計-
+ +
+ +

📄 資金流水

+ + +
+ + + + + + 〜 + + + +
+ + + + + + + + + + + + + +
日付摘要相手科目入金出金
+ + + + diff --git a/frontend/trial-balance.html b/frontend/trial-balance.html index dd22787..b43302b 100644 --- a/frontend/trial-balance.html +++ b/frontend/trial-balance.html @@ -1,56 +1,107 @@ - - - 试算表 - - - + + + 試算表 + + + +

試算表

-

試算表

+ + + + + + + + + + + + + + + + + + + + + + + +
科目コード科目名称期首残高借方合計贷方合計期末残高
合計0000
- - - - - - - - - - - - - - - - - - - -
科目代码科目名称借方合计贷方合计
合计00
+ + async function loadTrialBalance() { + const res = await fetch(`${API_BASE}/trial-balance`); + if (!res.ok) { + alert("試算表の取得に失敗しました"); + return; + } - + const data = await res.json(); + + const tbody = document.querySelector("#trialBalanceTable tbody"); + tbody.innerHTML = ""; + + data.accounts.forEach((acc) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${acc.account_code} + ${acc.account_name} + ${Number(acc.opening_balance).toLocaleString()} + ${Number(acc.debit).toLocaleString()} + ${Number(acc.credit).toLocaleString()} + ${Number(acc.closing_balance).toLocaleString()} + `; + tbody.appendChild(tr); + }); + + // totals + document.getElementById("totalOpening").innerText = Number( + data.totals.opening_balance + ).toLocaleString(); + document.getElementById("totalDebit").innerText = Number( + data.totals.debit + ).toLocaleString(); + document.getElementById("totalCredit").innerText = Number( + data.totals.credit + ).toLocaleString(); + document.getElementById("totalClosing").innerText = Number( + data.totals.closing_balance + ).toLocaleString(); + } + + loadTrialBalance(); + + diff --git a/メモ.txt b/メモ.txt index af2e8d2..1cd98db 100644 --- a/メモ.txt +++ b/メモ.txt @@ -118,4 +118,24 @@ updated_reason: 可空 -例如:金额修正、补录摘要、对账调整 \ No newline at end of file +例如:金额修正、补录摘要、对账调整 + + +------------------------------- +/volume1/docker/rc_ollama_bot/ +bot.py +① 停掉并删除旧容器(非常关键) +docker stop ollama-rc-bot +docker rm ollama-rc-bot +docker build --no-cache -t ollama-rc-bot . +② 重新 build 镜像(必须) +docker build --no-cache -t ollama-rc-bot . +③ 重新启动容器 +docker run -d \ + --name ollama-rc-bot \ + -p 5100:5005 \ + ollama-rc-bot + +docker run -d --name ollama-rc-bot -p 5100:5005 ollama-rc-bot +④ 看日志,确认新代码真的在跑 +docker logs -f ollama-rc-bot