feat(req-002): implement portfolio tagging and tag filters

This commit is contained in:
2026-04-14 01:06:10 +09:00
parent 2dab17cbb1
commit a776ea3b1d
20 changed files with 1033 additions and 32 deletions
+56 -2
View File
@@ -7,7 +7,7 @@ import json
from fastapi import Depends
from app.core.config import Settings, get_settings
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet
from app.domain.services import (
apply_buy,
apply_delete,
@@ -28,6 +28,8 @@ from app.schemas.portfolio import (
InvestRequest,
PortfolioDetailResponse,
PortfolioListItem,
PortfolioTagMutationRequest,
PortfolioTagResponse,
ResetPortfolioRequest,
SellRequest,
TransferRequest,
@@ -49,6 +51,7 @@ def _to_portfolio_summary(portfolio: Portfolio):
status=portfolio.status,
name=portfolio.name,
symbol=portfolio.symbol,
tags=list(portfolio.tags or []),
owner_user_id=portfolio.owner_user_id,
cash_balance=portfolio.cash_balance,
asset_quantity=portfolio.asset_quantity,
@@ -121,6 +124,31 @@ def _to_comment_response(comment: PortfolioComment) -> CommentResponse:
)
def _to_tag_response(tag: PortfolioTag) -> PortfolioTagResponse:
return PortfolioTagResponse(portfolio_id=tag.portfolio_id, tag=tag.tag, created_at=tag.created_at)
def _normalize_tag(value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("tag는 비어 있을 수 없습니다")
return normalized
def _normalize_tag_list(values: list[str] | None) -> list[str] | None:
if not values:
return None
normalized: list[str] = []
seen: set[str] = set()
for value in values:
item = _normalize_tag(value)
if item in seen:
continue
seen.add(item)
normalized.append(item)
return normalized or None
def _to_detail_response(status_result) -> PortfolioDetailResponse:
return PortfolioDetailResponse(
portfolio=_to_portfolio_summary(status_result.portfolio),
@@ -144,8 +172,21 @@ class PortfolioService:
broker: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
) -> list[PortfolioListItem]:
portfolios = self._repository.list_portfolios(broker=broker, status=status, owner_user_id=owner_user_id)
normalized_mode = None if match_mode is None else match_mode.upper()
if normalized_mode is not None and normalized_mode not in {"AND", "OR"}:
raise ValueError("matchMode는 AND 또는 OR 여야 합니다")
portfolios = self._repository.list_portfolios(
broker=broker,
status=status,
owner_user_id=owner_user_id,
include_tags=_normalize_tag_list(include_tags),
exclude_tags=_normalize_tag_list(exclude_tags),
match_mode=normalized_mode,
)
return [
PortfolioListItem(
**_to_portfolio_summary(portfolio).model_dump(),
@@ -302,6 +343,19 @@ class PortfolioService:
self._repository.get(portfolio_id)
self._repository.delete_comment(portfolio_id, comment_id)
def add_tag(self, portfolio_id: str, payload: PortfolioTagMutationRequest) -> PortfolioTagResponse:
self._repository.get(portfolio_id)
tag = self._repository.add_tag(portfolio_id, tag=_normalize_tag(payload.tag))
return _to_tag_response(tag)
def list_tags(self, portfolio_id: str) -> list[PortfolioTagResponse]:
self._repository.get(portfolio_id)
return [_to_tag_response(tag) for tag in self._repository.list_tags(portfolio_id)]
def remove_tag(self, portfolio_id: str, payload: PortfolioTagMutationRequest) -> None:
self._repository.get(portfolio_id)
self._repository.remove_tag(portfolio_id, tag=_normalize_tag(payload.tag))
def get_portfolio_service(
repository: PortfolioRepository = Depends(get_portfolio_repository),