修正
This commit is contained in:
16
_check_compose.py
Normal file
16
_check_compose.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import paramiko
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
|
||||||
|
|
||||||
|
# NAS上の実際のdocker-compose.ymlを確認
|
||||||
|
_, o, _ = c.exec_command('cat /volume1/docker/njts-accounting/docker-compose.yml')
|
||||||
|
print("=== docker-compose.yml ===")
|
||||||
|
print(o.read().decode())
|
||||||
|
|
||||||
|
# コンテナの全マウントを確認
|
||||||
|
_, o2, _ = c.exec_command('docker inspect njts-accounting-backend --format "{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}"')
|
||||||
|
print("=== マウント一覧 ===")
|
||||||
|
print(o2.read().decode())
|
||||||
|
|
||||||
|
c.close()
|
||||||
7
_check_containers.py
Normal file
7
_check_containers.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import paramiko
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
|
||||||
|
_, o, _ = c.exec_command('cd /volume1/docker/njts-accounting && docker compose ps')
|
||||||
|
print(o.read().decode())
|
||||||
|
c.close()
|
||||||
15
_check_mount.py
Normal file
15
_check_mount.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import paramiko
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
|
||||||
|
# コンテナ内の /app/frontend の内容確認
|
||||||
|
_, o, _ = c.exec_command('docker exec njts-accounting-backend ls -la /app/frontend/ | head -20')
|
||||||
|
print("=== コンテナ内 /app/frontend ===")
|
||||||
|
print(o.read().decode())
|
||||||
|
# コンテナのマウント情報
|
||||||
|
_, o2, _ = c.exec_command('docker inspect njts-accounting-backend --format "{{json .Mounts}}"')
|
||||||
|
import json
|
||||||
|
mounts = json.loads(o2.read().decode())
|
||||||
|
for m in mounts:
|
||||||
|
print(f" {m.get('Source')} -> {m.get('Destination')}")
|
||||||
|
c.close()
|
||||||
10
_check_nas_index.py
Normal file
10
_check_nas_index.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import paramiko
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
|
||||||
|
_, o, _ = c.exec_command('grep -n "bank-statement" /volume1/docker/njts-accounting/frontend/index.html')
|
||||||
|
result = o.read().decode()
|
||||||
|
print("NAS index.html grep結果:", repr(result))
|
||||||
|
_, o2, _ = c.exec_command('wc -l /volume1/docker/njts-accounting/frontend/index.html')
|
||||||
|
print("行数:", o2.read().decode().strip())
|
||||||
|
c.close()
|
||||||
7
_check_nas_index2.py
Normal file
7
_check_nas_index2.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import paramiko
|
||||||
|
c = paramiko.SSHClient()
|
||||||
|
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
c.connect('192.168.0.61', username='root', password='59911784', timeout=15)
|
||||||
|
_, o, _ = c.exec_command('sed -n "187,205p" /volume1/docker/njts-accounting/frontend/index.html')
|
||||||
|
print(o.read().decode())
|
||||||
|
c.close()
|
||||||
@@ -146,6 +146,13 @@ def run_auto_migration():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ ねんきんポータル テーブル作成エラー(無視): {e}")
|
print(f"⚠️ ねんきんポータル テーブル作成エラー(無視): {e}")
|
||||||
|
|
||||||
|
# ── 銀行明細テーブル作成 ──
|
||||||
|
try:
|
||||||
|
from app.modules.bank_statements.migration import create_bank_statement_table
|
||||||
|
create_bank_statement_table()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ 銀行明細テーブル作成エラー(無視): {e}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ 無法連接到數據庫或執行遷移: {e}")
|
print(f"\n❌ 無法連接到數據庫或執行遷移: {e}")
|
||||||
print("\n提示:")
|
print("\n提示:")
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ app.include_router(file_upload.router)
|
|||||||
from app.modules.egov.router import router as egov_router
|
from app.modules.egov.router import router as egov_router
|
||||||
app.include_router(egov_router)
|
app.include_router(egov_router)
|
||||||
|
|
||||||
|
from app.modules.bank_statements.router import router as bank_statements_router
|
||||||
|
app.include_router(bank_statements_router)
|
||||||
|
|
||||||
# ─────────────────────────
|
# ─────────────────────────
|
||||||
# 給与モジュール (Payroll Module)
|
# 給与モジュール (Payroll Module)
|
||||||
# ─────────────────────────
|
# ─────────────────────────
|
||||||
|
|||||||
0
backend/app/modules/bank_statements/__init__.py
Normal file
0
backend/app/modules/bank_statements/__init__.py
Normal file
41
backend/app/modules/bank_statements/migration.py
Normal file
41
backend/app/modules/bank_statements/migration.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""銀行明細テーブルのマイグレーション"""
|
||||||
|
import os
|
||||||
|
from psycopg import connect
|
||||||
|
|
||||||
|
|
||||||
|
def create_bank_statement_table():
|
||||||
|
conn = connect(
|
||||||
|
host=os.getenv("DB_HOST"),
|
||||||
|
port=int(os.getenv("DB_PORT", 5432)),
|
||||||
|
dbname=os.getenv("DB_NAME"),
|
||||||
|
user=os.getenv("DB_USER"),
|
||||||
|
password=os.getenv("DB_PASSWORD"),
|
||||||
|
)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS bank_statement_rows (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tx_date DATE NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
debit BIGINT NOT NULL DEFAULT 0,
|
||||||
|
credit BIGINT NOT NULL DEFAULT 0,
|
||||||
|
balance BIGINT,
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_name TEXT NOT NULL DEFAULT '',
|
||||||
|
import_batch_id TEXT NOT NULL,
|
||||||
|
row_hash TEXT NOT NULL,
|
||||||
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_bank_stmt_hash UNIQUE (row_hash)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bank_stmt_date
|
||||||
|
ON bank_statement_rows (tx_date)
|
||||||
|
""")
|
||||||
|
cur.execute("""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bank_stmt_batch
|
||||||
|
ON bank_statement_rows (import_batch_id)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
print("✓ bank_statement_rows テーブル確認完了")
|
||||||
|
conn.close()
|
||||||
427
backend/app/modules/bank_statements/router.py
Normal file
427
backend/app/modules/bank_statements/router.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
銀行明細 CSV 取込・照会モジュール
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.database import get_connection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bank-statements", tags=["銀行明細"])
|
||||||
|
|
||||||
|
# ── CSV 列名検出パターン ──────────────────────────────────────
|
||||||
|
|
||||||
|
_DATE_KEYS = ['日付', '取引日', '年月日', '取引年月日', 'date', '取引日付']
|
||||||
|
_DESC_KEYS = ['摘要', '内容', '取引内容', '支払先', '摘要/メモ', '入出金先内容',
|
||||||
|
'description', '取引先', 'お取引内容', '取引詳細']
|
||||||
|
_DEBIT_KEYS = ['お支払い金額', 'お支払金額', '支払額', '引出し', '出金', '引出額',
|
||||||
|
'借方', 'debit', '出金金額', '支払い金額', '引き出し金額']
|
||||||
|
_CREDIT_KEYS = ['お預かり金額', '預入れ', '預入', '入金', '預入金額',
|
||||||
|
'貸方', 'credit', '入金金額', 'お預り金額', '預け入れ金額']
|
||||||
|
_BALANCE_KEYS = ['残高', '差引残高', 'balance', '帳簿残高', '取引後残高']
|
||||||
|
_MEMO_KEYS = ['メモ', '備考', '通帳メモ', 'memo', '注記', '備考欄']
|
||||||
|
|
||||||
|
|
||||||
|
def _find_col(headers: List[str], patterns: List[str]) -> Optional[int]:
|
||||||
|
lowers = [h.strip().lower() for h in headers]
|
||||||
|
for pat in patterns:
|
||||||
|
p = pat.lower()
|
||||||
|
for i, h in enumerate(lowers):
|
||||||
|
if p in h:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_amount(s: str) -> int:
|
||||||
|
if not s:
|
||||||
|
return 0
|
||||||
|
cleaned = (s.strip()
|
||||||
|
.replace(',', '').replace(',', '')
|
||||||
|
.replace('¥', '').replace('¥', '')
|
||||||
|
.replace(' ', '').replace('\u3000', ''))
|
||||||
|
if cleaned in ('', '-', '―', '-', '▲', 'ー'):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(float(cleaned))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: str) -> Optional[date]:
|
||||||
|
s = (s.strip()
|
||||||
|
.replace('/', '-').replace('.', '-')
|
||||||
|
.replace('年', '-').replace('月', '-').replace('日', ''))
|
||||||
|
for fmt in ['%Y-%m-%d', '%y-%m-%d']:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
s2 = s.replace('-', '')
|
||||||
|
if len(s2) == 8:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s2, '%Y%m%d').date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if len(s2) == 6:
|
||||||
|
try:
|
||||||
|
return datetime.strptime('20' + s2, '%Y%m%d').date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_encoding(raw: bytes) -> str:
|
||||||
|
for enc in ['utf-8-sig', 'utf-8', 'shift_jis', 'cp932', 'euc-jp']:
|
||||||
|
try:
|
||||||
|
raw.decode(enc)
|
||||||
|
return enc
|
||||||
|
except (UnicodeDecodeError, LookupError):
|
||||||
|
continue
|
||||||
|
return 'utf-8'
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hash(tx_date: str, description: str, debit: int,
|
||||||
|
credit: int, balance: Optional[int]) -> str:
|
||||||
|
s = f"{tx_date}|{description}|{debit}|{credit}|{balance}"
|
||||||
|
return hashlib.md5(s.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 三菱UFJ MEISAI フォーマット対応 ──────────────────────────
|
||||||
|
# 行タイプ: "1"=口座ヘッダー, "2"=明細, "8"=フッター, "9"=合計
|
||||||
|
# 列: [タイプ, 日付, 摘要区分, 摘要詳細, 出金額, 入金額, 残高]
|
||||||
|
|
||||||
|
def _is_meisai_format(rows: List[List[str]]) -> bool:
|
||||||
|
"""三菱UFJ銀行 MEISAI フォーマット判定"""
|
||||||
|
type1 = any(r and r[0] == '1' for r in rows)
|
||||||
|
type2 = any(r and r[0] == '2' for r in rows)
|
||||||
|
return type1 and type2
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_meisai_rows(rows: List[List[str]]) -> tuple:
|
||||||
|
"""MEISAI フォーマットをパース。(明細リスト, 銀行名ヒント) を返す"""
|
||||||
|
bank_hint = ''
|
||||||
|
parsed = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
row_type = row[0]
|
||||||
|
|
||||||
|
if row_type == '1' and len(row) >= 3:
|
||||||
|
# ヘッダー行: [1, 支店コード, 支店名, ..., 科目, 口座番号, 口座名, ...]
|
||||||
|
branch = row[2].strip() if len(row) > 2 else ''
|
||||||
|
bank_hint = f"三菱UFJ銀行 {branch}" if branch else '三菱UFJ銀行'
|
||||||
|
|
||||||
|
elif row_type == '2' and len(row) >= 5:
|
||||||
|
# 明細行: [2, 日付, 摘要区分, 摘要詳細, 出金額, 入金額, 残高]
|
||||||
|
tx_date = _parse_date(row[1])
|
||||||
|
if tx_date is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
summary1 = row[2].strip() if len(row) > 2 else ''
|
||||||
|
summary2 = row[3].strip() if len(row) > 3 else ''
|
||||||
|
desc = f"{summary1} {summary2}".strip(' ') if summary2 else summary1
|
||||||
|
|
||||||
|
debit = _parse_amount(row[4]) if len(row) > 4 else 0
|
||||||
|
credit = _parse_amount(row[5]) if len(row) > 5 else 0
|
||||||
|
bal_str = row[6].strip() if len(row) > 6 else ''
|
||||||
|
balance = _parse_amount(bal_str) if bal_str else None
|
||||||
|
|
||||||
|
parsed.append({
|
||||||
|
'tx_date': tx_date.isoformat(),
|
||||||
|
'description': desc,
|
||||||
|
'debit': debit,
|
||||||
|
'credit': credit,
|
||||||
|
'balance': balance,
|
||||||
|
'memo': '',
|
||||||
|
'row_hash': _make_hash(tx_date.isoformat(), desc, debit, credit, balance),
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed, bank_hint
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv_bytes(content: bytes) -> tuple:
|
||||||
|
"""CSVをパース。(明細リスト, 銀行名ヒント) を返す"""
|
||||||
|
enc = _detect_encoding(content)
|
||||||
|
text = content.decode(enc).replace('\x00', '').strip()
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(text))
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=400, detail="CSVファイルが空です")
|
||||||
|
|
||||||
|
# MEISAI(三菱UFJ)フォーマット優先検出
|
||||||
|
if _is_meisai_format(rows):
|
||||||
|
return _parse_meisai_rows(rows)
|
||||||
|
|
||||||
|
# ── 汎用ヘッダー行検出 ──
|
||||||
|
header_row = 0
|
||||||
|
for i, row in enumerate(rows[:15]):
|
||||||
|
row_str = ' '.join(row).lower()
|
||||||
|
if any(k.lower() in row_str for k in ['日付', '取引日', 'date', '年月日']):
|
||||||
|
header_row = i
|
||||||
|
break
|
||||||
|
|
||||||
|
headers = rows[header_row]
|
||||||
|
date_col = _find_col(headers, _DATE_KEYS)
|
||||||
|
desc_col = _find_col(headers, _DESC_KEYS)
|
||||||
|
debit_col = _find_col(headers, _DEBIT_KEYS)
|
||||||
|
credit_col = _find_col(headers, _CREDIT_KEYS)
|
||||||
|
balance_col = _find_col(headers, _BALANCE_KEYS)
|
||||||
|
memo_col = _find_col(headers, _MEMO_KEYS)
|
||||||
|
|
||||||
|
if date_col is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="日付列が見つかりません。CSVのヘッダー行に「日付」「取引日」等の列名が必要です。"
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = []
|
||||||
|
for row in rows[header_row + 1:]:
|
||||||
|
if not row or all(c.strip() == '' for c in row):
|
||||||
|
continue
|
||||||
|
if len(row) <= date_col:
|
||||||
|
continue
|
||||||
|
raw_date = row[date_col].strip()
|
||||||
|
if not raw_date:
|
||||||
|
continue
|
||||||
|
tx_date = _parse_date(raw_date)
|
||||||
|
if tx_date is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _safe(col: Optional[int]) -> str:
|
||||||
|
return row[col].strip() if col is not None and len(row) > col else ''
|
||||||
|
|
||||||
|
desc = _safe(desc_col)
|
||||||
|
debit = _parse_amount(_safe(debit_col))
|
||||||
|
credit = _parse_amount(_safe(credit_col))
|
||||||
|
bal_str = _safe(balance_col)
|
||||||
|
balance = _parse_amount(bal_str) if bal_str else None
|
||||||
|
memo = _safe(memo_col)
|
||||||
|
|
||||||
|
parsed.append({
|
||||||
|
'tx_date': tx_date.isoformat(),
|
||||||
|
'description': desc,
|
||||||
|
'debit': debit,
|
||||||
|
'credit': credit,
|
||||||
|
'balance': balance,
|
||||||
|
'memo': memo,
|
||||||
|
'row_hash': _make_hash(tx_date.isoformat(), desc, debit, credit, balance),
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed, ''
|
||||||
|
|
||||||
|
|
||||||
|
# ── エンドポイント ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/preview", summary="CSVプレビュー(重複チェック)")
|
||||||
|
async def preview_csv(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
bank_name: str = Form(""),
|
||||||
|
):
|
||||||
|
"""CSVを解析し、重複チェック結果付きでプレビューを返す(DBには保存しない)"""
|
||||||
|
content = await file.read()
|
||||||
|
if len(content) > 10_000_000:
|
||||||
|
raise HTTPException(status_code=400, detail="ファイルサイズが大きすぎます(上限10MB)")
|
||||||
|
|
||||||
|
rows, bank_hint = _parse_csv_bytes(content)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=400, detail="有効なデータ行がありません")
|
||||||
|
|
||||||
|
# 銀行名未入力の場合はCSVから自動検出した値を使用
|
||||||
|
if not bank_name.strip() and bank_hint:
|
||||||
|
bank_name = bank_hint
|
||||||
|
|
||||||
|
# DB上の既存ハッシュを取得して重複チェック
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT row_hash FROM bank_statement_rows")
|
||||||
|
existing_hashes = {r['row_hash'] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
row['status'] = 'duplicate' if row['row_hash'] in existing_hashes else 'new'
|
||||||
|
result.append(row)
|
||||||
|
|
||||||
|
new_count = sum(1 for r in result if r['status'] == 'new')
|
||||||
|
dup_count = sum(1 for r in result if r['status'] == 'duplicate')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': len(result),
|
||||||
|
'new': new_count,
|
||||||
|
'duplicates': dup_count,
|
||||||
|
'bank_name': bank_name,
|
||||||
|
'rows': result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRow(BaseModel):
|
||||||
|
tx_date: str
|
||||||
|
description: str
|
||||||
|
debit: int
|
||||||
|
credit: int
|
||||||
|
balance: Optional[int] = None
|
||||||
|
memo: str = ''
|
||||||
|
row_hash: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRequest(BaseModel):
|
||||||
|
bank_name: str = ''
|
||||||
|
rows: List[ImportRow]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", summary="CSV取込確定")
|
||||||
|
def import_rows(req: ImportRequest):
|
||||||
|
"""プレビュー確認後、新規行のみDBに取込む"""
|
||||||
|
if not req.rows:
|
||||||
|
raise HTTPException(status_code=400, detail="取込データがありません")
|
||||||
|
|
||||||
|
batch_id = str(uuid.uuid4())
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
for row in req.rows:
|
||||||
|
# row_hash が空の場合は再計算
|
||||||
|
rh = row.row_hash or _make_hash(
|
||||||
|
row.tx_date, row.description, row.debit, row.credit, row.balance
|
||||||
|
)
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO bank_statement_rows
|
||||||
|
(tx_date, description, debit, credit, balance,
|
||||||
|
memo, bank_name, import_batch_id, row_hash)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (row_hash) DO NOTHING
|
||||||
|
""", (
|
||||||
|
row.tx_date, row.description,
|
||||||
|
row.debit, row.credit, row.balance,
|
||||||
|
row.memo, req.bank_name, batch_id, rh,
|
||||||
|
))
|
||||||
|
if cur.rowcount > 0:
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'batch_id': batch_id,
|
||||||
|
'inserted': inserted,
|
||||||
|
'skipped': skipped,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", summary="明細一覧取得")
|
||||||
|
def list_statements(
|
||||||
|
year: Optional[int] = None,
|
||||||
|
month: Optional[int] = None,
|
||||||
|
bank_name: Optional[str] = None,
|
||||||
|
limit: int = 10000,
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
conditions: list = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if year:
|
||||||
|
conditions.append("EXTRACT(YEAR FROM tx_date)::int = %s")
|
||||||
|
params.append(year)
|
||||||
|
if month:
|
||||||
|
conditions.append("EXTRACT(MONTH FROM tx_date)::int = %s")
|
||||||
|
params.append(month)
|
||||||
|
if bank_name:
|
||||||
|
conditions.append("bank_name = %s")
|
||||||
|
params.append(bank_name)
|
||||||
|
|
||||||
|
where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||||
|
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
# 明細行
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT id,
|
||||||
|
TO_CHAR(tx_date, 'YYYY-MM-DD') AS tx_date,
|
||||||
|
description,
|
||||||
|
debit::bigint,
|
||||||
|
credit::bigint,
|
||||||
|
balance::bigint,
|
||||||
|
memo,
|
||||||
|
bank_name,
|
||||||
|
import_batch_id
|
||||||
|
FROM bank_statement_rows
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY tx_date, id
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""", params + [limit, offset])
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
# 月別集計
|
||||||
|
cur.execute(f"""
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(tx_date, 'YYYY-MM') AS ym,
|
||||||
|
SUM(debit)::bigint AS total_debit,
|
||||||
|
SUM(credit)::bigint AS total_credit,
|
||||||
|
COUNT(*)::int AS count
|
||||||
|
FROM bank_statement_rows
|
||||||
|
{where_clause}
|
||||||
|
GROUP BY ym
|
||||||
|
ORDER BY ym
|
||||||
|
""", params)
|
||||||
|
monthly = cur.fetchall()
|
||||||
|
|
||||||
|
# 総件数
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT COUNT(*)::int AS cnt FROM bank_statement_rows {where_clause}",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
total = cur.fetchone()['cnt']
|
||||||
|
|
||||||
|
# 銀行名リスト
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT bank_name
|
||||||
|
FROM bank_statement_rows
|
||||||
|
WHERE bank_name != ''
|
||||||
|
ORDER BY bank_name
|
||||||
|
""")
|
||||||
|
banks = [r['bank_name'] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total': total,
|
||||||
|
'rows': rows,
|
||||||
|
'monthly': monthly,
|
||||||
|
'banks': banks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/batches", summary="取込バッチ一覧")
|
||||||
|
def list_batches():
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
import_batch_id,
|
||||||
|
bank_name,
|
||||||
|
MIN(tx_date)::text AS date_from,
|
||||||
|
MAX(tx_date)::text AS date_to,
|
||||||
|
COUNT(*)::int AS count,
|
||||||
|
MIN(imported_at)::text AS imported_at
|
||||||
|
FROM bank_statement_rows
|
||||||
|
GROUP BY import_batch_id, bank_name
|
||||||
|
ORDER BY MIN(imported_at) DESC
|
||||||
|
LIMIT 100
|
||||||
|
""")
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/batch/{batch_id}", summary="バッチ削除")
|
||||||
|
def delete_batch(batch_id: str):
|
||||||
|
with get_connection() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM bank_statement_rows WHERE import_batch_id = %s",
|
||||||
|
(batch_id,)
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
return {'deleted': deleted}
|
||||||
63
create_bank_table.py
Normal file
63
create_bank_table.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""銀行明細テーブルを直接作成する"""
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST = "192.168.0.61"
|
||||||
|
USER = "root"
|
||||||
|
PASS = "59911784"
|
||||||
|
|
||||||
|
SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS bank_statement_rows (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tx_date DATE NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
debit BIGINT NOT NULL DEFAULT 0,
|
||||||
|
credit BIGINT NOT NULL DEFAULT 0,
|
||||||
|
balance BIGINT,
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_name TEXT NOT NULL DEFAULT '',
|
||||||
|
import_batch_id TEXT NOT NULL,
|
||||||
|
row_hash TEXT NOT NULL,
|
||||||
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_bank_stmt_hash UNIQUE (row_hash)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bank_stmt_date ON bank_statement_rows (tx_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bank_stmt_batch ON bank_statement_rows (import_batch_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=15)
|
||||||
|
|
||||||
|
cmd = f"""docker exec njts-accounting-backend python -c "
|
||||||
|
import os, psycopg
|
||||||
|
conn = psycopg.connect(host=os.getenv('DB_HOST'), port=os.getenv('DB_PORT'), dbname=os.getenv('DB_NAME'), user=os.getenv('DB_USER'), password=os.getenv('DB_PASSWORD'))
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(\\\"\\\"\\\"
|
||||||
|
CREATE TABLE IF NOT EXISTS bank_statement_rows (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tx_date DATE NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
debit BIGINT NOT NULL DEFAULT 0,
|
||||||
|
credit BIGINT NOT NULL DEFAULT 0,
|
||||||
|
balance BIGINT,
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_name TEXT NOT NULL DEFAULT '',
|
||||||
|
import_batch_id TEXT NOT NULL,
|
||||||
|
row_hash TEXT NOT NULL,
|
||||||
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_bank_stmt_hash UNIQUE (row_hash)
|
||||||
|
)
|
||||||
|
\\\"\\\"\\\")
|
||||||
|
cur.execute('CREATE INDEX IF NOT EXISTS idx_bank_stmt_date ON bank_statement_rows (tx_date)')
|
||||||
|
cur.execute('CREATE INDEX IF NOT EXISTS idx_bank_stmt_batch ON bank_statement_rows (import_batch_id)')
|
||||||
|
conn.commit()
|
||||||
|
print('テーブル作成完了')
|
||||||
|
conn.close()
|
||||||
|
"
|
||||||
|
"""
|
||||||
|
|
||||||
|
_, o, e = client.exec_command(cmd, timeout=30)
|
||||||
|
print(o.read().decode())
|
||||||
|
err = e.read().decode()
|
||||||
|
if err: print("STDERR:", err)
|
||||||
|
client.close()
|
||||||
32
docs/MEISAI20260531234440.csv
Normal file
32
docs/MEISAI20260531234440.csv
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"1","082","錦糸町支店","1","0","普通","0788058","新日本テクノソリューションズ 株式会社","2026.4.1-2026.5.31","全明細","2026.5.31","23:44","","",""
|
||||||
|
"2","2026.4.15","振込1","カ)ニユ-ソリユ-シヨンズ","0","630000","17158067"
|
||||||
|
"2","2026.4.20","口座振替3","チユウタイキヨウカケキン","41000","0","17117067"
|
||||||
|
"2","2026.4.28","口座振替3","ZHゼイリシホウシユウ","11000","0","17106067"
|
||||||
|
"2","2026.4.30","社会保険料","","247066","0","16859001"
|
||||||
|
"2","2026.4.30","振込1","スズヨシンワ-ト(カ","0","1606000","18465001"
|
||||||
|
"2","2026.4.30","振込BZ2","カ)エフケ-エス","170000","0","18295001"
|
||||||
|
"2","2026.4.30","為替手数料","フリコミ テスウリヨウ","660","0","18294341"
|
||||||
|
"2","2026.4.30","振込BZ1","オウ カク","353499","0","17940842"
|
||||||
|
"2","2026.4.30","為替手数料","フリコミ テスウリヨウ","330","0","17940512"
|
||||||
|
"2","2026.4.30","振込BZ1","チヨウ シヨウカク","4026825","0","13913687"
|
||||||
|
"2","2026.4.30","為替手数料","フリコミ テスウリヨウ","330","0","13913357"
|
||||||
|
"2","2026.5.1","カ-ド","","1000000","0","12913357"
|
||||||
|
"2","2026.5.7","カ-ド","","1000000","0","11913357"
|
||||||
|
"2","2026.5.15","振込1","カ)ニユ-ソリユ-シヨンズ","0","630000","12543357"
|
||||||
|
"2","2026.5.18","口座振替3","チユウタイキヨウカケキン","41000","0","12502357"
|
||||||
|
"2","2026.5.18","振込1","エンタツ (カ","0","4620000","17122357"
|
||||||
|
"2","2026.5.20","振込BZ1","オウ カク","2787776","0","14334581"
|
||||||
|
"2","2026.5.20","為替手数料","フリコミ テスウリヨウ","330","0","14334251"
|
||||||
|
"2","2026.5.28","口座振替3","ZHゼイリシホウシユウ","11000","0","14323251"
|
||||||
|
"2","2026.5.29","振込BZ2","カ)エフケ-エス","170000","0","14153251"
|
||||||
|
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","660","0","14152591"
|
||||||
|
"2","2026.5.29","振込BZ2","チヨウシヨウカク","350781","0","13801810"
|
||||||
|
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","660","0","13801150"
|
||||||
|
"2","2026.5.29","振込BZ1","オウ カク","353091","0","13448059"
|
||||||
|
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","330","0","13447729"
|
||||||
|
"2","2026.5.29","振込BZ2","エンタツ (カ","824340","0","12623389"
|
||||||
|
"2","2026.5.29","為替手数料","フリコミ テスウリヨウ","660","0","12622729"
|
||||||
|
"2","2026.5.29","振込1","スズヨシンワ-ト(カ","0","1694000","14316729"
|
||||||
|
"2","2026.5.29","振込1","エンタツ (カ","0","825000","15141729"
|
||||||
|
"8"
|
||||||
|
"9","23","6","","11391338","10005000","16528067","15141729"
|
||||||
|
BIN
docs/三菱UFJ_APIのアカウント資料.xlsx
Normal file
BIN
docs/三菱UFJ_APIのアカウント資料.xlsx
Normal file
Binary file not shown.
768
frontend/bank-statement.html
Normal file
768
frontend/bank-statement.html
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<script src="js/auth.js"></script>
|
||||||
|
<title>銀行明細照会</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
<link rel="stylesheet" href="/css/mobile.css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "MS Gothic", "Meiryo", sans-serif;
|
||||||
|
margin: 24px auto;
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
/* ── ヘッダー ── */
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.page-header h2 { margin: 0; }
|
||||||
|
.header-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── 検索フォーム ── */
|
||||||
|
.search-form {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.search-form label { font-weight: bold; white-space: nowrap; }
|
||||||
|
.search-form select,
|
||||||
|
.search-form input[type="number"] {
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ボタン共通 ── */
|
||||||
|
.btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #4caf50; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #43a047; }
|
||||||
|
.btn-blue { background: #2196f3; color: #fff; }
|
||||||
|
.btn-blue:hover { background: #1976d2; }
|
||||||
|
.btn-orange { background: #ff9800; color: #fff; }
|
||||||
|
.btn-orange:hover { background: #f57c00; }
|
||||||
|
.btn-danger { background: #e53935; color: #fff; }
|
||||||
|
.btn-danger:hover { background: #c62828; }
|
||||||
|
.btn-gray { background: #9e9e9e; color: #fff; }
|
||||||
|
.btn-gray:hover { background: #757575; }
|
||||||
|
|
||||||
|
/* ── 月別サマリー ── */
|
||||||
|
.monthly-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.month-chip {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.month-chip:hover { background: #e3f2fd; border-color: #2196f3; }
|
||||||
|
.month-chip.active { background: #2196f3; color: #fff; border-color: #1976d2; }
|
||||||
|
|
||||||
|
/* ── 統計バー ── */
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat-item { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.stat-label { color: #666; }
|
||||||
|
.stat-value { font-weight: bold; }
|
||||||
|
.stat-value.debit { color: #e53935; }
|
||||||
|
.stat-value.credit { color: #1565c0; }
|
||||||
|
|
||||||
|
/* ── テーブル ── */
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #e8e8e8;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td { background: #fff; }
|
||||||
|
tr:nth-child(even) td { background: #fafafa; }
|
||||||
|
tr:hover td { background: #e3f2fd; }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-left { text-align: left; }
|
||||||
|
td.debit-cell { color: #c62828; }
|
||||||
|
td.credit-cell { color: #1565c0; }
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
padding: 30px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── モーダル ── */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 30px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal-overlay.active { display: flex; }
|
||||||
|
.modal-box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 920px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.modal-box h3 { margin: 0 0 18px; font-size: 17px; }
|
||||||
|
.modal-form-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modal-form-row label { font-weight: bold; white-space: nowrap; }
|
||||||
|
.modal-form-row input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
/* ドラッグ&ドロップエリア */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed #999;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.drop-zone:hover, .drop-zone.dragover {
|
||||||
|
border-color: #2196f3;
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1565c0;
|
||||||
|
}
|
||||||
|
.drop-zone input[type="file"] { display: none; }
|
||||||
|
.drop-zone .drop-icon { font-size: 36px; display: block; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* プレビュー結果 */
|
||||||
|
.preview-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.preview-summary .ps-item { font-size: 14px; }
|
||||||
|
.ps-new { color: #2e7d32; font-weight: bold; }
|
||||||
|
.ps-dup { color: #e65100; }
|
||||||
|
.ps-total { font-weight: bold; }
|
||||||
|
|
||||||
|
.preview-table-wrap {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.preview-table-wrap table { font-size: 12px; }
|
||||||
|
.preview-table-wrap th { position: sticky; top: 0; z-index: 1; }
|
||||||
|
tr.row-new td { background: #e8f5e9 !important; }
|
||||||
|
tr.row-dup td { background: #fff3e0 !important; color: #999; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.badge-new { background: #c8e6c9; color: #1b5e20; }
|
||||||
|
.badge-dup { background: #ffe0b2; color: #bf360c; }
|
||||||
|
|
||||||
|
.modal-btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── メッセージ ── */
|
||||||
|
.msg-bar {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.msg-bar.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
|
||||||
|
.msg-bar.success { background: #e8f5e9; color: #1b5e20; border: 1px solid #a5d6a7; }
|
||||||
|
.msg-bar.error { background: #ffebee; color: #b71c1c; border: 1px solid #ef9a9a; }
|
||||||
|
|
||||||
|
/* ── ページネーション ── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.page-info { font-size: 13px; color: #333; }
|
||||||
|
.btn-page { background: #f0f0f0; color: #333; border: 1px solid #bbb; }
|
||||||
|
.btn-page:hover:not(:disabled) { background: #e0e0e0; }
|
||||||
|
.btn-page:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
/* ── 印刷スタイル ── */
|
||||||
|
@media print {
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
body { margin: 0; font-size: 12px; }
|
||||||
|
table { font-size: 11px; }
|
||||||
|
th, td { padding: 4px 6px; }
|
||||||
|
.monthly-summary, .stats-bar { margin-bottom: 6px; }
|
||||||
|
.month-chip { border: 1px solid #999 !important; color: #000 !important; background: #fff !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-header no-print">
|
||||||
|
<h2>🏦 銀行明細照会</h2>
|
||||||
|
<div class="header-btns">
|
||||||
|
<button class="btn btn-orange" onclick="openImportModal()">CSV取込</button>
|
||||||
|
<button class="btn btn-blue" onclick="window.print()">印刷</button>
|
||||||
|
<a class="btn btn-gray" href="index.html">← メニューへ戻る</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="msgBar" class="msg-bar no-print"></div>
|
||||||
|
|
||||||
|
<!-- 検索フォーム -->
|
||||||
|
<div class="search-form no-print">
|
||||||
|
<label>年:</label>
|
||||||
|
<input type="number" id="filterYear" min="2000" max="2099" style="width:80px" />
|
||||||
|
<label>月:</label>
|
||||||
|
<select id="filterMonth">
|
||||||
|
<option value="">全月</option>
|
||||||
|
<option value="1">1月</option><option value="2">2月</option>
|
||||||
|
<option value="3">3月</option><option value="4">4月</option>
|
||||||
|
<option value="5">5月</option><option value="6">6月</option>
|
||||||
|
<option value="7">7月</option><option value="8">8月</option>
|
||||||
|
<option value="9">9月</option><option value="10">10月</option>
|
||||||
|
<option value="11">11月</option><option value="12">12月</option>
|
||||||
|
</select>
|
||||||
|
<label>銀行:</label>
|
||||||
|
<select id="filterBank">
|
||||||
|
<option value="">全銀行</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" onclick="loadData()">検索</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 月別チップ -->
|
||||||
|
<div class="monthly-summary" id="monthlyChips"></div>
|
||||||
|
|
||||||
|
<!-- 統計バー -->
|
||||||
|
<div class="stats-bar" id="statsBar"></div>
|
||||||
|
|
||||||
|
<!-- 明細テーブル -->
|
||||||
|
<div id="tableWrap">
|
||||||
|
<div class="no-data">データを読み込み中…</div>
|
||||||
|
</div>
|
||||||
|
<div id="paginationBar" class="pagination no-print"></div>
|
||||||
|
|
||||||
|
<!-- ========== CSV取込モーダル ========== -->
|
||||||
|
<div class="modal-overlay no-print" id="importModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>📂 銀行明細 CSV 取込</h3>
|
||||||
|
|
||||||
|
<!-- ステップ1: ファイル選択 -->
|
||||||
|
<div id="step1">
|
||||||
|
<div class="modal-form-row">
|
||||||
|
<label>銀行名:</label>
|
||||||
|
<input type="text" id="bankNameInput" placeholder="例:三菱UFJ銀行 本店普通口座" />
|
||||||
|
</div>
|
||||||
|
<div class="drop-zone" id="dropZone" onclick="document.getElementById('csvFile').click()">
|
||||||
|
<span class="drop-icon">📄</span>
|
||||||
|
<div>CSVファイルをドラッグ&ドロップ、またはクリックして選択</div>
|
||||||
|
<div style="font-size:12px;color:#999;margin-top:6px">対応形式: CSV(UTF-8 / Shift-JIS)</div>
|
||||||
|
<input type="file" id="csvFile" accept=".csv,.txt" onchange="onFileSelected(this)" />
|
||||||
|
</div>
|
||||||
|
<div id="step1msg" class="msg-bar"></div>
|
||||||
|
<div class="modal-btn-row">
|
||||||
|
<button class="btn btn-gray" onclick="closeImportModal()">キャンセル</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ステップ2: プレビュー -->
|
||||||
|
<div id="step2" style="display:none">
|
||||||
|
<div class="preview-summary" id="previewSummary"></div>
|
||||||
|
<div class="preview-table-wrap">
|
||||||
|
<table id="previewTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:36px">状態</th>
|
||||||
|
<th>日付</th>
|
||||||
|
<th>摘要</th>
|
||||||
|
<th>出金</th>
|
||||||
|
<th>入金</th>
|
||||||
|
<th>残高</th>
|
||||||
|
<th>メモ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="previewBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="step2msg" class="msg-bar"></div>
|
||||||
|
<div class="modal-btn-row">
|
||||||
|
<button class="btn btn-gray" onclick="backToStep1()">← 戻る</button>
|
||||||
|
<button class="btn btn-primary" id="importConfirmBtn" onclick="confirmImport()">
|
||||||
|
新規データを取込む
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ステップ3: 完了 -->
|
||||||
|
<div id="step3" style="display:none">
|
||||||
|
<div id="step3msg" class="msg-bar success" style="display:block"></div>
|
||||||
|
<div class="modal-btn-row" style="margin-top:16px">
|
||||||
|
<button class="btn btn-primary" onclick="closeImportModal(); loadData()">
|
||||||
|
明細を表示する
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-orange" onclick="backToStep1()">続けて取込む</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── 初期化 ──────────────────────────────────────────────
|
||||||
|
let _previewRows = [];
|
||||||
|
let _bankName = '';
|
||||||
|
let _allRows = [];
|
||||||
|
let _currentPage = 1;
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadBanks();
|
||||||
|
loadData();
|
||||||
|
initDropZone();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── メッセージ表示 ──────────────────────────────────────
|
||||||
|
function showMsg(id, text, type) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.textContent = text;
|
||||||
|
el.className = 'msg-bar ' + type;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
function hideMsg(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 銀行名リスト読み込み ────────────────────────────────
|
||||||
|
async function loadBanks() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/bank-statements?limit=1');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const sel = document.getElementById('filterBank');
|
||||||
|
const cur = sel.value;
|
||||||
|
// 既存オプションを保持しつつ追加
|
||||||
|
const existing = Array.from(sel.options).map(o => o.value);
|
||||||
|
data.banks.forEach(b => {
|
||||||
|
if (!existing.includes(b)) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = b; opt.textContent = b;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (cur) sel.value = cur;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 明細データ読み込み ──────────────────────────────────
|
||||||
|
async function loadData() {
|
||||||
|
const year = document.getElementById('filterYear').value;
|
||||||
|
const month = document.getElementById('filterMonth').value;
|
||||||
|
const bank = document.getElementById('filterBank').value;
|
||||||
|
|
||||||
|
hideMsg('msgBar');
|
||||||
|
|
||||||
|
const qp = [];
|
||||||
|
if (year) qp.push(`year=${year}`);
|
||||||
|
if (month) qp.push(`month=${month}`);
|
||||||
|
if (bank) qp.push(`bank_name=${encodeURIComponent(bank)}`);
|
||||||
|
const url = '/bank-statements' + (qp.length ? '?' + qp.join('&') : '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
_allRows = data.rows;
|
||||||
|
_currentPage = 1;
|
||||||
|
renderMonthlyChips(data.monthly, year, month);
|
||||||
|
renderStats();
|
||||||
|
renderTable();
|
||||||
|
updateBankFilter(data.banks);
|
||||||
|
} catch (e) {
|
||||||
|
showMsg('msgBar', 'データ取得エラー: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBankFilter(banks) {
|
||||||
|
const sel = document.getElementById('filterBank');
|
||||||
|
const cur = sel.value;
|
||||||
|
// 全銀行以外のオプションをクリアして再構築
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
banks.forEach(b => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = b; opt.textContent = b;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (cur) sel.value = cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 月別チップ ──────────────────────────────────────────
|
||||||
|
function renderMonthlyChips(monthly, selYear, selMonth) {
|
||||||
|
const wrap = document.getElementById('monthlyChips');
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
if (!monthly.length) return;
|
||||||
|
|
||||||
|
// 「全月」チップ
|
||||||
|
const allChip = document.createElement('span');
|
||||||
|
allChip.className = 'month-chip no-print' + (!selMonth ? ' active' : '');
|
||||||
|
allChip.textContent = '全月';
|
||||||
|
allChip.onclick = () => {
|
||||||
|
document.getElementById('filterMonth').value = '';
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
wrap.appendChild(allChip);
|
||||||
|
|
||||||
|
monthly.forEach(m => {
|
||||||
|
const [y, mo] = m.ym.split('-');
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
const isActive = (String(selYear) === y && String(selMonth) === String(parseInt(mo)));
|
||||||
|
chip.className = 'month-chip no-print' + (isActive ? ' active' : '');
|
||||||
|
chip.innerHTML = `${parseInt(mo)}月 <small style="color:inherit;opacity:.8">${
|
||||||
|
(m.count)}件</small>`;
|
||||||
|
chip.title =
|
||||||
|
`出金 ${fmt(m.total_debit)} / 入金 ${fmt(m.total_credit)}`;
|
||||||
|
chip.onclick = () => {
|
||||||
|
document.getElementById('filterYear').value = y;
|
||||||
|
document.getElementById('filterMonth').value = parseInt(mo);
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
wrap.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 統計バー ────────────────────────────────────────────
|
||||||
|
function renderStats() {
|
||||||
|
const bar = document.getElementById('statsBar');
|
||||||
|
if (!_allRows.length) { bar.innerHTML = ''; return; }
|
||||||
|
const totalDebit = _allRows.reduce((s, r) => s + (r.debit || 0), 0);
|
||||||
|
const totalCredit = _allRows.reduce((s, r) => s + (r.credit || 0), 0);
|
||||||
|
bar.innerHTML = `
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">総件数:</span>
|
||||||
|
<span class="stat-value">${_allRows.length.toLocaleString()} 件</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">出金合計:</span>
|
||||||
|
<span class="stat-value debit">${fmt(totalDebit)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">入金合計:</span>
|
||||||
|
<span class="stat-value credit">${fmt(totalCredit)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">差引:</span>
|
||||||
|
<span class="stat-value">${fmt(totalCredit - totalDebit)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 明細テーブル ─────────────────────────────────────────
|
||||||
|
function renderTable() {
|
||||||
|
const wrap = document.getElementById('tableWrap');
|
||||||
|
if (!_allRows.length) {
|
||||||
|
wrap.innerHTML = '<div class="no-data">該当する明細データがありません</div>';
|
||||||
|
renderPagination(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = (_currentPage - 1) * PAGE_SIZE;
|
||||||
|
const pageRows = _allRows.slice(start, start + PAGE_SIZE);
|
||||||
|
let html = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-center">日付</th>
|
||||||
|
<th class="text-left">摘要</th>
|
||||||
|
<th class="text-right">出金</th>
|
||||||
|
<th class="text-right">入金</th>
|
||||||
|
<th class="text-right">残高</th>
|
||||||
|
<th class="text-left">メモ</th>
|
||||||
|
<th class="text-center no-print">銀行</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
pageRows.forEach(r => {
|
||||||
|
html += `<tr>
|
||||||
|
<td class="text-center">${r.tx_date}</td>
|
||||||
|
<td class="text-left">${esc(r.description)}</td>
|
||||||
|
<td class="text-right debit-cell">${r.debit ? fmt(r.debit) : ''}</td>
|
||||||
|
<td class="text-right credit-cell">${r.credit ? fmt(r.credit) : ''}</td>
|
||||||
|
<td class="text-right">${r.balance != null ? fmt(r.balance) : ''}</td>
|
||||||
|
<td class="text-left">${esc(r.memo || '')}</td>
|
||||||
|
<td class="text-center no-print">${esc(r.bank_name || '')}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
renderPagination(_allRows.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(total) {
|
||||||
|
const bar = document.getElementById('paginationBar');
|
||||||
|
if (!bar) return;
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
if (totalPages <= 1) { bar.innerHTML = ''; return; }
|
||||||
|
const start = (_currentPage - 1) * PAGE_SIZE + 1;
|
||||||
|
const end = Math.min(_currentPage * PAGE_SIZE, total);
|
||||||
|
bar.innerHTML = `
|
||||||
|
<button class="btn btn-page" onclick="changePage(${_currentPage - 1})" ${_currentPage <= 1 ? 'disabled' : ''}>◀ 前へ</button>
|
||||||
|
<span class="page-info">${_currentPage} / ${totalPages} ページ(${start}〜${end}件 / 全${total}件)</span>
|
||||||
|
<button class="btn btn-page" onclick="changePage(${_currentPage + 1})" ${_currentPage >= totalPages ? 'disabled' : ''}>次へ ▶</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(page) {
|
||||||
|
const totalPages = Math.ceil(_allRows.length / PAGE_SIZE);
|
||||||
|
if (page < 1 || page > totalPages) return;
|
||||||
|
_currentPage = page;
|
||||||
|
renderTable();
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSV取込モーダル ──────────────────────────────────────
|
||||||
|
function openImportModal() {
|
||||||
|
backToStep1();
|
||||||
|
document.getElementById('importModal').classList.add('active');
|
||||||
|
}
|
||||||
|
function closeImportModal() {
|
||||||
|
document.getElementById('importModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
function backToStep1() {
|
||||||
|
document.getElementById('step1').style.display = '';
|
||||||
|
document.getElementById('step2').style.display = 'none';
|
||||||
|
document.getElementById('step3').style.display = 'none';
|
||||||
|
document.getElementById('csvFile').value = '';
|
||||||
|
hideMsg('step1msg');
|
||||||
|
hideMsg('step2msg');
|
||||||
|
_previewRows = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ドラッグ&ドロップ初期化
|
||||||
|
function initDropZone() {
|
||||||
|
const zone = document.getElementById('dropZone');
|
||||||
|
zone.addEventListener('dragover', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||||
|
zone.addEventListener('drop', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
zone.classList.remove('dragover');
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f) uploadAndPreview(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelected(input) {
|
||||||
|
if (input.files[0]) uploadAndPreview(input.files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAndPreview(file) {
|
||||||
|
if (!file.name.match(/\.(csv|txt)$/i)) {
|
||||||
|
showMsg('step1msg', 'CSVファイルを選択してください(.csv または .txt)', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMsg('step1msg', '解析中...', 'info');
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
fd.append('bank_name', document.getElementById('bankNameInput').value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/bank-statements/preview', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
showMsg('step1msg', data.detail || 'エラーが発生しました', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideMsg('step1msg');
|
||||||
|
_previewRows = data.rows;
|
||||||
|
// 銀行名: 入力欄が空でAPIがヒントを返した場合は自動セット
|
||||||
|
if (data.bank_name && !document.getElementById('bankNameInput').value.trim()) {
|
||||||
|
document.getElementById('bankNameInput').value = data.bank_name;
|
||||||
|
}
|
||||||
|
_bankName = document.getElementById('bankNameInput').value;
|
||||||
|
renderPreview(data);
|
||||||
|
document.getElementById('step1').style.display = 'none';
|
||||||
|
document.getElementById('step2').style.display = '';
|
||||||
|
} catch (e) {
|
||||||
|
showMsg('step1msg', 'アップロードエラー: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(data) {
|
||||||
|
// サマリー
|
||||||
|
document.getElementById('previewSummary').innerHTML = `
|
||||||
|
<div class="ps-item ps-total">合計: ${data.total} 件</div>
|
||||||
|
<div class="ps-item ps-new">🆕 新規: ${data.new} 件(取込対象)</div>
|
||||||
|
<div class="ps-item ps-dup">⚠️ 重複: ${data.duplicates} 件(スキップ)</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 取込ボタンの状態
|
||||||
|
const btn = document.getElementById('importConfirmBtn');
|
||||||
|
if (data.new === 0) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '新規データなし(取込不要)';
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = `新規 ${data.new} 件を取込む`;
|
||||||
|
btn.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// プレビューテーブル
|
||||||
|
const tbody = document.getElementById('previewBody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
data.rows.forEach(r => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = r.status === 'new' ? 'row-new' : 'row-dup';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge badge-${r.status === 'new' ? 'new' : 'dup'}">
|
||||||
|
${r.status === 'new' ? '新規' : '重複'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${r.tx_date}</td>
|
||||||
|
<td>${esc(r.description)}</td>
|
||||||
|
<td class="text-right">${r.debit ? fmt(r.debit) : ''}</td>
|
||||||
|
<td class="text-right">${r.credit ? fmt(r.credit) : ''}</td>
|
||||||
|
<td class="text-right">${r.balance != null ? fmt(r.balance) : ''}</td>
|
||||||
|
<td>${esc(r.memo || '')}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImport() {
|
||||||
|
const newRows = _previewRows.filter(r => r.status === 'new');
|
||||||
|
if (!newRows.length) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('importConfirmBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '取込中...';
|
||||||
|
hideMsg('step2msg');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/bank-statements/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bank_name: _bankName, rows: newRows }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
showMsg('step2msg', data.detail || '取込エラー', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = `新規 ${newRows.length} 件を取込む`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 完了画面
|
||||||
|
document.getElementById('step2').style.display = 'none';
|
||||||
|
document.getElementById('step3').style.display = '';
|
||||||
|
document.getElementById('step3msg').textContent =
|
||||||
|
`✅ 取込完了:${data.inserted} 件を登録しました` +
|
||||||
|
(data.skipped ? `(${data.skipped} 件スキップ)` : '');
|
||||||
|
loadBanks();
|
||||||
|
} catch (e) {
|
||||||
|
showMsg('step2msg', '通信エラー: ' + e.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = `新規 ${newRows.length} 件を取込む`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ユーティリティ ──────────────────────────────────────
|
||||||
|
function fmt(v) {
|
||||||
|
if (v == null || v === '') return '';
|
||||||
|
return Number(v).toLocaleString('ja-JP');
|
||||||
|
}
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
62
frontend/chat/Caddyfile
Normal file
62
frontend/chat/Caddyfile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
chat.njts.co.jp:2015 {
|
||||||
|
tls /certs/chat.njts.co.jp/fullchain.pem /certs/chat.njts.co.jp/key.pem
|
||||||
|
reverse_proxy rocketchat:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
nas.njts.co.jp:2015 {
|
||||||
|
tls /certs/nas.njts.co.jp/fullchain.pem /certs/nas.njts.co.jp/key.pem
|
||||||
|
reverse_proxy 192.168.0.61:5666
|
||||||
|
}
|
||||||
|
|
||||||
|
synas.njts.co.jp:2015 {
|
||||||
|
tls /certs/synas.njts.co.jp/fullchain.pem /certs/synas.njts.co.jp/key.pem
|
||||||
|
reverse_proxy 192.168.0.193:5000
|
||||||
|
}
|
||||||
|
|
||||||
|
meet.njts.co.jp:2015 {
|
||||||
|
tls /certs/meet.njts.co.jp/fullchain.pem /certs/meet.njts.co.jp/key.pem
|
||||||
|
|
||||||
|
reverse_proxy jitsi-web-1:80
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
matrix.njts.co.jp:2015 {
|
||||||
|
tls /certs/matrix.njts.co.jp/fullchain.pem /certs/matrix.njts.co.jp/key.pem
|
||||||
|
|
||||||
|
reverse_proxy matrix-synapse:8008
|
||||||
|
}
|
||||||
|
|
||||||
|
element.njts.co.jp:2015 {
|
||||||
|
tls /certs/element.njts.co.jp/fullchain.pem /certs/element.njts.co.jp/key.pem
|
||||||
|
reverse_proxy element-web:80
|
||||||
|
}
|
||||||
|
|
||||||
|
router.njts.co.jp:2015 {
|
||||||
|
tls /certs/router.njts.co.jp/fullchain.pem /certs/router.njts.co.jp/key.pem
|
||||||
|
reverse_proxy 192.168.0.1:80
|
||||||
|
}
|
||||||
|
|
||||||
|
cmr.njts.co.jp:2015 {
|
||||||
|
|
||||||
|
basicauth {
|
||||||
|
zhangxianghe $2a$14$a00NihP7NRLzd3j5EvFa2uFYXHJ.NoWyCcgKA3NKpUG22607HN/Ri
|
||||||
|
}
|
||||||
|
|
||||||
|
tls /certs/cmr.njts.co.jp/fullchain.pem /certs/cmr.njts.co.jp/key.pem
|
||||||
|
reverse_proxy 192.168.0.61:5000
|
||||||
|
}
|
||||||
|
|
||||||
|
openclaw.njts.co.jp:2015 {
|
||||||
|
tls /certs/openclaw.njts.co.jp/fullchain.pem /certs/openclaw.njts.co.jp/key.pem
|
||||||
|
reverse_proxy 192.168.0.61:18789
|
||||||
|
}
|
||||||
|
|
||||||
|
chatai.njts.co.jp:2015 {
|
||||||
|
tls /certs/chatai.njts.co.jp/fullchain.pem /certs/chatai.njts.co.jp/key.pem
|
||||||
|
reverse_proxy 192.168.0.61:3000
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.njts.co.jp:2015 {
|
||||||
|
tls /certs/sys.njts.co.jp/fullchain.pem /certs/sys.njts.co.jp/key.pem
|
||||||
|
reverse_proxy 192.168.0.61:18000
|
||||||
|
}
|
||||||
1
frontend/chat/caddy_config/caddy/autosave.json
Normal file
1
frontend/chat/caddy_config/caddy/autosave.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"apps":{"http":{"servers":{"srv0":{"listen":[":8443"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"rocketchat:3000"}]}]}]}],"match":[{"host":["chat.njts.co.jp"]}],"terminal":true}]}}}}}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"status": "valid",
|
||||||
|
"termsOfServiceAgreed": true,
|
||||||
|
"location": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/254799123"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIKj+HhAaYqX1lWm4mLP2Et8b5rxVmCuGgMZpfvT/KEN4oAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAED9JJ499KtFgQS78wE5cxUs3iQoBq75s3my0knVRJah0H3VwwOtJq
|
||||||
|
vKMmgyX4vUT0fDTYfgrvrLALcJ9QT4Bx9Q==
|
||||||
|
-----END EC PRIVATE KEY-----
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"status": "valid",
|
||||||
|
"termsOfServiceAgreed": true,
|
||||||
|
"location": "https://acme-v02.api.letsencrypt.org/acme/acct/2926552736"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIDs4tIRZUhyrIoIgZSO6ZBAYg+HzW0l0j2OlVoMzJY6ioAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEv375la4g5tzk4LddVNaLZ7jBelpGzkADYERdZzELLLFL0NEciAa1
|
||||||
|
Lus7P6OxGoc30AsCw34co3SXMhWEp3gsAg==
|
||||||
|
-----END EC PRIVATE KEY-----
|
||||||
1
frontend/chat/caddy_data/caddy/instance.uuid
Normal file
1
frontend/chat/caddy_data/caddy/instance.uuid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
268cd375-73e5-4ccb-88ca-c0051a74b1f2
|
||||||
1
frontend/chat/caddy_data/caddy/last_clean.json
Normal file
1
frontend/chat/caddy_data/caddy/last_clean.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"tls":{"timestamp":"2026-01-01T11:51:10.149840886Z","instance_id":"268cd375-73e5-4ccb-88ca-c0051a74b1f2"}}
|
||||||
68
frontend/chat/certs/fullchain.pem
Normal file
68
frontend/chat/certs/fullchain.pem
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIID9jCCA3ygAwIBAgIQcfzZIhLrRJHPhJLzeOAN5DAKBggqhkjOPQQDAzBLMQsw
|
||||||
|
CQYDVQQGEwJBVDEQMA4GA1UEChMHWmVyb1NTTDEqMCgGA1UEAxMhWmVyb1NTTCBF
|
||||||
|
Q0MgRG9tYWluIFNlY3VyZSBTaXRlIENBMB4XDTI1MTIzMTAwMDAwMFoXDTI2MDMz
|
||||||
|
MTIzNTk1OVowGjEYMBYGA1UEAxMPY2hhdC5uanRzLmNvLmpwMFkwEwYHKoZIzj0C
|
||||||
|
AQYIKoZIzj0DAQcDQgAEOp7tnlqOPWi6GswskLGaQ7J8pjU5YQjlNgPXVYr93VWT
|
||||||
|
Wp5oBt+Q1EPZoLSErJ05xzVgYTzzPf5Z92jk+crZsqOCAnEwggJtMB8GA1UdIwQY
|
||||||
|
MBaAFA9r5kvOOUeu9n6QHnnwMJGSyF+jMB0GA1UdDgQWBBQsF22yoIoKX6aCGL6f
|
||||||
|
2veSYHlq1TAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAK
|
||||||
|
BggrBgEFBQcDATBJBgNVHSAEQjBAMDQGCysGAQQBsjEBAgJOMCUwIwYIKwYBBQUH
|
||||||
|
AgEWF2h0dHBzOi8vc2VjdGlnby5jb20vQ1BTMAgGBmeBDAECATCBiAYIKwYBBQUH
|
||||||
|
AQEEfDB6MEsGCCsGAQUFBzAChj9odHRwOi8vemVyb3NzbC5jcnQuc2VjdGlnby5j
|
||||||
|
b20vWmVyb1NTTEVDQ0RvbWFpblNlY3VyZVNpdGVDQS5jcnQwKwYIKwYBBQUHMAGG
|
||||||
|
H2h0dHA6Ly96ZXJvc3NsLm9jc3Auc2VjdGlnby5jb20wggEEBgorBgEEAdZ5AgQC
|
||||||
|
BIH1BIHyAPAAdgAOV5S8866pPjMbLJkHs/eQ35vCPXEyJd0hqSWsYcVOIQAAAZtz
|
||||||
|
uvUJAAAEAwBHMEUCIHjB8YtQ9aDlxSRut60eoDQ/h7fraLoPxLOEsBB/Hu/AAiEA
|
||||||
|
5USOxiL99VrBdF4HTcJoM2AomuA+MvmpfVcsr1bXUqQAdgDRbqmlaAd+ZjWgPzel
|
||||||
|
3bwDpTxBEhTUiBj16TGzI8uVBAAAAZtzuvXXAAAEAwBHMEUCIBGuQL/+LnZdckCW
|
||||||
|
jGHUMPwztijW/8gfzcK8j3ULmm6FAiEAzI2YGnTwt5rYKIVaVU9nZGuATFlx9gtB
|
||||||
|
iiz2Roppz9UwGgYDVR0RBBMwEYIPY2hhdC5uanRzLmNvLmpwMAoGCCqGSM49BAMD
|
||||||
|
A2gAMGUCMQDb3BnY/YT49vWBxF1/h4eX26Ayn+5AEzn7ZcDpSVl20pAwJsqRjn7f
|
||||||
|
hodKgvtO3Y0CMDAkUJFzHKY4VP2oVhtldYIgfveAauS3YYGFEY25NVnVI224NSt6
|
||||||
|
CiUqiJf/0Pjpdw==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDhTCCAwygAwIBAgIQI7dt48G7KxpRlh4I6rdk6DAKBggqhkjOPQQDAzCBiDEL
|
||||||
|
MAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNl
|
||||||
|
eSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMT
|
||||||
|
JVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMjAwMTMw
|
||||||
|
MDAwMDAwWhcNMzAwMTI5MjM1OTU5WjBLMQswCQYDVQQGEwJBVDEQMA4GA1UEChMH
|
||||||
|
WmVyb1NTTDEqMCgGA1UEAxMhWmVyb1NTTCBFQ0MgRG9tYWluIFNlY3VyZSBTaXRl
|
||||||
|
IENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAENkFhFytTJe2qypTk1tpIV+9QuoRk
|
||||||
|
gte7BRvWHwYk9qUznYzn8QtVaGOCMBBfjWXsqqivl8q1hs4wAYl03uNOXgFu7iZ7
|
||||||
|
zFP6I6T3RB0+TR5fZqathfby47yOCZiAJI4go4IBdTCCAXEwHwYDVR0jBBgwFoAU
|
||||||
|
OuEJhtTPGcKWdnRJdtzgNcZjY5owHQYDVR0OBBYEFA9r5kvOOUeu9n6QHnnwMJGS
|
||||||
|
yF+jMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQW
|
||||||
|
MBQGCCsGAQUFBwMBBggrBgEFBQcDAjAiBgNVHSAEGzAZMA0GCysGAQQBsjEBAgJO
|
||||||
|
MAgGBmeBDAECATBQBgNVHR8ESTBHMEWgQ6BBhj9odHRwOi8vY3JsLnVzZXJ0cnVz
|
||||||
|
dC5jb20vVVNFUlRydXN0RUNDQ2VydGlmaWNhdGlvbkF1dGhvcml0eS5jcmwwdgYI
|
||||||
|
KwYBBQUHAQEEajBoMD8GCCsGAQUFBzAChjNodHRwOi8vY3J0LnVzZXJ0cnVzdC5j
|
||||||
|
b20vVVNFUlRydXN0RUNDQWRkVHJ1c3RDQS5jcnQwJQYIKwYBBQUHMAGGGWh0dHA6
|
||||||
|
Ly9vY3NwLnVzZXJ0cnVzdC5jb20wCgYIKoZIzj0EAwMDZwAwZAIwJHBUDwHJQN3I
|
||||||
|
VNltVMrICMqYQ3TYP/TXqV9t8mG5cAomG2MwqIsxnL937Gewf6WIAjAlrauksO6N
|
||||||
|
UuDdDXyd330druJcZJx0+H5j5cFOYBaGsKdeGW7sCMaR2PsDFKGllas=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIID0zCCArugAwIBAgIQVmcdBOpPmUxvEIFHWdJ1lDANBgkqhkiG9w0BAQwFADB7
|
||||||
|
MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD
|
||||||
|
VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE
|
||||||
|
AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDMxMjAwMDAwMFoXDTI4
|
||||||
|
MTIzMTIzNTk1OVowgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5
|
||||||
|
MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO
|
||||||
|
ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgRUNDIENlcnRpZmljYXRpb24gQXV0
|
||||||
|
aG9yaXR5MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEGqxUWqn5aCPnetUkb1PGWthL
|
||||||
|
q8bVttHmc3Gu3ZzWDGH926CJA7gFFOxXzu5dP+Ihs8731Ip54KODfi2X0GHE8Znc
|
||||||
|
JZFjq38wo7Rw4sehM5zzvy5cU7Ffs30yf4o043l5o4HyMIHvMB8GA1UdIwQYMBaA
|
||||||
|
FKARCiM+lvEH7OKvKe+CpX/QMKS0MB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1
|
||||||
|
xmNjmjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zARBgNVHSAECjAI
|
||||||
|
MAYGBFUdIAAwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5j
|
||||||
|
b20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQG
|
||||||
|
CCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQEM
|
||||||
|
BQADggEBABns652JLCALBIAdGN5CmXKZFjK9Dpx1WywV4ilAbe7/ctvbq5AfjJXy
|
||||||
|
ij0IckKJUAfiORVsAYfZFhr1wHUrxeZWEQff2Ji8fJ8ZOd+LygBkc7xGEJuTI42+
|
||||||
|
FsMuCIKchjN0djsoTI0DQoWz4rIjQtUfenVqGtF8qmchxDM6OW1TyaLtYiKou+JV
|
||||||
|
bJlsQ2uRl9EMC5MCHdK8aXdJ5htN978UeAOwproLtOGFfy/cQjutdAFI3tZs4RmY
|
||||||
|
CV4Ks2dH/hzg1cEo70qLRDEmBDeNiXQ2Lu+lIg+DdEmSx/cQwgwp+7e9un/jX9Wf
|
||||||
|
8qn0dNW44bOwgeThpWOjzOoEeJBuv/c=
|
||||||
|
-----END CERTIFICATE-----
|
||||||
5
frontend/chat/certs/key.pem
Normal file
5
frontend/chat/certs/key.pem
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIPjpQxKBjFFc8UvRnWDDKX2OP+ypSHXGYZ2cpRgPBP3koAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEOp7tnlqOPWi6GswskLGaQ7J8pjU5YQjlNgPXVYr93VWTWp5oBt+Q
|
||||||
|
1EPZoLSErJ05xzVgYTzzPf5Z92jk+crZsg==
|
||||||
|
-----END EC PRIVATE KEY-----
|
||||||
62
frontend/chat/docker-compose.yml
Normal file
62
frontend/chat/docker-compose.yml
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
rocketchat:
|
||||||
|
image: registry.rocket.chat/rocketchat/rocket.chat:8.2.1
|
||||||
|
container_name: rocketchat
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MONGO_URL: mongodb://rocketchat-mongo:27017/rocketchat?replicaSet=rs0
|
||||||
|
MONGO_OPLOG_URL: mongodb://rocketchat-mongo:27017/local?replicaSet=rs0
|
||||||
|
ROOT_URL: https://chat.njts.co.jp
|
||||||
|
PORT: 3000
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
networks:
|
||||||
|
- chat
|
||||||
|
|
||||||
|
mongo:
|
||||||
|
image: mongo:8.0.13
|
||||||
|
container_name: rocketchat-mongo
|
||||||
|
restart: unless-stopped
|
||||||
|
command: mongod --replSet rs0 --oplogSize 128
|
||||||
|
volumes:
|
||||||
|
- mongo-data:/data/db
|
||||||
|
networks:
|
||||||
|
- chat
|
||||||
|
|
||||||
|
mongo-init:
|
||||||
|
image: mongo:8.0.13
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
- mongo
|
||||||
|
command: >
|
||||||
|
mongosh --host mongo:27017 --eval
|
||||||
|
"rs.initiate({_id:'rs0',members:[{_id:0,host:'mongo:27017'}]})"
|
||||||
|
networks:
|
||||||
|
- chat
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "2015:2015"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- /etc/caddy/certs:/certs:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
networks:
|
||||||
|
- chat
|
||||||
|
- shared_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo-data:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
chat:
|
||||||
|
shared_net:
|
||||||
|
external: true
|
||||||
@@ -323,4 +323,58 @@ html {
|
|||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── split-layout: PC は左右分割、モバイルは上下積み ── */
|
||||||
|
/* 左リスト・右明細を縦方向に並べ直す */
|
||||||
|
.split-layout {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-left {
|
||||||
|
flex: none !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow-y: visible !important;
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-right {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-top: 2px solid #e0e8f0 !important;
|
||||||
|
padding-left: 0 !important;
|
||||||
|
padding-top: 16px !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow-y: visible !important;
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── タブ(給与計算・賞与計算・帳票出力)を折り返し ── */
|
||||||
|
.nav-tabs-calc {
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab-calc {
|
||||||
|
padding: 8px 14px !important;
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 新規計算モーダル(inline style の width:800px を上書き) ── */
|
||||||
|
#calculateModal,
|
||||||
|
#bonusModal {
|
||||||
|
width: 92vw !important;
|
||||||
|
left: 4vw !important;
|
||||||
|
transform: none !important;
|
||||||
|
max-width: 92vw !important;
|
||||||
|
padding: 20px 16px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,7 +284,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>🏛 e-Gov 電子送達</h1>
|
<h1>🏛 e-Gov 電子送達</h1>
|
||||||
<a class="back-link" href="index.html">← メニューに戻る</a>
|
<a class="back-link" href="index.html" style="display:inline-block;padding:6px 16px;background:#757575;color:#fff;border-radius:4px;text-decoration:none;font-size:14px;">← メニューへ戻る</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- ログインカード -->
|
<!-- ログインカード -->
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
<title>NJTS 会計システム - メイン</title>
|
<title>NJTS 会計システム - メイン</title>
|
||||||
<link rel="stylesheet" href="css/style.css" />
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
<link rel="stylesheet" href="/css/mobile.css" />
|
<link rel="stylesheet" href="/css/mobile.css" />
|
||||||
@@ -190,6 +192,16 @@
|
|||||||
<p><a class="btn" href="payroll.html">給与システムを開く</a></p>
|
<p><a class="btn" href="payroll.html">給与システムを開く</a></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>🏦 銀行明細照会</h2>
|
||||||
|
<p>
|
||||||
|
銀行のCSVデータを取込み、明細を管理します。重複チェック付きで安全に取込め、月別・銀行別の検索・印刷が可能です。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="bank-statement.html" style="background: #1565c0">銀行明細を開く</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>🏛 e-Gov 電子送達</h2>
|
<h2>🏛 e-Gov 電子送達</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -263,18 +275,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dropdown toggle
|
|
||||||
const toggle = document.getElementById("accountingToggle");
|
|
||||||
const menu = document.getElementById("accountingMenu");
|
|
||||||
toggle.addEventListener("click", (e) => {
|
|
||||||
menu.style.display = menu.style.display === "block" ? "none" : "block";
|
|
||||||
});
|
|
||||||
// click outside to close
|
|
||||||
document.addEventListener("click", (e) => {
|
|
||||||
if (!toggle.contains(e.target) && !menu.contains(e.target)) {
|
|
||||||
menu.style.display = "none";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -257,22 +257,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<button
|
<a href="index.html" style="display:inline-block;margin:0 0 20px 0;padding:6px 16px;background:#757575;color:#fff;border-radius:4px;text-decoration:none;font-size:14px;">← メニューへ戻る</a>
|
||||||
onclick="location.href = 'index.html'"
|
|
||||||
class="back-button"
|
|
||||||
style="
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
← 首ページに戻る
|
|
||||||
</button>
|
|
||||||
<h2>仕訳入力</h2>
|
<h2>仕訳入力</h2>
|
||||||
|
|
||||||
<div class="row" style="margin-bottom: 12px">
|
<div class="row" style="margin-bottom: 12px">
|
||||||
|
|||||||
@@ -56,21 +56,7 @@
|
|||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</button>
|
||||||
<button
|
<a href="index.html" style="display:inline-block;margin:0 0 20px 0;padding:6px 16px;background:#757575;color:#fff;border-radius:4px;text-decoration:none;font-size:14px;">← メニューへ戻る</a>
|
||||||
onclick="location.href = 'index.html'"
|
|
||||||
style="
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
← メインメニューに戻る
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script src="js/api.js"></script>
|
<script src="js/api.js"></script>
|
||||||
<script src="js/opening_balance.js"></script>
|
<script src="js/opening_balance.js"></script>
|
||||||
|
|||||||
@@ -1237,6 +1237,7 @@
|
|||||||
? ((_child / _stdRemun) * 100).toFixed(3)
|
? ((_child / _stdRemun) * 100).toFixed(3)
|
||||||
: null;
|
: null;
|
||||||
const _socialSub = _health + _care + _pension + _emp + _child;
|
const _socialSub = _health + _care + _pension + _emp + _child;
|
||||||
|
const _healthCareChild = _health + _care + _child;
|
||||||
const _taxable = _totalPay - _socialSub;
|
const _taxable = _totalPay - _socialSub;
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
@@ -1328,6 +1329,10 @@
|
|||||||
: "令和8年5月支払分(4月分保険料)より控除開始 → 当月は対象外"
|
: "令和8年5月支払分(4月分保険料)より控除開始 → 当月は対象外"
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="calc-note" style="background:#eef6ee; border-left:3px solid #28a745; padding:6px 10px; margin:6px 0; font-size:0.88em; color:#1a5c1a; line-height:1.7">
|
||||||
|
健康保険 ¥${_health.toLocaleString()} + 介護保険 ¥${_care.toLocaleString()} + 子ども・子育て支援金 ¥${_child.toLocaleString()} = <strong>¥${_healthCareChild.toLocaleString()}</strong><br>
|
||||||
|
¥${_healthCareChild.toLocaleString()}(上記合計) + 厚生年金 ¥${_pension.toLocaleString()}${' '}${_emp > 0 ? `+ 雇用保険 ¥${_emp.toLocaleString()}` : ''}= <strong>社会保険小計 ¥${_socialSub.toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_socialSub.toLocaleString()}</span></div>
|
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_socialSub.toLocaleString()}</span></div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
|
<span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
|
||||||
@@ -1372,6 +1377,10 @@
|
|||||||
const splitRightSalary = document.getElementById("splitRightSalary");
|
const splitRightSalary = document.getElementById("splitRightSalary");
|
||||||
if (splitRightSalary) splitRightSalary.style.display = "block";
|
if (splitRightSalary) splitRightSalary.style.display = "block";
|
||||||
document.getElementById("payrollDetail").style.display = "block";
|
document.getElementById("payrollDetail").style.display = "block";
|
||||||
|
// モバイルでは明細パネルへ自動スクロール
|
||||||
|
if (window.innerWidth <= 767 && splitRightSalary) {
|
||||||
|
setTimeout(function() { splitRightSalary.scrollIntoView({ behavior: "smooth", block: "start" }); }, 50);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("給与明細の取得に失敗しました");
|
alert("給与明細の取得に失敗しました");
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -1387,7 +1396,7 @@
|
|||||||
|
|
||||||
// 編集フォームを表示
|
// 編集フォームを表示
|
||||||
const html = `
|
const html = `
|
||||||
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: 800px; max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000;">
|
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: min(800px, 92vw); max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000; box-sizing: border-box;">
|
||||||
<h2>給与データ編集</h2>
|
<h2>給与データ編集</h2>
|
||||||
<form id="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})">
|
<form id="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})">
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
@@ -1842,6 +1851,7 @@
|
|||||||
? ((_bChild / _bTotal) * 100).toFixed(4)
|
? ((_bChild / _bTotal) * 100).toFixed(4)
|
||||||
: null;
|
: null;
|
||||||
const _bSocialSub = _bHealth + _bCare + _bPension + _bEmp + _bChild;
|
const _bSocialSub = _bHealth + _bCare + _bPension + _bEmp + _bChild;
|
||||||
|
const _bHealthCareChild = _bHealth + _bCare + _bChild;
|
||||||
const _bTaxable = _bTotal - _bSocialSub;
|
const _bTaxable = _bTotal - _bSocialSub;
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
@@ -1906,6 +1916,10 @@
|
|||||||
: "令和8年5月支払分(4月分保険料)より控除開始 → 当賞与は対象外"
|
: "令和8年5月支払分(4月分保険料)より控除開始 → 当賞与は対象外"
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="calc-note" style="background:#eef6ee; border-left:3px solid #28a745; padding:6px 10px; margin:6px 0; font-size:0.88em; color:#1a5c1a; line-height:1.7">
|
||||||
|
健康保険 ¥${_bHealth.toLocaleString()} + 介護保険 ¥${_bCare.toLocaleString()} + 子ども・子育て支援金 ¥${_bChild.toLocaleString()} = <strong>¥${_bHealthCareChild.toLocaleString()}</strong><br>
|
||||||
|
¥${_bHealthCareChild.toLocaleString()}(上記合計) + 厚生年金 ¥${_bPension.toLocaleString()}${' '}${_bEmp > 0 ? `+ 雇用保険 ¥${_bEmp.toLocaleString()}` : ''}= <strong>社会保険小計 ¥${_bSocialSub.toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_bSocialSub.toLocaleString()}</span></div>
|
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${_bSocialSub.toLocaleString()}</span></div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span>所得税 <small style="color:#666;">(甲欄${bDependentCount}人)</small>:</span>
|
<span>所得税 <small style="color:#666;">(甲欄${bDependentCount}人)</small>:</span>
|
||||||
@@ -1948,6 +1962,10 @@
|
|||||||
const splitRightBonus = document.getElementById("splitRightBonus");
|
const splitRightBonus = document.getElementById("splitRightBonus");
|
||||||
if (splitRightBonus) splitRightBonus.style.display = "block";
|
if (splitRightBonus) splitRightBonus.style.display = "block";
|
||||||
document.getElementById("bonusDetail").style.display = "block";
|
document.getElementById("bonusDetail").style.display = "block";
|
||||||
|
// モバイルでは明細パネルへ自動スクロール
|
||||||
|
if (window.innerWidth <= 767 && splitRightBonus) {
|
||||||
|
setTimeout(function() { splitRightBonus.scrollIntoView({ behavior: "smooth", block: "start" }); }, 50);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("賞与明細の取得に失敗しました");
|
alert("賞与明細の取得に失敗しました");
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -1960,7 +1978,7 @@
|
|||||||
const bonus = await response.json();
|
const bonus = await response.json();
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: 600px; max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000;">
|
<div style="position: fixed; top: 50px; left: 50%; transform: translateX(-50%); width: min(600px, 92vw); max-height: 90vh; overflow-y: auto; background: white; padding: 30px; border: 2px solid #007bff; border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.2); z-index: 1000; box-sizing: border-box;">
|
||||||
<h2>賞与データ編集</h2>
|
<h2>賞与データ編集</h2>
|
||||||
<form id="editBonusForm" onsubmit="saveBonusEdit(event, ${bonusId})">
|
<form id="editBonusForm" onsubmit="saveBonusEdit(event, ${bonusId})">
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
|
|||||||
@@ -56,21 +56,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<button
|
<a href="index.html" style="display:inline-block;padding:6px 16px;background:#757575;color:#fff;border-radius:4px;text-decoration:none;font-size:14px;">← メニューへ戻る</a>
|
||||||
onclick="location.href = '../index.html'"
|
|
||||||
class="back-button"
|
|
||||||
style="
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
← メインメニューに戻る
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h1>給与管理システム</h1>
|
<h1>給与管理システム</h1>
|
||||||
<p>従業員の給与計算、扶養管理、保険料・税金の管理を行います</p>
|
<p>従業員の給与計算、扶養管理、保険料・税金の管理を行います</p>
|
||||||
|
|||||||
@@ -267,22 +267,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<button
|
<a href="index.html" style="display:inline-block;margin:0 0 20px 0;padding:6px 16px;background:#757575;color:#fff;border-radius:4px;text-decoration:none;font-size:14px;">← メニューへ戻る</a>
|
||||||
onclick="location.href = 'index.html'"
|
|
||||||
class="back-button"
|
|
||||||
style="
|
|
||||||
margin: 0 0 20px 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
← 首ページに戻る
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<h2>残高試算表(貸借・損益)</h2>
|
<h2>残高試算表(貸借・損益)</h2>
|
||||||
|
|
||||||
|
|||||||
68
nas_deploy.py
Normal file
68
nas_deploy.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""NASにファイルを転送してコンテナを再起動するスクリプト"""
|
||||||
|
import paramiko
|
||||||
|
import os
|
||||||
|
|
||||||
|
HOST = "192.168.0.61"
|
||||||
|
USER = "root"
|
||||||
|
PASS = "59911784"
|
||||||
|
NAS_BASE = "/volume1/docker/njts-accounting"
|
||||||
|
|
||||||
|
files = [
|
||||||
|
# (ローカルパス, NASパス)
|
||||||
|
(r"c:\workspace\njts-accounting-core\backend\app\modules\bank_statements\__init__.py",
|
||||||
|
f"{NAS_BASE}/backend/app/modules/bank_statements/__init__.py"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\backend\app\modules\bank_statements\router.py",
|
||||||
|
f"{NAS_BASE}/backend/app/modules/bank_statements/router.py"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\backend\app\modules\bank_statements\migration.py",
|
||||||
|
f"{NAS_BASE}/backend/app/modules/bank_statements/migration.py"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\backend\app\db_auto_migration.py",
|
||||||
|
f"{NAS_BASE}/backend/app/db_auto_migration.py"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\backend\app\main.py",
|
||||||
|
f"{NAS_BASE}/backend/app/main.py"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\frontend\bank-statement.html",
|
||||||
|
f"{NAS_BASE}/backend/frontend/bank-statement.html"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\frontend\index.html",
|
||||||
|
f"{NAS_BASE}/backend/frontend/index.html"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\frontend\journal-entry.html",
|
||||||
|
f"{NAS_BASE}/backend/frontend/journal-entry.html"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\frontend\trial-balance.html",
|
||||||
|
f"{NAS_BASE}/backend/frontend/trial-balance.html"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\frontend\opening_balance.html",
|
||||||
|
f"{NAS_BASE}/backend/frontend/opening_balance.html"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\frontend\egov.html",
|
||||||
|
f"{NAS_BASE}/backend/frontend/egov.html"),
|
||||||
|
(r"c:\workspace\njts-accounting-core\frontend\payroll.html",
|
||||||
|
f"{NAS_BASE}/backend/frontend/payroll.html"),
|
||||||
|
]
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=30)
|
||||||
|
print(f"SSH接続: {HOST}")
|
||||||
|
|
||||||
|
# ディレクトリ作成
|
||||||
|
stdin, stdout, stderr = client.exec_command(
|
||||||
|
f"mkdir -p {NAS_BASE}/backend/app/modules/bank_statements && mkdir -p {NAS_BASE}/backend/frontend"
|
||||||
|
)
|
||||||
|
stdout.read()
|
||||||
|
print("ディレクトリ作成完了")
|
||||||
|
|
||||||
|
# SFTPでファイル転送
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
for local_path, remote_path in files:
|
||||||
|
sftp.put(local_path, remote_path)
|
||||||
|
print(f"転送: {os.path.basename(local_path)} → {remote_path}")
|
||||||
|
sftp.close()
|
||||||
|
|
||||||
|
# コンテナ再起動
|
||||||
|
print("\nコンテナ再起動中...")
|
||||||
|
cmd = f"cd {NAS_BASE} && docker compose restart backend"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||||
|
out = stdout.read().decode()
|
||||||
|
err = stderr.read().decode()
|
||||||
|
code = stdout.channel.recv_exit_status()
|
||||||
|
if out: print("stdout:", out)
|
||||||
|
if err: print("stderr:", err)
|
||||||
|
print(f"再起動完了 (exit={code})")
|
||||||
|
client.close()
|
||||||
|
print("完了!")
|
||||||
Reference in New Issue
Block a user