fix: 平衡报工归属拆分并约束唯一性
This commit is contained in:
parent
6065ab031d
commit
4c0cbc9815
@ -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"),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user