JhHardwareWRS_BackPoint/app/services/report_over_limit.py
2026-07-25 20:44:22 +08:00

438 lines
16 KiB
Python

from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from sqlalchemy import func, select, text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models import Product, ProductionReport, ProductionReportItem, ReportStatus
from app.services.cleaning import is_cleaning_item
from app.services.misc_work import is_misc_item
from app.timezone import now
logger = logging.getLogger(__name__)
class OverLimitDataSourceError(RuntimeError):
pass
OVER_LIMIT_STATUS_NOT_CHECKED = "not_checked"
OVER_LIMIT_STATUS_OK = "ok"
OVER_LIMIT_STATUS_EXCEEDED = "exceeded"
OVER_LIMIT_STATUS_UNCHECKABLE = "uncheckable"
OVER_LIMIT_TYPE_FIRST_PROCESS_MATERIAL = "first_process_material"
OVER_LIMIT_TYPE_PREVIOUS_PROCESS = "previous_process"
OVER_LIMIT_TYPE_UNCHECKABLE = "uncheckable"
CHECK_SOURCE_SUBMIT = "submit"
CHECK_SOURCE_REVIEW = "review"
_TWO_PLACES = Decimal("0.01")
@dataclass(frozen=True)
class OverLimitResult:
status: str
over_limit_type: str | None = None
reason: str | None = None
batch_no: str | None = None
product_gross_weight_kg: Decimal | None = None
issued_weight_kg: Decimal | None = None
max_reportable_qty: Decimal | None = None
previous_good_qty: Decimal | None = None
current_cumulative_qty: Decimal | None = None
current_report_qty: Decimal | None = None
def _to_decimal(value) -> Decimal:
if value is None:
return Decimal("0")
if isinstance(value, Decimal):
return value
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return Decimal("0")
def _money_qty(value: Decimal) -> Decimal:
return value.quantize(_TWO_PLACES, rounding=ROUND_HALF_UP)
def _positive_decimal(value) -> Decimal | None:
number = _to_decimal(value)
return number if number > 0 else None
def _clean_text(value) -> str:
return str(value or "").strip()
def _batch_no(value) -> str:
return _clean_text(value)
def parse_process_order(process_name: str | None) -> int | None:
text_value = _clean_text(process_name)
match = re.fullmatch(r"0*(\d+)(?:\s*序)?", text_value)
if match is None:
return None
order = int(match.group(1))
return order if order > 0 else None
def current_report_qty(item) -> Decimal:
total = _to_decimal(getattr(item, "good_qty", None))
total += _to_decimal(getattr(item, "defect_qty", None))
total += _to_decimal(getattr(item, "scrap_qty", None))
return _money_qty(total)
def read_batch_issued_weight_kg(
db: Session,
batch_no: str | None,
project_no: str | None,
product_name: str | None,
) -> Decimal | None:
normalized_batch_no = _batch_no(batch_no)
normalized_project_no = _clean_text(project_no)
normalized_product_name = _clean_text(product_name)
if not normalized_batch_no:
return None
try:
value = db.scalar(
text(
"""
select sum(ledger.total_issued_weight_kg)
from pp_production_batch_ledger ledger
join md_item product on product.id = ledger.product_item_id
where ledger.material_lot_no = :batch_no
and coalesce(ledger.miniapp_selectable, 1) = 1
and (
(:product_name <> '' and :project_no <> '' and product.item_name = :product_name and product.item_code = :project_no)
or (:product_name <> '' and :project_no = '' and product.item_name = :product_name)
or (:product_name = '' and :project_no <> '' and product.item_code = :project_no)
)
"""
),
{
"batch_no": normalized_batch_no,
"project_no": normalized_project_no,
"product_name": normalized_product_name,
},
)
except SQLAlchemyError as exc:
logger.exception(
"failed to read production batch issued weight for batch=%s project=%s product=%s",
normalized_batch_no,
normalized_project_no,
normalized_product_name,
)
raise OverLimitDataSourceError("failed to read batch issued weight") from exc
weight = _positive_decimal(value)
return weight
def _product_catalog_row(db: Session, item: ProductionReportItem) -> Product | None:
return db.get(
Product,
{
"attendance_point_name": getattr(item, "attendance_point_name", "") or "",
"project_no": getattr(item, "project_no", "") or "",
"product_name": getattr(item, "product_name", "") or "",
"device_no": "",
"process_name": getattr(item, "process_name", "") or "",
},
)
def _process_orders_for_product(db: Session, item: ProductionReportItem) -> list[int]:
rows = db.scalars(
select(Product.process_name).where(
Product.attendance_point_name == (getattr(item, "attendance_point_name", "") or ""),
Product.project_no == (getattr(item, "project_no", "") or ""),
Product.product_name == (getattr(item, "product_name", "") or ""),
Product.device_no == "",
)
).all()
return sorted({order for name in rows if (order := parse_process_order(name)) is not None})
def _previous_process_order(db: Session, item: ProductionReportItem, current_order: int) -> int | None:
previous_orders = [order for order in _process_orders_for_product(db, item) if order < current_order]
return max(previous_orders) if previous_orders else None
def _process_name_candidates(process_order: int) -> tuple[str, ...]:
values: set[str] = set()
for width in range(1, 5):
value = str(process_order).zfill(width)
values.add(value)
values.add(f"{value}")
values.add(f"{value}")
return tuple(sorted(values))
def _history_items(
db: Session,
item: ProductionReportItem,
process_order: int,
exclude_report_id: int | None,
) -> list[ProductionReportItem]:
query = (
select(ProductionReportItem)
.join(ProductionReport)
.where(
ProductionReport.status.in_([ReportStatus.pending, ReportStatus.approved]),
ProductionReport.is_voided.is_(False),
ProductionReportItem.attendance_point_name == (getattr(item, "attendance_point_name", "") or ""),
ProductionReportItem.project_no == (getattr(item, "project_no", "") or ""),
ProductionReportItem.product_name == (getattr(item, "product_name", "") or ""),
func.trim(ProductionReportItem.raw_material_batch_no) == _batch_no(getattr(item, "raw_material_batch_no", "")),
func.trim(ProductionReportItem.process_name).in_(_process_name_candidates(process_order)),
)
)
if exclude_report_id is not None:
query = query.where(ProductionReport.id != exclude_report_id)
return list(db.scalars(query).all())
def _historical_consumed_qty(
db: Session,
item: ProductionReportItem,
process_order: int,
exclude_report_id: int | None,
) -> Decimal:
total = Decimal("0")
for history_item in _history_items(db, item, process_order, exclude_report_id):
total += current_report_qty(history_item)
return _money_qty(total)
def _historical_good_qty(
db: Session,
item: ProductionReportItem,
process_order: int,
exclude_report_id: int | None,
) -> Decimal:
total = Decimal("0")
for history_item in _history_items(db, item, process_order, exclude_report_id):
total += _to_decimal(history_item.good_qty)
return _money_qty(total)
def _format_process(order: int) -> str:
return f"{order}"
def _same_scope(left: ProductionReportItem, right: ProductionReportItem) -> bool:
return (
(getattr(left, "attendance_point_name", "") or "") == (getattr(right, "attendance_point_name", "") or "")
and (getattr(left, "project_no", "") or "") == (getattr(right, "project_no", "") or "")
and (getattr(left, "product_name", "") or "") == (getattr(right, "product_name", "") or "")
and _batch_no(getattr(left, "raw_material_batch_no", "")) == _batch_no(getattr(right, "raw_material_batch_no", ""))
)
def _current_report_consumed_qty(
report: ProductionReport,
item: ProductionReportItem,
process_order: int,
) -> Decimal:
total = Decimal("0")
for report_item in list(getattr(report, "items", []) or []):
if _same_scope(item, report_item) and parse_process_order(getattr(report_item, "process_name", None)) == process_order:
total += current_report_qty(report_item)
return _money_qty(total)
def _current_report_good_qty(
report: ProductionReport,
item: ProductionReportItem,
process_order: int,
) -> Decimal:
total = Decimal("0")
for report_item in list(getattr(report, "items", []) or []):
if _same_scope(item, report_item) and parse_process_order(getattr(report_item, "process_name", None)) == process_order:
total += _to_decimal(getattr(report_item, "good_qty", 0))
return _money_qty(total)
def evaluate_report_item_over_limit(
db: Session,
report: ProductionReport,
item: ProductionReportItem,
*,
exclude_report_id: int | None = None,
) -> OverLimitResult:
if exclude_report_id is None:
exclude_report_id = getattr(report, "id", None)
batch_no = _batch_no(getattr(item, "raw_material_batch_no", "")) or None
report_qty = current_report_qty(item)
if is_cleaning_item(item) or is_misc_item(item) or str(getattr(item, "process_name", "") or "").strip() == "清洗":
return OverLimitResult(
status=OVER_LIMIT_STATUS_NOT_CHECKED,
batch_no=batch_no,
current_report_qty=report_qty,
)
current_order = parse_process_order(getattr(item, "process_name", None))
if current_order is None:
return OverLimitResult(
status=OVER_LIMIT_STATUS_NOT_CHECKED,
batch_no=batch_no,
current_report_qty=report_qty,
)
if not batch_no:
return OverLimitResult(
status=OVER_LIMIT_STATUS_UNCHECKABLE,
over_limit_type=OVER_LIMIT_TYPE_UNCHECKABLE,
reason="缺少材料批次,无法校验超额",
current_report_qty=report_qty,
)
historical_qty = _historical_consumed_qty(db, item, current_order, exclude_report_id)
current_cumulative_qty = _money_qty(historical_qty + _current_report_consumed_qty(report, item, current_order))
previous_order = _previous_process_order(db, item, current_order)
if previous_order is None:
product = _product_catalog_row(db, item)
gross_weight = _positive_decimal(getattr(product, "product_gross_weight_kg", None))
if gross_weight is None:
return OverLimitResult(
status=OVER_LIMIT_STATUS_UNCHECKABLE,
over_limit_type=OVER_LIMIT_TYPE_UNCHECKABLE,
reason="缺少产品毛重,无法校验超额",
batch_no=batch_no,
current_cumulative_qty=current_cumulative_qty,
current_report_qty=report_qty,
)
try:
issued_weight = read_batch_issued_weight_kg(db, batch_no, item.project_no, item.product_name)
except OverLimitDataSourceError:
return OverLimitResult(
status=OVER_LIMIT_STATUS_UNCHECKABLE,
over_limit_type=OVER_LIMIT_TYPE_UNCHECKABLE,
reason="读取批次投入重量失败,无法校验超额",
batch_no=batch_no,
product_gross_weight_kg=gross_weight,
current_cumulative_qty=current_cumulative_qty,
current_report_qty=report_qty,
)
if issued_weight is None:
return OverLimitResult(
status=OVER_LIMIT_STATUS_UNCHECKABLE,
over_limit_type=OVER_LIMIT_TYPE_UNCHECKABLE,
reason="缺少批次投入重量,无法校验超额",
batch_no=batch_no,
product_gross_weight_kg=gross_weight,
current_cumulative_qty=current_cumulative_qty,
current_report_qty=report_qty,
)
max_reportable_qty = _money_qty(issued_weight / gross_weight)
status = OVER_LIMIT_STATUS_EXCEEDED if current_cumulative_qty > max_reportable_qty else OVER_LIMIT_STATUS_OK
reason = None
if status == OVER_LIMIT_STATUS_EXCEEDED:
reason = (
f"{_format_process(current_order)}累计报工数量超过本批次材料可支撑数量:"
f"{current_cumulative_qty} > {max_reportable_qty}"
)
return OverLimitResult(
status=status,
over_limit_type=OVER_LIMIT_TYPE_FIRST_PROCESS_MATERIAL if status == OVER_LIMIT_STATUS_EXCEEDED else None,
reason=reason,
batch_no=batch_no,
product_gross_weight_kg=gross_weight,
issued_weight_kg=issued_weight,
max_reportable_qty=max_reportable_qty,
current_cumulative_qty=current_cumulative_qty,
current_report_qty=report_qty,
)
previous_good_qty = _money_qty(
_historical_good_qty(db, item, previous_order, exclude_report_id)
+ _current_report_good_qty(report, item, previous_order)
)
status = OVER_LIMIT_STATUS_EXCEEDED if current_cumulative_qty > previous_good_qty else OVER_LIMIT_STATUS_OK
reason = None
if status == OVER_LIMIT_STATUS_EXCEEDED:
reason = (
f"{_format_process(current_order)}累计报工数量超过{_format_process(previous_order)}累计成品数量:"
f"{current_cumulative_qty} > {previous_good_qty}"
)
return OverLimitResult(
status=status,
over_limit_type=OVER_LIMIT_TYPE_PREVIOUS_PROCESS if status == OVER_LIMIT_STATUS_EXCEEDED else None,
reason=reason,
batch_no=batch_no,
previous_good_qty=previous_good_qty,
current_cumulative_qty=current_cumulative_qty,
current_report_qty=report_qty,
)
def _apply_result_to_item(
item: ProductionReportItem,
result: OverLimitResult,
*,
source: str,
checked_at: datetime,
) -> None:
item.over_limit_status = result.status
item.over_limit_type = result.over_limit_type
item.over_limit_reason = result.reason
item.over_limit_checked_at = checked_at
item.over_limit_check_source = source
item.over_limit_batch_no = result.batch_no
item.over_limit_product_gross_weight_kg = result.product_gross_weight_kg
item.over_limit_issued_weight_kg = result.issued_weight_kg
item.over_limit_max_reportable_qty = result.max_reportable_qty
item.over_limit_previous_good_qty = result.previous_good_qty
item.over_limit_current_cumulative_qty = result.current_cumulative_qty
item.over_limit_current_report_qty = result.current_report_qty
def refresh_report_over_limit_snapshot(
db: Session,
report: ProductionReport,
source: str,
*,
checked_at: datetime | None = None,
) -> None:
timestamp = checked_at or now()
exceeded_count = 0
uncheckable_count = 0
for item in list(getattr(report, "items", []) or []):
result = evaluate_report_item_over_limit(db, report, item)
_apply_result_to_item(item, result, source=source, checked_at=timestamp)
if result.status == OVER_LIMIT_STATUS_EXCEEDED:
exceeded_count += 1
elif result.status == OVER_LIMIT_STATUS_UNCHECKABLE:
uncheckable_count += 1
report.has_over_limit = exceeded_count > 0
if exceeded_count and uncheckable_count:
report.over_limit_summary = f"{exceeded_count}项超额,{uncheckable_count}项无法校验"
elif exceeded_count:
report.over_limit_summary = f"{exceeded_count}项超额"
elif uncheckable_count:
report.over_limit_summary = f"{uncheckable_count}项无法校验"
else:
report.over_limit_summary = None
db.flush()