feat: persist finance daily summaries
This commit is contained in:
@@ -41,7 +41,7 @@ def get_db():
|
|||||||
def init_db():
|
def init_db():
|
||||||
"""Create all tables."""
|
"""Create all tables."""
|
||||||
# import models so SQLAlchemy registers them
|
# import models so SQLAlchemy registers them
|
||||||
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key, external_api_log # noqa: F401
|
from app.models import admin_user, upstream, snapshot, webhook_config, notification_log, custom_page, website, revoked_token, upstream_key, external_api_log, finance_daily_summary # noqa: F401
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
_ensure_indexes()
|
_ensure_indexes()
|
||||||
_migrate_custom_pages()
|
_migrate_custom_pages()
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Finance daily summary snapshot model.
|
||||||
|
|
||||||
|
Stores pre-computed daily reconciliation results in a single JSON column.
|
||||||
|
This keeps the schema stable: adding new summary fields requires no migration.
|
||||||
|
"""
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, DateTime, Integer, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceDailySummary(Base):
|
||||||
|
__tablename__ = "finance_daily_summaries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
stat_date: Mapped[date] = mapped_column(Date, unique=True, nullable=False, index=True)
|
||||||
|
summary_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
success: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
computed_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("stat_date", name="uq_finance_summary_date"),
|
||||||
|
)
|
||||||
+200
-19
@@ -1,42 +1,223 @@
|
|||||||
"""Finance router — daily revenue vs cost reconciliation."""
|
"""Finance router — daily revenue vs cost reconciliation with snapshot storage."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
from typing import Any, Optional
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.upstream import Upstream
|
from app.services.finance_service import (
|
||||||
from app.models.website import Website
|
compute_daily_summary,
|
||||||
from app.services.finance_service import get_daily_summary, yesterday_shanghai
|
get_or_create_daily_summary,
|
||||||
|
overwrite_daily_summary,
|
||||||
|
today_shanghai,
|
||||||
|
yesterday_shanghai,
|
||||||
|
)
|
||||||
from app.utils.auth import get_current_user
|
from app.utils.auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/finance", tags=["finance"])
|
router = APIRouter(prefix="/api/finance", tags=["finance"])
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_date(date_str: str | None) -> _dt.date:
|
||||||
|
"""Parse optional date string; return yesterday Asia/Shanghai if omitted."""
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
return _dt.date.fromisoformat(date_str)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"Invalid date format: {date_str!r}. Expected YYYY-MM-DD.")
|
||||||
|
return yesterday_shanghai()
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_not_future(target: _dt.date) -> None:
|
||||||
|
"""Reject future dates — we can't save snapshots for days that haven't happened."""
|
||||||
|
if target > today_shanghai():
|
||||||
|
raise HTTPException(400, f"Cannot query future date: {target.isoformat()}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/daily-summary")
|
@router.get("/daily-summary")
|
||||||
def daily_summary(
|
def daily_summary(
|
||||||
date: Optional[str] = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"),
|
date: str | None = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: Any = Depends(get_current_user),
|
_: Any = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return daily revenue vs cost summary.
|
"""Return stored daily summary snapshot.
|
||||||
|
|
||||||
Never raises for individual item failures — each failed website/upstream
|
If no snapshot exists for the requested date, computes one on the fly,
|
||||||
is included in the response with status='failed' and an error message.
|
saves it, and returns it. Never raises for individual item failures.
|
||||||
Only raises HTTP errors for bad request parameters.
|
|
||||||
"""
|
"""
|
||||||
if date:
|
target = _resolve_date(date)
|
||||||
try:
|
_validate_not_future(target)
|
||||||
target = _dt.date.fromisoformat(date)
|
return get_or_create_daily_summary(db, target)
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, f"Invalid date format: {date!r}. Expected YYYY-MM-DD.")
|
|
||||||
|
@router.post("/daily-summary/compare")
|
||||||
|
def compare_daily_summary(
|
||||||
|
date: str | None = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: Any = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Compare stored snapshot against a fresh real-time computation.
|
||||||
|
|
||||||
|
Returns {stored, current, has_difference, diff_items}. Does NOT write to DB.
|
||||||
|
"""
|
||||||
|
target = _resolve_date(date)
|
||||||
|
_validate_not_future(target)
|
||||||
|
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
|
||||||
|
stored_row = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == target
|
||||||
|
).first()
|
||||||
|
|
||||||
|
current = compute_daily_summary(db, target)
|
||||||
|
|
||||||
|
if stored_row:
|
||||||
|
stored_data = json.loads(stored_row.summary_json)
|
||||||
|
stored_data["computed_at"] = stored_row.computed_at.isoformat()
|
||||||
|
stored_data["from_snapshot"] = True
|
||||||
|
diff_items, has_diff = _compute_diff_items(stored_data, current)
|
||||||
else:
|
else:
|
||||||
target = yesterday_shanghai()
|
stored_data = None
|
||||||
|
diff_items = []
|
||||||
|
has_diff = True # no stored data = definitely different
|
||||||
|
|
||||||
websites = db.query(Website).filter(Website.enabled == True).all()
|
return {
|
||||||
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
"stored": stored_data,
|
||||||
|
"current": current,
|
||||||
|
"has_difference": has_diff,
|
||||||
|
"diff_items": diff_items,
|
||||||
|
}
|
||||||
|
|
||||||
return get_daily_summary(websites, upstreams, target)
|
|
||||||
|
@router.post("/daily-summary/overwrite")
|
||||||
|
def overwrite_daily_summary_endpoint(
|
||||||
|
date: str | None = Query(None, description="YYYY-MM-DD, defaults to yesterday Asia/Shanghai"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: Any = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""Recompute and overwrite the stored snapshot for the requested date."""
|
||||||
|
target = _resolve_date(date)
|
||||||
|
_validate_not_future(target)
|
||||||
|
return overwrite_daily_summary(db, target)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_difference(stored: dict, current: dict) -> bool:
|
||||||
|
"""Compare stored vs current summary at the total and item level.
|
||||||
|
|
||||||
|
Items are compared by (id, upstream_type) identity rather than by array
|
||||||
|
position, so that a replaced website/upstream doesn't falsely appear
|
||||||
|
identical, and reordered items don't falsely appear different.
|
||||||
|
"""
|
||||||
|
for key in ("total_revenue", "total_cost", "net_income", "success"):
|
||||||
|
if stored.get(key) != current.get(key):
|
||||||
|
return True
|
||||||
|
for side in ("website_items", "upstream_items"):
|
||||||
|
s_list = stored.get(side, [])
|
||||||
|
c_list = current.get(side, [])
|
||||||
|
if len(s_list) != len(c_list):
|
||||||
|
return True
|
||||||
|
# Build lookup map by (id, upstream_type)
|
||||||
|
s_map = {(_item_id(s), s.get("upstream_type")): s for s in s_list}
|
||||||
|
c_map = {(_item_id(c), c.get("upstream_type")): c for c in c_list}
|
||||||
|
# Check that keys match exactly
|
||||||
|
if s_map.keys() != c_map.keys():
|
||||||
|
return True
|
||||||
|
# Compare each item by its stable identity
|
||||||
|
for key_ident, s_item in s_map.items():
|
||||||
|
c_item = c_map[key_ident]
|
||||||
|
for attr in ("status", "amount", "error"):
|
||||||
|
if s_item.get(attr) != c_item.get(attr):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _item_id(item: dict) -> int | str:
|
||||||
|
"""Return item id, defaulting to a sentinel for items that lack one."""
|
||||||
|
v = item.get("id")
|
||||||
|
return v if v is not None else -1
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_diff_items(stored: dict, current: dict) -> tuple[list[dict], bool]:
|
||||||
|
"""Compare stored vs current and return (diff_items, has_difference).
|
||||||
|
|
||||||
|
diff_items is a list of changes at the item level, each with:
|
||||||
|
side, name, id, upstream_type, old_{status,amount,error}, new_{status,amount,error}.
|
||||||
|
|
||||||
|
has_difference also covers total-level changes and count mismatches.
|
||||||
|
"""
|
||||||
|
diff_items: list[dict] = []
|
||||||
|
has_diff = False
|
||||||
|
|
||||||
|
# Total-level checks
|
||||||
|
for key in ("total_revenue", "total_cost", "net_income", "success"):
|
||||||
|
if stored.get(key) != current.get(key):
|
||||||
|
has_diff = True
|
||||||
|
|
||||||
|
# Item-level checks
|
||||||
|
for side in ("website_items", "upstream_items"):
|
||||||
|
s_list = stored.get(side, [])
|
||||||
|
c_list = current.get(side, [])
|
||||||
|
if len(s_list) != len(c_list):
|
||||||
|
has_diff = True
|
||||||
|
s_map = {(_item_id(s), s.get("upstream_type")): s for s in s_list}
|
||||||
|
c_map = {(_item_id(c), c.get("upstream_type")): c for c in c_list}
|
||||||
|
if s_map.keys() != c_map.keys():
|
||||||
|
has_diff = True
|
||||||
|
# Items in both sides — compare attributes
|
||||||
|
for key_ident, s_item in s_map.items():
|
||||||
|
c_item = c_map.get(key_ident)
|
||||||
|
if c_item is None:
|
||||||
|
continue # already counted as key mismatch
|
||||||
|
for attr in ("status", "amount", "error"):
|
||||||
|
if s_item.get(attr) != c_item.get(attr):
|
||||||
|
has_diff = True
|
||||||
|
diff_items.append({
|
||||||
|
"side": side,
|
||||||
|
"id": _item_id(s_item),
|
||||||
|
"name": s_item.get("name", ""),
|
||||||
|
"upstream_type": s_item.get("upstream_type", ""),
|
||||||
|
"old_status": s_item.get("status"),
|
||||||
|
"new_status": c_item.get("status"),
|
||||||
|
"old_amount": s_item.get("amount"),
|
||||||
|
"new_amount": c_item.get("amount"),
|
||||||
|
"old_error": s_item.get("error"),
|
||||||
|
"new_error": c_item.get("error"),
|
||||||
|
})
|
||||||
|
break # one diff entry per item
|
||||||
|
# Items only in stored (removed)
|
||||||
|
for key_ident in s_map.keys() - c_map.keys():
|
||||||
|
has_diff = True
|
||||||
|
s_item = s_map[key_ident]
|
||||||
|
diff_items.append({
|
||||||
|
"side": side,
|
||||||
|
"id": _item_id(s_item),
|
||||||
|
"name": s_item.get("name", ""),
|
||||||
|
"upstream_type": s_item.get("upstream_type", ""),
|
||||||
|
"old_status": s_item.get("status"),
|
||||||
|
"new_status": None,
|
||||||
|
"old_amount": s_item.get("amount"),
|
||||||
|
"new_amount": None,
|
||||||
|
"old_error": s_item.get("error"),
|
||||||
|
"new_error": None,
|
||||||
|
})
|
||||||
|
# Items only in current (added)
|
||||||
|
for key_ident in c_map.keys() - s_map.keys():
|
||||||
|
has_diff = True
|
||||||
|
c_item = c_map[key_ident]
|
||||||
|
diff_items.append({
|
||||||
|
"side": side,
|
||||||
|
"id": _item_id(c_item),
|
||||||
|
"name": c_item.get("name", ""),
|
||||||
|
"upstream_type": c_item.get("upstream_type", ""),
|
||||||
|
"old_status": None,
|
||||||
|
"new_status": c_item.get("status"),
|
||||||
|
"old_amount": None,
|
||||||
|
"new_amount": c_item.get("amount"),
|
||||||
|
"old_error": None,
|
||||||
|
"new_error": c_item.get("error"),
|
||||||
|
})
|
||||||
|
|
||||||
|
return diff_items, has_diff
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ Architecture:
|
|||||||
within the client context — retains cookies, 401 refresh, external API log.
|
within the client context — retains cookies, 401 refresh, external API log.
|
||||||
- Upstream cost (New-API/Nox-API): same UpstreamClient pattern.
|
- Upstream cost (New-API/Nox-API): same UpstreamClient pattern.
|
||||||
|
|
||||||
No DB writes. Each item fails independently; failures do NOT contribute to
|
New service functions (get_or_create_daily_summary, compute_daily_summary,
|
||||||
totals, and the UI must treat any failed item as incomplete accounting data.
|
overwrite_daily_summary) wrap get_daily_summary with DB-backed snapshot storage.
|
||||||
|
Each item fails independently; failures do NOT contribute to totals, and the UI
|
||||||
|
must treat any failed item as incomplete accounting data.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.upstream import Upstream
|
from app.models.upstream import Upstream
|
||||||
from app.models.website import Website
|
from app.models.website import Website
|
||||||
from app.services.upstream_client import UpstreamClient
|
from app.services.upstream_client import UpstreamClient
|
||||||
@@ -26,7 +30,7 @@ from app.services.website_client import Sub2ApiWebsiteClient, WebsiteError
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SHANGHAI = pytz.timezone("Asia/Shanghai")
|
SHANGHAI_TZ = pytz.timezone("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@@ -63,13 +67,17 @@ def classify_upstream(upstream: Upstream) -> str:
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
def yesterday_shanghai() -> date:
|
def yesterday_shanghai() -> date:
|
||||||
return datetime.now(_SHANGHAI).date() - timedelta(days=1)
|
return datetime.now(SHANGHAI_TZ).date() - timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def today_shanghai() -> date:
|
||||||
|
return datetime.now(SHANGHAI_TZ).date()
|
||||||
|
|
||||||
|
|
||||||
def date_to_shanghai_timestamps(d: date) -> tuple[int, int]:
|
def date_to_shanghai_timestamps(d: date) -> tuple[int, int]:
|
||||||
"""Return (start_unix, end_unix) for 00:00:00..23:59:59 Asia/Shanghai on date d."""
|
"""Return (start_unix, end_unix) for 00:00:00..23:59:59 Asia/Shanghai on date d."""
|
||||||
start = _SHANGHAI.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
|
start = SHANGHAI_TZ.localize(datetime(d.year, d.month, d.day, 0, 0, 0))
|
||||||
end = _SHANGHAI.localize(datetime(d.year, d.month, d.day, 23, 59, 59))
|
end = SHANGHAI_TZ.localize(datetime(d.year, d.month, d.day, 23, 59, 59))
|
||||||
return int(start.timestamp()), int(end.timestamp())
|
return int(start.timestamp()), int(end.timestamp())
|
||||||
|
|
||||||
|
|
||||||
@@ -294,3 +302,142 @@ def get_daily_summary(
|
|||||||
"failed_count": failed_count,
|
"failed_count": failed_count,
|
||||||
"success": failed_count == 0,
|
"success": failed_count == 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# DB-backed snapshot storage
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _truncate_errors(summary: dict[str, Any], max_len: int = 500) -> None:
|
||||||
|
"""Truncate error messages in place to keep summary_json from growing unbounded."""
|
||||||
|
for item in summary.get("website_items", []) + summary.get("upstream_items", []):
|
||||||
|
err = item.get("error")
|
||||||
|
if err and len(err) > max_len:
|
||||||
|
item["error"] = err[:max_len] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def _save_summary(db: Session, date_key: date, summary: dict[str, Any]) -> datetime:
|
||||||
|
"""Save a computed summary to the database. Returns computed_at."""
|
||||||
|
_truncate_errors(summary)
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
row = FinanceDailySummary(
|
||||||
|
stat_date=date_key,
|
||||||
|
summary_json=json.dumps(summary, ensure_ascii=False, default=str),
|
||||||
|
success=summary.get("success", False),
|
||||||
|
computed_at=now,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return now
|
||||||
|
|
||||||
|
|
||||||
|
def compute_daily_summary(
|
||||||
|
db: Session,
|
||||||
|
target_date: date,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compute a fresh daily summary (no DB write). Used for compare."""
|
||||||
|
websites = db.query(Website).filter(Website.enabled == True).all()
|
||||||
|
upstreams = db.query(Upstream).filter(Upstream.enabled == True).all()
|
||||||
|
return get_daily_summary(websites, upstreams, target_date)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_summary(row: Any) -> dict[str, Any]:
|
||||||
|
"""Load summary dict from a FinanceDailySummary row, adding metadata."""
|
||||||
|
data = json.loads(row.summary_json)
|
||||||
|
data["computed_at"] = row.computed_at.isoformat()
|
||||||
|
data["from_snapshot"] = True
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_daily_summary(
|
||||||
|
db: Session,
|
||||||
|
target_date: date,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return stored summary for target_date, or compute + save if missing.
|
||||||
|
|
||||||
|
If a concurrent request already saved a row between our query and commit,
|
||||||
|
we rollback and return the existing row. This prevents duplicate-key errors.
|
||||||
|
"""
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
|
||||||
|
existing = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == target_date
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return _load_summary(existing)
|
||||||
|
|
||||||
|
# Compute and save
|
||||||
|
summary = compute_daily_summary(db, target_date)
|
||||||
|
try:
|
||||||
|
now = _save_summary(db, target_date, summary)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
# Race: another request may have inserted this date. Read again.
|
||||||
|
existing = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == target_date
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return _load_summary(existing)
|
||||||
|
# Not a race — real error. Re-raise.
|
||||||
|
raise
|
||||||
|
|
||||||
|
summary["computed_at"] = now.isoformat()
|
||||||
|
summary["from_snapshot"] = False
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def save_summary_if_absent(
|
||||||
|
db: Session,
|
||||||
|
target_date: date,
|
||||||
|
) -> bool:
|
||||||
|
"""Compute and save summary for target_date if no snapshot exists yet.
|
||||||
|
|
||||||
|
Returns True if a new snapshot was saved, False if one already existed.
|
||||||
|
Safe against concurrent callers: on IntegrityError rolls back and
|
||||||
|
re-checks. Used by the scheduler cron job.
|
||||||
|
"""
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
|
||||||
|
existing = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == target_date
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return False
|
||||||
|
|
||||||
|
summary = compute_daily_summary(db, target_date)
|
||||||
|
try:
|
||||||
|
_save_summary(db, target_date, summary)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
# Race: another caller may have inserted this date.
|
||||||
|
existing = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == target_date
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def overwrite_daily_summary(
|
||||||
|
db: Session,
|
||||||
|
target_date: date,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Recompute and overwrite the stored snapshot for target_date.
|
||||||
|
|
||||||
|
Used when the user explicitly requests a refresh.
|
||||||
|
Deletes any existing row for this date first, then inserts the new one.
|
||||||
|
"""
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
|
||||||
|
db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == target_date
|
||||||
|
).delete()
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
summary = compute_daily_summary(db, target_date)
|
||||||
|
now = _save_summary(db, target_date, summary)
|
||||||
|
summary["computed_at"] = now.isoformat()
|
||||||
|
summary["from_snapshot"] = False
|
||||||
|
return summary
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""APScheduler background scheduler for upstream checks."""
|
"""APScheduler background scheduler for upstream checks and daily tasks."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
@@ -365,6 +366,16 @@ def start_scheduler() -> None:
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
misfire_grace_time=3600,
|
misfire_grace_time=3600,
|
||||||
)
|
)
|
||||||
|
# Daily finance summary at 00:05 Asia/Shanghai
|
||||||
|
_scheduler.add_job(
|
||||||
|
_compute_finance_daily_summary,
|
||||||
|
trigger=CronTrigger(timezone="Asia/Shanghai", hour=0, minute=5),
|
||||||
|
id="finance_daily_summary",
|
||||||
|
replace_existing=True,
|
||||||
|
coalesce=True,
|
||||||
|
max_instances=1,
|
||||||
|
misfire_grace_time=3600,
|
||||||
|
)
|
||||||
logger.info("scheduler started with %d upstream job(s)", len(upstreams))
|
logger.info("scheduler started with %d upstream job(s)", len(upstreams))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -383,6 +394,28 @@ def _cleanup_external_logs() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_finance_daily_summary() -> None:
|
||||||
|
"""Compute and save finance daily summary for yesterday (Asia/Shanghai).
|
||||||
|
|
||||||
|
Runs daily at 00:05 Asia/Shanghai. Delegates to save_summary_if_absent
|
||||||
|
which handles the skip-if-exists logic and concurrent-save race.
|
||||||
|
"""
|
||||||
|
from app.services.finance_service import yesterday_shanghai, save_summary_if_absent
|
||||||
|
|
||||||
|
target = yesterday_shanghai()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
saved = save_summary_if_absent(db, target)
|
||||||
|
if saved:
|
||||||
|
logger.info("finance daily summary for %s saved", target)
|
||||||
|
else:
|
||||||
|
logger.info("finance daily summary for %s already exists, skipping", target)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to compute finance daily summary for %s", target)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
if _scheduler.running:
|
if _scheduler.running:
|
||||||
_scheduler.shutdown(wait=True)
|
_scheduler.shutdown(wait=True)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for finance daily summary service."""
|
"""Tests for finance daily summary service."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
import json
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -404,3 +405,383 @@ def test_get_daily_summary_all_success(monkeypatch):
|
|||||||
assert abs(result["total_cost"] - 40.0) < 1e-6
|
assert abs(result["total_cost"] - 40.0) < 1e-6
|
||||||
assert abs(result["net_income"] - 60.0) < 1e-6
|
assert abs(result["net_income"] - 60.0) < 1e-6
|
||||||
assert abs(result["margin_percent"] - 60.0) < 1e-6
|
assert abs(result["margin_percent"] - 60.0) < 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# DB-backed snapshot tests (FinanceDailySummary)
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_inmemory_db():
|
||||||
|
"""Create an isolated in-memory SQLite session with all tables."""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
# Import models so they register with Base.metadata
|
||||||
|
from app.models import finance_daily_summary # noqa: F401
|
||||||
|
from app.models.website import Website, WebsiteGroupBinding, WebsiteSyncLog # noqa: F401
|
||||||
|
from app.models.upstream import Upstream # noqa: F401
|
||||||
|
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
return Session()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_minimal_test_data(db):
|
||||||
|
"""Add one Website and one Upstream so compute_daily_summary has data to iterate."""
|
||||||
|
from app.models.website import Website
|
||||||
|
from app.models.upstream import Upstream
|
||||||
|
|
||||||
|
w = Website(
|
||||||
|
id=1, name="TestSite", base_url="http://t1", api_prefix="/api/v1/admin",
|
||||||
|
auth_type="api_key", auth_config_json="{}", enabled=True,
|
||||||
|
)
|
||||||
|
u = Upstream(
|
||||||
|
id=1, name="TestUp", base_url="http://u1", api_prefix="api/v1",
|
||||||
|
auth_type="bearer", auth_config_json='{"token":"t"}', enabled=True,
|
||||||
|
)
|
||||||
|
db.add_all([w, u])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_no_existing_computes_and_saves(monkeypatch):
|
||||||
|
"""Missing date triggers compute and save, returning from_snapshot=False."""
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
_add_minimal_test_data(db)
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None))
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None))
|
||||||
|
|
||||||
|
from app.services.finance_service import get_or_create_daily_summary
|
||||||
|
|
||||||
|
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert result["from_snapshot"] is False
|
||||||
|
assert abs(result["total_revenue"] - 100.0) < 1e-6
|
||||||
|
assert abs(result["total_cost"] - 40.0) < 1e-6
|
||||||
|
|
||||||
|
# Verify it was saved
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
row = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||||
|
).first()
|
||||||
|
assert row is not None
|
||||||
|
assert row.success is True
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_or_create_existing_returns_stored(monkeypatch):
|
||||||
|
"""Existing date returns stored data without calling upstream fetchers."""
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
from app.services.finance_service import get_or_create_daily_summary
|
||||||
|
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
|
||||||
|
# Pre-save a row
|
||||||
|
stored_data = {
|
||||||
|
"date": "2026-07-02", "total_revenue": 200.0, "total_cost": 80.0,
|
||||||
|
"net_income": 120.0, "margin_percent": 60.0,
|
||||||
|
"website_items": [], "upstream_items": [],
|
||||||
|
"failed_count": 0, "success": True,
|
||||||
|
}
|
||||||
|
row = FinanceDailySummary(
|
||||||
|
stat_date=date(2026, 7, 2),
|
||||||
|
summary_json=json.dumps(stored_data),
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If it calls fetch_*, the monkeypatched function will raise
|
||||||
|
called = []
|
||||||
|
def _fail(*a, **kw):
|
||||||
|
called.append(True)
|
||||||
|
raise RuntimeError("should not be called")
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", _fail)
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", _fail)
|
||||||
|
|
||||||
|
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert result["from_snapshot"] is True
|
||||||
|
assert abs(result["total_revenue"] - 200.0) < 1e-6
|
||||||
|
assert len(called) == 0
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_does_not_write(monkeypatch):
|
||||||
|
"""POST /daily-summary/compare must NOT write to the database."""
|
||||||
|
from app.routers.finance import compare_daily_summary
|
||||||
|
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
_add_minimal_test_data(db)
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None))
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None))
|
||||||
|
|
||||||
|
result = compare_daily_summary(date="2026-07-02", db=db, _=None)
|
||||||
|
|
||||||
|
assert result["has_difference"] is True # no stored data
|
||||||
|
assert result["stored"] is None
|
||||||
|
assert abs(result["current"]["total_revenue"] - 100.0) < 1e-6
|
||||||
|
|
||||||
|
# Verify NOT saved
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
row = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||||
|
).first()
|
||||||
|
assert row is None
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_overwrite_replaces_existing(monkeypatch):
|
||||||
|
"""Overwrite updates the same date's record."""
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
from app.services.finance_service import overwrite_daily_summary
|
||||||
|
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
_add_minimal_test_data(db)
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None))
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None))
|
||||||
|
|
||||||
|
# Save once
|
||||||
|
overwrite_daily_summary(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
# Change revenue for second save
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (200.0, None))
|
||||||
|
result2 = overwrite_daily_summary(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert abs(result2["total_revenue"] - 200.0) < 1e-6
|
||||||
|
|
||||||
|
# Only one row
|
||||||
|
rows = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||||
|
).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].success is True
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_truncation(monkeypatch):
|
||||||
|
"""Error messages over 500 chars are truncated."""
|
||||||
|
from app.services.finance_service import get_or_create_daily_summary
|
||||||
|
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
_add_minimal_test_data(db)
|
||||||
|
long_err = "x" * 1000
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.finance_service.fetch_website_revenue",
|
||||||
|
lambda *_: (0.0, long_err),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.services.finance_service.fetch_upstream_cost_sub2api",
|
||||||
|
lambda *_: (40.0, None),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
err = result["website_items"][0]["error"]
|
||||||
|
assert len(err) == 503 # 500 + "..."
|
||||||
|
assert err.endswith("...")
|
||||||
|
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
row = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||||
|
).first()
|
||||||
|
stored = json.loads(row.summary_json)
|
||||||
|
assert len(stored["website_items"][0]["error"]) == 503
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_date_rejected():
|
||||||
|
"""Future dates must return 400."""
|
||||||
|
from app.routers.finance import _validate_not_future
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.finance_service import today_shanghai
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_validate_not_future(today_shanghai() + _dt.timedelta(days=1))
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_date_format_rejected():
|
||||||
|
"""Invalid date format must return 400."""
|
||||||
|
from app.routers.finance import _resolve_date
|
||||||
|
from fastapi import HTTPException
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_resolve_date("not-a-date")
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_failure_still_saved(monkeypatch):
|
||||||
|
"""Partially failed results are saved, with success=False and failed_count."""
|
||||||
|
from app.services.finance_service import get_or_create_daily_summary
|
||||||
|
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
_add_minimal_test_data(db)
|
||||||
|
|
||||||
|
# Override the first website to fail
|
||||||
|
original_revenue_calls = []
|
||||||
|
def mock_website_revenue(website, _date):
|
||||||
|
original_revenue_calls.append(website.id)
|
||||||
|
if website.id == 1:
|
||||||
|
return (0.0, "timeout")
|
||||||
|
return (50.0, None)
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", mock_website_revenue)
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None))
|
||||||
|
|
||||||
|
result = get_or_create_daily_summary(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["failed_count"] >= 1
|
||||||
|
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
row = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||||
|
).first()
|
||||||
|
assert row is not None
|
||||||
|
assert row.success is False
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_summary_if_absent_saves_new(monkeypatch):
|
||||||
|
"""save_summary_if_absent computes and saves when no snapshot exists."""
|
||||||
|
from app.services.finance_service import save_summary_if_absent
|
||||||
|
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
_add_minimal_test_data(db)
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", lambda *_: (100.0, None))
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_upstream_cost_sub2api", lambda *_: (40.0, None))
|
||||||
|
|
||||||
|
saved = save_summary_if_absent(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert saved is True
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
row = db.query(FinanceDailySummary).filter(
|
||||||
|
FinanceDailySummary.stat_date == date(2026, 7, 2)
|
||||||
|
).first()
|
||||||
|
assert row is not None
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_summary_if_absent_skips_existing(monkeypatch):
|
||||||
|
"""save_summary_if_absent returns False and does nothing when snapshot exists."""
|
||||||
|
from app.models.finance_daily_summary import FinanceDailySummary
|
||||||
|
from app.services.finance_service import save_summary_if_absent
|
||||||
|
|
||||||
|
db = _make_inmemory_db()
|
||||||
|
|
||||||
|
# Pre-save a row
|
||||||
|
row = FinanceDailySummary(
|
||||||
|
stat_date=date(2026, 7, 2),
|
||||||
|
summary_json='{"date":"2026-07-02"}',
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If it tries to compute, the monkeypatched function would raise
|
||||||
|
def _fail(*a, **kw):
|
||||||
|
raise RuntimeError("should not be called")
|
||||||
|
monkeypatch.setattr("app.services.finance_service.fetch_website_revenue", _fail)
|
||||||
|
|
||||||
|
saved = save_summary_if_absent(db, date(2026, 7, 2))
|
||||||
|
|
||||||
|
assert saved is False
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_test_summary(website_items, upstream_items=None):
|
||||||
|
"""Return a summary dict with deep-copied items to prevent cross-contamination."""
|
||||||
|
return {
|
||||||
|
"total_revenue": 100.0, "total_cost": 40.0,
|
||||||
|
"net_income": 60.0, "success": True,
|
||||||
|
"website_items": [dict(it) for it in website_items],
|
||||||
|
"upstream_items": [dict(it) for it in (upstream_items or [
|
||||||
|
{"id": 1, "name": "U1", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||||
|
])],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_difference_by_identity():
|
||||||
|
"""_has_difference compares items by (id, upstream_type), not by position."""
|
||||||
|
from app.routers.finance import _has_difference
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||||
|
{"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||||
|
]
|
||||||
|
stored = _make_test_summary(items)
|
||||||
|
current = _make_test_summary(list(reversed(items)))
|
||||||
|
|
||||||
|
assert _has_difference(stored, current) is False
|
||||||
|
|
||||||
|
# Different amount on same identity — should be different
|
||||||
|
current["website_items"][1]["amount"] = 50.0 # item id=1 amount changed
|
||||||
|
assert _has_difference(stored, current) is True
|
||||||
|
|
||||||
|
# Different item count — should be different
|
||||||
|
current["website_items"].pop()
|
||||||
|
assert _has_difference(stored, current) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_diff_items_unchanged():
|
||||||
|
"""_compute_diff_items returns empty diff_items when data matches."""
|
||||||
|
from app.routers.finance import _compute_diff_items
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||||
|
{"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||||
|
]
|
||||||
|
stored = _make_test_summary(items)
|
||||||
|
current = _make_test_summary(list(reversed(items)))
|
||||||
|
|
||||||
|
diff_items, has_diff = _compute_diff_items(stored, current)
|
||||||
|
assert has_diff is False
|
||||||
|
assert diff_items == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_diff_items_amount_change():
|
||||||
|
"""_compute_diff_items returns the changed item when amount differs."""
|
||||||
|
from app.routers.finance import _compute_diff_items
|
||||||
|
|
||||||
|
stored = _make_test_summary([
|
||||||
|
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||||
|
])
|
||||||
|
current = _make_test_summary([
|
||||||
|
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 55.0, "status": "success", "error": None},
|
||||||
|
])
|
||||||
|
|
||||||
|
diff_items, has_diff = _compute_diff_items(stored, current)
|
||||||
|
assert has_diff is True
|
||||||
|
assert len(diff_items) == 1
|
||||||
|
d = diff_items[0]
|
||||||
|
assert d["side"] == "website_items"
|
||||||
|
assert d["id"] == 1
|
||||||
|
assert d["name"] == "A"
|
||||||
|
assert d["old_amount"] == 60.0
|
||||||
|
assert d["new_amount"] == 55.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_diff_items_added_and_removed():
|
||||||
|
"""_compute_diff_items handles items added or removed between snapshots."""
|
||||||
|
from app.routers.finance import _compute_diff_items
|
||||||
|
|
||||||
|
stored = _make_test_summary([
|
||||||
|
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||||
|
])
|
||||||
|
current = _make_test_summary([
|
||||||
|
{"id": 1, "name": "A", "upstream_type": "sub2api", "amount": 60.0, "status": "success", "error": None},
|
||||||
|
{"id": 2, "name": "B", "upstream_type": "sub2api", "amount": 40.0, "status": "success", "error": None},
|
||||||
|
])
|
||||||
|
|
||||||
|
diff_items, has_diff = _compute_diff_items(stored, current)
|
||||||
|
assert has_diff is True
|
||||||
|
# B is new — find it
|
||||||
|
added = [d for d in diff_items if d["name"] == "B"]
|
||||||
|
assert len(added) == 1
|
||||||
|
assert added[0]["old_amount"] is None
|
||||||
|
assert added[0]["new_amount"] == 40.0
|
||||||
|
|||||||
+315
-19
@@ -7,7 +7,7 @@
|
|||||||
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
<el-icon class="title-icon"><TrendCharts /></el-icon>
|
||||||
财务对账
|
财务对账
|
||||||
</h1>
|
</h1>
|
||||||
<p class="page-subtitle">实时计算,数据不入库;失败项不计入利润,避免虚高</p>
|
<p class="page-subtitle">已统计入库数据,定时快照不自动覆盖</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
@@ -23,6 +23,15 @@
|
|||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
刷新
|
刷新
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="summary"
|
||||||
|
:loading="comparing"
|
||||||
|
@click="handleCompare"
|
||||||
|
class="compare-btn"
|
||||||
|
>
|
||||||
|
<el-icon><DataAnalysis /></el-icon>
|
||||||
|
重新统计并比对
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,12 +43,17 @@
|
|||||||
<div class="stat-card revenue">
|
<div class="stat-card revenue">
|
||||||
<div class="stat-label">网站收入</div>
|
<div class="stat-label">网站收入</div>
|
||||||
<div class="stat-value">{{ formatAmount(summary.total_revenue) }}</div>
|
<div class="stat-value">{{ formatAmount(summary.total_revenue) }}</div>
|
||||||
<div class="stat-sub">{{ summary.date }}</div>
|
<div class="stat-sub">
|
||||||
|
{{ summary.date }}
|
||||||
|
<span v-if="summary.from_snapshot && summary.computed_at" class="snapshot-badge">
|
||||||
|
· 快照 {{ formatTime(summary.computed_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card cost">
|
<div class="stat-card cost">
|
||||||
<div class="stat-label">上游消费</div>
|
<div class="stat-label">上游消费</div>
|
||||||
<div class="stat-value">{{ formatAmount(summary.total_cost) }}</div>
|
<div class="stat-value">{{ formatAmount(summary.total_cost) }}</div>
|
||||||
<div class="stat-sub">{{ enabledUpstreamCount }} 个上游</div>
|
<div class="stat-sub">{{ upstreamItemCount }} 个上游</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card net" :class="summary.net_income >= 0 ? 'positive' : 'negative'">
|
<div class="stat-card net" :class="summary.net_income >= 0 ? 'positive' : 'negative'">
|
||||||
<div class="stat-label">净收入</div>
|
<div class="stat-label">净收入</div>
|
||||||
@@ -70,8 +84,7 @@
|
|||||||
网站收入明细
|
网站收入明细
|
||||||
</span>
|
</span>
|
||||||
<el-tag :type="allWebsitesOk ? 'success' : 'danger'" size="small">
|
<el-tag :type="allWebsitesOk ? 'success' : 'danger'" size="small">
|
||||||
{{ summary.website_items.filter(i => i.status === 'success').length }} /
|
{{ successWebsiteCount }} / {{ summary.website_items.length }} 成功
|
||||||
{{ summary.website_items.length }} 成功
|
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="summary.website_items" class="detail-table" stripe>
|
<el-table :data="summary.website_items" class="detail-table" stripe>
|
||||||
@@ -108,8 +121,7 @@
|
|||||||
上游消费明细
|
上游消费明细
|
||||||
</span>
|
</span>
|
||||||
<el-tag :type="allUpstreamsOk ? 'success' : 'danger'" size="small">
|
<el-tag :type="allUpstreamsOk ? 'success' : 'danger'" size="small">
|
||||||
{{ summary.upstream_items.filter(i => i.status === 'success').length }} /
|
{{ successUpstreamCount }} / {{ summary.upstream_items.length }} 成功
|
||||||
{{ summary.upstream_items.length }} 成功
|
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="summary.upstream_items" class="detail-table" stripe>
|
<el-table :data="summary.upstream_items" class="detail-table" stripe>
|
||||||
@@ -148,13 +160,128 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Compare Dialog -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="compareDialogVisible"
|
||||||
|
title="重新统计比对结果"
|
||||||
|
width="700px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<template v-if="compareResult">
|
||||||
|
<el-alert
|
||||||
|
v-if="!compareResult.has_difference"
|
||||||
|
title="比对结果一致,与已存快照无差异"
|
||||||
|
type="success"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="compare-alert"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-else
|
||||||
|
title="发现差异,下方展示快照与实时统计的对比"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="compare-alert"
|
||||||
|
/>
|
||||||
|
<div class="compare-grid">
|
||||||
|
<div class="compare-col">
|
||||||
|
<h4 class="compare-col-title">已存快照</h4>
|
||||||
|
<div class="compare-stat" v-if="compareResult.stored">
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">收入</span>
|
||||||
|
<span class="compare-value">{{ formatAmount(compareResult.stored.total_revenue) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">消费</span>
|
||||||
|
<span class="compare-value">{{ formatAmount(compareResult.stored.total_cost) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">净收入</span>
|
||||||
|
<span class="compare-value" :class="compareResult.stored.net_income >= 0 ? 'positive' : 'negative'">
|
||||||
|
{{ formatAmount(compareResult.stored.net_income) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">失败项</span>
|
||||||
|
<span class="compare-value">{{ compareResult.stored.failed_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compare-empty">无快照</div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-col">
|
||||||
|
<h4 class="compare-col-title">实时统计</h4>
|
||||||
|
<div class="compare-stat">
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">收入</span>
|
||||||
|
<span class="compare-value">{{ formatAmount(compareResult.current.total_revenue) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">消费</span>
|
||||||
|
<span class="compare-value">{{ formatAmount(compareResult.current.total_cost) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">净收入</span>
|
||||||
|
<span class="compare-value" :class="compareResult.current.net_income >= 0 ? 'positive' : 'negative'">
|
||||||
|
{{ formatAmount(compareResult.current.net_income) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="compare-row">
|
||||||
|
<span class="compare-label">失败项</span>
|
||||||
|
<span class="compare-value">{{ compareResult.current.failed_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Changed items table -->
|
||||||
|
<div v-if="compareResult.diff_items && compareResult.diff_items.length > 0" class="diff-section">
|
||||||
|
<h4 class="diff-title">变动明细({{ compareResult.diff_items.length }} 项)</h4>
|
||||||
|
<el-table :data="compareResult.diff_items" class="detail-table" stripe size="small">
|
||||||
|
<el-table-column label="名称" prop="name" min-width="120" />
|
||||||
|
<el-table-column label="类型" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.side === 'website_items' ? '网站' : '上游' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="字段" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ diffFieldLabel(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="旧值" min-width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="diffValueClass(row, 'old')">{{ formatDiffValue(row, 'old') }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="新值" min-width="100" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :class="diffValueClass(row, 'new')">{{ formatDiffValue(row, 'new') }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="compareDialogVisible = false">关闭</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="compareResult?.has_difference"
|
||||||
|
type="primary"
|
||||||
|
:loading="overwriting"
|
||||||
|
@click="handleOverwrite"
|
||||||
|
>
|
||||||
|
确认覆盖快照
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { TrendCharts, Refresh, OfficeBuilding, Connection } from '@element-plus/icons-vue'
|
import { TrendCharts, Refresh, OfficeBuilding, Connection, DataAnalysis } from '@element-plus/icons-vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
interface FinanceItem {
|
interface FinanceItem {
|
||||||
@@ -176,17 +303,49 @@ interface DailySummary {
|
|||||||
upstream_items: FinanceItem[]
|
upstream_items: FinanceItem[]
|
||||||
failed_count: number
|
failed_count: number
|
||||||
success: boolean
|
success: boolean
|
||||||
|
computed_at?: string
|
||||||
|
from_snapshot?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiffItem {
|
||||||
|
side: 'website_items' | 'upstream_items'
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
upstream_type: string
|
||||||
|
old_status: string | null
|
||||||
|
new_status: string | null
|
||||||
|
old_amount: number | null
|
||||||
|
new_amount: number | null
|
||||||
|
old_error: string | null
|
||||||
|
new_error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiffField = 'amount' | 'status' | 'error'
|
||||||
|
type DiffSide = 'old' | 'new'
|
||||||
|
|
||||||
|
interface CompareResponse {
|
||||||
|
stored: DailySummary | null
|
||||||
|
current: DailySummary
|
||||||
|
has_difference: boolean
|
||||||
|
diff_items: DiffItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const comparing = ref(false)
|
||||||
|
const overwriting = ref(false)
|
||||||
const fetchError = ref<string | null>(null)
|
const fetchError = ref<string | null>(null)
|
||||||
const summary = ref<DailySummary | null>(null)
|
const summary = ref<DailySummary | null>(null)
|
||||||
const selectedDate = ref<string | null>(null)
|
const selectedDate = ref<string | null>(null)
|
||||||
|
|
||||||
|
const compareDialogVisible = ref(false)
|
||||||
|
const compareResult = ref<CompareResponse | null>(null)
|
||||||
|
|
||||||
const allWebsitesOk = computed(() => summary.value?.website_items.every(i => i.status === 'success') ?? false)
|
const allWebsitesOk = computed(() => summary.value?.website_items.every(i => i.status === 'success') ?? false)
|
||||||
const allUpstreamsOk = computed(() => summary.value?.upstream_items.every(i => i.status === 'success') ?? false)
|
const allUpstreamsOk = computed(() => summary.value?.upstream_items.every(i => i.status === 'success') ?? false)
|
||||||
const enabledUpstreamCount = computed(() => summary.value?.upstream_items.length ?? 0)
|
const successWebsiteCount = computed(() => summary.value?.website_items.filter(i => i.status === 'success').length ?? 0)
|
||||||
|
const successUpstreamCount = computed(() => summary.value?.upstream_items.filter(i => i.status === 'success').length ?? 0)
|
||||||
|
const upstreamItemCount = computed(() => summary.value?.upstream_items.length ?? 0)
|
||||||
|
|
||||||
function disabledDate(d: Date) {
|
function disabledDate(d: Date) {
|
||||||
return d > new Date()
|
return d > new Date()
|
||||||
@@ -197,20 +356,71 @@ function formatAmount(val: number): string {
|
|||||||
return '$' + val.toFixed(6)
|
return '$' + val.toFixed(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffField(row: DiffItem): DiffField {
|
||||||
|
if (row.old_amount !== row.new_amount) return 'amount'
|
||||||
|
if (row.old_status !== row.new_status) return 'status'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffFieldLabel(row: DiffItem): string {
|
||||||
|
const labels: Record<DiffField, string> = {
|
||||||
|
amount: '金额',
|
||||||
|
status: '状态',
|
||||||
|
error: '错误',
|
||||||
|
}
|
||||||
|
return labels[diffField(row)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffRawValue(row: DiffItem, side: DiffSide): number | string | null {
|
||||||
|
const field = diffField(row)
|
||||||
|
if (field === 'amount') return side === 'old' ? row.old_amount : row.new_amount
|
||||||
|
if (field === 'status') return side === 'old' ? row.old_status : row.new_status
|
||||||
|
return side === 'old' ? row.old_error : row.new_error
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDiffValue(row: DiffItem, side: DiffSide): string {
|
||||||
|
const value = diffRawValue(row, side)
|
||||||
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
|
if (diffField(row) === 'amount') return formatAmount(Number(value))
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffValueClass(row: DiffItem, side: DiffSide): string {
|
||||||
|
const value = diffRawValue(row, side)
|
||||||
|
if (value === null || value === undefined || value === '') return 'absent-value'
|
||||||
|
const base = side === 'old' ? 'old-value' : 'new-value'
|
||||||
|
return diffField(row) === 'error' ? `${base} error-text` : base
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateParam(): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (selectedDate.value) params.set('date', selectedDate.value)
|
||||||
|
return params.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${auth.token}`, 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}))
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`)
|
||||||
|
}
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
fetchError.value = null
|
fetchError.value = null
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams()
|
const data = await apiFetch<DailySummary>(`/api/finance/daily-summary?${dateParam()}`)
|
||||||
if (selectedDate.value) params.set('date', selectedDate.value)
|
|
||||||
const resp = await fetch(`/api/finance/daily-summary?${params}`, {
|
|
||||||
headers: { Authorization: `Bearer ${auth.token}` },
|
|
||||||
})
|
|
||||||
if (!resp.ok) {
|
|
||||||
const err = await resp.json().catch(() => ({}))
|
|
||||||
throw new Error(err.detail || `HTTP ${resp.status}`)
|
|
||||||
}
|
|
||||||
const data: DailySummary = await resp.json()
|
|
||||||
summary.value = data
|
summary.value = data
|
||||||
if (data.failed_count > 0) {
|
if (data.failed_count > 0) {
|
||||||
ElMessage.warning(`${data.failed_count} 个数据源统计失败,净收入仅供参考`)
|
ElMessage.warning(`${data.failed_count} 个数据源统计失败,净收入仅供参考`)
|
||||||
@@ -225,6 +435,42 @@ async function loadData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCompare() {
|
||||||
|
comparing.value = true
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<CompareResponse>(
|
||||||
|
`/api/finance/daily-summary/compare?${dateParam()}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
compareResult.value = result
|
||||||
|
compareDialogVisible.value = true
|
||||||
|
if (!result.has_difference) {
|
||||||
|
ElMessage.info('比对结果一致,无差异')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error('比对失败: ' + (e.message || '请求失败'))
|
||||||
|
} finally {
|
||||||
|
comparing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOverwrite() {
|
||||||
|
overwriting.value = true
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<DailySummary>(
|
||||||
|
`/api/finance/daily-summary/overwrite?${dateParam()}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
summary.value = result
|
||||||
|
compareDialogVisible.value = false
|
||||||
|
ElMessage.success('快照已覆盖更新')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error('覆盖失败: ' + (e.message || '请求失败'))
|
||||||
|
} finally {
|
||||||
|
overwriting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
@@ -303,6 +549,10 @@ onMounted(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
.snapshot-badge {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-color-info);
|
||||||
|
}
|
||||||
.stat-card.net.positive .stat-value { color: #22c55e; }
|
.stat-card.net.positive .stat-value { color: #22c55e; }
|
||||||
.stat-card.net.negative .stat-value { color: var(--el-color-danger); }
|
.stat-card.net.negative .stat-value { color: var(--el-color-danger); }
|
||||||
.stat-card.failed.has-failures {
|
.stat-card.failed.has-failures {
|
||||||
@@ -342,4 +592,50 @@ onMounted(() => {
|
|||||||
.error-alert { margin-bottom: 20px; }
|
.error-alert { margin-bottom: 20px; }
|
||||||
.skeleton { margin-top: 16px; }
|
.skeleton { margin-top: 16px; }
|
||||||
.empty-state { margin-top: 60px; }
|
.empty-state { margin-top: 60px; }
|
||||||
|
|
||||||
|
/* ── Compare dialog ── */
|
||||||
|
.compare-alert { margin-bottom: 20px; }
|
||||||
|
.compare-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.compare-col-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
.compare-stat { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.compare-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.compare-label { font-size: 13px; color: var(--el-text-color-secondary); }
|
||||||
|
.compare-value { font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.compare-value.positive { color: #22c55e; }
|
||||||
|
.compare-value.negative { color: var(--el-color-danger); }
|
||||||
|
.compare-empty {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diff items table ── */
|
||||||
|
.diff-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.diff-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.old-value { color: var(--el-color-danger); }
|
||||||
|
.new-value { color: #22c55e; font-weight: 600; }
|
||||||
|
.absent-value { color: var(--el-text-color-placeholder); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user