fix: 完善报工归属输出安全性

This commit is contained in:
souplearn 2026-07-25 04:39:25 +08:00
parent b0cb217ce3
commit bb9ce5601c
4 changed files with 239 additions and 10 deletions

View File

@ -192,6 +192,7 @@ def _dashboard_rows(
"product_name": item.product_name, "product_name": item.product_name,
"process_name": process_name, "process_name": process_name,
"report_ids": set(), "report_ids": set(),
"source_report_dates": set(),
"effective_minutes": 0.0, "effective_minutes": 0.0,
"shift_day_minutes": 0.0, "shift_day_minutes": 0.0,
"shift_overtime_minutes": 0.0, "shift_overtime_minutes": 0.0,
@ -230,6 +231,7 @@ def _dashboard_rows(
allocated_minutes = 0 if is_cleaning else (as_float(item.allocated_minutes) or fallback_minutes) allocated_minutes = 0 if is_cleaning else (as_float(item.allocated_minutes) or fallback_minutes)
expected_workload = allocated_minutes * 60 / standard_beat if standard_beat > 0 else 0 expected_workload = allocated_minutes * 60 / standard_beat if standard_beat > 0 else 0
group["report_ids"].add(report.id) group["report_ids"].add(report.id)
group["source_report_dates"].add(report.report_date)
group["effective_minutes"] += allocated_minutes group["effective_minutes"] += allocated_minutes
group["expected_workload"] += expected_workload group["expected_workload"] += expected_workload
group["shift_day_minutes"] += as_float(getattr(item, "_shift_day_minutes", 0)) group["shift_day_minutes"] += as_float(getattr(item, "_shift_day_minutes", 0))
@ -277,11 +279,14 @@ def _dashboard_rows(
"overtime": group["shift_overtime_minutes"], "overtime": group["shift_overtime_minutes"],
"night": group["shift_night_minutes"], "night": group["shift_night_minutes"],
} }
source_report_dates = sorted(group["source_report_dates"])
rows.append( rows.append(
DashboardRow( DashboardRow(
id=group["id"], id=group["id"],
attendance_point_name=group["attendance_point_name"], attendance_point_name=group["attendance_point_name"],
report_date=group["report_date"], report_date=group["report_date"],
source_report_date=source_report_dates[-1] if source_report_dates else group["report_date"],
source_report_dates=source_report_dates,
employee_phone=group["employee_phone"], employee_phone=group["employee_phone"],
employee_name=group["employee_name"], employee_name=group["employee_name"],
worker_type=group["worker_type"], worker_type=group["worker_type"],

View File

@ -1,7 +1,9 @@
from datetime import datetime from datetime import date, datetime
from sqlalchemy import select from sqlalchemy import inspect, select
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import NO_VALUE
from app.models import ( from app.models import (
AttendancePoint, AttendancePoint,
@ -358,7 +360,31 @@ def report_allocation_out(row) -> ReportAllocationOut:
) )
def _loaded_relationship_rows(obj, relationship_name: str) -> list:
try:
state = inspect(obj)
except NoInspectionAvailable:
return list(getattr(obj, relationship_name, []) or [])
if relationship_name not in state.attrs:
return list(getattr(obj, relationship_name, []) or [])
relationship_state = state.attrs[relationship_name]
if relationship_state.loaded_value is NO_VALUE:
return []
return list(relationship_state.value or [])
def _sorted_allocation_rows(rows) -> list:
return sorted(
list(rows or []),
key=lambda row: (
getattr(row, "allocation_date", None) or date.min,
getattr(row, "id", None) or 0,
),
)
def report_item_out(item: ProductionReportItem, corrections: dict | None = None) -> ReportItemOut: def report_item_out(item: ProductionReportItem, corrections: dict | None = None) -> ReportItemOut:
allocation_rows = _sorted_allocation_rows(_loaded_relationship_rows(item, "allocations"))
return ReportItemOut( return ReportItemOut(
id=item.id, id=item.id,
attendance_point_name=item.attendance_point_name, attendance_point_name=item.attendance_point_name,
@ -384,7 +410,7 @@ 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,
allocations=[report_allocation_out(row) for row in getattr(item, "allocations", []) or []], allocations=[report_allocation_out(row) for row in allocation_rows],
corrections=corrections or {}, corrections=corrections or {},
) )
@ -423,6 +449,7 @@ def _cleaning_report_metrics(report: ProductionReport) -> dict:
def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> ReportOut: def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> ReportOut:
allocation_rows = _sorted_allocation_rows(_loaded_relationship_rows(report, "allocations"))
is_cleaning_report = _report_is_cleaning(report) is_cleaning_report = _report_is_cleaning(report)
calculated_metrics = ( calculated_metrics = (
_cleaning_report_metrics(report) _cleaning_report_metrics(report)
@ -504,6 +531,6 @@ def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = N
if is_cleaning_report if is_cleaning_report
else ("处理杂活已提交,等待管理员审核" if _report_is_misc_only(report) else build_result_text(metrics)) else ("处理杂活已提交,等待管理员审核" if _report_is_misc_only(report) else build_result_text(metrics))
), ),
allocation_summary_text=allocation_summary_text(list(getattr(report, "allocations", []) or [])), allocation_summary_text=allocation_summary_text(allocation_rows),
corrections=report_corrections, corrections=report_corrections,
) )

View File

@ -0,0 +1,105 @@
from datetime import date, datetime
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import (
AttendancePoint,
Personnel,
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
SessionStatus,
WorkSession,
)
from app.routers.dashboard import _dashboard_rows
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw):
return "INTEGER"
def _db_session():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, future=True)
return SessionLocal()
def test_dashboard_rows_populate_source_report_dates_from_original_reports():
db = _db_session()
try:
point_name = "总厂"
report_date = date(2026, 7, 25)
person = Personnel(phone="13800000000", name="张三")
product = Product(
attendance_point_name=point_name,
project_no="P1",
product_name="23#",
device_no="",
process_name="冲压",
operator_count=1,
process_unit_price_yuan=2,
standard_beat=1,
standard_workload=0,
)
db.add_all([
AttendancePoint(name=point_name),
person,
product,
])
for index, start_hour in enumerate([8, 10], start=1):
session = WorkSession(
id=index,
attendance_point_name=point_name,
employee_phone=person.phone,
start_at=datetime(2026, 7, 25, start_hour, 0),
end_at=datetime(2026, 7, 25, start_hour + 1, 0),
status=SessionStatus.submitted,
)
report = ProductionReport(
id=index,
session_id=session.id,
attendance_point_name=point_name,
employee_phone=person.phone,
report_date=report_date,
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=60,
break_minutes=0,
effective_minutes=60,
status=ReportStatus.approved,
submitted_at=session.end_at,
)
item = ProductionReportItem(
id=index,
report_id=report.id,
attendance_point_name=point_name,
device_no="23#",
project_no=product.project_no,
product_name=product.product_name,
process_name=product.process_name,
operator_count=1,
process_unit_price_yuan=2,
standard_beat=1,
standard_workload=0,
good_qty=10,
defect_qty=0,
scrap_qty=0,
allocated_minutes=60,
)
db.add_all([session, report, item])
db.commit()
rows = _dashboard_rows(db, report_date, report_date)
assert len(rows) == 1
assert rows[0].report_count == 2
assert rows[0].source_report_date == report_date
assert rows[0].source_report_dates == [report_date]
finally:
db.close()

View File

@ -4,7 +4,7 @@ from types import SimpleNamespace
from sqlalchemy import BigInteger, create_engine, select from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import raiseload, selectinload, sessionmaker
from app.database import Base from app.database import Base
from app.models import ( from app.models import (
@ -511,16 +511,33 @@ def test_report_out_exposes_allocation_summary_and_item_allocations():
changeover_count=Decimal("1.50"), changeover_count=Decimal("1.50"),
reference_wage=Decimal("251.25"), reference_wage=Decimal("251.25"),
) )
item.allocations = [allocation] previous_allocation = ProductionReportAllocation(
id=2,
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 24),
day_minutes=Decimal("0.00"),
overtime_minutes=Decimal("0.00"),
night_minutes=Decimal("60.00"),
effective_minutes=Decimal("60.00"),
good_qty=Decimal("40.00"),
defect_qty=Decimal("0.00"),
scrap_qty=Decimal("0.00"),
changeover_count=Decimal("0.00"),
reference_wage=Decimal("100.00"),
)
item.allocations = [allocation, previous_allocation]
report.items = [item] report.items = [item]
report.allocations = [allocation] report.allocations = [allocation, previous_allocation]
report.audit_logs = [] report.audit_logs = []
output = report_out(report) output = report_out(report)
assert output.allocation_summary_text == "2026-07-25 白班2.01小时、加班0.5小时" assert output.allocation_summary_text == "2026-07-24 夜班1小时2026-07-25 白班2.01小时、加班0.5小时"
assert len(output.items[0].allocations) == 1 assert [row.allocation_date for row in output.items[0].allocations] == [date(2026, 7, 24), date(2026, 7, 25)]
item_allocation = output.items[0].allocations[0] item_allocation = output.items[0].allocations[1]
assert item_allocation.allocation_date == date(2026, 7, 25) assert item_allocation.allocation_date == date(2026, 7, 25)
assert item_allocation.day_minutes == 120.5 assert item_allocation.day_minutes == 120.5
assert item_allocation.overtime_minutes == 30.25 assert item_allocation.overtime_minutes == 30.25
@ -643,6 +660,81 @@ def _sqlite_db():
return SessionLocal() return SessionLocal()
def test_report_out_skips_unloaded_allocations_without_lazy_load():
db = _sqlite_db()
try:
person = Personnel(phone="13800000000", name="张三")
session = WorkSession(
id=1,
attendance_point_name="总厂",
employee_phone=person.phone,
start_at=datetime(2026, 7, 25, 8, 0),
end_at=datetime(2026, 7, 25, 10, 0),
status=SessionStatus.submitted,
)
report = ProductionReport(
id=1,
session_id=session.id,
attendance_point_name="总厂",
employee_phone=person.phone,
report_date=date(2026, 7, 25),
start_at=datetime(2026, 7, 25, 8, 0),
end_at=datetime(2026, 7, 25, 10, 0),
break_minutes=0,
status=ReportStatus.pending,
submitted_at=datetime(2026, 7, 25, 10, 1),
)
item = ProductionReportItem(
id=1,
report_id=report.id,
attendance_point_name="总厂",
device_no="23#",
project_no="P1",
product_name="23#",
process_name="冲压",
process_unit_price_yuan=1,
good_qty=100,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
standard_beat=1,
standard_workload=0,
)
allocation = ProductionReportAllocation(
id=1,
report_id=report.id,
report_item_id=item.id,
attendance_point_name="总厂",
employee_phone=person.phone,
allocation_date=date(2026, 7, 25),
day_minutes=120,
effective_minutes=120,
good_qty=100,
reference_wage=100,
)
db.add_all([person, session, report, item, allocation])
db.commit()
loaded_report = db.scalar(
select(ProductionReport)
.options(
selectinload(ProductionReport.employee),
selectinload(ProductionReport.items).raiseload(ProductionReportItem.allocations),
selectinload(ProductionReport.audit_logs),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
raiseload(ProductionReport.allocations),
)
.where(ProductionReport.id == report.id)
)
output = report_out(loaded_report)
assert output.allocation_summary_text == ""
assert output.items[0].allocations == []
finally:
db.close()
def _product( def _product(
*, *,
point_name: str = "总厂", point_name: str = "总厂",