from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import extract, func, select from sqlalchemy.orm import Session from app.database import get_db from app.deps import require_roles from app.models import Personnel, Product, ProductionReport, ProductionReportItem, ReconciliationLedgerEntry, ReportStatus, Role from app.schemas import ( ReconciliationEntryUpdate, ReconciliationLedgerOut, ReconciliationMonthCell, ReconciliationRow, ReconciliationYearsOut, ) from app.services.common import as_float, round2 from app.services.cleaning import is_cleaning_product from app.services.misc_work import is_misc_product from app.services.process_names import process_order from app.services.report_lifecycle import purge_expired_voided_reports from app.timezone import today router = APIRouter(prefix="/api/reconciliation", tags=["reconciliation"]) def _latest_processes_by_product(db: Session) -> dict[tuple[str, str], set[str]]: products = db.scalars( select(Product) .where(Product.device_no == "", Product.product_name != "", Product.process_name != "") .order_by(Product.attendance_point_name.asc(), Product.product_name.asc(), Product.process_name.asc()) ).all() latest: dict[tuple[str, str], tuple[tuple[int, str], set[str]]] = {} for product in products: if is_cleaning_product(product) or is_misc_product(product): continue point_name = str(product.attendance_point_name or "").strip() product_name = str(product.product_name or "").strip() process_name = str(product.process_name or "").strip() if not point_name or not product_name or not process_name: continue order = process_order(process_name) product_key = (point_name, product_name) current = latest.get(product_key) if current is None or order > current[0]: latest[product_key] = (order, {process_name}) elif order == current[0]: current[1].add(process_name) return {product_key: process_names for product_key, (_order, process_names) in latest.items()} def _month_range(year: int, month: int) -> tuple[date, date]: start = date(year, month, 1) if month == 12: end = date(year, 12, 31) else: end = date(year, month + 1, 1) return start, end def _reported_good_quantities( db: Session, *, year: int, product_keys: list[tuple[str, str]], latest_processes: dict[tuple[str, str], set[str]], ) -> dict[tuple[str, str, int], float]: if not product_keys: return {} point_names = sorted({point_name for point_name, _product_name in product_keys}) product_names = sorted({product_name for _point_name, product_name in product_keys}) rows = db.execute( select( ProductionReportItem.attendance_point_name, ProductionReportItem.product_name, ProductionReportItem.process_name, extract("month", ProductionReport.report_date).label("month"), func.sum(ProductionReportItem.good_qty).label("good_qty"), ) .join(ProductionReport, ProductionReport.id == ProductionReportItem.report_id) .where( ProductionReport.status == ReportStatus.approved, ProductionReport.is_voided.is_(False), extract("year", ProductionReport.report_date) == year, ProductionReportItem.attendance_point_name.in_(point_names), ProductionReportItem.product_name.in_(product_names), ) .group_by( ProductionReportItem.attendance_point_name, ProductionReportItem.product_name, ProductionReportItem.process_name, extract("month", ProductionReport.report_date), ) ).all() totals: dict[tuple[str, str, int], float] = {} for point_name, product_name, process_name, month, good_qty in rows: product_key = (point_name, product_name) if str(process_name or "").strip() not in latest_processes.get(product_key, set()): continue key = (point_name, product_name, int(month)) totals[key] = round2(totals.get(key, 0) + as_float(good_qty)) return totals def _ledger_quantities(db: Session, *, year: int) -> dict[tuple[str, str, int], dict[str, float]]: entries = db.scalars( select(ReconciliationLedgerEntry) .where(ReconciliationLedgerEntry.year == year) ).all() return { (entry.attendance_point_name, entry.product_name, int(entry.month)): { "reconciled_good_qty": as_float(entry.reconciled_good_qty), "return_qty": as_float(entry.return_qty), } for entry in entries } @router.get("/years", response_model=ReconciliationYearsOut) def list_reconciliation_years( _: Personnel = Depends(require_roles(Role.manager)), db: Session = Depends(get_db), ) -> ReconciliationYearsOut: purge_expired_voided_reports(db) current_year = today().year years = {current_year} report_years = db.execute( select(extract("year", ProductionReport.report_date)) .where( ProductionReport.status == ReportStatus.approved, ProductionReport.is_voided.is_(False), ) .distinct() ).all() years.update(int(row[0]) for row in report_years if row[0]) ledger_years = db.execute(select(ReconciliationLedgerEntry.year).distinct()).all() years.update(int(row[0]) for row in ledger_years if row[0]) return ReconciliationYearsOut( years=sorted(year for year in years if year <= current_year), current_year=current_year, ) @router.get("", response_model=ReconciliationLedgerOut) def get_reconciliation_ledger( year: int = Query(..., ge=2000, le=2100), _: Personnel = Depends(require_roles(Role.manager)), db: Session = Depends(get_db), ) -> ReconciliationLedgerOut: purge_expired_voided_reports(db) current = today() if year > current.year: raise HTTPException(status_code=400, detail="不能查看未来年份") latest_processes = _latest_processes_by_product(db) product_keys = sorted(latest_processes) reported = _reported_good_quantities( db, year=year, product_keys=product_keys, latest_processes=latest_processes, ) ledger = _ledger_quantities(db, year=year) visible_month_count = current.month if year == current.year else 12 rows = [ ReconciliationRow( attendance_point_name=point_name, product_name=product_name, months=[ ReconciliationMonthCell( month=month, reported_good_qty=reported.get((point_name, product_name, month), 0), reconciled_good_qty=ledger.get((point_name, product_name, month), {}).get("reconciled_good_qty", 0), return_qty=ledger.get((point_name, product_name, month), {}).get("return_qty", 0), ) for month in range(1, visible_month_count + 1) ], ) for point_name, product_name in product_keys ] return ReconciliationLedgerOut( year=year, current_year=current.year, current_month=current.month, rows=rows, ) @router.put("/entry", response_model=ReconciliationMonthCell) def save_reconciliation_entry( payload: ReconciliationEntryUpdate, user: Personnel = Depends(require_roles(Role.manager)), db: Session = Depends(get_db), ) -> ReconciliationMonthCell: current = today() if payload.year > current.year or (payload.year == current.year and payload.month > current.month): raise HTTPException(status_code=400, detail="不能维护未来月份") product_exists = db.scalar( select(Product.product_name) .where( Product.attendance_point_name == payload.attendance_point_name, Product.product_name == payload.product_name, ) .limit(1) ) if not product_exists: raise HTTPException(status_code=404, detail="产品不存在") key = { "attendance_point_name": payload.attendance_point_name, "year": payload.year, "month": payload.month, "product_name": payload.product_name, } entry = db.get(ReconciliationLedgerEntry, key) if entry is None: entry = ReconciliationLedgerEntry(**key) db.add(entry) if payload.reconciled_good_qty is not None: entry.reconciled_good_qty = round2(payload.reconciled_good_qty) if payload.return_qty is not None: entry.return_qty = round2(payload.return_qty) entry.updated_by = user.phone db.commit() db.refresh(entry) return ReconciliationMonthCell( month=entry.month, reported_good_qty=0, reconciled_good_qty=as_float(entry.reconciled_good_qty), return_qty=as_float(entry.return_qty), )