File size: 9,640 Bytes
992d9b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""Admin review endpoints. Collection runs only from collect.py."""
from datetime import date, datetime
from typing import Literal
from urllib.parse import urlparse
import re

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from auth import require_admin
from models import Company, ReturnSource, ReturnDocument, ReturnEvent, ReturnPolicy, ReturnAudit
from shareholder_returns import audit, document_data, load_documents, serialize
from return_lifecycle import automatic_status


class StrictModel(BaseModel):
    model_config = ConfigDict(extra="forbid", allow_inf_nan=False)


class EventInput(StrictModel):
    year: int = Field(ge=2000, le=2100)
    amount: float | None = Field(default=None, ge=0)
    scope: Literal["common", "company", "preferred", "unknown"] = "unknown"
    cancellation_date: date | None = None
    outside_policy: bool = False
    evidence: str = Field(default="", max_length=10000)

    @model_validator(mode="after")
    def date_matches_year(self):
        if self.cancellation_date and self.cancellation_date.year != self.year:
            raise ValueError("μ†Œκ°μ˜ˆμ •μΌκ³Ό 적용 연도가 λ‹€λ¦…λ‹ˆλ‹€.")
        return self


class PolicyInput(StrictModel):
    year: int = Field(ge=2000, le=2100)
    kind: Literal["cancellation", "total_amount", "total_ratio", "none"]
    scope: Literal["common", "company", "preferred", "unknown"] = "unknown"
    amount: float | None = Field(default=None, ge=0)
    ratio: float | None = Field(default=None, ge=0, le=1)
    cancellation_commitment: bool = False
    income_basis: str = Field(default="unknown", max_length=200)
    income_amount: float | None = None
    dividend_amount: float | None = Field(default=None, ge=0)
    evidence: str = Field(default="", max_length=10000)


def validate_url(value):
    url = urlparse(value)
    if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password:
        raise ValueError("곡식 자료의 http(s) URL을 μž…λ ₯ν•˜μ„Έμš”.")
    return value


class ReviewInput(StrictModel):
    ticker: str = Field(pattern=r"^[0-9A-Z]{6}$")
    source_key: str = Field(min_length=1, max_length=500)
    source_url: str = Field(max_length=2000)
    title: str = Field(min_length=1, max_length=500)
    published_at: date | None = None
    evidence: str = Field(default="", max_length=30000)
    status: Literal["active", "reference", "pending", "approved", "excluded"] = "active"
    supersedes_id: int | None = None
    is_withdrawal: bool = False
    events: list[EventInput] = Field(default_factory=list, max_length=100)
    policies: list[PolicyInput] = Field(default_factory=list, max_length=100)

    _url = field_validator("source_url")(validate_url)

    @model_validator(mode="after")
    def approval_requires_evidence(self):
        if self.status in {"active", "approved"}:
            if not self.evidence.strip():
                raise ValueError("μŠΉμΈν•˜λ €λ©΄ κ·Όκ±° 문ꡬ가 ν•„μš”ν•©λ‹ˆλ‹€.")
            if self.is_withdrawal and not self.supersedes_id:
                raise ValueError("철회 λŒ€μƒ 자료λ₯Ό μ§€μ •ν•˜μ„Έμš”.")
            if not self.is_withdrawal and not (self.events or self.policies):
                raise ValueError("μŠΉμΈν•  μ†Œκ° 사건 λ˜λŠ” 정책이 ν•„μš”ν•©λ‹ˆλ‹€.")
            for e in self.events:
                if e.amount is None or not e.evidence.strip():
                    raise ValueError("μ†Œκ° μ‚¬κ±΄μ—λŠ” κΈˆμ•‘κ³Ό κ·Όκ±°κ°€ ν•„μš”ν•©λ‹ˆλ‹€.")
            for p in self.policies:
                if not p.evidence.strip():
                    raise ValueError("μ •μ±…μ˜ κ·Όκ±°κ°€ ν•„μš”ν•©λ‹ˆλ‹€.")
                if p.kind in ("cancellation", "total_amount") and p.amount is None:
                    raise ValueError("μ •μ±…μ˜ κΈˆμ•‘μ΄ ν•„μš”ν•©λ‹ˆλ‹€.")
                if p.kind == "total_ratio" and p.ratio is None:
                    raise ValueError("ν™˜μ›μœ¨μ΄ ν•„μš”ν•©λ‹ˆλ‹€. 50%λŠ” 0.5둜 μž…λ ₯ν•˜μ„Έμš”.")
                if p.kind in ("total_amount", "total_ratio") and not p.cancellation_commitment:
                    raise ValueError("μž”μ—¬μ•‘μ„ μ†Œκ°ν•˜λ €λŠ” νšŒμ‚¬ 방침이 ν•„μš”ν•©λ‹ˆλ‹€.")
        return self


class SourceInput(StrictModel):
    ticker: str = Field(pattern=r"^[0-9A-Z]{6}$")
    url: str = Field(max_length=2000)
    is_index: bool = False
    enabled: bool = True
    _url = field_validator("url")(validate_url)


class StatusInput(StrictModel):
    action: Literal["cancel", "restore"]
    reason: str = Field(default="", max_length=2000)


def write_review(db, payload, doc=None, actor_id=None):
    if not db.get(Company, payload.ticker):
        raise HTTPException(404, "μ’…λͺ©μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
    duplicate = db.query(ReturnDocument).filter_by(source_key=payload.source_key).first()
    if duplicate and (doc is None or duplicate.id != doc.id):
        raise HTTPException(409, "이미 λ“±λ‘λœ 좜처 μ‹λ³„μžμž…λ‹ˆλ‹€.")
    if doc and (doc.ticker != payload.ticker or doc.source_key != payload.source_key):
        raise HTTPException(422, "μ’…λͺ©κ³Ό 좜처 μ‹λ³„μžλŠ” λ³€κ²½ν•  수 μ—†μŠ΅λ‹ˆλ‹€.")
    if payload.status == "approved" and re.search(r"μ •μ •|철회|μ·¨μ†Œ", payload.title) and not payload.supersedes_id:
        raise HTTPException(422, "μ •μ •Β·μ² νšŒ μžλ£ŒλŠ” λŒ€μ²΄ν•˜λŠ” 이전 자료λ₯Ό μ§€μ •ν•˜μ„Έμš”.")
    if payload.supersedes_id:
        parent = db.get(ReturnDocument, payload.supersedes_id)
        visited = {doc.id} if doc else set()
        if not parent or parent.ticker != payload.ticker:
            raise HTTPException(422, "같은 μ’…λͺ©μ˜ κΈ°μ‘΄ 자료λ₯Ό μ§€μ •ν•˜μ„Έμš”.")
        while parent:
            if parent.id in visited:
                raise HTTPException(422, "μ •μ • 관계가 μˆœν™˜ν•©λ‹ˆλ‹€.")
            visited.add(parent.id)
            parent = db.get(ReturnDocument, parent.supersedes_id) if parent.supersedes_id else None
        if payload.status == "approved":
            sibling = db.query(ReturnDocument).filter_by(supersedes_id=payload.supersedes_id, status="approved").first()
            if sibling and (not doc or sibling.id != doc.id):
                raise HTTPException(409, "이미 승인된 μ •μ • 자료λ₯Ό λŒ€μƒμœΌλ‘œ μ§€μ •ν•˜μ„Έμš”.")
    before = document_data(db, doc) if doc else None
    if doc is None:
        doc = ReturnDocument()
        db.add(doc)
    values = payload.model_dump(mode="json", exclude={"events", "policies"})
    for key, value in values.items():
        setattr(doc, key, value)
    doc.updated_at = datetime.utcnow()
    doc.error = None
    db.flush()
    for model in (ReturnEvent, ReturnPolicy):
        db.query(model).filter_by(document_id=doc.id).delete(synchronize_session=False)
    for key, model in (("events", ReturnEvent), ("policies", ReturnPolicy)):
        for item in getattr(payload, key):
            db.add(model(document_id=doc.id, **item.model_dump(mode="json")))
    audit(db, doc, "review", before, actor_id)
    db.commit()
    return document_data(db, doc)


def create_return_router(get_db, invalidate):
    router = APIRouter(prefix="/api/admin/returns", dependencies=[Depends(require_admin)])

    @router.get("/documents")
    def list_documents(db=Depends(get_db)):
        return load_documents(db)

    @router.post("/documents")
    def add_document(payload: ReviewInput, db=Depends(get_db), user=Depends(require_admin)):
        result = write_review(db, payload, actor_id=user.id)
        invalidate()
        return result

    @router.put("/documents/{document_id}")
    def edit_document(document_id: int, payload: ReviewInput, db=Depends(get_db), user=Depends(require_admin)):
        doc = db.get(ReturnDocument, document_id)
        if not doc:
            raise HTTPException(404, "자료λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
        result = write_review(db, payload, doc, user.id)
        invalidate()
        return result

    @router.get("/documents/{document_id}/audit")
    def history(document_id: int, db=Depends(get_db)):
        return [serialize(a) for a in db.query(ReturnAudit).filter_by(document_id=document_id).order_by(ReturnAudit.id)]

    @router.post("/documents/{document_id}/status")
    def change_status(document_id: int, payload: StatusInput, db=Depends(get_db), user=Depends(require_admin)):
        doc = db.get(ReturnDocument, document_id)
        if not doc:
            raise HTTPException(404, "자료λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
        before = document_data(db, doc)
        desired = "excluded" if payload.action == "cancel" else automatic_status(before)
        if doc.status != desired:
            doc.status, doc.updated_at = desired, datetime.utcnow()
            audit(db, doc, payload.action, before, user.id, payload.reason)
            db.commit()
            invalidate()
        return document_data(db, doc)

    @router.get("/sources")
    def sources(db=Depends(get_db)):
        return [serialize(s) for s in db.query(ReturnSource).order_by(ReturnSource.id)]

    @router.post("/sources")
    def add_source(payload: SourceInput, db=Depends(get_db)):
        if not db.get(Company, payload.ticker):
            raise HTTPException(404, "μ’…λͺ©μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
        source = db.query(ReturnSource).filter_by(ticker=payload.ticker, url=payload.url).first()
        if source is None:
            source = ReturnSource(ticker=payload.ticker, url=payload.url)
            db.add(source)
        source.is_index, source.enabled = payload.is_index, payload.enabled
        db.commit()
        return serialize(source)

    return router