Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion src/UseCases/Line/HandleIntentOperationUseCase.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ def _execute_pending(self, line_user_id: str) -> None:
self._line_response_service.add_message(
f'"{item_name}" を期限{parsed_expiry_date.strftime("%Y年%m月%d日")}で登録しました。'
)
self._pending_operation_service.clear(line_user_id)
self._pending_operation_service.save(
line_user_id,
{"intent": "update_recent_notify", "item_name": item_name},
)
return
else:
self._line_response_service.add_message(f'"{item_name}" を登録しました。')
self._line_response_service.add_message(
Expand Down Expand Up @@ -410,11 +416,20 @@ def _handle_recent_register_expiry_update(self, line_user_id: str, message: str)
return False

operation = pending["operation"]
if operation.get("intent") == "update_recent_notify":
return self._handle_recent_notify_update(line_user_id, message, operation)

if operation.get("intent") != "update_recent_expiry":
return False

item_name = operation.get("item_name")

if message in ("なし", "不要", "いらない", "NO", "no"):
self._pending_operation_service.clear(line_user_id)
self._pending_operation_service.save(
line_user_id,
{"intent": "update_recent_notify", "item_name": item_name},
)
self._line_response_service.add_message("期限なしのままにしました。")
return True

Expand All @@ -425,7 +440,6 @@ def _handle_recent_register_expiry_update(self, line_user_id: str, message: str)
)
return True

item_name = operation.get("item_name")
stocks = self._stock_repository.find(
query={"owner_id": line_user_id, "item_name": item_name, "status": 1},
sort=[("created_at", "desc")],
Expand All @@ -441,11 +455,48 @@ def _handle_recent_register_expiry_update(self, line_user_id: str, message: str)
new_values={"expiry_date": parsed_date},
)
self._pending_operation_service.clear(line_user_id)
self._pending_operation_service.save(
line_user_id,
{"intent": "update_recent_notify", "item_name": item_name},
)
self._line_response_service.add_message(
f'"{item_name}" の期限を{parsed_date.strftime("%Y年%m月%d日")}に更新しました。'
)
return True

def _handle_recent_notify_update(self, line_user_id: str, message: str, operation: dict) -> bool:
"""直近登録アイテムへの通知設定フォローアップを処理する。"""
item_name = operation.get("item_name")

# アイテム名を補完してNLPに渡す(例: "確定申告の通知は3日前から")
parsed = self._intent_parser_service.parse(f"{item_name}の{message}")

if parsed.get("intent") != "update_stock_notify":
# 通知設定変更として解釈できない → コンテキスト破棄して通常処理へ
self._pending_operation_service.clear(line_user_id)
return False

self._update_recent_item_notify(line_user_id, item_name, parsed.get("notify_days_before"))
self._pending_operation_service.clear(line_user_id)
return True

def _update_recent_item_notify(self, line_user_id: str, item_name: str, notify_days_before) -> None:
stocks = self._stock_repository.find(
query={"owner_id": line_user_id, "item_name": item_name, "status": 1},
sort=[("created_at", "desc")],
)
if not stocks:
self._line_response_service.add_message(f'"{item_name}" が見つかりませんでした。')
return
self._stock_repository.update(
query={"_id": stocks[0]._id},
new_values={"notify_days_before": notify_days_before},
)
label = "常に通知" if notify_days_before is None else f"{notify_days_before}日前から通知"
self._line_response_service.add_message(
f'"{item_name}" の通知を{label}に設定しました。'
)

def _parse_followup_expiry_date(self, message: str):
text = message.strip()
if text == "":
Expand Down
120 changes: 118 additions & 2 deletions src/UseCases/Line/tests/test_handle_intent_operation_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,15 @@ def find(self, query=None, sort=None) -> list[Stock]:


class DummyIntentParserService:
def __init__(self, parsed):
def __init__(self, parsed, extra_responses=None):
self.parsed = parsed
# メッセージ部分文字列 → 返却値 のマッピング(テスト用)
self.extra_responses = extra_responses or {}

def parse(self, message: str):
for substring, response in self.extra_responses.items():
if substring in message:
return response
return self.parsed


Expand Down Expand Up @@ -302,7 +307,8 @@ def test_followup_expiry_date_updates_recently_registered_item():
assert repo.updated_query == {"_id": "S1"}
assert repo.updated_values is not None
assert repo.updated_values["expiry_date"] is not None
assert pending.get("U1") is None
# 期限更新後は通知フォローアップコンテキストが保存される
assert pending.get("U1")["operation"]["intent"] == "update_recent_notify"


def test_register_with_notify_days_before():
Expand Down Expand Up @@ -630,3 +636,113 @@ def test_update_habit_log_not_found_creates():
assert log_repo.created.result == "done"
assert log_repo.created.habit_task_id == "HT1"
assert any("修正しました" in m for m in res.messages)


def test_followup_notify_after_register_with_expiry():
"""期限あり登録の直後に「通知は3日前から」で通知設定が反映される。"""
req = DummyLineRequestService(message="確定申告は3/15まで")
res = DummyLineResponseService()
repo = DummyStockRepository()
repo.found_stocks = [Stock(_id="S1", item_name="確定申告", owner_id="U1", status=1)]
# 「通知は3日前から」を含む補完済みメッセージが来たら update_stock_notify を返す
parser = DummyIntentParserService(
{"intent": "register", "item_name": "確定申告", "expiry_date": "2026-03-15"},
extra_responses={
"通知は3日前から": {
"intent": "update_stock_notify",
"item_name": "確定申告",
"notify_days_before": 3,
}
},
)
pending = DummyPendingOperationService()
use_case = HandleIntentOperationUseCase(
stock_repository=repo,
line_request_service=req,
line_response_service=res,
intent_parser_service=parser,
pending_operation_service=pending,
)

use_case.execute()
req.message = "はい"
use_case.execute()

# 登録完了後、通知フォローアップコンテキストが保存される
assert pending.get("U1")["operation"]["intent"] == "update_recent_notify"
assert pending.get("U1")["operation"]["item_name"] == "確定申告"

# 「通知は3日前から」を送ると通知設定が更新される
req.message = "通知は3日前から"
use_case.execute()

assert repo.updated_values["notify_days_before"] == 3
assert any("3日前から通知" in m for m in res.messages)
assert pending.get("U1") is None


def test_followup_notify_after_expiry_set():
"""期限なし登録 → 期限設定 → 「通知は3日前から」で通知設定が反映される。"""
res = DummyLineResponseService()
repo = DummyStockRepository()
repo.found_stocks = [Stock(_id="S1", item_name="卵", owner_id="U1", status=1)]
parser = DummyIntentParserService(
{"intent": "none", "item_name": None, "expiry_date": None},
extra_responses={
"通知は3日前から": {
"intent": "update_stock_notify",
"item_name": "卵",
"notify_days_before": 3,
}
},
)
pending = DummyPendingOperationService()
pending.save("U1", {"intent": "update_recent_expiry", "item_name": "卵"})
req = DummyLineRequestService(message="明日で")
use_case = HandleIntentOperationUseCase(
stock_repository=repo,
line_request_service=req,
line_response_service=res,
intent_parser_service=parser,
pending_operation_service=pending,
)

use_case.execute()

# 期限設定後、通知フォローアップコンテキストに切り替わる
assert pending.get("U1")["operation"]["intent"] == "update_recent_notify"

# 「通知は3日前から」を送ると通知設定が更新される
req.message = "通知は3日前から"
use_case.execute()

assert repo.updated_values["notify_days_before"] == 3
assert any("3日前から通知" in m for m in res.messages)
assert pending.get("U1") is None


def test_followup_notify_unrelated_message_falls_through():
"""update_recent_notify 中に無関係なメッセージを送るとコンテキストが破棄される。"""
res = DummyLineResponseService()
repo = DummyStockRepository()
# NLPは「none」を返す(通知設定変更として解釈できない)
parser = DummyIntentParserService(
{"intent": "none", "item_name": None, "expiry_date": None}
)
pending = DummyPendingOperationService()
pending.save("U1", {"intent": "update_recent_notify", "item_name": "牛乳"})
req = DummyLineRequestService(message="ありがとう")
use_case = HandleIntentOperationUseCase(
stock_repository=repo,
line_request_service=req,
line_response_service=res,
intent_parser_service=parser,
pending_operation_service=pending,
)

use_case.execute()

# コンテキスト破棄
assert pending.get("U1") is None
# notify_days_before は更新されない
assert repo.updated_values is None
2 changes: 1 addition & 1 deletion src/routes/web/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def index():
near_due_count = 0
notify_on_count = 0
for stock in stocks:
if stock.notify_enabled:
if stock.notify_days_before is not None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include always-notify stocks in notify-on KPI

notify_on_count is incremented only when notify_days_before is not None, but this codebase treats notify_days_before == None as “常に通知” (for example _should_notify returns True in src/UseCases/Line/CheckExpiredStockUseCase.py:11). As a result, stocks configured for always-notify are excluded from the “通知ONアイテム” dashboard metric, so the KPI is systematically undercounted (often near zero) even when notifications are effectively enabled.

Useful? React with 👍 / 👎.

notify_on_count += 1
if stock.expiry_date is None:
continue
Expand Down
37 changes: 1 addition & 36 deletions src/templates/pages/stock/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,4 @@
<!-- ページヘッダー -->
<div class="page-header d-flex justify-content-between align-items-center">
<h1 class="page-title">アイテム一覧</h1>
<a href="{{ url_for('views_blueprint.stock_trash') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-trash-alt me-1"></i>削除済みアイテム
</a>
</div>

<!-- 通知時刻設定 -->
<div class="app-card p-3 mb-4">
<form
action="{{ url_for('views_blueprint.update_notify_schedule') }}"
method="POST"
class="d-flex align-items-center gap-2"
>
<label for="notify_time" class="form-label m-0 text-nowrap">通知時刻</label>
<input
id="notify_time"
name="notify_time"
type="time"
class="form-control form-control-sm"
value="{{ notify_time }}"
style="max-width: 160px"
required
/>
<button type="submit" class="btn btn-sm btn-outline-primary">
保存
</button>
</form>
</div>

<div class="row">
<div class="col">
{% import 'components/_macros.html' as ui with context %} {{
ui.stock_table(page_contents, form) }}
</div>
</div>
</div>
{% endblock %}
<a href="{{ url_for('views_blueprint.view_deleted_stock_list') }}" class="btn btn-outline-secondary btn-sm">