月次レポート機能の改善として、開始日・終了日によるフィルタリング機能を追加し、店舗区分および経費区分の選択肢を実装。経費区分と店舗マスタの管理画面を新規作成し、関連するマイグレーションを追加。CSV取込画面における経費区分の表示を改善。作業ログをdiary.mdに追記。

This commit is contained in:
president
2025-12-22 12:34:53 +09:00
parent 7ae367cd66
commit 814477b1e2
15 changed files with 684 additions and 37 deletions

View File

@@ -31,3 +31,6 @@
- 月次レポートに明細行一覧を追加(各集計の下に日付/利用先/金額/備考)
- 金額のカンマ区切りと右寄せを全体に適用humanize + amountクラス
- favicon/apple-touch-iconの404回避と簡易アイコンを追加
- 店舗区分の初期値(砂村板金/インフラ)投入用マイグレーションを追加・適用
- 月次レポートに抽出条件(期間/店舗区分/経費区分/利用先を追加し、PDF出力に条件を引き継ぐように対応
- サブスク候補を仕様書に整理し、Apple課金やドメイン/レンタルサーバーの注記(価格変動・表記揺れ)を追加

130
Sample-data/z.csv Normal file
View File

@@ -0,0 +1,130 @@
カード名称, ,,,,,
お支払日,2025/11/07,,,,,
今回ご請求額,3370,,,,,
,,,,,,
利用日,ご利用店名及び商品名,本人・家族区分,支払区分名称,締前入金区分,利用金額,備考
2025/09/14,ETC東日本  (奈井江砂川→三笠),,1回,,970,
2025/09/14,ETC東日本  (三笠→滝川),,1回,,1430,
2025/09/24,ETC東日本  (三笠→奈井江砂川),,1回,,970,
,,,,,,
カード名称, ,,,,,
お支払日,2025/12/08,,,,,
今回ご請求額,4690,,,,,
,,,,,,
利用日,ご利用店名及び商品名,本人・家族区分,支払区分名称,締前入金区分,利用金額,備考
2025/09/26,ETC東日本  (三笠→奈井江砂川),,1回,,970,
2025/09/26,ETC東日本  (奈井江砂川→三笠),,1回,,970,
2025/10/08,ETC東日本  (旭川鷹栖→砂川SAス),,1回,,1780,
2025/10/30,ETC東日本  (三笠→奈井江砂川),,1回,,970,
鬠ソカード名称,出光ゴールドカード,,,,,
お支払日,2025/08/07,,,,,
今回ご請求額,33794,,,,,
,,,,,,
利用日,ご利用店名及び商品名,本人・家族区分,支払区分名称,締前入金区分,利用金額,備考
2025/06/10,  ,,1回,,150,
2025/06/12,  ,,1回,,480,
2025/06/14,コ−ク オン ペイ,,1回,,130,
2025/06/14,コ−ク オン ペイ,,1回,,130,
2025/06/15,コメリパワー砂川店,,1回,,2474,
2025/06/18,ツルハ,,1回,,1968,
2025/06/18,コ−ク オン ペイ,,1回,,100,
2025/06/20,オカモトスナカワ,,1回,,4164,
2025/06/20,コ−ク オン ペイ,,1回,,120,
2025/06/20,コ−ク オン ペイ,,1回,,130,
2025/06/24,ABEMAプレミアム,,1回,,1080,
2025/06/24,コ−ク オン ペイ,,1回,,110,
2025/06/25,コ−ク オン ペイ,,1回,,110,
2025/06/25,コ−ク オン ペイ,,1回,,160,
2025/06/26,  ,,1回,,3305,現地通貨額:22.00 USD
, ,,,,,円換算レート:6/27 150.2273
2025/06/26,コ−ク オン ペイ,,1回,,130,
2025/06/26,コ−ク オン ペイ,,1回,,130,
2025/07/01,エックスサ−バ−/M,,1回,,2200,
2025/07/01,お名前.com ドメインサービス,,1回,,268,
2025/07/01,  ,,1回,,1200,
2025/07/03,,,1回,,2980,
2025/07/05,,,1回,,1520,
2025/07/07,ケ−ズデンキ,,1回,,3241,
2025/07/08,お名前.comレンタルサ−バ−,,1回,,2153,
2025/07/10,アット・ニフティ,,1回,,5361,
,,,,,,
,,,,,,
2025/06/10,  ,,1回,,150,
2025/06/12,  ,,1回,,480,
2025/06/14,コ−ク オン ペイ,,1回,,130,
2025/06/14,コ−ク オン ペイ,,1回,,130,
2025/06/15,コメリパワー砂川店,,1回,,2474,
2025/06/18,ツルハ,,1回,,1968,
2025/06/18,コ−ク オン ペイ,,1回,,100,
2025/06/20,オカモトスナカワ,,1回,,4164,
2025/06/20,コ−ク オン ペイ,,1回,,120,
2025/06/20,コ−ク オン ペイ,,1回,,130,
2025/06/24,ABEMAプレミアム,,1回,,1080,
2025/06/24,コ−ク オン ペイ,,1回,,110,
2025/06/25,コ−ク オン ペイ,,1回,,110,
2025/06/25,コ−ク オン ペイ,,1回,,160,
2025/06/26,  ,,1回,,3305,現地通貨額:22.00 USD
2025/06/26,コ−ク オン ペイ,,1回,,130,
2025/06/26,コ−ク オン ペイ,,1回,,130,
2025/07/01,エックスサ−バ−/M,,1回,,2200,
2025/07/01,お名前.com ドメインサービス,,1回,,268,
2025/07/01,  ,,1回,,1200,
2025/07/03,,,1回,,2980,
2025/07/05,,,1回,,1520,
2025/07/07,ケ−ズデンキ,,1回,,3241,
2025/07/08,お名前.comレンタルサ−バ−,,1回,,2153,
2025/07/10,アット・ニフティ,,1回,,5361,
カード名称,出光ゴールドカード,,,,,
お支払日,2025/10/07,,,,,
今回ご請求額,56846,,,,,
,,,,,,
利用日,ご利用店名及び商品名,本人・家族区分,支払区分名称,締前入金区分,利用金額,備考
2025/08/09,ETC還元超過 (三笠→奈井江砂川),,1回,,890,
2025/08/10,  ,,1回,,150,
2025/08/12,  ,,1回,,480,
2025/08/14,ETC東日本  (奈井江砂川→江別東),,1回,,1530,
2025/08/20,プロノ タキカワテン,,1回,,4927,
2025/08/21,,,1回,,2199,
2025/08/22,ETC東日本  (砂川SAス→旭川鷹栖),,1回,,1780,
2025/08/24,ABEMAプレミアム,,1回,,1080,
2025/08/26,  ,,1回,,3369,現地通貨額:22.00 USD
, ,,,,,円換算レート:8/27 153.1364
2025/08/28,ケ-ズデンキ,,1回,,19950,
2025/08/31,コメリパワー砂川店,,1回,,1652,
2025/08/31,コメリパワー砂川店,,1回,,1144,
2025/09/01,エックスサ-バ-/M,,1回,,2200,
2025/09/01,お名前.com ドメインサービス,,1回,,270,
2025/09/01,  ,,1回,,1200,
2025/09/05,コ-ク オン ペイ,,1回,,100,
2025/09/08,お名前.comレンタルサ-バ-,,1回,,2612,
2025/09/10,アット・ニフティ,,1回,,11313,
カード名称,出光ゴールドカード,,,,,
お支払日,2025/11/07,,,,,
今回ご請求額,61542,,,,,
,,,,,,
利用日,ご利用店名及び商品名,本人・家族区分,支払区分名称,締前入金区分,利用金額,備考
2025/09/10,  ,,1回,,150,
2025/09/10,ツルハ,,1回,,1423,
2025/09/12,  ,,1回,,480,
2025/09/14,コメリパワー砂川店,,1回,,2490,
2025/09/16,,,1回,,3058,
2025/09/17,,,1回,,3992,
2025/09/18,コメリパワー砂川店,,1回,,2476,
2025/09/21,,,1回,,5026,
2025/09/24,ABEMAプレミアム,,1回,,1080,
2025/09/26,  ,,1回,,3419,現地通貨額:22.00 USD
, ,,,,,円換算レート:9/27 155.4091
2025/09/27,プロノ タキカワテン,,1回,,2133,
2025/09/29,コメリパワー砂川店,,1回,,2050,
2025/09/30,コメリパワー砂川店,,1回,,5440,
2025/10/01,エックスサ-バ-/M,,1回,,2200,
2025/10/01,お名前.com ドメインサービス,,1回,,270,
2025/10/01,プロノ タキカワテン,,1回,,2485,
2025/10/01,ゼンリンストア(BtoB),,1回,,660,
2025/10/01,  ,,1回,,1200,
2025/10/03,コメリパワー砂川店,,1回,,6160,
2025/10/06,,,1回,,12738,
2025/10/08,お名前.comレンタルサ-バ-,,1回,,2612,
1 カード名称 apollostation card
2 お支払日 2025/11/07
3 今回ご請求額 3370
4
5 利用日 ご利用店名及び商品名 本人・家族区分 支払区分名称 締前入金区分 利用金額 備考
6 2025/09/14 ETC東日本  (奈井江砂川→三笠) 1回 970
7 2025/09/14 ETC東日本  (三笠→滝川) 1回 1430
8 2025/09/24 ETC東日本  (三笠→奈井江砂川) 1回 970
9
10 カード名称 apollostation card
11 お支払日 2025/12/08
12 今回ご請求額 4690
13
14 利用日 ご利用店名及び商品名 本人・家族区分 支払区分名称 締前入金区分 利用金額 備考
15 2025/09/26 ETC東日本  (三笠→奈井江砂川) 1回 970
16 2025/09/26 ETC東日本  (奈井江砂川→三笠) 1回 970
17 2025/10/08 ETC東日本  (旭川鷹栖→砂川SAス) 1回 1780
18 2025/10/30 ETC東日本  (三笠→奈井江砂川) 1回 970
19 鬠ソカード名称 出光ゴールドカード
20 お支払日 2025/08/07
21 今回ご請求額 33794
22
23 利用日 ご利用店名及び商品名 本人・家族区分 支払区分名称 締前入金区分 利用金額 備考
24 2025/06/10 APPLE COM BILL 1回 150
25 2025/06/12 APPLE COM BILL 1回 480
26 2025/06/14 コ−ク オン ペイ 1回 130
27 2025/06/14 コ−ク オン ペイ 1回 130
28 2025/06/15 コメリパワー砂川店 1回 2474
29 2025/06/18 ツルハ 1回 1968
30 2025/06/18 コ−ク オン ペイ 1回 100
31 2025/06/20 オカモトスナカワ 1回 4164
32 2025/06/20 コ−ク オン ペイ 1回 120
33 2025/06/20 コ−ク オン ペイ 1回 130
34 2025/06/24 ABEMAプレミアム 1回 1080
35 2025/06/24 コ−ク オン ペイ 1回 110
36 2025/06/25 コ−ク オン ペイ 1回 110
37 2025/06/25 コ−ク オン ペイ 1回 160
38 2025/06/26 OPENAI *CHATGPT SUBSCR 1回 3305 現地通貨額:22.00 USD
39 (SAN FRANCISCO) 円換算レート:6/27 150.2273
40 2025/06/26 コ−ク オン ペイ 1回 130
41 2025/06/26 コ−ク オン ペイ 1回 130
42 2025/07/01 エックスサ−バ−/M 1回 2200
43 2025/07/01 お名前.com ドメインサービス 1回 268
44 2025/07/01 APPLE COM BILL 1回 1200
45 2025/07/03 AMAZON.CO.JP 1回 2980
46 2025/07/05 AMAZON.CO.JP 1回 1520
47 2025/07/07 ケ−ズデンキ 1回 3241
48 2025/07/08 お名前.comレンタルサ−バ− 1回 2153
49 2025/07/10 アット・ニフティ 1回 5361
50
51
52 2025/06/10 APPLE COM BILL 1回 150
53 2025/06/12 APPLE COM BILL 1回 480
54 2025/06/14 コ−ク オン ペイ 1回 130
55 2025/06/14 コ−ク オン ペイ 1回 130
56 2025/06/15 コメリパワー砂川店 1回 2474
57 2025/06/18 ツルハ 1回 1968
58 2025/06/18 コ−ク オン ペイ 1回 100
59 2025/06/20 オカモトスナカワ 1回 4164
60 2025/06/20 コ−ク オン ペイ 1回 120
61 2025/06/20 コ−ク オン ペイ 1回 130
62 2025/06/24 ABEMAプレミアム 1回 1080
63 2025/06/24 コ−ク オン ペイ 1回 110
64 2025/06/25 コ−ク オン ペイ 1回 110
65 2025/06/25 コ−ク オン ペイ 1回 160
66 2025/06/26 OPENAI *CHATGPT SUBSCR 1回 3305 現地通貨額:22.00 USD
67 2025/06/26 コ−ク オン ペイ 1回 130
68 2025/06/26 コ−ク オン ペイ 1回 130
69 2025/07/01 エックスサ−バ−/M 1回 2200
70 2025/07/01 お名前.com ドメインサービス 1回 268
71 2025/07/01 APPLE COM BILL 1回 1200
72 2025/07/03 AMAZON.CO.JP 1回 2980
73 2025/07/05 AMAZON.CO.JP 1回 1520
74 2025/07/07 ケ−ズデンキ 1回 3241
75 2025/07/08 お名前.comレンタルサ−バ− 1回 2153
76 2025/07/10 アット・ニフティ 1回 5361
77 カード名称 出光ゴールドカード
78 お支払日 2025/10/07
79 今回ご請求額 56846
80
81 利用日 ご利用店名及び商品名 本人・家族区分 支払区分名称 締前入金区分 利用金額 備考
82 2025/08/09 ETC還元超過 (三笠→奈井江砂川) 1回 890
83 2025/08/10 APPLE COM BILL 1回 150
84 2025/08/12 APPLE COM BILL 1回 480
85 2025/08/14 ETC東日本  (奈井江砂川→江別東) 1回 1530
86 2025/08/20 プロノ タキカワテン 1回 4927
87 2025/08/21 DCM 1回 2199
88 2025/08/22 ETC東日本  (砂川SAス→旭川鷹栖) 1回 1780
89 2025/08/24 ABEMAプレミアム 1回 1080
90 2025/08/26 OPENAI *CHATGPT SUBSCR 1回 3369 現地通貨額:22.00 USD
91 (SAN FRANCISCO) 円換算レート:8/27 153.1364
92 2025/08/28 ケ-ズデンキ 1回 19950
93 2025/08/31 コメリパワー砂川店 1回 1652
94 2025/08/31 コメリパワー砂川店 1回 1144
95 2025/09/01 エックスサ-バ-/M 1回 2200
96 2025/09/01 お名前.com ドメインサービス 1回 270
97 2025/09/01 APPLE COM BILL 1回 1200
98 2025/09/05 コ-ク オン ペイ 1回 100
99 2025/09/08 お名前.comレンタルサ-バ- 1回 2612
100 2025/09/10 アット・ニフティ 1回 11313
101 カード名称 出光ゴールドカード
102 お支払日 2025/11/07
103 今回ご請求額 61542
104
105 利用日 ご利用店名及び商品名 本人・家族区分 支払区分名称 締前入金区分 利用金額 備考
106 2025/09/10 APPLE COM BILL 1回 150
107 2025/09/10 ツルハ 1回 1423
108 2025/09/12 APPLE COM BILL 1回 480
109 2025/09/14 コメリパワー砂川店 1回 2490
110 2025/09/16 DCM 1回 3058
111 2025/09/17 AMAZON.CO.JP 1回 3992
112 2025/09/18 コメリパワー砂川店 1回 2476
113 2025/09/21 DCM 1回 5026
114 2025/09/24 ABEMAプレミアム 1回 1080
115 2025/09/26 OPENAI *CHATGPT SUBSCR 1回 3419 現地通貨額:22.00 USD
116 (SAN FRANCISCO) 円換算レート:9/27 155.4091
117 2025/09/27 プロノ タキカワテン 1回 2133
118 2025/09/29 コメリパワー砂川店 1回 2050
119 2025/09/30 コメリパワー砂川店 1回 5440
120 2025/10/01 エックスサ-バ-/M 1回 2200
121 2025/10/01 お名前.com ドメインサービス 1回 270
122 2025/10/01 プロノ タキカワテン 1回 2485
123 2025/10/01 ゼンリンストア(BtoB) 1回 660
124 2025/10/01 APPLE COM BILL 1回 1200
125 2025/10/03 コメリパワー砂川店 1回 6160
126 2025/10/06 DCM 1回 12738
127 2025/10/08 お名前.comレンタルサ-バ- 1回 2612

View File

@@ -35,3 +35,9 @@ source .venv/bin/activate
DB_NAME=accounting DB_USER=account_user DB_PASSWORD=account_Hideyukey-1234 DB_HOST=labo.sunamura-llc.com DB_PORT=5432 .venv/bin/python manage.py runserver
DJANGO_DEBUG=true DB_NAME=accounting DB_USER=account_user DB_PASSWORD=account_Hideyukey-1234 DB_HOST=labo.sunamura-llc.com DB_PORT=5432 .venv/bin/python manage.py runserver
マイグレーション
DJANGO_DEBUG=true DB_NAME=accounting DB_USER=account_user DB_PASSWORD=account_Hideyukey-1234 DB_HOST=labo.sunamura-llc.com DB_PORT=5432 .venv/bin/python manage.py migrate
または 
set -a; source .env; set +a
python manage.py migrate

View File

@@ -0,0 +1,22 @@
from django.db import migrations
def seed_stores(apps, schema_editor):
Store = apps.get_model('expenses', 'Store')
for name in ('砂村板金', 'インフラ'):
Store.objects.get_or_create(name=name, defaults={'is_active': True})
def unseed_stores(apps, schema_editor):
Store = apps.get_model('expenses', 'Store')
Store.objects.filter(name__in=('砂村板金', 'インフラ')).delete()
class Migration(migrations.Migration):
dependencies = [
('expenses', '0002_owner_type_cancel'),
]
operations = [
migrations.RunPython(seed_stores, unseed_stores),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def seed_expense_categories(apps, schema_editor):
ExpenseCategory = apps.get_model('expenses', 'ExpenseCategory')
for name in ('雑費', '交通費', '通信費', '福利厚生費'):
ExpenseCategory.objects.get_or_create(name=name, defaults={'is_active': True})
def unseed_expense_categories(apps, schema_editor):
ExpenseCategory = apps.get_model('expenses', 'ExpenseCategory')
ExpenseCategory.objects.filter(
name__in=('雑費', '交通費', '通信費', '福利厚生費')
).delete()
class Migration(migrations.Migration):
dependencies = [
('expenses', '0003_seed_stores'),
]
operations = [
migrations.RunPython(seed_expense_categories, unseed_expense_categories),
]

View File

@@ -51,12 +51,30 @@ class MonthlyReportResult:
unclassified_counts: dict
def build_monthly_report(start_date: date, end_date: date) -> MonthlyReportResult:
queryset = Expense.objects.filter(
is_canceled=False,
use_date__gte=start_date,
use_date__lt=end_date,
)
def build_monthly_report(
start_date: date | None,
end_date: date | None,
store_id: int | None = None,
store_is_null: bool = False,
expense_category_id: int | None = None,
expense_category_is_null: bool = False,
description_query: str | None = None,
) -> MonthlyReportResult:
queryset = Expense.objects.filter(is_canceled=False)
if start_date is not None:
queryset = queryset.filter(use_date__gte=start_date)
if end_date is not None:
queryset = queryset.filter(use_date__lt=end_date)
if store_is_null:
queryset = queryset.filter(store__isnull=True)
elif store_id is not None:
queryset = queryset.filter(store_id=store_id)
if expense_category_is_null:
queryset = queryset.filter(expense_category__isnull=True)
elif expense_category_id is not None:
queryset = queryset.filter(expense_category_id=expense_category_id)
if description_query:
queryset = queryset.filter(description__icontains=description_query)
detail_queryset = (
queryset.select_related('store', 'expense_category')
.order_by('use_date', 'id')
@@ -67,6 +85,7 @@ def build_monthly_report(start_date: date, end_date: date) -> MonthlyReportResul
for expense in detail_queryset:
detail = {
'use_date': expense.use_date,
'expense_category_name': expense.expense_category.name if expense.expense_category else None,
'description': expense.description,
'amount': expense.amount,
'note': expense.note,
@@ -103,7 +122,7 @@ def build_monthly_report(start_date: date, end_date: date) -> MonthlyReportResul
'total': queryset.count(),
}
return MonthlyReportResult(
target_month=start_date,
target_month=start_date or date.today(),
total_amount=total_amount,
store_totals=store_totals,
category_totals=category_totals,

View File

@@ -6,6 +6,8 @@ from . import views
urlpatterns = [
path('', views.csv_upload, name='csv_upload'),
path('expenses/', views.expense_list, name='expense_list'),
path('stores/', views.store_master, name='store_master'),
path('expense-categories/', views.expense_category_master, name='expense_category_master'),
path('expenses/<int:expense_id>/update/', views.expense_update, name='expense_update'),
path('expenses/<int:expense_id>/cancel/', views.expense_cancel, name='expense_cancel'),
path('reports/monthly/', views.monthly_report, name='monthly_report'),

View File

@@ -1,9 +1,10 @@
import io
import json
from datetime import date, datetime
from datetime import date, datetime, timedelta
from urllib.parse import urlencode
import chardet
from django.db import IntegrityError
from django.http import JsonResponse
from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, render
@@ -57,6 +58,78 @@ def expense_list(request):
)
def store_master(request):
error_message = None
if request.method == 'POST':
name = request.POST.get('name', '').strip()
store_id = request.POST.get('store_id')
action = request.POST.get('action')
if action == 'deactivate' and store_id:
store = get_object_or_404(Store, pk=store_id)
store.is_active = False
store.save(update_fields=['is_active'])
else:
is_active = request.POST.get('is_active') == '1'
if not name:
error_message = '店舗名を入力してください。'
else:
try:
if store_id:
store = get_object_or_404(Store, pk=store_id)
store.name = name
store.is_active = is_active
store.save(update_fields=['name', 'is_active'])
else:
Store.objects.create(name=name, is_active=is_active)
except IntegrityError:
error_message = '同名の店舗が既に存在します。'
stores = Store.objects.order_by('name', 'id')
return render(
request,
'expenses/store_master.html',
{
'stores': stores,
'error_message': error_message,
},
)
def expense_category_master(request):
error_message = None
if request.method == 'POST':
name = request.POST.get('name', '').strip()
category_id = request.POST.get('category_id')
action = request.POST.get('action')
if action == 'deactivate' and category_id:
category = get_object_or_404(ExpenseCategory, pk=category_id)
category.is_active = False
category.save(update_fields=['is_active'])
else:
is_active = request.POST.get('is_active') == '1'
if not name:
error_message = '経費区分名を入力してください。'
else:
try:
if category_id:
category = get_object_or_404(ExpenseCategory, pk=category_id)
category.name = name
category.is_active = is_active
category.save(update_fields=['name', 'is_active'])
else:
ExpenseCategory.objects.create(name=name, is_active=is_active)
except IntegrityError:
error_message = '同名の経費区分が既に存在します。'
categories = ExpenseCategory.objects.order_by('name', 'id')
return render(
request,
'expenses/expense_category_master.html',
{
'categories': categories,
'error_message': error_message,
},
)
@require_POST
def expense_update(request, expense_id: int):
try:
@@ -111,51 +184,165 @@ def expense_update(request, expense_id: int):
def monthly_report(request):
error_message = None
target_month = request.GET.get('target_month')
if target_month:
start_date_param = request.GET.get('start_date', '')
end_date_param = request.GET.get('end_date', '')
all_time = request.GET.get('all_time') == '1'
store_param = request.GET.get('store_id', '')
category_param = request.GET.get('expense_category_id', '')
description_query = request.GET.get('description', '').strip()
show_report = request.GET.get('show') == '1'
store_id = None
store_is_null = False
expense_category_id = None
expense_category_is_null = False
if store_param == 'unassigned':
store_is_null = True
elif store_param:
try:
start_date = datetime.strptime(target_month, '%Y-%m').date().replace(day=1)
store_id = int(store_param)
except ValueError:
error_message = '年月の形式が不正です。'
error_message = '店舗区分の指定が不正です。'
if category_param == 'unassigned':
expense_category_is_null = True
elif category_param:
try:
expense_category_id = int(category_param)
except ValueError:
error_message = '経費区分の指定が不正です。'
if start_date_param or end_date_param:
all_time = False
if all_time:
start_date = None
end_date = None
end_date_display = ''
else:
start_date = None
end_date = None
end_date_display = end_date_param
if start_date_param:
try:
start_date = datetime.strptime(start_date_param, '%Y-%m-%d').date()
except ValueError:
error_message = '開始日の形式が不正です。'
if end_date_param:
try:
end_date = datetime.strptime(end_date_param, '%Y-%m-%d').date()
except ValueError:
error_message = '終了日の形式が不正です。'
if start_date is None and end_date is None and error_message is None:
start_date = date.today().replace(day=1)
else:
start_date = date.today().replace(day=1)
if start_date.month == 12:
end_date = start_date.replace(year=start_date.year + 1, month=1)
else:
end_date = start_date.replace(month=start_date.month + 1)
report = build_monthly_report(start_date, end_date)
if start_date.month == 12:
next_month = start_date.replace(year=start_date.year + 1, month=1)
else:
next_month = start_date.replace(month=start_date.month + 1)
end_date = next_month - timedelta(days=1)
start_date_param = start_date.strftime('%Y-%m-%d')
end_date_display = end_date.strftime('%Y-%m-%d')
if end_date is not None:
end_date = end_date + timedelta(days=1)
stores = Store.objects.filter(is_active=True).order_by('name')
categories = ExpenseCategory.objects.filter(is_active=True).order_by('name')
report = None
if show_report and error_message is None:
report = build_monthly_report(
start_date,
end_date,
store_id=store_id,
store_is_null=store_is_null,
expense_category_id=expense_category_id,
expense_category_is_null=expense_category_is_null,
description_query=description_query or None,
)
return render(
request,
'expenses/monthly_report.html',
{
'report': report,
'target_month': start_date.strftime('%Y-%m'),
'start_date': start_date_param or (start_date.strftime('%Y-%m-%d') if start_date else ''),
'end_date': end_date_display,
'all_time': all_time,
'stores': stores,
'selected_store': store_param,
'categories': categories,
'selected_category': category_param,
'description_query': description_query,
'show_report': show_report,
'error_message': error_message,
},
)
def monthly_report_pdf(request):
target_month = request.GET.get('target_month')
if target_month:
start_date_param = request.GET.get('start_date', '')
end_date_param = request.GET.get('end_date', '')
all_time = request.GET.get('all_time') == '1'
store_param = request.GET.get('store_id', '')
category_param = request.GET.get('expense_category_id', '')
description_query = request.GET.get('description', '').strip()
store_id = None
store_is_null = False
expense_category_id = None
expense_category_is_null = False
if store_param == 'unassigned':
store_is_null = True
elif store_param:
try:
start_date = datetime.strptime(target_month, '%Y-%m').date().replace(day=1)
store_id = int(store_param)
except ValueError:
store_id = None
if category_param == 'unassigned':
expense_category_is_null = True
elif category_param:
try:
expense_category_id = int(category_param)
except ValueError:
expense_category_id = None
if start_date_param or end_date_param:
all_time = False
if all_time:
start_date = None
end_date = None
target_period = '全期間'
else:
start_date = None
end_date = None
if start_date_param:
try:
start_date = datetime.strptime(start_date_param, '%Y-%m-%d').date()
except ValueError:
start_date = None
if end_date_param:
try:
end_date = datetime.strptime(end_date_param, '%Y-%m-%d').date()
except ValueError:
end_date = None
if start_date is None and end_date is None:
start_date = date.today().replace(day=1)
else:
start_date = date.today().replace(day=1)
if start_date.month == 12:
end_date = start_date.replace(year=start_date.year + 1, month=1)
else:
end_date = start_date.replace(month=start_date.month + 1)
report = build_monthly_report(start_date, end_date)
if start_date.month == 12:
next_month = start_date.replace(year=start_date.year + 1, month=1)
else:
next_month = start_date.replace(month=start_date.month + 1)
end_date = next_month - timedelta(days=1)
start_label = start_date.strftime('%Y-%m-%d') if start_date else '未指定'
end_label = end_date.strftime('%Y-%m-%d') if end_date else '未指定'
target_period = f'{start_label}{end_label}'
if end_date is not None:
end_date = end_date + timedelta(days=1)
report = build_monthly_report(
start_date,
end_date,
store_id=store_id,
store_is_null=store_is_null,
expense_category_id=expense_category_id,
expense_category_is_null=expense_category_is_null,
description_query=description_query or None,
)
return render(
request,
'expenses/monthly_report_pdf.html',
{
'report': report,
'target_month': start_date.strftime('%Y-%m'),
'target_period': target_period,
},
)

View File

@@ -18,6 +18,8 @@
<nav>
<a href="{% url 'csv_upload' %}">CSV取込</a>
<a href="{% url 'expense_list' %}">明細編集</a>
<a href="{% url 'store_master' %}">店舗マスタ</a>
<a href="{% url 'expense_category_master' %}">経費区分マスタ</a>
<a href="{% url 'monthly_report' %}">月次レポート</a>
</nav>
</header>

View File

@@ -0,0 +1,74 @@
{% extends 'base.html' %}
{% block title %}経費区分マスタ管理{% endblock %}
{% block content %}
<section>
<h2>経費区分マスタ管理</h2>
{% if error_message %}
<p>{{ error_message }}</p>
{% endif %}
<h3>経費区分を追加</h3>
<form method="post">
{% csrf_token %}
<label>
経費区分名
<input type="text" name="name" required>
</label>
<label>
<input type="checkbox" name="is_active" value="1" checked>
有効
</label>
<button type="submit">追加</button>
</form>
<h3>一覧</h3>
<table>
<thead>
<tr>
<th>経費区分名</th>
<th>有効</th>
<th>更新</th>
<th>削除</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<form method="post">
{% csrf_token %}
<input type="hidden" name="category_id" value="{{ category.id }}">
<td>
<input type="text" name="name" value="{{ category.name }}" required>
</td>
<td>
<input
type="checkbox"
name="is_active"
value="1"
{% if category.is_active %}checked{% endif %}
>
</td>
<td>
<button type="submit">保存</button>
</td>
</form>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="category_id" value="{{ category.id }}">
<input type="hidden" name="action" value="deactivate">
<button type="submit" {% if not category.is_active %}disabled{% endif %}>
無効化
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4">経費区分がありません。</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -129,6 +129,12 @@
}
function coerceValue(field, value) {
if (value === '') {
return value;
}
if (field === 'store_id' || field === 'expense_category_id') {
return Number(value);
}
return value;
}

View File

@@ -8,14 +8,87 @@
<h2>月次レポート</h2>
<form method="get">
<label>
年月
<input type="month" name="target_month" value="{{ target_month }}">
開始日
<input type="date" name="start_date" value="{{ start_date }}">
</label>
<label>
終了日
<input type="date" name="end_date" value="{{ end_date }}">
</label>
<label>
<input type="checkbox" name="all_time" value="1" {% if all_time %}checked{% endif %}>
全期間
</label>
<label>
店舗区分
<select name="store_id">
<option value="" {% if not selected_store %}selected{% endif %}>全て</option>
<option value="unassigned" {% if selected_store == "unassigned" %}selected{% endif %}>
未設定
</option>
{% for store in stores %}
<option value="{{ store.id }}" {% if selected_store == store.id|stringformat:"s" %}selected{% endif %}>
{{ store.name }}
</option>
{% endfor %}
</select>
</label>
<label>
経費区分
<select name="expense_category_id">
<option value="" {% if not selected_category %}selected{% endif %}>全て</option>
<option value="unassigned" {% if selected_category == "unassigned" %}selected{% endif %}>
未設定
</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if selected_category == category.id|stringformat:"s" %}selected{% endif %}>
{{ category.name }}
</option>
{% endfor %}
</select>
</label>
<label>
利用先
<input type="search" name="description" value="{{ description_query }}">
</label>
<input type="hidden" name="show" value="1">
<button type="submit">表示</button>
{% if show_report %}
<a
href="{% url 'monthly_report_pdf' %}?start_date={{ start_date }}&end_date={{ end_date }}&store_id={{ selected_store }}&expense_category_id={{ selected_category }}&description={{ description_query|urlencode }}&all_time={% if all_time %}1{% endif %}"
target="_blank"
rel="noopener"
>
PDF出力
</a>
{% endif %}
</form>
<script>
const startInput = document.querySelector('input[name="start_date"]');
const endInput = document.querySelector('input[name="end_date"]');
const allTimeInput = document.querySelector('input[name="all_time"]');
if (startInput && endInput && allTimeInput) {
const syncAllTime = () => {
if (startInput.value || endInput.value) {
allTimeInput.checked = false;
}
};
startInput.addEventListener('change', syncAllTime);
endInput.addEventListener('change', syncAllTime);
allTimeInput.addEventListener('change', () => {
if (allTimeInput.checked) {
startInput.value = '';
endInput.value = '';
}
});
}
</script>
{% if error_message %}
<p style="color: #b00020;">{{ error_message }}</p>
{% endif %}
{% if not show_report %}
<p>条件を指定して表示ボタンを押してください。</p>
{% else %}
<div>
<h3>合計</h3>
<p class="amount">{{ report.total_amount|intcomma }} 円</p>
@@ -50,6 +123,7 @@
<thead>
<tr>
<th>日付</th>
<th>経費区分</th>
<th>利用先</th>
<th class="amount">金額</th>
<th>備考</th>
@@ -59,13 +133,14 @@
{% for detail in row.details %}
<tr>
<td>{{ detail.use_date }}</td>
<td>{{ detail.expense_category_name|default:"未設定" }}</td>
<td>{{ detail.description }}</td>
<td class="amount">{{ detail.amount|intcomma }}</td>
<td>{{ detail.note }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4">対象データがありません。</td>
<td colspan="5">対象データがありません。</td>
</tr>
{% endfor %}
</tbody>
@@ -102,6 +177,7 @@
<thead>
<tr>
<th>日付</th>
<th>経費区分</th>
<th>利用先</th>
<th class="amount">金額</th>
<th>備考</th>
@@ -111,13 +187,14 @@
{% for detail in row.details %}
<tr>
<td>{{ detail.use_date }}</td>
<td>{{ detail.expense_category_name|default:"未設定" }}</td>
<td>{{ detail.description }}</td>
<td class="amount">{{ detail.amount|intcomma }}</td>
<td>{{ detail.note }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4">対象データがありません。</td>
<td colspan="5">対象データがありません。</td>
</tr>
{% endfor %}
</tbody>
@@ -154,6 +231,7 @@
<thead>
<tr>
<th>日付</th>
<th>経費区分</th>
<th>利用先</th>
<th class="amount">金額</th>
<th>備考</th>
@@ -163,13 +241,14 @@
{% for detail in row.details %}
<tr>
<td>{{ detail.use_date }}</td>
<td>{{ detail.expense_category_name|default:"未設定" }}</td>
<td>{{ detail.description }}</td>
<td class="amount">{{ detail.amount|intcomma }}</td>
<td>{{ detail.note }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4">対象データがありません。</td>
<td colspan="5">対象データがありません。</td>
</tr>
{% endfor %}
</tbody>
@@ -185,5 +264,6 @@
<li>対象総件数: {{ report.unclassified_counts.total }}</li>
</ul>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -14,7 +14,7 @@
</head>
<body>
<h1>月次レポート</h1>
<p>対象年月: {{ target_month }}</p>
<p>対象期間: {{ target_period }}</p>
<h2>合計</h2>
<p class="amount">{{ report.total_amount|intcomma }} 円</p>
<h2>店舗別合計</h2>

View File

@@ -0,0 +1,74 @@
{% extends 'base.html' %}
{% block title %}店舗マスタ管理{% endblock %}
{% block content %}
<section>
<h2>店舗マスタ管理</h2>
{% if error_message %}
<p>{{ error_message }}</p>
{% endif %}
<h3>店舗を追加</h3>
<form method="post">
{% csrf_token %}
<label>
店舗名
<input type="text" name="name" required>
</label>
<label>
<input type="checkbox" name="is_active" value="1" checked>
有効
</label>
<button type="submit">追加</button>
</form>
<h3>一覧</h3>
<table>
<thead>
<tr>
<th>店舗名</th>
<th>有効</th>
<th>更新</th>
<th>削除</th>
</tr>
</thead>
<tbody>
{% for store in stores %}
<tr>
<form method="post">
{% csrf_token %}
<input type="hidden" name="store_id" value="{{ store.id }}">
<td>
<input type="text" name="name" value="{{ store.name }}" required>
</td>
<td>
<input
type="checkbox"
name="is_active"
value="1"
{% if store.is_active %}checked{% endif %}
>
</td>
<td>
<button type="submit">保存</button>
</td>
</form>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="store_id" value="{{ store.id }}">
<input type="hidden" name="action" value="deactivate">
<button type="submit" {% if not store.is_active %}disabled{% endif %}>
無効化
</button>
</form>
</td>
</tr>
{% empty %}
<tr>
<td colspan="4">店舗がありません。</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -129,3 +129,21 @@ UNIQUE(source, source_hash)
過剰なUI装飾やフロントフレームワークは不要。
業務実装として堅牢・拡張可能なコードを優先する。
---
## サブスク判定ルール案(暫定)
### 候補リスト(利用先文字列 + 金額)
-    480 -> 事務用品費、ウェザーニュース
-    150 -> 事務用品費、iCloud 追加Disk
-    1200 -> 未設定、アップルニュージック
- ABEMAプレミアム 1080 -> 未設定、個人の娯楽
-    3305 -> 事務用品費、GPT
- エックスサ−バ−/M 2200 ->  通信費               //文字の揺らぎ 下と同じ
- エックスサ-バ-/M 2200 -> 通信費、Nextcloud運営VPS
- お名前.com ドメインサービス 268/270 -> 通信費、ドメインサービス
- お名前.comレンタルサ−バ− 2153 -> 通信費、レンタルサーバー(価格変動あり/表記揺れあり)
- お名前.comレンタルサ-バ- 2612 -> 通信費、レンタルサーバー(価格変動あり/表記揺れあり)
- アット・ニフティ 5361 -> (要確認)