259 lines
9.6 KiB
Python
259 lines
9.6 KiB
Python
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import and_, 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,
|
|
and_(
|
|
ProductionReportItem.id == ProductionReportAllocation.report_item_id,
|
|
ProductionReportItem.report_id == ProductionReportAllocation.report_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),
|
|
)
|