This commit is contained in:
admin
2026-06-01 00:35:29 +09:00
parent ebc87f96f9
commit 8c66d820c8
34 changed files with 1775 additions and 77 deletions

16
_check_compose.py Normal file
View 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
View 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
View 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
View 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
View 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()

View File

@@ -146,6 +146,13 @@ def run_auto_migration():
except Exception as 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:
print(f"\n❌ 無法連接到數據庫或執行遷移: {e}")
print("\n提示:")

View File

@@ -126,6 +126,9 @@ app.include_router(file_upload.router)
from app.modules.egov.router import router as 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)
# ─────────────────────────

View 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()

View 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
View 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()

View 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"
1 1 082 錦糸町支店 1 0 普通 0788058 新日本テクノソリューションズ 株式会社 2026.4.1-2026.5.31 全明細 2026.5.31 23:44
2 2 2026.4.15 振込1 カ)ニユ-ソリユ-シヨンズ 0 630000 17158067
3 2 2026.4.20 口座振替3 チユウタイキヨウカケキン 41000 0 17117067
4 2 2026.4.28 口座振替3 ZHゼイリシホウシユウ 11000 0 17106067
5 2 2026.4.30 社会保険料 247066 0 16859001
6 2 2026.4.30 振込1 スズヨシンワ-ト(カ 0 1606000 18465001
7 2 2026.4.30 振込BZ2 カ)エフケ-エス 170000 0 18295001
8 2 2026.4.30 為替手数料 フリコミ テスウリヨウ 660 0 18294341
9 2 2026.4.30 振込BZ1 オウ カク 353499 0 17940842
10 2 2026.4.30 為替手数料 フリコミ テスウリヨウ 330 0 17940512
11 2 2026.4.30 振込BZ1 チヨウ シヨウカク 4026825 0 13913687
12 2 2026.4.30 為替手数料 フリコミ テスウリヨウ 330 0 13913357
13 2 2026.5.1 カ-ド 1000000 0 12913357
14 2 2026.5.7 カ-ド 1000000 0 11913357
15 2 2026.5.15 振込1 カ)ニユ-ソリユ-シヨンズ 0 630000 12543357
16 2 2026.5.18 口座振替3 チユウタイキヨウカケキン 41000 0 12502357
17 2 2026.5.18 振込1 エンタツ (カ 0 4620000 17122357
18 2 2026.5.20 振込BZ1 オウ カク 2787776 0 14334581
19 2 2026.5.20 為替手数料 フリコミ テスウリヨウ 330 0 14334251
20 2 2026.5.28 口座振替3 ZHゼイリシホウシユウ 11000 0 14323251
21 2 2026.5.29 振込BZ2 カ)エフケ-エス 170000 0 14153251
22 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 660 0 14152591
23 2 2026.5.29 振込BZ2 チヨウシヨウカク 350781 0 13801810
24 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 660 0 13801150
25 2 2026.5.29 振込BZ1 オウ カク 353091 0 13448059
26 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 330 0 13447729
27 2 2026.5.29 振込BZ2 エンタツ (カ 824340 0 12623389
28 2 2026.5.29 為替手数料 フリコミ テスウリヨウ 660 0 12622729
29 2 2026.5.29 振込1 スズヨシンワ-ト(カ 0 1694000 14316729
30 2 2026.5.29 振込1 エンタツ (カ 0 825000 15141729
31 8
32 9 23 6 11391338 10005000 16528067 15141729

Binary file not shown.

View 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">対応形式: CSVUTF-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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
</script>
</body>
</html>

62
frontend/chat/Caddyfile Normal file
View 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
}

View 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}]}}}}}

View File

@@ -0,0 +1,5 @@
{
"status": "valid",
"termsOfServiceAgreed": true,
"location": "https://acme-staging-v02.api.letsencrypt.org/acme/acct/254799123"
}

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIKj+HhAaYqX1lWm4mLP2Et8b5rxVmCuGgMZpfvT/KEN4oAoGCCqGSM49
AwEHoUQDQgAED9JJ499KtFgQS78wE5cxUs3iQoBq75s3my0knVRJah0H3VwwOtJq
vKMmgyX4vUT0fDTYfgrvrLALcJ9QT4Bx9Q==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1,5 @@
{
"status": "valid",
"termsOfServiceAgreed": true,
"location": "https://acme-v02.api.letsencrypt.org/acme/acct/2926552736"
}

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDs4tIRZUhyrIoIgZSO6ZBAYg+HzW0l0j2OlVoMzJY6ioAoGCCqGSM49
AwEHoUQDQgAEv375la4g5tzk4LddVNaLZ7jBelpGzkADYERdZzELLLFL0NEciAa1
Lus7P6OxGoc30AsCw34co3SXMhWEp3gsAg==
-----END EC PRIVATE KEY-----

View File

@@ -0,0 +1 @@
268cd375-73e5-4ccb-88ca-c0051a74b1f2

View File

@@ -0,0 +1 @@
{"tls":{"timestamp":"2026-01-01T11:51:10.149840886Z","instance_id":"268cd375-73e5-4ccb-88ca-c0051a74b1f2"}}

View 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-----

View File

@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIPjpQxKBjFFc8UvRnWDDKX2OP+ypSHXGYZ2cpRgPBP3koAoGCCqGSM49
AwEHoUQDQgAEOp7tnlqOPWi6GswskLGaQ7J8pjU5YQjlNgPXVYr93VWTWp5oBt+Q
1EPZoLSErJ05xzVgYTzzPf5Z92jk+crZsg==
-----END EC PRIVATE KEY-----

View 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

View File

@@ -323,4 +323,58 @@ html {
width: 100% !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;
}
}

View File

@@ -284,7 +284,7 @@
<body>
<header>
<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>
<!-- ログインカード -->

View File

@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<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>
<link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
@@ -190,6 +192,16 @@
<p><a class="btn" href="payroll.html">給与システムを開く</a></p>
</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">
<h2>🏛 e-Gov 電子送達</h2>
<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>
</body>
</html>

View File

@@ -257,22 +257,7 @@
</style>
</head>
<body>
<button
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>
<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>
<h2>仕訳入力</h2>
<div class="row" style="margin-bottom: 12px">

View File

@@ -56,21 +56,7 @@
>
保存
</button>
<button
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>
<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>
<script src="js/api.js"></script>
<script src="js/opening_balance.js"></script>

View File

@@ -1237,6 +1237,7 @@
? ((_child / _stdRemun) * 100).toFixed(3)
: null;
const _socialSub = _health + _care + _pension + _emp + _child;
const _healthCareChild = _health + _care + _child;
const _taxable = _totalPay - _socialSub;
const html = `
@@ -1328,6 +1329,10 @@
: "令和8年5月支払分4月分保険料より控除開始 → 当月は対象外"
}
</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>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
@@ -1372,6 +1377,10 @@
const splitRightSalary = document.getElementById("splitRightSalary");
if (splitRightSalary) splitRightSalary.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) {
alert("給与明細の取得に失敗しました");
console.error(error);
@@ -1387,7 +1396,7 @@
// 編集フォームを表示
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>
<form id="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})">
<div class="detail-section">
@@ -1842,6 +1851,7 @@
? ((_bChild / _bTotal) * 100).toFixed(4)
: null;
const _bSocialSub = _bHealth + _bCare + _bPension + _bEmp + _bChild;
const _bHealthCareChild = _bHealth + _bCare + _bChild;
const _bTaxable = _bTotal - _bSocialSub;
const html = `
@@ -1906,6 +1916,10 @@
: "令和8年5月支払分4月分保険料より控除開始 → 当賞与は対象外"
}
</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>所得税 <small style="color:#666;">(甲欄${bDependentCount}人)</small>:</span>
@@ -1948,6 +1962,10 @@
const splitRightBonus = document.getElementById("splitRightBonus");
if (splitRightBonus) splitRightBonus.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) {
alert("賞与明細の取得に失敗しました");
console.error(error);
@@ -1960,7 +1978,7 @@
const bonus = await response.json();
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>
<form id="editBonusForm" onsubmit="saveBonusEdit(event, ${bonusId})">
<div class="detail-section">

View File

@@ -56,21 +56,7 @@
</head>
<body>
<div class="container">
<button
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>
<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>
<h1>給与管理システム</h1>
<p>従業員の給与計算、扶養管理、保険料・税金の管理を行います</p>

View File

@@ -267,22 +267,7 @@
</style>
</head>
<body>
<button
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>
<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>
<h2>残高試算表(貸借・損益)</h2>

68
nas_deploy.py Normal file
View 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("完了!")