JhHardwareWRS_BackPoint/app/routers/reconciliation.py
2026-07-25 05:08:43 +08:00

253 lines
9.4 KiB
Python

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,
ProductionReportAllocation,
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 _fold_reported_good_quantity_rows(
rows,
latest_processes: dict[tuple[str, str], set[str]],
) -> dict[tuple[str, str, int], float]:
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 _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", ProductionReportAllocation.allocation_date).label("month"),
func.sum(ProductionReportAllocation.good_qty).label("good_qty"),
)
.select_from(ProductionReportAllocation)
.join(ProductionReportItem, ProductionReportItem.id == ProductionReportAllocation.report_item_id)
.join(ProductionReport, ProductionReport.id == ProductionReportAllocation.report_id)
.where(
ProductionReport.status == ReportStatus.approved,
ProductionReport.is_voided.is_(False),
extract("year", ProductionReportAllocation.allocation_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", ProductionReportAllocation.allocation_date),
)
).all()
return _fold_reported_good_quantity_rows(rows, latest_processes)
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", ProductionReportAllocation.allocation_date))
.select_from(ProductionReportAllocation)
.join(ProductionReport, ProductionReport.id == ProductionReportAllocation.report_id)
.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),
)