feat: 增加报工超额快照字段
This commit is contained in:
parent
4db517ac49
commit
58ff209d0a
@ -13,6 +13,7 @@ from sqlalchemy import (
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import JSON
|
||||
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)
|
||||
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)
|
||||
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(
|
||||
Enum(ReportStatus),
|
||||
nullable=False,
|
||||
@ -290,6 +298,7 @@ class ProductionReport(Base):
|
||||
Index("idx_reports_status_date", "status", "report_date"),
|
||||
Index("idx_reports_reviewer", "reviewer_phone", "reviewed_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)
|
||||
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)
|
||||
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)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
remark: Mapped[str | None] = mapped_column(String(500))
|
||||
@ -329,6 +355,15 @@ class ProductionReportItem(Base):
|
||||
__table_args__ = (
|
||||
Index("idx_report_items_report", "report_id"),
|
||||
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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
177
scripts/migrate_report_over_limit_snapshot.py
Normal file
177
scripts/migrate_report_over_limit_snapshot.py
Normal 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()
|
||||
58
tests/test_report_over_limit.py
Normal file
58
tests/test_report_over_limit.py
Normal file
@ -0,0 +1,58 @@
|
||||
from sqlalchemy import BigInteger, create_engine, inspect
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
|
||||
from app.database import Base
|
||||
from app.models import ProductionReport, ProductionReportItem # noqa: F401
|
||||
|
||||
|
||||
@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"
|
||||
Loading…
Reference in New Issue
Block a user