fix: 平衡报工归属拆分并约束唯一性

This commit is contained in:
souplearn 2026-07-25 06:33:12 +08:00
parent 6065ab031d
commit 4c0cbc9815
5 changed files with 283 additions and 27 deletions

View File

@ -362,6 +362,13 @@ class ProductionReportAllocation(Base):
item: Mapped[ProductionReportItem | None] = relationship(back_populates="allocations") item: Mapped[ProductionReportItem | None] = relationship(back_populates="allocations")
__table_args__ = ( __table_args__ = (
Index(
"uq_report_allocations_report_item_date",
"report_id",
"report_item_id",
"allocation_date",
unique=True,
),
Index("idx_report_allocations_report", "report_id"), Index("idx_report_allocations_report", "report_id"),
Index("idx_report_allocations_item", "report_item_id"), Index("idx_report_allocations_item", "report_item_id"),
Index("idx_report_allocations_date", "allocation_date"), Index("idx_report_allocations_date", "allocation_date"),

View File

@ -1,11 +1,11 @@
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from sqlalchemy import delete from sqlalchemy import delete, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import ProductionReportAllocation from app.models import ProductionReport, ProductionReportAllocation
from app.services.common import as_float, round2 from app.services.common import as_float, round2
from app.services.metrics import ( from app.services.metrics import (
SHIFT_BUCKETS, SHIFT_BUCKETS,
@ -429,6 +429,91 @@ def _draft_from_minutes(report, item, allocation_date: date, minutes: dict[str,
) )
SPLIT_RESIDUAL_FIELDS = (
"good_qty",
"defect_qty",
"scrap_qty",
"changeover_count",
"reference_wage",
)
def _item_split_targets(item) -> dict[str, float]:
return {
"good_qty": round2(getattr(item, "good_qty", 0)),
"defect_qty": round2(getattr(item, "defect_qty", 0)),
"scrap_qty": round2(getattr(item, "scrap_qty", 0)),
"changeover_count": round2(getattr(item, "changeover_count", 0)),
"reference_wage": round2(_item_reference_wage(item)),
}
def _balance_item_split_residuals(
drafts: list[ReportAllocationDraft],
targets: dict[str, float],
) -> list[ReportAllocationDraft]:
if not drafts:
return drafts
mutable_values = [
{field: int(round(as_float(getattr(draft, field)) * 100)) for field in SPLIT_RESIDUAL_FIELDS}
for draft in drafts
]
for field in SPLIT_RESIDUAL_FIELDS:
target_cents = int(round(round2(targets[field]) * 100))
current_cents = sum(values[field] for values in mutable_values)
residual = target_cents - current_cents
if residual == 0:
continue
if residual > 0:
ordered_indexes = sorted(
range(len(drafts)),
key=lambda index: (
-as_float(drafts[index].effective_minutes),
drafts[index].allocation_date.isoformat(),
index,
),
)
else:
ordered_indexes = sorted(
range(len(drafts)),
key=lambda index: (
-mutable_values[index][field],
-as_float(drafts[index].effective_minutes),
drafts[index].allocation_date.isoformat(),
index,
),
)
step = 1 if residual > 0 else -1
remaining = abs(residual)
while remaining > 0:
changed = False
for index in ordered_indexes:
if step < 0 and mutable_values[index][field] <= 0:
continue
mutable_values[index][field] += step
remaining -= 1
changed = True
if remaining == 0:
break
if not changed:
break
return [
replace(
draft,
**{
field: mutable_values[index][field] / 100
for field in SPLIT_RESIDUAL_FIELDS
},
)
for index, draft in enumerate(drafts)
]
def build_report_allocation_drafts(report, schedule: WorkScheduleConfig | None = None) -> list[ReportAllocationDraft]: def build_report_allocation_drafts(report, schedule: WorkScheduleConfig | None = None) -> list[ReportAllocationDraft]:
items = list(getattr(report, "items", []) or []) items = list(getattr(report, "items", []) or [])
if not items: if not items:
@ -452,12 +537,14 @@ def build_report_allocation_drafts(report, schedule: WorkScheduleConfig | None =
if total_effective <= 0: if total_effective <= 0:
rows.append(_fallback_row(report, item)) rows.append(_fallback_row(report, item))
continue continue
item_rows: list[ReportAllocationDraft] = []
for allocation_date in sorted(item_minutes): for allocation_date in sorted(item_minutes):
minutes = item_minutes[allocation_date] minutes = item_minutes[allocation_date]
effective = sum(minutes.get(key, 0.0) for key in SHIFT_BUCKETS) effective = sum(minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
if effective <= 0: if effective <= 0:
continue continue
rows.append(_draft_from_minutes(report, item, allocation_date, minutes, effective / total_effective)) item_rows.append(_draft_from_minutes(report, item, allocation_date, minutes, effective / total_effective))
rows.extend(_balance_item_split_residuals(item_rows, _item_split_targets(item)))
return rows return rows
@ -489,7 +576,14 @@ def refresh_report_allocations(
commit: bool = False, commit: bool = False,
) -> list[ProductionReportAllocation]: ) -> list[ProductionReportAllocation]:
"""Rebuild allocation rows with metrics semantics, updating item allocation-derived fields.""" """Rebuild allocation rows with metrics semantics, updating item allocation-derived fields."""
db.execute(delete(ProductionReportAllocation).where(ProductionReportAllocation.report_id == getattr(report, "id"))) report_id = getattr(report, "id")
if isinstance(report_id, int):
db.execute(
select(ProductionReport.id)
.where(ProductionReport.id == report_id)
.with_for_update()
).scalar_one_or_none()
db.execute(delete(ProductionReportAllocation).where(ProductionReportAllocation.report_id == report_id))
rows = [ rows = [
ProductionReportAllocation( ProductionReportAllocation(
report_id=draft.report_id, report_id=draft.report_id,

View File

@ -49,11 +49,21 @@ REQUIRED_COLUMNS = {
}, },
} }
REQUIRED_INDEXES = { REQUIRED_INDEXES = {
"idx_report_allocations_report": ("report_id",), "uq_report_allocations_report_item_date": {
"idx_report_allocations_item": ("report_item_id",), "columns": ("report_id", "report_item_id", "allocation_date"),
"idx_report_allocations_date": ("allocation_date",), "non_unique": 0,
"idx_report_allocations_point_date": ("attendance_point_name", "allocation_date"), },
"idx_report_allocations_employee_date": ("employee_phone", "allocation_date"), "idx_report_allocations_report": {"columns": ("report_id",), "non_unique": 1},
"idx_report_allocations_item": {"columns": ("report_item_id",), "non_unique": 1},
"idx_report_allocations_date": {"columns": ("allocation_date",), "non_unique": 1},
"idx_report_allocations_point_date": {
"columns": ("attendance_point_name", "allocation_date"),
"non_unique": 1,
},
"idx_report_allocations_employee_date": {
"columns": ("employee_phone", "allocation_date"),
"non_unique": 1,
},
} }
REQUIRED_FOREIGN_KEYS = { REQUIRED_FOREIGN_KEYS = {
"report_id": ("production_reports", "id", "CASCADE"), "report_id": ("production_reports", "id", "CASCADE"),
@ -153,22 +163,51 @@ def existing_foreign_keys(conn) -> dict[str, tuple[str, str, str]]:
return {row[0]: (row[1], row[2], row[3]) for row in rows} return {row[0]: (row[1], row[2], row[3]) for row in rows}
def find_equivalent_index(indexes: dict[str, IndexMetadata], columns: tuple[str, ...]) -> str | None: def required_index_columns(index_name: str) -> tuple[str, ...]:
return REQUIRED_INDEXES[index_name]["columns"]
def required_index_non_unique(index_name: str) -> int:
return int(REQUIRED_INDEXES[index_name]["non_unique"])
def find_equivalent_index(
indexes: dict[str, IndexMetadata],
columns: tuple[str, ...],
non_unique: int,
) -> str | None:
for index_name, metadata in indexes.items(): for index_name, metadata in indexes.items():
if index_name != "PRIMARY" and metadata["columns"] == columns and metadata["non_unique"] == 1: if (
index_name != "PRIMARY"
and metadata["columns"] == columns
and metadata["non_unique"] == non_unique
):
return index_name return index_name
return None return None
def create_index(conn, index_name: str, columns: tuple[str, ...]) -> None: def create_index(conn, index_name: str, columns: tuple[str, ...]) -> None:
if index_name not in REQUIRED_INDEXES or REQUIRED_INDEXES[index_name] != columns: if index_name not in REQUIRED_INDEXES or required_index_columns(index_name) != columns:
raise ValueError(f"unexpected index definition: {index_name} {columns}") raise ValueError(f"unexpected index definition: {index_name} {columns}")
non_unique = required_index_non_unique(index_name)
indexes = existing_indexes(conn) indexes = existing_indexes(conn)
if index_name in indexes: if (
index_name in indexes
and indexes[index_name]["columns"] == columns
and indexes[index_name]["non_unique"] == non_unique
):
return return
if index_name in indexes:
conn.execute(
text(
f"ALTER TABLE {quote_identifier(TABLE_NAME)} "
f"DROP INDEX {quote_identifier(index_name)}"
)
)
indexes = existing_indexes(conn)
equivalent_index = find_equivalent_index(indexes, columns) equivalent_index = find_equivalent_index(indexes, columns, non_unique)
if equivalent_index: if equivalent_index:
conn.execute( conn.execute(
text( text(
@ -179,14 +218,51 @@ def create_index(conn, index_name: str, columns: tuple[str, ...]) -> None:
return return
column_sql = ", ".join(quote_identifier(column) for column in columns) column_sql = ", ".join(quote_identifier(column) for column in columns)
unique_sql = "UNIQUE " if non_unique == 0 else ""
conn.execute( conn.execute(
text( text(
f"CREATE INDEX {quote_identifier(index_name)} " f"CREATE {unique_sql}INDEX {quote_identifier(index_name)} "
f"ON {quote_identifier(TABLE_NAME)} ({column_sql})" f"ON {quote_identifier(TABLE_NAME)} ({column_sql})"
) )
) )
def cleanup_duplicate_allocation_keys(conn) -> None:
if not table_exists(conn):
return
conn.execute(
text(
"""
DELETE target
FROM production_report_allocations target
JOIN (
SELECT id
FROM (
SELECT allocation.id
FROM production_report_allocations allocation
JOIN (
SELECT
report_id,
report_item_id,
allocation_date,
MIN(id) AS keep_id
FROM production_report_allocations
WHERE report_item_id IS NOT NULL
GROUP BY report_id, report_item_id, allocation_date
HAVING COUNT(*) > 1
) duplicate_key
ON duplicate_key.report_id = allocation.report_id
AND duplicate_key.report_item_id = allocation.report_item_id
AND duplicate_key.allocation_date = allocation.allocation_date
WHERE allocation.id <> duplicate_key.keep_id
) duplicate_rows
) doomed
ON doomed.id = target.id
"""
)
)
def cleanup_duplicate_fk_indexes(conn) -> None: def cleanup_duplicate_fk_indexes(conn) -> None:
indexes = existing_indexes(conn) indexes = existing_indexes(conn)
duplicates = [ duplicates = [
@ -235,6 +311,8 @@ def ensure_schema(conn) -> None:
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uq_report_allocations_report_item_date
(report_id, report_item_id, allocation_date),
KEY idx_report_allocations_report (report_id), KEY idx_report_allocations_report (report_id),
KEY idx_report_allocations_item (report_item_id), KEY idx_report_allocations_item (report_item_id),
KEY idx_report_allocations_date (allocation_date), KEY idx_report_allocations_date (allocation_date),
@ -251,8 +329,9 @@ def ensure_schema(conn) -> None:
) )
) )
for index_name, columns in REQUIRED_INDEXES.items(): cleanup_duplicate_allocation_keys(conn)
create_index(conn, index_name, columns) for index_name in REQUIRED_INDEXES:
create_index(conn, index_name, required_index_columns(index_name))
cleanup_duplicate_fk_indexes(conn) cleanup_duplicate_fk_indexes(conn)
@ -319,19 +398,21 @@ def verify_schema(conn) -> None:
wrong_indexes = [ wrong_indexes = [
f"{index_name}({', '.join(indexes[index_name]['columns'])})" f"{index_name}({', '.join(indexes[index_name]['columns'])})"
for index_name, columns in REQUIRED_INDEXES.items() for index_name in REQUIRED_INDEXES
if indexes.get(index_name, {}).get("columns") != columns if indexes.get(index_name, {}).get("columns") != required_index_columns(index_name)
] ]
if wrong_indexes: if wrong_indexes:
raise RuntimeError(f"{TABLE_NAME} incompatible indexes: {', '.join(wrong_indexes)}") raise RuntimeError(f"{TABLE_NAME} incompatible indexes: {', '.join(wrong_indexes)}")
unique_required_indexes = [ wrong_index_uniqueness = [
index_name index_name
for index_name in REQUIRED_INDEXES for index_name in REQUIRED_INDEXES
if indexes[index_name]["non_unique"] != 1 if indexes[index_name]["non_unique"] != required_index_non_unique(index_name)
] ]
if unique_required_indexes: if wrong_index_uniqueness:
raise RuntimeError(f"{TABLE_NAME} required indexes are unique: {', '.join(unique_required_indexes)}") raise RuntimeError(
f"{TABLE_NAME} incompatible index uniqueness: {', '.join(wrong_index_uniqueness)}"
)
duplicate_single_column_indexes = [ duplicate_single_column_indexes = [
index_name index_name

View File

@ -2,8 +2,10 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from types import SimpleNamespace from types import SimpleNamespace
from sqlalchemy import BigInteger, create_engine, select import pytest
from sqlalchemy import BigInteger, create_engine, inspect, select
from sqlalchemy.ext.compiler import compiles from sqlalchemy.ext.compiler import compiles
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import raiseload, selectinload, sessionmaker from sqlalchemy.orm import raiseload, selectinload, sessionmaker
from app.database import Base from app.database import Base
@ -203,6 +205,31 @@ def test_short_shared_report_rounding_residual_keeps_aggregate_night_total():
assert all(row.effective_minutes == round(row.day_minutes + row.overtime_minutes + row.night_minutes, 2) for row in rows) assert all(row.effective_minutes == round(row.day_minutes + row.overtime_minutes + row.night_minutes, 2) for row in rows)
def test_item_split_fields_keep_original_totals_after_rounding_residual():
item = _item(
good_qty=1,
defect_qty=1,
scrap_qty=1,
changeover_count=1,
process_unit_price_yuan=1,
)
report = _report(datetime(2026, 7, 25, 6, 0), datetime(2026, 7, 28, 6, 0), item)
rows = build_report_allocation_drafts(report)
assert [row.allocation_date for row in rows] == [
date(2026, 7, 25),
date(2026, 7, 26),
date(2026, 7, 27),
]
assert [row.effective_minutes for row in rows] == [1340, 1340, 1340]
assert round(sum(row.good_qty for row in rows), 2) == 1
assert round(sum(row.defect_qty for row in rows), 2) == 1
assert round(sum(row.scrap_qty for row in rows), 2) == 1
assert round(sum(row.changeover_count for row in rows), 2) == 1
assert round(sum(row.reference_wage for row in rows), 2) == 1
def test_items_sharing_same_started_at_split_the_segment_minutes(): def test_items_sharing_same_started_at_split_the_segment_minutes():
started_at = datetime(2026, 7, 25, 8, 0) started_at = datetime(2026, 7, 25, 8, 0)
first_item = _item(id=1, good_qty=10, started_at=started_at) first_item = _item(id=1, good_qty=10, started_at=started_at)
@ -660,6 +687,51 @@ def _sqlite_db():
return SessionLocal() return SessionLocal()
def test_report_allocation_schema_has_unique_report_item_date_key():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
inspector = inspect(engine)
unique_column_sets = [
tuple(constraint["column_names"])
for constraint in inspector.get_unique_constraints("production_report_allocations")
]
unique_column_sets.extend(
tuple(index["column_names"])
for index in inspector.get_indexes("production_report_allocations")
if index.get("unique")
)
assert ("report_id", "report_item_id", "allocation_date") in unique_column_sets
def test_report_allocation_rejects_duplicate_report_item_date_key():
db = _sqlite_db()
try:
first = ProductionReportAllocation(
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 25),
)
duplicate = ProductionReportAllocation(
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 25),
)
db.add(first)
db.flush()
db.add(duplicate)
with pytest.raises(IntegrityError):
db.flush()
finally:
db.close()
def test_report_out_skips_unloaded_allocations_without_lazy_load(): def test_report_out_skips_unloaded_allocations_without_lazy_load():
db = _sqlite_db() db = _sqlite_db()
try: try:

View File

@ -861,10 +861,12 @@ def test_approved_allocations_query_requires_approved_unvoided_matching_point_an
report_item_id=104, report_item_id=104,
attendance_point_name="二厂", attendance_point_name="二厂",
), ),
_db_report(id=5),
_db_item(id=105, report_id=5),
_db_allocation( _db_allocation(
id=1005, id=1005,
report_id=1, report_id=5,
report_item_id=101, report_item_id=105,
attendance_point_name="二厂", attendance_point_name="二厂",
), ),
_db_allocation(id=1006, report_id=1, report_item_id=102), _db_allocation(id=1006, report_id=1, report_item_id=102),