Compare commits

...

6 Commits

Author SHA1 Message Date
souplearn
00dd4f93b5 fix: 审核未修正时保留超额提交快照 2026-07-25 21:23:58 +08:00
souplearn
7cdb528b1c feat: 经理看板展示报工超额信息 2026-07-25 20:57:22 +08:00
souplearn
58d8272448 feat: 输出报工超额快照字段 2026-07-25 20:49:34 +08:00
souplearn
b203b782de feat: 报工提交和审核刷新超额快照 2026-07-25 20:44:22 +08:00
souplearn
1ed48314be feat: 增加报工超额校验服务 2026-07-25 20:27:00 +08:00
souplearn
58ff209d0a feat: 增加报工超额快照字段 2026-07-25 19:57:18 +08:00
11 changed files with 1928 additions and 8 deletions

View File

@ -13,6 +13,7 @@ from sqlalchemy import (
Numeric, Numeric,
String, String,
Text, Text,
text,
) )
from sqlalchemy.dialects.mysql import JSON from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -249,6 +250,13 @@ class ProductionReport(Base):
expected_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0) expected_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
pace_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0) pace_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
workload_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0) workload_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
has_over_limit: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
server_default=text("0"),
)
over_limit_summary: Mapped[str | None] = mapped_column(String(500))
status: Mapped[ReportStatus] = mapped_column( status: Mapped[ReportStatus] = mapped_column(
Enum(ReportStatus), Enum(ReportStatus),
nullable=False, nullable=False,
@ -290,6 +298,7 @@ class ProductionReport(Base):
Index("idx_reports_status_date", "status", "report_date"), Index("idx_reports_status_date", "status", "report_date"),
Index("idx_reports_reviewer", "reviewer_phone", "reviewed_at"), Index("idx_reports_reviewer", "reviewer_phone", "reviewed_at"),
Index("idx_reports_voided", "is_voided", "voided_at"), Index("idx_reports_voided", "is_voided", "voided_at"),
Index("idx_reports_over_limit", "has_over_limit", "submitted_at"),
) )
@ -315,6 +324,23 @@ class ProductionReportItem(Base):
good_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0) good_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
defect_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0) defect_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
scrap_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0) scrap_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
over_limit_status: Mapped[str] = mapped_column(
String(32),
nullable=False,
default="not_checked",
server_default="not_checked",
)
over_limit_type: Mapped[str | None] = mapped_column(String(64))
over_limit_reason: Mapped[str | None] = mapped_column(String(500))
over_limit_checked_at: Mapped[datetime | None] = mapped_column(DateTime)
over_limit_check_source: Mapped[str | None] = mapped_column(String(32))
over_limit_batch_no: Mapped[str | None] = mapped_column(String(128))
over_limit_product_gross_weight_kg: Mapped[float | None] = mapped_column(Numeric(12, 4))
over_limit_issued_weight_kg: Mapped[float | None] = mapped_column(Numeric(18, 6))
over_limit_max_reportable_qty: Mapped[float | None] = mapped_column(Numeric(12, 2))
over_limit_previous_good_qty: Mapped[float | None] = mapped_column(Numeric(12, 2))
over_limit_current_cumulative_qty: Mapped[float | None] = mapped_column(Numeric(12, 2))
over_limit_current_report_qty: Mapped[float | None] = mapped_column(Numeric(12, 2))
allocated_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0) allocated_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
started_at: Mapped[datetime | None] = mapped_column(DateTime) started_at: Mapped[datetime | None] = mapped_column(DateTime)
remark: Mapped[str | None] = mapped_column(String(500)) remark: Mapped[str | None] = mapped_column(String(500))
@ -329,6 +355,15 @@ class ProductionReportItem(Base):
__table_args__ = ( __table_args__ = (
Index("idx_report_items_report", "report_id"), Index("idx_report_items_report", "report_id"),
Index("idx_report_items_product", "project_no", "product_name"), Index("idx_report_items_product", "project_no", "product_name"),
Index("idx_report_items_over_limit_status", "over_limit_status"),
Index(
"idx_report_items_process_batch",
"attendance_point_name",
"project_no",
"product_name",
"raw_material_batch_no",
"process_name",
),
) )

View File

@ -264,6 +264,11 @@ def _dashboard_rows_from_allocations(allocations: list) -> list[DashboardRow]:
"is_voided": False, "is_voided": False,
"review_remarks": [], "review_remarks": [],
"review_remark_keys": set(), "review_remark_keys": set(),
"has_over_limit": False,
"over_limit_exceeded_keys": set(),
"over_limit_uncheckable_keys": set(),
"over_limit_reasons": [],
"over_limit_reason_keys": set(),
"correction_pairs": [], "correction_pairs": [],
"correction_pair_keys": set(), "correction_pair_keys": set(),
}, },
@ -303,6 +308,17 @@ def _dashboard_rows_from_allocations(allocations: list) -> list[DashboardRow]:
group["is_multi_person_assistant"] = ( group["is_multi_person_assistant"] = (
group["is_multi_person_assistant"] or bool(report.is_multi_person_assistant) group["is_multi_person_assistant"] or bool(report.is_multi_person_assistant)
) )
over_limit_status = str(getattr(item, "over_limit_status", "") or "")
over_limit_reason = str(getattr(item, "over_limit_reason", "") or "").strip()
over_limit_item_key = getattr(item, "id", None) or id(item)
if over_limit_status == "exceeded":
group["has_over_limit"] = True
group["over_limit_exceeded_keys"].add(over_limit_item_key)
elif over_limit_status == "uncheckable":
group["over_limit_uncheckable_keys"].add(over_limit_item_key)
if over_limit_reason and over_limit_reason not in group["over_limit_reason_keys"]:
group["over_limit_reason_keys"].add(over_limit_reason)
group["over_limit_reasons"].append(over_limit_reason)
if report.review_remark: if report.review_remark:
review_remark = str(report.review_remark).strip() review_remark = str(report.review_remark).strip()
if review_remark and review_remark not in group["review_remark_keys"]: if review_remark and review_remark not in group["review_remark_keys"]:
@ -333,6 +349,16 @@ def _dashboard_rows_from_allocations(allocations: list) -> list[DashboardRow]:
"night": group["shift_night_minutes"], "night": group["shift_night_minutes"],
} }
source_report_dates = sorted(group["source_report_dates"]) source_report_dates = sorted(group["source_report_dates"])
exceeded_count = len(group["over_limit_exceeded_keys"])
uncheckable_count = len(group["over_limit_uncheckable_keys"])
if exceeded_count and uncheckable_count:
over_limit_summary = f"{exceeded_count}项超额,{uncheckable_count}项无法校验"
elif exceeded_count:
over_limit_summary = f"{exceeded_count}项超额"
elif uncheckable_count:
over_limit_summary = f"{uncheckable_count}项无法校验"
else:
over_limit_summary = None
rows.append( rows.append(
DashboardRow( DashboardRow(
id=group["id"], id=group["id"],
@ -380,6 +406,9 @@ def _dashboard_rows_from_allocations(allocations: list) -> list[DashboardRow]:
is_system_auto_submitted=group["is_system_auto_submitted"], is_system_auto_submitted=group["is_system_auto_submitted"],
is_voided=group["is_voided"], is_voided=group["is_voided"],
review_remark="".join(group["review_remarks"]) or None, review_remark="".join(group["review_remarks"]) or None,
has_over_limit=group["has_over_limit"],
over_limit_summary=over_limit_summary,
over_limit_reasons="".join(group["over_limit_reasons"]) or None,
correction_pairs=group["correction_pairs"], correction_pairs=group["correction_pairs"],
) )
) )

View File

@ -41,6 +41,7 @@ from app.services.misc_work import is_misc_product
from app.services.mold_locks import release_session_locks from app.services.mold_locks import release_session_locks
from app.services.report_allocations import refresh_report_allocations from app.services.report_allocations import refresh_report_allocations
from app.services.report_lifecycle import purge_expired_voided_reports from app.services.report_lifecycle import purge_expired_voided_reports
from app.services.report_over_limit import CHECK_SOURCE_SUBMIT, refresh_report_over_limit_snapshot
from app.services.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired from app.services.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired
from app.services.serializers import equipment_option, product_option, report_out from app.services.serializers import equipment_option, product_option, report_out
from app.services.work_schedule import get_work_schedule_config from app.services.work_schedule import get_work_schedule_config
@ -460,6 +461,7 @@ def submit_report(
db.add(report) db.add(report)
db.flush() db.flush()
refresh_report_allocations(db, report, schedule=schedule, commit=False) refresh_report_allocations(db, report, schedule=schedule, commit=False)
refresh_report_over_limit_snapshot(db, report, source=CHECK_SOURCE_SUBMIT, checked_at=submitted_at)
db.commit() db.commit()
saved = _get_report_or_404(db, report.id) saved = _get_report_or_404(db, report.id)

View File

@ -36,6 +36,7 @@ from app.services.report_lifecycle import (
purge_expired_voided_reports, purge_expired_voided_reports,
restore_voided_report, restore_voided_report,
) )
from app.services.report_over_limit import CHECK_SOURCE_REVIEW, refresh_report_over_limit_snapshot
from app.services.serializers import report_out from app.services.serializers import report_out
from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config
from app.timezone import now from app.timezone import now
@ -115,6 +116,14 @@ def _validate_normal_report_devices(db: Session, report: ProductionReport) -> No
raise HTTPException(status_code=400, detail=f"设备 {device_no} 不是该考勤点的冲压设备") raise HTTPException(status_code=400, detail=f"设备 {device_no} 不是该考勤点的冲压设备")
def _missing_over_limit_snapshot(report: ProductionReport) -> bool:
return any(
item.over_limit_checked_at is None and not item.over_limit_check_source
for item in report.items
if not is_cleaning_item(item)
)
def _report_query(): def _report_query():
return select(ProductionReport).options( return select(ProductionReport).options(
selectinload(ProductionReport.allocations), selectinload(ProductionReport.allocations),
@ -397,6 +406,7 @@ def approve_report(
schedule = get_work_schedule_config(db, report.attendance_point_name) schedule = get_work_schedule_config(db, report.attendance_point_name)
item_map = {item.id: item for item in report.items} item_map = {item.id: item for item in report.items}
has_correction = False has_correction = False
over_limit_input_changed = False
next_start_at = _effective_review_time(payload.start_at, report.start_at) next_start_at = _effective_review_time(payload.start_at, report.start_at)
next_end_at = _effective_review_time(payload.end_at, report.end_at) next_end_at = _effective_review_time(payload.end_at, report.end_at)
@ -442,11 +452,14 @@ def approve_report(
if correction.raw_material_batch_no is not None if correction.raw_material_batch_no is not None
else item.raw_material_batch_no else item.raw_material_batch_no
) )
if ( product_identity_changed = (
next_device_no != item.device_no next_project_no != item.project_no
or next_project_no != item.project_no
or next_product_name != item.product_name or next_product_name != item.product_name
or next_process_name != item.process_name or next_process_name != item.process_name
)
if (
next_device_no != item.device_no
or product_identity_changed
): ):
product = db.scalar( product = db.scalar(
select(Product).where( select(Product).where(
@ -480,10 +493,13 @@ def approve_report(
item.standard_beat = as_float(product.standard_beat) item.standard_beat = as_float(product.standard_beat)
item.standard_workload = 0 item.standard_workload = 0
has_correction = True has_correction = True
if product_identity_changed:
over_limit_input_changed = True
if next_raw_material_batch_no != item.raw_material_batch_no: if next_raw_material_batch_no != item.raw_material_batch_no:
item.raw_material_batch_no = next_raw_material_batch_no item.raw_material_batch_no = next_raw_material_batch_no
has_correction = True has_correction = True
over_limit_input_changed = True
if correction.changeover_count is not None: if correction.changeover_count is not None:
next_changeover_count = as_float(correction.changeover_count) next_changeover_count = as_float(correction.changeover_count)
@ -498,12 +514,15 @@ def approve_report(
if correction.good_qty is not None and as_float(correction.good_qty) != as_float(item.good_qty): if correction.good_qty is not None and as_float(correction.good_qty) != as_float(item.good_qty):
item.good_qty = correction.good_qty item.good_qty = correction.good_qty
has_correction = True has_correction = True
over_limit_input_changed = True
if correction.defect_qty is not None and as_float(correction.defect_qty) != as_float(item.defect_qty): if correction.defect_qty is not None and as_float(correction.defect_qty) != as_float(item.defect_qty):
item.defect_qty = correction.defect_qty item.defect_qty = correction.defect_qty
has_correction = True has_correction = True
over_limit_input_changed = True
if correction.scrap_qty is not None and as_float(correction.scrap_qty) != as_float(item.scrap_qty): if correction.scrap_qty is not None and as_float(correction.scrap_qty) != as_float(item.scrap_qty):
item.scrap_qty = correction.scrap_qty item.scrap_qty = correction.scrap_qty
has_correction = True has_correction = True
over_limit_input_changed = True
if item_is_misc and correction.remark is not None: if item_is_misc and correction.remark is not None:
next_remark = str(correction.remark or "").strip() or None next_remark = str(correction.remark or "").strip() or None
if next_remark != item.remark: if next_remark != item.remark:
@ -532,6 +551,8 @@ def approve_report(
_apply_metrics(report, schedule) _apply_metrics(report, schedule)
refresh_report_allocations(db, report, schedule=schedule, commit=False) refresh_report_allocations(db, report, schedule=schedule, commit=False)
reviewed_at = now() reviewed_at = now()
if not is_cleaning and (over_limit_input_changed or _missing_over_limit_snapshot(report)):
refresh_report_over_limit_snapshot(db, report, source=CHECK_SOURCE_REVIEW, checked_at=reviewed_at)
was_pending = report.status == ReportStatus.pending was_pending = report.status == ReportStatus.pending
report.status = ReportStatus.approved report.status = ReportStatus.approved
report.reviewer_phone = user.phone report.reviewer_phone = user.phone

View File

@ -491,6 +491,18 @@ class ReportItemOut(BaseModel):
is_continuous_die: bool = False is_continuous_die: bool = False
is_multi_person: bool = False is_multi_person: bool = False
remark: str | None = None remark: str | None = None
over_limit_status: str = "not_checked"
over_limit_type: str | None = None
over_limit_reason: str | None = None
over_limit_checked_at: datetime | None = None
over_limit_check_source: str | None = None
over_limit_batch_no: str | None = None
over_limit_product_gross_weight_kg: float | None = None
over_limit_issued_weight_kg: float | None = None
over_limit_max_reportable_qty: float | None = None
over_limit_previous_good_qty: float | None = None
over_limit_current_cumulative_qty: float | None = None
over_limit_current_report_qty: float | None = None
allocations: list[ReportAllocationOut] = Field(default_factory=list) allocations: list[ReportAllocationOut] = Field(default_factory=list)
corrections: dict[str, Any] = Field(default_factory=dict) corrections: dict[str, Any] = Field(default_factory=dict)
@ -530,6 +542,8 @@ class ReportOut(BaseModel):
submitted_at: datetime submitted_at: datetime
is_system_auto_submitted: bool = False is_system_auto_submitted: bool = False
auto_submit_reason: str | None = None auto_submit_reason: str | None = None
has_over_limit: bool = False
over_limit_summary: str | None = None
is_multi_person_assistant: bool = False is_multi_person_assistant: bool = False
multi_person_source_report_id: int | None = None multi_person_source_report_id: int | None = None
is_voided: bool = False is_voided: bool = False
@ -644,6 +658,9 @@ class DashboardRow(BaseModel):
is_system_auto_submitted: bool = False is_system_auto_submitted: bool = False
is_voided: bool = False is_voided: bool = False
review_remark: str | None = None review_remark: str | None = None
has_over_limit: bool = False
over_limit_summary: str | None = None
over_limit_reasons: str | None = None
correction_pairs: list[dict[str, Any]] = Field(default_factory=list) correction_pairs: list[dict[str, Any]] = Field(default_factory=list)

View File

@ -133,6 +133,9 @@ def export_dashboard_rows(rows: list) -> bytes:
"操作人数", "操作人数",
"工序单价", "工序单价",
"参考工资", "参考工资",
"是否超额",
"超额摘要",
"超额原因",
"报工标记", "报工标记",
"校正项", "校正项",
"备注", "备注",
@ -184,6 +187,9 @@ def export_dashboard_rows(rows: list) -> bytes:
row.operator_count, row.operator_count,
row.process_unit_price_yuan, row.process_unit_price_yuan,
row.reference_wage, row.reference_wage,
"" if getattr(row, "has_over_limit", False) else "",
getattr(row, "over_limit_summary", None),
getattr(row, "over_limit_reasons", None),
"".join(report_flags), "".join(report_flags),
correction_text, correction_text,
getattr(row, "review_remark", None), getattr(row, "review_remark", None),

View File

@ -0,0 +1,437 @@
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()

View File

@ -52,6 +52,10 @@ def temporary_expired(person: Personnel) -> bool:
) )
def _optional_float(value) -> float | None:
return None if value is None else as_float(value)
def attendance_point_out(point: AttendancePoint) -> AttendancePointOut: def attendance_point_out(point: AttendancePoint) -> AttendancePointOut:
return AttendancePointOut( return AttendancePointOut(
name=point.name, name=point.name,
@ -123,7 +127,6 @@ def personnel_summary_out(person: Personnel) -> dict:
def product_out(product: Product) -> ProductOut: def product_out(product: Product) -> ProductOut:
optional_float = lambda value: None if value is None else as_float(value)
return ProductOut( return ProductOut(
attendance_point_name=product.attendance_point_name, attendance_point_name=product.attendance_point_name,
project_no=product.project_no, project_no=product.project_no,
@ -132,10 +135,10 @@ def product_out(product: Product) -> ProductOut:
material_code=product.material_code, material_code=product.material_code,
material_name=product.material_name, material_name=product.material_name,
supplier=product.supplier, supplier=product.supplier,
product_net_weight_kg=optional_float(product.product_net_weight_kg), product_net_weight_kg=_optional_float(product.product_net_weight_kg),
product_gross_weight_kg=optional_float(product.product_gross_weight_kg), product_gross_weight_kg=_optional_float(product.product_gross_weight_kg),
scrap_loss_rate=optional_float(product.scrap_loss_rate), scrap_loss_rate=_optional_float(product.scrap_loss_rate),
waste_price_yuan_per_kg=optional_float(product.waste_price_yuan_per_kg), waste_price_yuan_per_kg=_optional_float(product.waste_price_yuan_per_kg),
device_no=product.device_no, device_no=product.device_no,
process_name=product.process_name, process_name=product.process_name,
stamping_method=product.stamping_method, stamping_method=product.stamping_method,
@ -410,6 +413,18 @@ def report_item_out(item: ProductionReportItem, corrections: dict | None = None)
is_continuous_die=is_continuous_die_item(item), is_continuous_die=is_continuous_die_item(item),
is_multi_person=is_multi_person_item(item), is_multi_person=is_multi_person_item(item),
remark=item.remark, remark=item.remark,
over_limit_status=item.over_limit_status or "not_checked",
over_limit_type=item.over_limit_type,
over_limit_reason=item.over_limit_reason,
over_limit_checked_at=item.over_limit_checked_at,
over_limit_check_source=item.over_limit_check_source,
over_limit_batch_no=item.over_limit_batch_no,
over_limit_product_gross_weight_kg=_optional_float(item.over_limit_product_gross_weight_kg),
over_limit_issued_weight_kg=_optional_float(item.over_limit_issued_weight_kg),
over_limit_max_reportable_qty=_optional_float(item.over_limit_max_reportable_qty),
over_limit_previous_good_qty=_optional_float(item.over_limit_previous_good_qty),
over_limit_current_cumulative_qty=_optional_float(item.over_limit_current_cumulative_qty),
over_limit_current_report_qty=_optional_float(item.over_limit_current_report_qty),
allocations=[report_allocation_out(row) for row in allocation_rows], allocations=[report_allocation_out(row) for row in allocation_rows],
corrections=corrections or {}, corrections=corrections or {},
) )
@ -505,6 +520,8 @@ def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = N
submitted_at=report.submitted_at, submitted_at=report.submitted_at,
is_system_auto_submitted=bool(report.is_system_auto_submitted), is_system_auto_submitted=bool(report.is_system_auto_submitted),
auto_submit_reason=report.auto_submit_reason, auto_submit_reason=report.auto_submit_reason,
has_over_limit=bool(report.has_over_limit),
over_limit_summary=report.over_limit_summary,
is_multi_person_assistant=bool(report.is_multi_person_assistant), is_multi_person_assistant=bool(report.is_multi_person_assistant),
multi_person_source_report_id=report.multi_person_source_report_id, multi_person_source_report_id=report.multi_person_source_report_id,
is_voided=bool(report.is_voided), is_voided=bool(report.is_voided),

View File

@ -0,0 +1,177 @@
from pathlib import Path
import re
import sys
from sqlalchemy import text
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import engine # noqa: E402
IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
REQUIRED_COLUMNS = {
"production_reports": {
"has_over_limit": "BOOLEAN NOT NULL DEFAULT FALSE AFTER workload_rate",
"over_limit_summary": "VARCHAR(500) NULL AFTER has_over_limit",
},
"production_report_items": {
"over_limit_status": "VARCHAR(32) NOT NULL DEFAULT 'not_checked' AFTER scrap_qty",
"over_limit_type": "VARCHAR(64) NULL AFTER over_limit_status",
"over_limit_reason": "VARCHAR(500) NULL AFTER over_limit_type",
"over_limit_checked_at": "DATETIME NULL AFTER over_limit_reason",
"over_limit_check_source": "VARCHAR(32) NULL AFTER over_limit_checked_at",
"over_limit_batch_no": "VARCHAR(128) NULL AFTER over_limit_check_source",
"over_limit_product_gross_weight_kg": "DECIMAL(12, 4) NULL AFTER over_limit_batch_no",
"over_limit_issued_weight_kg": "DECIMAL(18, 6) NULL AFTER over_limit_product_gross_weight_kg",
"over_limit_max_reportable_qty": "DECIMAL(12, 2) NULL AFTER over_limit_issued_weight_kg",
"over_limit_previous_good_qty": "DECIMAL(12, 2) NULL AFTER over_limit_max_reportable_qty",
"over_limit_current_cumulative_qty": "DECIMAL(12, 2) NULL AFTER over_limit_previous_good_qty",
"over_limit_current_report_qty": "DECIMAL(12, 2) NULL AFTER over_limit_current_cumulative_qty",
},
}
REQUIRED_INDEXES = {
"production_reports": {
"idx_reports_over_limit": ("has_over_limit", "submitted_at"),
},
"production_report_items": {
"idx_report_items_over_limit_status": ("over_limit_status",),
"idx_report_items_process_batch": (
"attendance_point_name",
"project_no",
"product_name",
"raw_material_batch_no",
"process_name",
),
},
}
def quote_identifier(identifier: str) -> str:
if not IDENTIFIER_RE.fullmatch(identifier):
raise ValueError(f"unexpected SQL identifier: {identifier}")
return f"`{identifier}`"
def table_exists(conn, table_name: str) -> bool:
return bool(
conn.execute(
text(
"""
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = :table_name
"""
),
{"table_name": table_name},
).scalar_one()
)
def existing_columns(conn, table_name: str) -> set[str]:
rows = conn.execute(
text(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
"""
),
{"table_name": table_name},
).all()
return {row[0] for row in rows}
def existing_indexes(conn, table_name: str) -> dict[str, tuple[str, ...]]:
rows = conn.execute(
text(
"""
SELECT index_name, column_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = :table_name
ORDER BY index_name, seq_in_index
"""
),
{"table_name": table_name},
).all()
indexes: dict[str, list[str]] = {}
for index_name, column_name in rows:
indexes.setdefault(index_name, []).append(column_name)
return {index_name: tuple(columns) for index_name, columns in indexes.items()}
def add_missing_columns(conn, table_name: str) -> None:
columns = existing_columns(conn, table_name)
for column_name, column_definition in REQUIRED_COLUMNS[table_name].items():
if column_name in columns:
continue
conn.execute(
text(
f"ALTER TABLE {quote_identifier(table_name)} "
f"ADD COLUMN {quote_identifier(column_name)} {column_definition}"
)
)
columns.add(column_name)
def create_missing_indexes(conn, table_name: str) -> None:
indexes = existing_indexes(conn, table_name)
for index_name, columns in REQUIRED_INDEXES[table_name].items():
existing_columns_for_index = indexes.get(index_name)
if existing_columns_for_index == columns:
continue
if existing_columns_for_index is not None:
raise RuntimeError(
f"{table_name}.{index_name} exists with columns "
f"{existing_columns_for_index}, expected {columns}; "
"please drop or rename the conflicting index before rerunning"
)
column_sql = ", ".join(quote_identifier(column) for column in columns)
conn.execute(
text(
f"CREATE INDEX {quote_identifier(index_name)} "
f"ON {quote_identifier(table_name)} ({column_sql})"
)
)
def verify_schema(conn) -> None:
missing: list[str] = []
for table_name, columns in REQUIRED_COLUMNS.items():
if not table_exists(conn, table_name):
missing.append(f"{table_name} table")
continue
existing_column_names = existing_columns(conn, table_name)
for column_name in columns:
if column_name not in existing_column_names:
missing.append(f"{table_name}.{column_name}")
indexes = existing_indexes(conn, table_name)
for index_name, index_columns in REQUIRED_INDEXES[table_name].items():
if indexes.get(index_name) != index_columns:
missing.append(f"{table_name}.{index_name}")
if missing:
raise RuntimeError(f"missing report over limit snapshot objects: {', '.join(missing)}")
def main() -> None:
with engine.begin() as conn:
for table_name in REQUIRED_COLUMNS:
if not table_exists(conn, table_name):
raise RuntimeError(f"missing required table: {table_name}")
add_missing_columns(conn, table_name)
create_missing_indexes(conn, table_name)
verify_schema(conn)
print("report over limit snapshot migrated")
if __name__ == "__main__":
main()

View File

@ -156,3 +156,237 @@ def test_export_dashboard_rows_writes_allocation_and_source_dates_with_warning_c
assert sheet.cell(row=2, column=3).value == "2026-07-23、2026-07-25" assert sheet.cell(row=2, column=3).value == "2026-07-23、2026-07-25"
assert sheet.cell(row=2, column=12).fill.fgColor.rgb == "FFFF6666" assert sheet.cell(row=2, column=12).fill.fgColor.rgb == "FFFF6666"
assert sheet.cell(row=2, column=15).fill.fgColor.rgb == "FFFF6666" assert sheet.cell(row=2, column=15).fill.fgColor.rgb == "FFFF6666"
def test_dashboard_rows_include_over_limit_summary_from_reports():
report = SimpleNamespace(
id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=date(2026, 7, 25),
has_over_limit=True,
over_limit_summary="1项超额",
review_remark=None,
employee=SimpleNamespace(name="张三", is_temporary=False),
items=[],
audit_logs=[],
session=None,
is_system_auto_submitted=False,
is_voided=False,
is_multi_person_assistant=False,
)
item = SimpleNamespace(
id=1,
report=report,
report_id=1,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
material_code="M1",
material_name="材料A",
raw_material_batch_no="LOT-1",
process_name="1",
remark=None,
stamping_method="冲压",
operator_count=1,
process_unit_price_yuan=1,
standard_beat=1,
good_qty=60,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
allocated_minutes=60,
over_limit_status="exceeded",
over_limit_reason="1序累计报工数量超过本批次材料可支撑数量",
)
report.items = [item]
allocation = SimpleNamespace(
report=report,
item=item,
report_id=1,
allocation_date=date(2026, 7, 25),
attendance_point_name="总厂",
employee_phone="13800000000",
good_qty=60,
defect_qty=0,
scrap_qty=0,
effective_minutes=60,
day_minutes=60,
overtime_minutes=0,
night_minutes=0,
reference_wage=60,
changeover_count=0,
)
rows = dashboard._dashboard_rows_from_allocations([allocation])
assert rows[0].has_over_limit is True
assert rows[0].over_limit_summary == "1项超额"
assert rows[0].over_limit_reasons == "1序累计报工数量超过本批次材料可支撑数量"
def test_export_dashboard_rows_writes_over_limit_columns():
row = SimpleNamespace(
attendance_point_name="总厂",
report_date=date(2026, 7, 24),
source_report_dates=[date(2026, 7, 24)],
source_report_date=None,
employee_name="张三",
employee_phone="13800000000",
worker_type="正式工",
project_no="P1",
product_name="23#",
process_name="1",
effective_minutes=60,
shift_distribution_text="白班1小时",
total_good_qty=50,
total_defect_qty=2,
expected_workload=100,
actual_beat=20,
standard_beat=10,
profile_no="PF-1",
material_code="M-1",
material_name="铝材",
raw_material_batch_no="BATCH-1",
device_no="A-01",
stamping_method="单冲",
changeover_count=1,
operator_count=1,
process_unit_price_yuan=2,
reference_wage=100,
has_over_limit=True,
over_limit_summary="1项超额",
over_limit_reasons="1序累计报工数量超过本批次材料可支撑数量",
is_voided=False,
is_multi_person=False,
is_continuous_die=False,
correction_pairs=[],
review_remark="复核通过",
)
workbook = load_workbook(BytesIO(export_dashboard_rows([row])))
sheet = workbook["报工看板"]
headers = [sheet.cell(row=1, column=index).value for index in range(1, sheet.max_column + 1)]
assert "是否超额" in headers
assert "超额摘要" in headers
assert "超额原因" in headers
assert sheet.cell(row=2, column=headers.index("是否超额") + 1).value == ""
assert sheet.cell(row=2, column=headers.index("超额摘要") + 1).value == "1项超额"
assert sheet.cell(row=2, column=headers.index("超额原因") + 1).value == "1序累计报工数量超过本批次材料可支撑数量"
def test_dashboard_over_limit_does_not_spread_to_other_items_in_same_report():
report = SimpleNamespace(
id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=date(2026, 7, 25),
has_over_limit=True,
over_limit_summary="1项超额",
review_remark=None,
employee=SimpleNamespace(name="张三", is_temporary=False),
items=[],
audit_logs=[],
session=None,
is_system_auto_submitted=False,
is_voided=False,
is_multi_person_assistant=False,
)
exceeded_item = SimpleNamespace(
id=1,
report=report,
report_id=1,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
material_code="M1",
material_name="材料A",
raw_material_batch_no="LOT-1",
process_name="1",
remark=None,
stamping_method="冲压",
operator_count=1,
process_unit_price_yuan=1,
standard_beat=1,
good_qty=60,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
allocated_minutes=60,
over_limit_status="exceeded",
over_limit_reason="1序累计报工数量超过本批次材料可支撑数量",
)
normal_item = SimpleNamespace(
id=2,
report=report,
report_id=1,
attendance_point_name="总厂",
device_no="B",
project_no="P1",
product_name="产品A",
material_code="M1",
material_name="材料A",
raw_material_batch_no="LOT-1",
process_name="2",
remark=None,
stamping_method="冲压",
operator_count=1,
process_unit_price_yuan=1,
standard_beat=1,
good_qty=40,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
allocated_minutes=40,
over_limit_status="ok",
over_limit_reason=None,
)
report.items = [exceeded_item, normal_item]
allocations = [
SimpleNamespace(
report=report,
item=exceeded_item,
report_id=1,
allocation_date=date(2026, 7, 25),
attendance_point_name="总厂",
employee_phone="13800000000",
good_qty=60,
defect_qty=0,
scrap_qty=0,
effective_minutes=60,
day_minutes=60,
overtime_minutes=0,
night_minutes=0,
reference_wage=60,
changeover_count=0,
),
SimpleNamespace(
report=report,
item=normal_item,
report_id=1,
allocation_date=date(2026, 7, 25),
attendance_point_name="总厂",
employee_phone="13800000000",
good_qty=40,
defect_qty=0,
scrap_qty=0,
effective_minutes=40,
day_minutes=40,
overtime_minutes=0,
night_minutes=0,
reference_wage=40,
changeover_count=0,
),
]
rows = dashboard._dashboard_rows_from_allocations(allocations)
rows_by_process = {row.process_name: row for row in rows}
assert rows_by_process["1"].has_over_limit is True
assert rows_by_process["1"].over_limit_reasons == "1序累计报工数量超过本批次材料可支撑数量"
assert rows_by_process["2"].has_over_limit is False
assert rows_by_process["2"].over_limit_summary is None
assert rows_by_process["2"].over_limit_reasons is None

View File

@ -0,0 +1,945 @@
from datetime import date, datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
from sqlalchemy import BigInteger, create_engine, inspect
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import Equipment, Product, ProductionReport, ProductionReportItem, ReportStatus, Role, SessionStatus, WorkSession, WorkSessionDevice
from app.routers import reports as reports_router
from app.routers import reviews as reviews_router
from app.schemas import (
ApproveReportRequest,
ReportItemCorrection,
ReportSubmitBlock,
ReportSubmitItem,
ReportSubmitRequest,
)
from app.services.report_over_limit import (
OVER_LIMIT_STATUS_EXCEEDED,
OVER_LIMIT_STATUS_NOT_CHECKED,
OVER_LIMIT_TYPE_UNCHECKABLE,
current_report_qty,
evaluate_report_item_over_limit,
parse_process_order,
refresh_report_over_limit_snapshot,
)
from app.services.serializers import report_out
from app.timezone import now
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw):
return "INTEGER"
def test_report_over_limit_columns_exist_on_sqlite_metadata():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
inspector = inspect(engine)
report_column_rows = inspector.get_columns("production_reports")
item_column_rows = inspector.get_columns("production_report_items")
report_columns = {column["name"] for column in report_column_rows}
item_columns = {column["name"] for column in item_column_rows}
report_column_by_name = {column["name"]: column for column in report_column_rows}
item_column_by_name = {column["name"]: column for column in item_column_rows}
report_indexes = {index["name"]: tuple(index["column_names"]) for index in inspector.get_indexes("production_reports")}
item_indexes = {
index["name"]: tuple(index["column_names"])
for index in inspector.get_indexes("production_report_items")
}
assert {"has_over_limit", "over_limit_summary"}.issubset(report_columns)
assert {
"over_limit_status",
"over_limit_type",
"over_limit_reason",
"over_limit_checked_at",
"over_limit_check_source",
"over_limit_batch_no",
"over_limit_product_gross_weight_kg",
"over_limit_issued_weight_kg",
"over_limit_max_reportable_qty",
"over_limit_previous_good_qty",
"over_limit_current_cumulative_qty",
"over_limit_current_report_qty",
}.issubset(item_columns)
assert report_indexes["idx_reports_over_limit"] == ("has_over_limit", "submitted_at")
assert item_indexes["idx_report_items_over_limit_status"] == ("over_limit_status",)
assert item_indexes["idx_report_items_process_batch"] == (
"attendance_point_name",
"project_no",
"product_name",
"raw_material_batch_no",
"process_name",
)
assert report_column_by_name["has_over_limit"]["nullable"] is False
assert str(report_column_by_name["has_over_limit"]["default"]).strip("'\"") in {"0", "false", "FALSE"}
assert item_column_by_name["over_limit_status"]["nullable"] is False
assert str(item_column_by_name["over_limit_status"]["default"]).strip("'\"") == "not_checked"
def _sqlite_db():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, future=True)
return SessionLocal()
def test_parse_process_order_accepts_numeric_process_names():
assert parse_process_order("1") == 1
assert parse_process_order("1序") == 1
assert parse_process_order(" 02 序 ") == 2
def test_parse_process_order_rejects_special_and_text_process_names():
assert parse_process_order("杂活") is None
assert parse_process_order("清洗") is None
assert parse_process_order("第一序") is None
def test_current_report_qty_uses_good_defect_and_scrap():
item = SimpleNamespace(good_qty=Decimal("90"), defect_qty=Decimal("5"), scrap_qty=Decimal("5"))
assert current_report_qty(item) == Decimal("100.00")
def _report(report_id: int, status: ReportStatus = ReportStatus.pending) -> ProductionReport:
return ProductionReport(
id=report_id,
session_id=report_id,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=datetime(2026, 7, 25).date(),
start_at=datetime(2026, 7, 25, 8),
end_at=datetime(2026, 7, 25, 10),
status=status,
)
def _numeric_product(*, gross_weight: Decimal = Decimal("2")) -> Product:
return Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=gross_weight,
standard_beat=1,
standard_workload=0,
)
def _press_equipment() -> Equipment:
return Equipment(attendance_point_name="总厂", device_no="E1", device_type="冲压设备")
def _numeric_session(*, status: SessionStatus = SessionStatus.reporting) -> WorkSession:
end_at = now() - timedelta(minutes=1)
start_at = end_at - timedelta(hours=2)
return WorkSession(
attendance_point_name="总厂",
employee_phone="13800000000",
start_at=start_at,
end_at=end_at,
status=status,
devices=[
WorkSessionDevice(
attendance_point_name="总厂",
device_no="产品A",
process_name="1",
scanned_at=start_at,
sort_order=0,
)
],
)
def test_first_process_exceeds_material_limit_with_submit_snapshot(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
report = _report(1)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=60,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, report, item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
result = evaluate_report_item_over_limit(db, report, item, exclude_report_id=report.id)
assert result.status == OVER_LIMIT_STATUS_EXCEEDED
assert result.max_reportable_qty == Decimal("50.00")
assert result.current_report_qty == Decimal("60.00")
assert result.current_cumulative_qty == Decimal("60.00")
assert "1序累计报工数量超过本批次材料可支撑数量" in result.reason
finally:
db.close()
def test_evaluate_defaults_to_excluding_current_report(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
report = _report(1)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=50,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, report, item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
result = evaluate_report_item_over_limit(db, report, item)
assert result.current_cumulative_qty == Decimal("50.00")
assert result.status != OVER_LIMIT_STATUS_EXCEEDED
finally:
db.close()
def test_current_report_sibling_items_contribute_to_current_cumulative(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
report = _report(1)
first_item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=30,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
second_item = ProductionReportItem(
id=2,
report=report,
attendance_point_name="总厂",
device_no="B",
project_no="P1",
product_name="产品A",
raw_material_batch_no=" LOT-1 ",
process_name="1",
good_qty=30,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, report, first_item, second_item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
result = evaluate_report_item_over_limit(db, report, first_item)
assert result.current_report_qty == Decimal("30.00")
assert result.current_cumulative_qty == Decimal("60.00")
assert result.status == OVER_LIMIT_STATUS_EXCEEDED
finally:
db.close()
def test_history_batch_numbers_are_compared_after_trim(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
previous_report = _report(1, ReportStatus.approved)
previous_item = ProductionReportItem(
id=1,
report=previous_report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no=" LOT-1 ",
process_name="1",
good_qty=30,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
report = _report(2)
item = ProductionReportItem(
id=2,
report=report,
attendance_point_name="总厂",
device_no="B",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=30,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, previous_report, previous_item, report, item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
result = evaluate_report_item_over_limit(db, report, item)
assert result.current_cumulative_qty == Decimal("60.00")
assert result.status == OVER_LIMIT_STATUS_EXCEEDED
finally:
db.close()
def test_erp_weight_lookup_sql_error_has_distinct_uncheckable_reason():
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
report = _report(1)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=30,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, report, item])
db.flush()
result = evaluate_report_item_over_limit(db, report, item)
assert result.over_limit_type == OVER_LIMIT_TYPE_UNCHECKABLE
assert result.reason == "读取批次投入重量失败,无法校验超额"
finally:
db.close()
def test_evaluate_report_item_over_limit_does_not_write_snapshot_fields(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
report = _report(1)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=60,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, report, item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
result = evaluate_report_item_over_limit(db, report, item)
assert result.status == OVER_LIMIT_STATUS_EXCEEDED
assert item.over_limit_status == OVER_LIMIT_STATUS_NOT_CHECKED
assert item.over_limit_type is None
assert item.over_limit_reason is None
finally:
db.close()
def test_later_process_exceeds_previous_good_quantity():
db = _sqlite_db()
try:
db.add_all(
[
Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
standard_beat=1,
),
Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="3",
standard_beat=1,
),
]
)
previous_report = _report(1, ReportStatus.approved)
previous_item = ProductionReportItem(
id=1,
report=previous_report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=100,
defect_qty=10,
scrap_qty=5,
standard_beat=1,
)
report = _report(2)
report.employee_phone = "13800000001"
item = ProductionReportItem(
id=2,
report=report,
attendance_point_name="总厂",
device_no="B",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="3",
good_qty=98,
defect_qty=3,
scrap_qty=2,
standard_beat=1,
)
db.add_all([previous_report, previous_item, report, item])
db.flush()
result = evaluate_report_item_over_limit(db, report, item, exclude_report_id=report.id)
assert result.status == OVER_LIMIT_STATUS_EXCEEDED
assert result.previous_good_qty == Decimal("100.00")
assert result.current_report_qty == Decimal("103.00")
assert result.current_cumulative_qty == Decimal("103.00")
assert "3序累计报工数量超过1序累计成品数量" in result.reason
finally:
db.close()
def test_history_process_names_are_compared_after_trim(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
previous_report = _report(1, ReportStatus.approved)
previous_item = ProductionReportItem(
id=1,
report=previous_report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name=" 01序 ",
good_qty=30,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
report = _report(2)
item = ProductionReportItem(
id=2,
report=report,
attendance_point_name="总厂",
device_no="B",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=30,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, previous_report, previous_item, report, item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
result = evaluate_report_item_over_limit(db, report, item)
assert result.current_cumulative_qty == Decimal("60.00")
assert result.status == OVER_LIMIT_STATUS_EXCEEDED
finally:
db.close()
def test_refresh_report_over_limit_snapshot_writes_item_and_report_summary(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="1",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
report = _report(1)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no=" LOT-1 ",
process_name="1",
good_qty=60,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, report, item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
refresh_report_over_limit_snapshot(
db,
report,
source="submit",
checked_at=datetime(2026, 7, 25, 10),
)
assert report.has_over_limit is True
assert report.over_limit_summary == "1项超额"
assert item.over_limit_status == OVER_LIMIT_STATUS_EXCEEDED
assert item.over_limit_check_source == "submit"
assert item.over_limit_checked_at == datetime(2026, 7, 25, 10)
assert item.over_limit_batch_no == "LOT-1"
assert item.over_limit_max_reportable_qty == Decimal("50.00")
finally:
db.close()
def test_refresh_report_over_limit_snapshot_skips_non_numeric_process(monkeypatch):
db = _sqlite_db()
try:
product = Product(
attendance_point_name="总厂",
project_no="P1",
product_name="产品A",
device_no="",
process_name="冲压",
product_gross_weight_kg=Decimal("2"),
standard_beat=1,
)
report = _report(1)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="冲压",
good_qty=60,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([product, report, item])
db.flush()
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
refresh_report_over_limit_snapshot(
db,
report,
source="submit",
checked_at=datetime(2026, 7, 25, 10),
)
assert report.has_over_limit is False
assert report.over_limit_summary is None
assert item.over_limit_status == OVER_LIMIT_STATUS_NOT_CHECKED
assert item.over_limit_type is None
assert item.over_limit_reason is None
finally:
db.close()
def test_refresh_report_over_limit_snapshot_accepts_source_positional_argument(monkeypatch):
db = _sqlite_db()
try:
report = _report(1)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="冲压",
good_qty=60,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
)
db.add_all([report, item])
db.flush()
refresh_report_over_limit_snapshot(db, report, "submit", checked_at=datetime(2026, 7, 25, 10))
assert item.over_limit_check_source == "submit"
finally:
db.close()
def test_submit_report_refreshes_over_limit_snapshot(monkeypatch):
db = _sqlite_db()
try:
user = SimpleNamespace(phone="13800000000")
session = _numeric_session()
db.add_all([_press_equipment(), _numeric_product(), session])
db.commit()
monkeypatch.setattr(reports_router, "report_out", lambda report, schedule=None: report)
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
report = reports_router.submit_report(
ReportSubmitRequest(
session_id=session.id,
device_blocks=[
ReportSubmitBlock(
device_no="产品A",
process_name="1",
items=[
ReportSubmitItem(
device_no="E1",
project_no="P1",
product_name="产品A",
process_name="1",
standard_beat=1,
raw_material_batch_no="LOT-1",
good_qty=60,
)
],
)
],
),
user=user,
db=db,
)
assert report.has_over_limit is True
assert report.over_limit_summary == "1项超额"
assert report.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED
assert report.items[0].over_limit_check_source == "submit"
assert report.items[0].over_limit_current_report_qty == Decimal("60.00")
finally:
db.close()
def test_approve_report_refreshes_over_limit_snapshot_after_correction(monkeypatch):
db = _sqlite_db()
try:
user = SimpleNamespace(phone="13900000000", role=Role.admin)
session = _numeric_session(status=SessionStatus.submitted)
report = ProductionReport(
attendance_point_name="总厂",
session=session,
employee_phone="13800000000",
report_date=date(2026, 7, 25),
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=120,
effective_minutes=120,
total_good_qty=40,
total_output_qty=40,
actual_beat=180,
standard_beat=1,
expected_workload=0,
pace_rate=0,
workload_rate=0,
status=ReportStatus.pending,
submitted_at=session.end_at,
items=[
ProductionReportItem(
attendance_point_name="总厂",
device_no="E1",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
standard_beat=1,
standard_workload=0,
good_qty=40,
defect_qty=0,
scrap_qty=0,
)
],
)
db.add_all([_press_equipment(), _numeric_product(), report])
db.commit()
monkeypatch.setattr(reviews_router, "accessible_point_names", lambda _db, _user: ["总厂"])
monkeypatch.setattr(reviews_router, "report_out", lambda report, schedule=None: report)
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("100"),
)
saved = reviews_router.approve_report(
report.id,
ApproveReportRequest(
item_corrections=[
ReportItemCorrection(id=report.items[0].id, good_qty=60)
]
),
user=user,
db=db,
)
assert saved.has_over_limit is True
assert saved.over_limit_summary == "1项超额"
assert saved.items[0].good_qty == 60
assert saved.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED
assert saved.items[0].over_limit_check_source == "review"
assert saved.items[0].over_limit_current_report_qty == Decimal("60.00")
finally:
db.close()
def test_approve_report_without_over_limit_input_changes_preserves_submit_snapshot(monkeypatch):
db = _sqlite_db()
try:
user = SimpleNamespace(phone="13900000000", role=Role.admin)
session = _numeric_session(status=SessionStatus.submitted)
report = ProductionReport(
attendance_point_name="总厂",
session=session,
employee_phone="13800000000",
report_date=date(2026, 7, 25),
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=120,
effective_minutes=120,
total_good_qty=60,
total_output_qty=60,
actual_beat=120,
standard_beat=1,
expected_workload=0,
pace_rate=0,
workload_rate=0,
status=ReportStatus.pending,
submitted_at=session.end_at,
has_over_limit=True,
over_limit_summary="1项超额",
items=[
ProductionReportItem(
attendance_point_name="总厂",
device_no="E1",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
standard_beat=1,
standard_workload=0,
good_qty=60,
defect_qty=0,
scrap_qty=0,
over_limit_status=OVER_LIMIT_STATUS_EXCEEDED,
over_limit_type="first_process_material",
over_limit_reason="提交时已超额",
over_limit_checked_at=datetime(2026, 7, 25, 10),
over_limit_check_source="submit",
over_limit_batch_no="LOT-1",
over_limit_product_gross_weight_kg=Decimal("2.0000"),
over_limit_issued_weight_kg=Decimal("100.000000"),
over_limit_max_reportable_qty=Decimal("50.00"),
over_limit_current_cumulative_qty=Decimal("60.00"),
over_limit_current_report_qty=Decimal("60.00"),
)
],
)
db.add_all([_press_equipment(), _numeric_product(), report])
db.commit()
monkeypatch.setattr(reviews_router, "accessible_point_names", lambda _db, _user: ["总厂"])
monkeypatch.setattr(reviews_router, "report_out", lambda report, schedule=None: report)
monkeypatch.setattr(
"app.services.report_over_limit.read_batch_issued_weight_kg",
lambda db, batch_no, project_no, product_name: Decimal("200"),
)
saved = reviews_router.approve_report(
report.id,
ApproveReportRequest(),
user=user,
db=db,
)
assert saved.has_over_limit is True
assert saved.over_limit_summary == "1项超额"
assert saved.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED
assert saved.items[0].over_limit_check_source == "submit"
assert saved.items[0].over_limit_reason == "提交时已超额"
assert saved.items[0].over_limit_issued_weight_kg == Decimal("100.000000")
assert saved.items[0].over_limit_max_reportable_qty == Decimal("50.00")
finally:
db.close()
def test_report_out_exposes_over_limit_snapshot_fields():
report = ProductionReport(
id=1,
session_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=datetime(2026, 7, 25).date(),
start_at=datetime(2026, 7, 25, 8),
end_at=datetime(2026, 7, 25, 10),
status=ReportStatus.pending,
submitted_at=datetime(2026, 7, 25, 10),
has_over_limit=True,
over_limit_summary="1项超额",
)
item = ProductionReportItem(
id=1,
report=report,
attendance_point_name="总厂",
device_no="A",
project_no="P1",
product_name="产品A",
raw_material_batch_no="LOT-1",
process_name="1",
good_qty=60,
defect_qty=0,
scrap_qty=0,
standard_beat=1,
over_limit_status=OVER_LIMIT_STATUS_EXCEEDED,
over_limit_type="first_process_material",
over_limit_reason="1序累计报工数量超过本批次材料可支撑数量",
over_limit_checked_at=datetime(2026, 7, 25, 10),
over_limit_check_source="submit",
over_limit_batch_no="LOT-1",
over_limit_product_gross_weight_kg=Decimal("2.0000"),
over_limit_issued_weight_kg=Decimal("100.000000"),
over_limit_max_reportable_qty=Decimal("50.00"),
over_limit_previous_good_qty=None,
over_limit_current_cumulative_qty=Decimal("60.00"),
over_limit_current_report_qty=Decimal("60.00"),
)
report.items = [item]
report.allocations = []
report.audit_logs = []
output = report_out(report)
assert output.has_over_limit is True
assert output.over_limit_summary == "1项超额"
assert output.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED
assert output.items[0].over_limit_reason == "1序累计报工数量超过本批次材料可支撑数量"
assert output.items[0].over_limit_max_reportable_qty == 50