JhHardwareWRS_BackPoint/scripts/migrate_report_allocations.py
2026-07-25 05:53:23 +08:00

424 lines
16 KiB
Python

from pathlib import Path
import re
import sys
from sqlalchemy import select, text
from sqlalchemy.orm import selectinload
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal, engine # noqa: E402
from app.models import ProductionReport, WorkSession # noqa: E402
from app.services.report_allocations import refresh_report_allocations # noqa: E402
from app.services.work_schedule import get_work_schedule_config # noqa: E402
TABLE_NAME = "production_report_allocations"
IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
REQUIRED_COLUMNS = {
"id": {"column_type": "bigint", "is_nullable": "NO", "extra_contains": ("auto_increment",)},
"report_id": {"column_type": "bigint", "is_nullable": "NO"},
"report_item_id": {"column_type": "bigint", "is_nullable": "YES"},
"attendance_point_name": {
"column_type": "varchar(128)",
"is_nullable": "NO",
"default": "empty_string",
},
"employee_phone": {"column_type": "varchar(20)", "is_nullable": "NO", "default": "empty_string"},
"allocation_date": {"column_type": "date", "is_nullable": "NO"},
"day_minutes": {"column_type": "decimal(10,2)", "is_nullable": "NO", "default": "zero"},
"overtime_minutes": {"column_type": "decimal(10,2)", "is_nullable": "NO", "default": "zero"},
"night_minutes": {"column_type": "decimal(10,2)", "is_nullable": "NO", "default": "zero"},
"effective_minutes": {"column_type": "decimal(10,2)", "is_nullable": "NO", "default": "zero"},
"good_qty": {"column_type": "decimal(12,2)", "is_nullable": "NO", "default": "zero"},
"defect_qty": {"column_type": "decimal(12,2)", "is_nullable": "NO", "default": "zero"},
"scrap_qty": {"column_type": "decimal(12,2)", "is_nullable": "NO", "default": "zero"},
"changeover_count": {"column_type": "decimal(12,2)", "is_nullable": "NO", "default": "zero"},
"reference_wage": {"column_type": "decimal(12,2)", "is_nullable": "NO", "default": "zero"},
"created_at": {
"column_type": "datetime",
"is_nullable": "NO",
"default": "current_timestamp",
},
"updated_at": {
"column_type": "datetime",
"is_nullable": "NO",
"default": "current_timestamp",
"extra_contains": ("on update current_timestamp",),
},
}
REQUIRED_INDEXES = {
"idx_report_allocations_report": ("report_id",),
"idx_report_allocations_item": ("report_item_id",),
"idx_report_allocations_date": ("allocation_date",),
"idx_report_allocations_point_date": ("attendance_point_name", "allocation_date"),
"idx_report_allocations_employee_date": ("employee_phone", "allocation_date"),
}
REQUIRED_FOREIGN_KEYS = {
"report_id": ("production_reports", "id", "CASCADE"),
"report_item_id": ("production_report_items", "id", "CASCADE"),
}
IndexMetadata = dict[str, tuple[str, ...] | int]
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) -> 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) -> dict[str, dict[str, str | None]]:
rows = conn.execute(
text(
"""
SELECT column_name, column_type, is_nullable, column_default, extra
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
"""
),
{"table_name": TABLE_NAME},
).all()
return {
row[0]: {
"column_type": row[1],
"is_nullable": row[2],
"column_default": row[3],
"extra": row[4],
}
for row in rows
}
def existing_indexes(conn) -> dict[str, IndexMetadata]:
rows = conn.execute(
text(
"""
SELECT index_name, non_unique, 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, dict[str, object]] = {}
for index_name, non_unique, column_name in rows:
metadata = indexes.setdefault(index_name, {"columns": [], "non_unique": int(non_unique)})
metadata["columns"].append(column_name)
return {
index_name: {
"columns": tuple(metadata["columns"]),
"non_unique": metadata["non_unique"],
}
for index_name, metadata in indexes.items()
}
def existing_foreign_keys(conn) -> dict[str, tuple[str, str, str]]:
rows = conn.execute(
text(
"""
SELECT k.column_name, k.referenced_table_name, k.referenced_column_name, r.delete_rule
FROM information_schema.key_column_usage k
JOIN information_schema.referential_constraints r
ON r.constraint_schema = k.constraint_schema
AND r.constraint_name = k.constraint_name
WHERE k.table_schema = DATABASE()
AND k.table_name = :table_name
AND k.referenced_table_name IS NOT NULL
ORDER BY k.constraint_name, k.ordinal_position
"""
),
{"table_name": TABLE_NAME},
).all()
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:
for index_name, metadata in indexes.items():
if index_name != "PRIMARY" and metadata["columns"] == columns and metadata["non_unique"] == 1:
return index_name
return 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:
raise ValueError(f"unexpected index definition: {index_name} {columns}")
indexes = existing_indexes(conn)
if index_name in indexes:
return
equivalent_index = find_equivalent_index(indexes, columns)
if equivalent_index:
conn.execute(
text(
f"ALTER TABLE {quote_identifier(TABLE_NAME)} "
f"RENAME INDEX {quote_identifier(equivalent_index)} TO {quote_identifier(index_name)}"
)
)
return
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 cleanup_duplicate_fk_indexes(conn) -> None:
indexes = existing_indexes(conn)
duplicates = [
index_name
for index_name, metadata in indexes.items()
if (
metadata["non_unique"] == 1
and (
(metadata["columns"] == ("report_id",) and index_name != "idx_report_allocations_report")
or (
metadata["columns"] == ("report_item_id",)
and index_name != "idx_report_allocations_item"
)
)
)
]
for index_name in duplicates:
conn.execute(
text(
f"ALTER TABLE {quote_identifier(TABLE_NAME)} "
f"DROP INDEX {quote_identifier(index_name)}"
)
)
def ensure_schema(conn) -> None:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS production_report_allocations (
id BIGINT NOT NULL AUTO_INCREMENT,
report_id BIGINT NOT NULL,
report_item_id BIGINT NULL,
attendance_point_name VARCHAR(128) NOT NULL DEFAULT '',
employee_phone VARCHAR(20) NOT NULL DEFAULT '',
allocation_date DATE NOT NULL,
day_minutes DECIMAL(10, 2) NOT NULL DEFAULT 0,
overtime_minutes DECIMAL(10, 2) NOT NULL DEFAULT 0,
night_minutes DECIMAL(10, 2) NOT NULL DEFAULT 0,
effective_minutes DECIMAL(10, 2) NOT NULL DEFAULT 0,
good_qty DECIMAL(12, 2) NOT NULL DEFAULT 0,
defect_qty DECIMAL(12, 2) NOT NULL DEFAULT 0,
scrap_qty DECIMAL(12, 2) NOT NULL DEFAULT 0,
changeover_count DECIMAL(12, 2) NOT NULL DEFAULT 0,
reference_wage DECIMAL(12, 2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_report_allocations_report (report_id),
KEY idx_report_allocations_item (report_item_id),
KEY idx_report_allocations_date (allocation_date),
KEY idx_report_allocations_point_date (attendance_point_name, allocation_date),
KEY idx_report_allocations_employee_date (employee_phone, allocation_date),
CONSTRAINT fk_report_allocations_report
FOREIGN KEY (report_id) REFERENCES production_reports (id)
ON DELETE CASCADE,
CONSTRAINT fk_report_allocations_item
FOREIGN KEY (report_item_id) REFERENCES production_report_items (id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
)
)
for index_name, columns in REQUIRED_INDEXES.items():
create_index(conn, index_name, columns)
cleanup_duplicate_fk_indexes(conn)
def normalized_default(value: str | None) -> str | None:
if value is None:
return None
lowered = str(value).strip().lower()
if lowered in {"current_timestamp()", "current_timestamp"}:
return "current_timestamp"
try:
return "zero" if float(lowered) == 0 else lowered
except ValueError:
return lowered
def column_matches(column: dict[str, str | None], expected: dict[str, object]) -> list[str]:
mismatches: list[str] = []
actual_type = str(column["column_type"]).lower()
actual_nullable = str(column["is_nullable"]).upper()
actual_default = normalized_default(column["column_default"])
actual_extra = str(column["extra"] or "").lower()
if actual_type != expected["column_type"]:
mismatches.append(f"column_type={column['column_type']}")
if actual_nullable != expected["is_nullable"]:
mismatches.append(f"is_nullable={column['is_nullable']}")
expected_default = expected.get("default")
if expected_default == "empty_string" and actual_default != "":
mismatches.append(f"column_default={column['column_default']!r}")
elif expected_default == "zero" and actual_default != "zero":
mismatches.append(f"column_default={column['column_default']!r}")
elif expected_default == "current_timestamp" and actual_default != "current_timestamp":
mismatches.append(f"column_default={column['column_default']!r}")
for extra_text in expected.get("extra_contains", ()):
if extra_text not in actual_extra:
mismatches.append(f"extra={column['extra']!r}")
return mismatches
def verify_schema(conn) -> None:
if not table_exists(conn):
raise RuntimeError(f"{TABLE_NAME} table is missing")
columns = existing_columns(conn)
missing_columns = sorted(set(REQUIRED_COLUMNS) - set(columns))
if missing_columns:
raise RuntimeError(f"{TABLE_NAME} missing columns: {', '.join(missing_columns)}")
incompatible_columns = [
f"{column_name}({', '.join(mismatches)})"
for column_name, expected in REQUIRED_COLUMNS.items()
if (mismatches := column_matches(columns[column_name], expected))
]
if incompatible_columns:
raise RuntimeError(f"{TABLE_NAME} incompatible columns: {', '.join(incompatible_columns)}")
indexes = existing_indexes(conn)
missing_indexes = [index_name for index_name in REQUIRED_INDEXES if index_name not in indexes]
if missing_indexes:
raise RuntimeError(f"{TABLE_NAME} missing indexes: {', '.join(missing_indexes)}")
wrong_indexes = [
f"{index_name}({', '.join(indexes[index_name]['columns'])})"
for index_name, columns in REQUIRED_INDEXES.items()
if indexes.get(index_name, {}).get("columns") != columns
]
if wrong_indexes:
raise RuntimeError(f"{TABLE_NAME} incompatible indexes: {', '.join(wrong_indexes)}")
unique_required_indexes = [
index_name
for index_name in REQUIRED_INDEXES
if indexes[index_name]["non_unique"] != 1
]
if unique_required_indexes:
raise RuntimeError(f"{TABLE_NAME} required indexes are unique: {', '.join(unique_required_indexes)}")
duplicate_single_column_indexes = [
index_name
for index_name, metadata in indexes.items()
if (
metadata["non_unique"] == 1
and (
(metadata["columns"] == ("report_id",) and index_name != "idx_report_allocations_report")
or (
metadata["columns"] == ("report_item_id",)
and index_name != "idx_report_allocations_item"
)
)
)
]
if duplicate_single_column_indexes:
raise RuntimeError(
f"{TABLE_NAME} duplicate FK indexes: {', '.join(sorted(duplicate_single_column_indexes))}"
)
unique_single_column_indexes = [
index_name
for index_name, metadata in indexes.items()
if (
metadata["non_unique"] != 1
and (
metadata["columns"] == ("report_id",)
or metadata["columns"] == ("report_item_id",)
)
)
]
if unique_single_column_indexes:
raise RuntimeError(
f"{TABLE_NAME} unique FK indexes are incompatible: {', '.join(sorted(unique_single_column_indexes))}"
)
foreign_keys = existing_foreign_keys(conn)
missing_foreign_keys = [
column
for column, definition in REQUIRED_FOREIGN_KEYS.items()
if foreign_keys.get(column) != definition
]
if missing_foreign_keys:
raise RuntimeError(f"{TABLE_NAME} missing foreign keys: {', '.join(missing_foreign_keys)}")
def _backfill() -> tuple[int, int]:
processed = 0
skipped = 0
query = (
select(ProductionReport)
.options(
selectinload(ProductionReport.items),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
)
.order_by(ProductionReport.id.asc())
)
with SessionLocal() as db:
reports = db.scalars(query).all()
for report in reports:
if report is None:
skipped += 1
continue
schedule = get_work_schedule_config(db, report.attendance_point_name)
refresh_report_allocations(db, report, schedule=schedule, commit=False)
processed += 1
if processed % 200 == 0:
db.commit()
db.commit()
return processed, skipped
def main() -> None:
with engine.begin() as conn:
ensure_schema(conn)
verify_schema(conn)
processed, skipped = _backfill()
print(f"report allocations migrated, processed={processed}, skipped={skipped}")
if __name__ == "__main__":
main()