chore: initialize mini program backend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit 8f62c99893
97 changed files with 13770 additions and 0 deletions
@@ -0,0 +1,89 @@
from collections import defaultdict
from hashlib import sha256
from pathlib import Path
import sys
from sqlalchemy import select
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal # noqa: E402
from app.models import Product # noqa: E402
def ratio(name: str, salt: str) -> float:
digest = sha256(f"{name}|{salt}".encode("utf-8")).hexdigest()
return int(digest[:8], 16) / 0xFFFFFFFF
def generated_values(product_name: str) -> tuple[float, float, float, float]:
name = product_name or ""
if "纵梁" in name and "加强板" not in name:
net = 2.6 + ratio(name, "net") * 1.8
elif "吸能盒" in name:
net = 0.75 + ratio(name, "net") * 0.65
elif "加强板" in name:
net = 0.55 + ratio(name, "net") * 0.65
elif any(keyword in name for keyword in ("安装板", "连接板", "端板", "盖板", "封板")):
net = 0.32 + ratio(name, "net") * 0.62
else:
net = 0.85 + ratio(name, "net") * 1.05
gross_factor = 1.08 + ratio(name, "gross") * 0.1
scrap_rate = 0.012 + ratio(name, "scrap") * 0.016
waste_price = 12.0 + ratio(name, "price") * 2.6
return (
round(net, 3),
round(net * gross_factor, 3),
round(scrap_rate, 4),
round(waste_price, 2),
)
def valid_positive(value) -> float | None:
if value is None:
return None
number = float(value)
return number if number > 0 else None
def valid_waste_price(value) -> float | None:
number = valid_positive(value)
if number is None or number > 25:
return None
return number
def group_values(product_name: str, items: list[Product]) -> tuple[float, float, float, float]:
generated = generated_values(product_name)
net = next((value for item in items if (value := valid_positive(item.product_net_weight_kg)) is not None), generated[0])
gross = next((value for item in items if (value := valid_positive(item.product_gross_weight_kg)) is not None), generated[1])
scrap_rate = next((value for item in items if (value := valid_positive(item.scrap_loss_rate)) is not None), generated[2])
waste_price = next((value for item in items if (value := valid_waste_price(item.waste_price_yuan_per_kg)) is not None), generated[3])
return net, gross, scrap_rate, waste_price
def main() -> None:
with SessionLocal() as db:
products = db.scalars(select(Product).order_by(Product.product_name, Product.process_name)).all()
groups: dict[str, list[Product]] = defaultdict(list)
for product in products:
groups[product.product_name].append(product)
updated_rows = 0
for product_name, items in groups.items():
net, gross, scrap_rate, waste_price = group_values(product_name, items)
for product in items:
product.product_net_weight_kg = net
product.product_gross_weight_kg = gross
product.scrap_loss_rate = scrap_rate
product.waste_price_yuan_per_kg = waste_price
updated_rows += 1
db.commit()
print(f"backfilled {updated_rows} product rows, {len(groups)} product names")
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
from argparse import ArgumentParser
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal # noqa: E402
from app.models import PersonRole, Personnel, Role # noqa: E402
def main() -> None:
parser = ArgumentParser(description="Create or update the first manager account.")
parser.add_argument("--phone", required=True, help="经理手机号")
parser.add_argument("--name", required=True, help="经理姓名")
args = parser.parse_args()
with SessionLocal() as db:
person = db.get(Personnel, args.phone)
if person is None:
person = Personnel(phone=args.phone)
db.add(person)
person.name = args.name
if db.get(PersonRole, {"phone": args.phone, "role": Role.manager}) is None:
db.add(PersonRole(phone=args.phone, role=Role.manager))
db.commit()
print(f"manager ready: {args.name} {args.phone}")
if __name__ == "__main__":
main()
@@ -0,0 +1,49 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
conn.execute(text("CREATE TABLE IF NOT EXISTS products_legacy_device_backup LIKE products"))
conn.execute(text("""
INSERT IGNORE INTO products_legacy_device_backup
SELECT *
FROM products
WHERE device_no <> ''
"""))
converted = conn.execute(text("""
UPDATE products p
LEFT JOIN products k
ON k.project_no = p.project_no
AND k.product_name = p.product_name
AND k.process_name = p.process_name
AND k.device_no = ''
SET p.device_no = ''
WHERE p.device_no <> ''
AND k.project_no IS NULL
""")).rowcount
deleted = conn.execute(text("""
DELETE p
FROM products p
INNER JOIN products k
ON k.project_no = p.project_no
AND k.product_name = p.product_name
AND k.process_name = p.process_name
AND k.device_no = ''
WHERE p.device_no <> ''
""")).rowcount
print(
"products legacy device rows deduped; "
f"converted={converted}, deleted={deleted}, backup_table=products_legacy_device_backup"
)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
from pathlib import Path
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
TARGET_CHARSET = "utf8mb4"
TARGET_COLLATION = "utf8mb4_unicode_ci"
APP_TABLES = [
"attendance_points",
"personnel",
"person_roles",
"person_attendance_points",
"products",
"equipment",
"work_schedules",
"work_sessions",
"work_session_devices",
"production_reports",
"production_report_items",
"report_audit_logs",
"device_qrcodes",
"notices",
"notice_points",
"reconciliation_ledger_entries",
]
def quote_identifier(value: str) -> str:
return f"`{value.replace('`', '``')}`"
def table_exists(conn, table: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = :table
AND table_type = 'BASE TABLE'
"""), {"table": table}).scalar_one())
def main() -> None:
with engine.begin() as conn:
database = conn.execute(text("SELECT DATABASE()")).scalar_one()
if database:
conn.execute(text(
f"ALTER DATABASE {quote_identifier(database)} "
f"CHARACTER SET {TARGET_CHARSET} COLLATE {TARGET_COLLATION}"
))
changed = []
skipped = []
for table in APP_TABLES:
if not table_exists(conn, table):
skipped.append(table)
continue
conn.execute(text(
f"ALTER TABLE {quote_identifier(table)} "
f"CONVERT TO CHARACTER SET {TARGET_CHARSET} COLLATE {TARGET_COLLATION}"
))
changed.append(table)
print(f"collation fixed: {', '.join(changed)}")
if skipped:
print(f"skipped missing tables: {', '.join(skipped)}")
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import Base, engine # noqa: E402
from app import models # noqa: F401,E402
def main() -> None:
Base.metadata.create_all(bind=engine)
print("database tables created")
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
from pathlib import Path
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
from app.services.work_schedule import DEFAULT_WORK_SCHEDULE # noqa: E402
SCHEDULE_FIELDS = [
"day_start",
"day_end",
"lunch_start",
"lunch_end",
"dinner_start",
"dinner_end",
"overtime_start",
"overtime_end",
"night_start",
"night_end",
]
def column_exists(conn, table: str, column: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table
AND column_name = :column
"""), {"table": table, "column": column}).scalar_one())
def main() -> None:
with engine.begin() as conn:
for field in SCHEDULE_FIELDS:
if column_exists(conn, "attendance_points", field):
continue
conn.execute(text(
f"ALTER TABLE attendance_points "
f"ADD COLUMN {field} VARCHAR(5) NOT NULL DEFAULT :default_value AFTER radius_meters"
), {"default_value": DEFAULT_WORK_SCHEDULE[field]})
has_global_schedule = bool(conn.execute(text("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'work_schedules'
""")).scalar_one())
if has_global_schedule:
set_clause = ", ".join(
f"ap.{field} = COALESCE(ws.{field}, :{field})"
for field in SCHEDULE_FIELDS
)
conn.execute(text(f"""
UPDATE attendance_points ap
JOIN work_schedules ws ON ws.id = 1
SET {set_clause}
"""), DEFAULT_WORK_SCHEDULE)
for field in SCHEDULE_FIELDS:
conn.execute(text(
f"UPDATE attendance_points SET {field} = :default_value "
f"WHERE {field} IS NULL OR {field} = ''"
), {"default_value": DEFAULT_WORK_SCHEDULE[field]})
print("attendance point schedules migrated")
if __name__ == "__main__":
main()
+207
View File
@@ -0,0 +1,207 @@
from pathlib import Path
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
from app.services.attendance_points import DEFAULT_ATTENDANCE_POINT_NAME # noqa: E402
TARGET_COLLATION = "utf8mb4_unicode_ci"
def column_exists(conn, table: str, column: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table
AND column_name = :column
"""), {"table": table, "column": column}).scalar_one())
def table_exists(conn, table: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = :table
"""), {"table": table}).scalar_one())
def primary_columns(conn, table: str) -> list[str]:
rows = conn.execute(text("""
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_schema = DATABASE()
AND table_name = :table
AND constraint_name = 'PRIMARY'
ORDER BY ordinal_position
"""), {"table": table}).all()
return [row[0] for row in rows]
def index_exists(conn, table: str, index_name: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = :table
AND index_name = :index_name
"""), {"table": table, "index_name": index_name}).scalar_one())
def create_index(conn, table: str, index_name: str, columns: str) -> None:
if not index_exists(conn, table, index_name):
conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({columns})"))
def add_point_column(conn, table: str, after: str | None = None) -> None:
if column_exists(conn, table, "attendance_point_name"):
return
after_clause = f" AFTER {after}" if after else ""
conn.execute(text(
f"ALTER TABLE {table} "
f"ADD COLUMN attendance_point_name VARCHAR(128) COLLATE {TARGET_COLLATION} NOT NULL DEFAULT :default_name{after_clause}"
), {"default_name": DEFAULT_ATTENDANCE_POINT_NAME})
def ensure_primary(conn, table: str, columns: list[str]) -> None:
if primary_columns(conn, table) == columns:
return
if primary_columns(conn, table):
conn.execute(text(f"ALTER TABLE {table} DROP PRIMARY KEY"))
joined = ", ".join(columns)
conn.execute(text(f"ALTER TABLE {table} ADD PRIMARY KEY ({joined})"))
def main() -> None:
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS attendance_points (
name VARCHAR(128) NOT NULL PRIMARY KEY,
latitude DECIMAL(10, 7) NULL,
longitude DECIMAL(10, 7) NULL,
radius_meters INT NOT NULL DEFAULT 500,
day_start VARCHAR(5) NOT NULL DEFAULT '08:00',
day_end VARCHAR(5) NOT NULL DEFAULT '17:20',
lunch_start VARCHAR(5) NOT NULL DEFAULT '11:40',
lunch_end VARCHAR(5) NOT NULL DEFAULT '12:40',
dinner_start VARCHAR(5) NOT NULL DEFAULT '17:20',
dinner_end VARCHAR(5) NOT NULL DEFAULT '18:00',
overtime_start VARCHAR(5) NOT NULL DEFAULT '18:00',
overtime_end VARCHAR(5) NOT NULL DEFAULT '20:00',
night_start VARCHAR(5) NOT NULL DEFAULT '20:00',
night_end VARCHAR(5) NOT NULL DEFAULT '06:00',
remark VARCHAR(500) NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""))
conn.execute(text("""
INSERT IGNORE INTO attendance_points (
name, latitude, longitude, radius_meters, remark, is_active
)
SELECT
:name,
attendance_latitude,
attendance_longitude,
COALESCE(attendance_radius_meters, 500),
'默认考勤点',
TRUE
FROM work_schedules
WHERE id = 1
"""), {"name": DEFAULT_ATTENDANCE_POINT_NAME})
conn.execute(text("""
INSERT IGNORE INTO attendance_points (name, radius_meters, remark, is_active)
VALUES (:name, 500, '默认考勤点', TRUE)
"""), {"name": DEFAULT_ATTENDANCE_POINT_NAME})
conn.execute(text("""
CREATE TABLE IF NOT EXISTS person_attendance_points (
phone VARCHAR(20) NOT NULL,
attendance_point_name VARCHAR(128) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (phone, attendance_point_name),
INDEX idx_person_points_point (attendance_point_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""))
conn.execute(text("""
INSERT IGNORE INTO person_attendance_points (phone, attendance_point_name)
SELECT phone, :name FROM personnel
"""), {"name": DEFAULT_ATTENDANCE_POINT_NAME})
if table_exists(conn, "products"):
add_point_column(conn, "products")
ensure_primary(conn, "products", [
"attendance_point_name",
"project_no",
"product_name",
"device_no",
"process_name",
])
create_index(conn, "products", "idx_products_point", "attendance_point_name")
if table_exists(conn, "equipment"):
add_point_column(conn, "equipment")
ensure_primary(conn, "equipment", ["attendance_point_name", "device_no"])
create_index(conn, "equipment", "idx_equipment_point", "attendance_point_name")
if table_exists(conn, "work_sessions"):
add_point_column(conn, "work_sessions", "id")
create_index(conn, "work_sessions", "idx_sessions_point_status", "attendance_point_name, status")
if table_exists(conn, "work_session_devices"):
add_point_column(conn, "work_session_devices", "session_id")
create_index(conn, "work_session_devices", "idx_session_devices_point", "attendance_point_name")
if table_exists(conn, "production_reports"):
add_point_column(conn, "production_reports", "id")
create_index(conn, "production_reports", "idx_reports_point_status", "attendance_point_name, status, report_date")
if table_exists(conn, "production_report_items"):
add_point_column(conn, "production_report_items", "report_id")
conn.execute(text("""
UPDATE production_report_items item
JOIN production_reports report ON report.id = item.report_id
SET item.attendance_point_name = report.attendance_point_name
WHERE item.attendance_point_name = ''
OR item.attendance_point_name IS NULL
"""))
create_index(conn, "production_report_items", "idx_report_items_point_product", "attendance_point_name, project_no, product_name")
if table_exists(conn, "device_qrcodes"):
add_point_column(conn, "device_qrcodes")
ensure_primary(conn, "device_qrcodes", ["attendance_point_name", "device_no", "process_name"])
conn.execute(text("""
CREATE TABLE IF NOT EXISTS notice_points (
notice_id BIGINT NOT NULL,
attendance_point_name VARCHAR(128) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (notice_id, attendance_point_name),
INDEX idx_notice_points_point (attendance_point_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""))
conn.execute(text("""
INSERT IGNORE INTO notice_points (notice_id, attendance_point_name)
SELECT id, :name FROM notices
"""), {"name": DEFAULT_ATTENDANCE_POINT_NAME})
if table_exists(conn, "reconciliation_ledger_entries"):
add_point_column(conn, "reconciliation_ledger_entries")
ensure_primary(conn, "reconciliation_ledger_entries", [
"attendance_point_name",
"year",
"month",
"product_name",
])
print("attendance points migrated")
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
from pathlib import Path
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
def _column_exists(conn, table_name: str, column_name: str) -> bool:
return bool(conn.execute(
text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""),
{"table_name": table_name, "column_name": column_name},
).scalar_one())
def main() -> None:
with engine.begin() as conn:
if not _column_exists(conn, "production_reports", "is_system_auto_submitted"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN is_system_auto_submitted TINYINT(1) NOT NULL DEFAULT 0 AFTER submitted_at
"""))
if not _column_exists(conn, "production_reports", "auto_submit_reason"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN auto_submit_reason VARCHAR(120) NULL AFTER is_system_auto_submitted
"""))
print("auto submitted report columns migrated")
if __name__ == "__main__":
main()
@@ -0,0 +1,19 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
conn.execute(text("ALTER TABLE production_report_items MODIFY device_no VARCHAR(255) NOT NULL"))
print("production_report_items.device_no migrated to varchar(255)")
if __name__ == "__main__":
main()
@@ -0,0 +1,128 @@
from pathlib import Path
import re
import sys
from sqlalchemy import select, text
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal, engine # noqa: E402
from app.models import DeviceQRCode, Product, ProductionReportItem, WorkSessionDevice # noqa: E402
from app.services.cleaning import CLEANING_STAMPING_METHOD, LEGACY_CLEANING_PROCESS_NAME # noqa: E402
def _column_exists(conn, table_name: str, column_name: str) -> bool:
return bool(conn.execute(
text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""),
{"table_name": table_name, "column_name": column_name},
).scalar_one())
def _sequence_number(process_name: str) -> int | None:
matched = re.search(r"\d+", str(process_name or ""))
return int(matched.group(0)) if matched else None
def _next_process_name(products: list[Product]) -> str:
existing = {str(product.process_name or "").strip() for product in products}
max_number = max(
(number for number in (_sequence_number(value) for value in existing) if number is not None),
default=0,
)
number = max_number + 1
while f"{number}序" in existing:
number += 1
return f"{number}序"
def main() -> None:
with engine.begin() as conn:
if not _column_exists(conn, "production_report_items", "stamping_method"):
conn.execute(text("""
ALTER TABLE production_report_items
ADD COLUMN stamping_method VARCHAR(128) NULL AFTER process_name
"""))
with SessionLocal() as db:
cleaning_products = db.scalars(
select(Product).where(Product.process_name == LEGACY_CLEANING_PROCESS_NAME)
).all()
migrated_products = 0
for product in cleaning_products:
old_process_name = product.process_name
siblings = db.scalars(
select(Product).where(
Product.project_no == product.project_no,
Product.product_name == product.product_name,
Product.device_no == product.device_no,
)
).all()
next_process_name = _next_process_name(siblings)
old_qr = db.get(DeviceQRCode, {
"device_no": product.product_name,
"process_name": old_process_name,
})
if old_qr is not None:
old_qr.process_name = next_process_name
for session_device in db.scalars(
select(WorkSessionDevice).where(
WorkSessionDevice.device_no == product.product_name,
WorkSessionDevice.process_name == old_process_name,
)
).all():
session_device.process_name = next_process_name
for report_item in db.scalars(
select(ProductionReportItem).where(
ProductionReportItem.project_no == product.project_no,
ProductionReportItem.product_name == product.product_name,
ProductionReportItem.process_name == old_process_name,
)
).all():
report_item.process_name = next_process_name
report_item.stamping_method = CLEANING_STAMPING_METHOD
report_item.standard_beat = 0
report_item.standard_workload = 0
report_item.allocated_minutes = 0
product.process_name = next_process_name
product.stamping_method = CLEANING_STAMPING_METHOD
product.standard_beat = 0
product.standard_workload = 0
migrated_products += 1
for product in db.scalars(
select(Product).where(Product.stamping_method == CLEANING_STAMPING_METHOD)
).all():
product.standard_beat = 0
product.standard_workload = 0
for report_item in db.scalars(select(ProductionReportItem)).all():
if report_item.stamping_method:
continue
product = db.scalar(
select(Product).where(
Product.project_no == report_item.project_no,
Product.product_name == report_item.product_name,
Product.process_name == (report_item.process_name or ""),
Product.device_no == "",
)
)
if product is not None:
report_item.stamping_method = product.stamping_method
db.commit()
print(f"cleaning migrated to stamping_method, products={migrated_products}")
if __name__ == "__main__":
main()
@@ -0,0 +1,33 @@
from sqlalchemy import text
from app.database import engine
def main() -> None:
with engine.begin() as conn:
exists = conn.execute(
text(
"""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'production_report_items'
AND column_name = 'changeover_count'
"""
)
).scalar()
if not exists:
conn.execute(
text(
"""
ALTER TABLE production_report_items
ADD COLUMN changeover_count DECIMAL(12, 2) NOT NULL DEFAULT 0
AFTER process_unit_price_yuan
"""
)
)
print("migrate_continuous_die_changeover_count done")
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS equipment (
device_no VARCHAR(64) NOT NULL PRIMARY KEY,
remark VARCHAR(500) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
conn.execute(text("""
INSERT IGNORE INTO equipment (device_no, remark)
SELECT DISTINCT device_no, '由历史产品清单带入'
FROM products
WHERE device_no IS NOT NULL AND device_no <> ''
"""))
print("equipment migrated")
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
exists = conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'equipment'
AND column_name = 'device_type'
""")).scalar()
if not exists:
conn.execute(text("""
ALTER TABLE equipment
ADD COLUMN device_type VARCHAR(32) NOT NULL DEFAULT '冲压设备' AFTER device_no
"""))
conn.execute(text("""
UPDATE equipment
SET device_type = '冲压设备'
WHERE device_type IS NULL OR device_type = ''
"""))
print("equipment device type migrated")
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
from pathlib import Path
import sys
from sqlalchemy import select, text
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal, engine # noqa: E402
from app.models import AttendancePoint # noqa: E402
from app.services.misc_work import ensure_misc_work_product # noqa: E402
def _column_exists(conn, table_name: str, column_name: str) -> bool:
dialect = conn.dialect.name
if dialect == "mysql":
return bool(conn.execute(
text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""),
{"table_name": table_name, "column_name": column_name},
).scalar_one())
rows = conn.execute(text(f"PRAGMA table_info({table_name})")).all()
return any(row[1] == column_name for row in rows)
def main() -> None:
with engine.begin() as conn:
if not _column_exists(conn, "production_report_items", "remark"):
conn.execute(text("""
ALTER TABLE production_report_items
ADD COLUMN remark VARCHAR(500) NULL
"""))
with SessionLocal() as db:
points = db.scalars(
select(AttendancePoint).where(AttendancePoint.is_active.is_(True))
).all()
for point in points:
ensure_misc_work_product(db, point.name)
db.commit()
print(f"misc work migrated, attendance_points={len(points)}")
if __name__ == "__main__":
main()
@@ -0,0 +1,36 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
result = conn.execute(
text(
"""
UPDATE work_session_devices d
JOIN work_sessions s ON s.id = d.session_id
SET d.released_at = NULL,
d.released_by = NULL,
d.release_reason = NULL
WHERE s.status = 'reporting'
AND d.release_reason IN ('下班释放占用', '历史非活动记录迁移释放占用')
AND NOT EXISTS (
SELECT 1
FROM production_reports r
WHERE r.session_id = s.id
)
"""
)
)
print(f"restored reporting mold locks: {result.rowcount}")
if __name__ == "__main__":
main()
+124
View File
@@ -0,0 +1,124 @@
from pathlib import Path
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
def column_exists(conn, table_name: str, column_name: str) -> bool:
return bool(
conn.execute(
text(
"""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""
),
{"table_name": table_name, "column_name": column_name},
).scalar()
)
def index_exists(conn, table_name: str, index_name: str) -> bool:
return bool(
conn.execute(
text(
"""
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND index_name = :index_name
"""
),
{"table_name": table_name, "index_name": index_name},
).scalar()
)
def main() -> None:
with engine.begin() as conn:
if not column_exists(conn, "work_session_devices", "released_at"):
conn.execute(text("ALTER TABLE work_session_devices ADD COLUMN released_at DATETIME NULL AFTER sort_order"))
if not column_exists(conn, "work_session_devices", "released_by"):
conn.execute(text("ALTER TABLE work_session_devices ADD COLUMN released_by VARCHAR(20) NULL AFTER released_at"))
if not column_exists(conn, "work_session_devices", "release_reason"):
conn.execute(text("ALTER TABLE work_session_devices ADD COLUMN release_reason VARCHAR(120) NULL AFTER released_by"))
if not index_exists(conn, "work_session_devices", "idx_session_devices_release"):
conn.execute(text("CREATE INDEX idx_session_devices_release ON work_session_devices (released_at)"))
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS mold_lock_feedbacks (
id BIGINT NOT NULL AUTO_INCREMENT,
attendance_point_name VARCHAR(128) NOT NULL DEFAULT '',
mold_name VARCHAR(255) NOT NULL,
process_name VARCHAR(128) NOT NULL DEFAULT '',
session_device_id BIGINT NOT NULL,
reporter_phone VARCHAR(20) NOT NULL,
occupied_phone VARCHAR(20) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
read_at DATETIME NULL,
handled_at DATETIME NULL,
handled_by VARCHAR(20) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_mold_lock_feedback_point_read (attendance_point_name, read_at, created_at),
KEY idx_mold_lock_feedback_status (status, created_at),
KEY idx_mold_lock_feedback_device (session_device_id),
CONSTRAINT fk_mold_lock_feedback_device
FOREIGN KEY (session_device_id) REFERENCES work_session_devices (id),
CONSTRAINT fk_mold_lock_feedback_reporter
FOREIGN KEY (reporter_phone) REFERENCES personnel (phone),
CONSTRAINT fk_mold_lock_feedback_occupied
FOREIGN KEY (occupied_phone) REFERENCES personnel (phone),
CONSTRAINT fk_mold_lock_feedback_handler
FOREIGN KEY (handled_by) REFERENCES personnel (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
)
)
conn.execute(
text(
"""
UPDATE work_session_devices d
JOIN work_sessions s ON s.id = d.session_id
SET d.released_at = COALESCE(s.end_at, s.updated_at, NOW()),
d.release_reason = '历史非活动记录迁移释放占用'
WHERE d.released_at IS NULL
AND s.status NOT IN ('active', 'reporting')
"""
)
)
conn.execute(
text(
"""
UPDATE work_session_devices d
JOIN work_sessions s ON s.id = d.session_id
JOIN (
SELECT session_id, MAX(sort_order) AS max_sort_order
FROM work_session_devices
GROUP BY session_id
) latest ON latest.session_id = d.session_id
SET d.released_at = NOW(),
d.release_reason = '历史切换记录迁移释放占用'
WHERE d.released_at IS NULL
AND s.status = 'active'
AND d.sort_order < latest.max_sort_order
"""
)
)
print("mold lock columns and feedback table migrated")
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
from pathlib import Path
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
def _column_exists(conn, table_name: str, column_name: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""), {"table_name": table_name, "column_name": column_name}).scalar())
def _primary_columns(conn, table_name: str) -> list[str]:
rows = conn.execute(text("""
SELECT column_name
FROM information_schema.key_column_usage
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND constraint_name = 'PRIMARY'
ORDER BY ordinal_position
"""), {"table_name": table_name}).all()
return [row[0] for row in rows]
def main() -> None:
with engine.begin() as conn:
if not _column_exists(conn, "work_session_devices", "process_name"):
conn.execute(text("""
ALTER TABLE work_session_devices
ADD COLUMN process_name VARCHAR(128) NOT NULL DEFAULT '' AFTER device_no
"""))
if not _column_exists(conn, "device_qrcodes", "process_name"):
conn.execute(text("""
ALTER TABLE device_qrcodes
ADD COLUMN process_name VARCHAR(128) NOT NULL DEFAULT '' AFTER device_no
"""))
primary_columns = _primary_columns(conn, "device_qrcodes")
if primary_columns != ["device_no", "process_name"]:
conn.execute(text("ALTER TABLE device_qrcodes DROP PRIMARY KEY"))
conn.execute(text("ALTER TABLE device_qrcodes ADD PRIMARY KEY (device_no, process_name)"))
print("mold process qrcode migrated")
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
conn.execute(text("ALTER TABLE work_session_devices MODIFY device_no VARCHAR(255) NOT NULL"))
conn.execute(text("ALTER TABLE device_qrcodes MODIFY device_no VARCHAR(255) NOT NULL"))
print("mold qrcode fields migrated")
if __name__ == "__main__":
main()
+57
View File
@@ -0,0 +1,57 @@
from pathlib import Path
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
def _column_exists(conn, table_name: str, column_name: str) -> bool:
return bool(conn.execute(
text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""),
{"table_name": table_name, "column_name": column_name},
).scalar_one())
def main() -> None:
with engine.begin() as conn:
if not _column_exists(conn, "production_report_items", "operator_count"):
conn.execute(text("""
ALTER TABLE production_report_items
ADD COLUMN operator_count DECIMAL(10, 2) NOT NULL DEFAULT 1 AFTER stamping_method
"""))
if not _column_exists(conn, "production_reports", "is_multi_person_assistant"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN is_multi_person_assistant TINYINT(1) NOT NULL DEFAULT 0 AFTER auto_submit_reason
"""))
if not _column_exists(conn, "production_reports", "multi_person_source_report_id"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN multi_person_source_report_id BIGINT NULL AFTER is_multi_person_assistant
"""))
conn.execute(text("""
UPDATE production_report_items item
JOIN products product
ON product.attendance_point_name = item.attendance_point_name
AND product.project_no = item.project_no
AND product.product_name = item.product_name
AND product.process_name = IFNULL(item.process_name, '')
AND product.device_no = ''
SET item.operator_count = product.operator_count
WHERE item.operator_count IS NULL OR item.operator_count <= 1
"""))
print("multi person report columns migrated")
if __name__ == "__main__":
main()
+165
View File
@@ -0,0 +1,165 @@
from pathlib import Path
import re
import sys
from sqlalchemy import select
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal # noqa: E402
from app.models import DeviceQRCode, MoldLockFeedback, Product, ProductionReportItem, WorkSessionDevice # noqa: E402
LEGACY_PROCESS_PATTERN = re.compile(r"0*(\d+)序")
def normalized(value: str | None) -> str | None:
text = str(value or "").strip()
matched = LEGACY_PROCESS_PATTERN.fullmatch(text)
if matched:
return str(int(matched.group(1)))
return None
def normalize_products(db, skipped: list[str]) -> int:
updated = 0
products = db.scalars(
select(Product)
.where(Product.device_no == "")
.order_by(Product.attendance_point_name, Product.project_no, Product.product_name, Product.process_name)
).all()
for product in products:
next_process_name = normalized(product.process_name)
if next_process_name is None or next_process_name == product.process_name:
continue
next_key = {
"attendance_point_name": product.attendance_point_name,
"project_no": product.project_no,
"product_name": product.product_name,
"device_no": product.device_no,
"process_name": next_process_name,
}
if db.get(Product, next_key) is not None:
skipped.append(
f"products: {product.attendance_point_name} / {product.project_no} / "
f"{product.product_name} / {product.process_name}"
)
continue
qr_conflict = db.get(DeviceQRCode, {
"attendance_point_name": product.attendance_point_name,
"device_no": product.product_name,
"process_name": next_process_name,
})
if qr_conflict is not None:
skipped.append(
f"products: {product.attendance_point_name} / {product.project_no} / "
f"{product.product_name} / {product.process_name} (二维码冲突)"
)
continue
old_process_name = product.process_name
point_name = product.attendance_point_name
product_name = product.product_name
for qr in db.scalars(select(DeviceQRCode).where(
DeviceQRCode.attendance_point_name == point_name,
DeviceQRCode.device_no == product_name,
DeviceQRCode.process_name == old_process_name,
)).all():
qr.process_name = next_process_name
for device in db.scalars(select(WorkSessionDevice).where(
WorkSessionDevice.attendance_point_name == point_name,
WorkSessionDevice.device_no == product_name,
WorkSessionDevice.process_name == old_process_name,
)).all():
device.process_name = next_process_name
for item in db.scalars(select(ProductionReportItem).where(
ProductionReportItem.attendance_point_name == point_name,
ProductionReportItem.product_name == product_name,
ProductionReportItem.process_name == old_process_name,
)).all():
item.process_name = next_process_name
for feedback in db.scalars(select(MoldLockFeedback).where(
MoldLockFeedback.attendance_point_name == point_name,
MoldLockFeedback.mold_name == product_name,
MoldLockFeedback.process_name == old_process_name,
)).all():
feedback.process_name = next_process_name
product.process_name = next_process_name
updated += 1
return updated
def normalize_remaining_references(db, skipped: list[str]) -> dict[str, int]:
counts = {
"device_qrcodes": 0,
"work_session_devices": 0,
"production_report_items": 0,
"mold_lock_feedbacks": 0,
}
for qr in db.scalars(select(DeviceQRCode)).all():
next_process_name = normalized(qr.process_name)
if next_process_name is None or next_process_name == qr.process_name:
continue
next_key = {
"attendance_point_name": qr.attendance_point_name,
"device_no": qr.device_no,
"process_name": next_process_name,
}
if db.get(DeviceQRCode, next_key) is not None:
skipped.append(f"device_qrcodes: {qr.attendance_point_name} / {qr.device_no} / {qr.process_name}")
continue
qr.process_name = next_process_name
counts["device_qrcodes"] += 1
for device in db.scalars(select(WorkSessionDevice)).all():
next_process_name = normalized(device.process_name)
if next_process_name is None or next_process_name == device.process_name:
continue
device.process_name = next_process_name
counts["work_session_devices"] += 1
for item in db.scalars(select(ProductionReportItem)).all():
next_process_name = normalized(item.process_name)
if next_process_name is None or next_process_name == item.process_name:
continue
item.process_name = next_process_name
counts["production_report_items"] += 1
for feedback in db.scalars(select(MoldLockFeedback)).all():
next_process_name = normalized(feedback.process_name)
if next_process_name is None or next_process_name == feedback.process_name:
continue
feedback.process_name = next_process_name
counts["mold_lock_feedbacks"] += 1
return counts
def main() -> None:
skipped: list[str] = []
with SessionLocal() as db:
product_updates = normalize_products(db, skipped)
reference_updates = normalize_remaining_references(db, skipped)
db.commit()
reference_total = sum(reference_updates.values())
print(
"numeric process names migrated, "
f"products={product_updates}, references={reference_total}, skipped={len(skipped)}"
)
for name, count in reference_updates.items():
print(f"{name}={count}")
for item in skipped[:20]:
print(f"skipped conflict: {item}")
if len(skipped) > 20:
print(f"... and {len(skipped) - 20} more")
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from sqlalchemy import text # noqa: E402
from app.database import SessionLocal # noqa: E402
def main() -> None:
with SessionLocal() as db:
db.execute(
text(
"""
CREATE TABLE IF NOT EXISTS person_roles (
phone VARCHAR(20) COLLATE utf8mb4_unicode_ci NOT NULL,
role ENUM('worker','admin','manager') COLLATE utf8mb4_unicode_ci NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (phone, role),
CONSTRAINT fk_person_roles_person
FOREIGN KEY (phone) REFERENCES personnel(phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
)
)
columns = db.execute(text("SHOW COLUMNS FROM personnel LIKE 'role'")).all()
if columns:
db.execute(
text(
"""
INSERT IGNORE INTO person_roles (phone, role, created_at)
SELECT phone, role, COALESCE(created_at, CURRENT_TIMESTAMP)
FROM personnel
"""
)
)
db.execute(text("ALTER TABLE personnel DROP COLUMN role"))
db.commit()
print("personnel migrated to one phone/name with multiple roles")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
from pathlib import Path
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
def _column_exists(conn, table_name: str, column_name: str) -> bool:
return bool(conn.execute(
text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""),
{"table_name": table_name, "column_name": column_name},
).scalar())
def main() -> None:
with engine.begin() as conn:
if not _column_exists(conn, "products", "process_unit_price_yuan"):
conn.execute(text("""
ALTER TABLE products
ADD COLUMN process_unit_price_yuan DECIMAL(12,4) NOT NULL DEFAULT 0 AFTER operator_count
"""))
if not _column_exists(conn, "production_report_items", "process_unit_price_yuan"):
conn.execute(text("""
ALTER TABLE production_report_items
ADD COLUMN process_unit_price_yuan DECIMAL(12,4) NOT NULL DEFAULT 0 AFTER operator_count
"""))
conn.execute(text("""
UPDATE production_report_items item
JOIN products product
ON product.attendance_point_name = item.attendance_point_name
AND product.project_no = item.project_no
AND product.product_name = item.product_name
AND product.process_name = IFNULL(item.process_name, '')
AND product.device_no = ''
SET item.process_unit_price_yuan = product.process_unit_price_yuan
WHERE item.process_unit_price_yuan IS NULL OR item.process_unit_price_yuan = 0
"""))
print("process unit price columns migrated")
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
from pathlib import Path
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
FIELDS = [
("product_net_weight_kg", "DECIMAL(12,4) NULL", "material_name"),
("product_gross_weight_kg", "DECIMAL(12,4) NULL", "product_net_weight_kg"),
("scrap_loss_rate", "DECIMAL(10,6) NULL", "product_gross_weight_kg"),
("waste_price_yuan_per_kg", "DECIMAL(12,4) NULL", "scrap_loss_rate"),
]
def column_exists(conn, column_name: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'products'
AND column_name = :column_name
"""), {"column_name": column_name}).scalar())
def main() -> None:
with engine.begin() as conn:
for column_name, column_type, after_column in FIELDS:
if not column_exists(conn, column_name):
conn.execute(text(
f"ALTER TABLE products ADD COLUMN {column_name} {column_type} AFTER {after_column}"
))
print("product ERP auxiliary fields migrated")
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from sqlalchemy import text # noqa: E402
from app.database import SessionLocal # noqa: E402
def main() -> None:
with SessionLocal() as db:
db.execute(text("UPDATE products SET process_name = '' WHERE process_name IS NULL"))
db.execute(text("ALTER TABLE products DROP PRIMARY KEY"))
db.execute(text("ALTER TABLE products MODIFY device_no VARCHAR(64) NOT NULL"))
db.execute(text("ALTER TABLE products MODIFY process_name VARCHAR(128) NOT NULL"))
db.execute(
text(
"ALTER TABLE products "
"ADD PRIMARY KEY (project_no, product_name, device_no, process_name)"
)
)
db.commit()
print("products primary key migrated to project_no + product_name + device_no + process_name")
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
from pathlib import Path
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
def column_exists(conn, column_name: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'products'
AND column_name = :column_name
"""), {"column_name": column_name}).scalar())
def main() -> None:
with engine.begin() as conn:
if not column_exists(conn, "supplier"):
conn.execute(text(
"ALTER TABLE products ADD COLUMN supplier VARCHAR(255) NULL AFTER material_name"
))
print("product supplier field migrated")
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS device_qrcode_batch_tasks (
id BIGINT NOT NULL AUTO_INCREMENT,
file_name VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
item_count INT NOT NULL DEFAULT 0,
completed_count INT NOT NULL DEFAULT 0,
failed_count INT NOT NULL DEFAULT 0,
items_json JSON NOT NULL,
zip_url VARCHAR(500) NULL,
error_message VARCHAR(1000) NULL,
created_by VARCHAR(20) NOT NULL,
started_at DATETIME NULL,
finished_at DATETIME NULL,
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_device_qrcode_batch_tasks_creator (created_by, created_at),
KEY idx_device_qrcode_batch_tasks_status (status, created_at),
CONSTRAINT fk_device_qrcode_batch_tasks_creator
FOREIGN KEY (created_by) REFERENCES personnel (phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
)
)
print("qrcode batch tasks migrated")
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS reconciliation_ledger_entries (
year INT NOT NULL,
month INT NOT NULL,
product_name VARCHAR(255) NOT NULL,
reconciled_good_qty DECIMAL(14, 2) NOT NULL DEFAULT 0,
return_qty DECIMAL(14, 2) NOT NULL DEFAULT 0,
updated_by VARCHAR(20) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (year, month, product_name),
INDEX idx_reconciliation_product (product_name),
INDEX idx_reconciliation_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
print("reconciliation ledger migrated")
if __name__ == "__main__":
main()
@@ -0,0 +1,34 @@
from pathlib import Path
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
def column_exists(conn, column_name: str) -> bool:
return bool(conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'reconciliation_ledger_entries'
AND column_name = :column_name
"""), {"column_name": column_name}).scalar())
def main() -> None:
with engine.begin() as conn:
if not column_exists(conn, "return_qty"):
conn.execute(text(
"ALTER TABLE reconciliation_ledger_entries "
"ADD COLUMN return_qty DECIMAL(14, 2) NOT NULL DEFAULT 0 "
"AFTER reconciled_good_qty"
))
print("reconciliation return quantity migrated")
if __name__ == "__main__":
main()
@@ -0,0 +1,30 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
exists = conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'production_report_items'
AND column_name = 'raw_material_batch_no'
""")).scalar()
if not exists:
conn.execute(text("""
ALTER TABLE production_report_items
ADD COLUMN raw_material_batch_no VARCHAR(128) NULL AFTER material_name
"""))
print("report item raw material batch migrated")
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
columns = conn.execute(
text(
"SELECT COUNT(*) FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = 'production_report_items' "
"AND COLUMN_NAME = 'started_at'"
)
).scalar()
if columns:
print("production_report_items.started_at already exists")
return
conn.execute(
text(
"ALTER TABLE production_report_items "
"ADD COLUMN started_at DATETIME NULL AFTER allocated_minutes"
)
)
print("added production_report_items.started_at")
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
from pathlib import Path
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
def _column_exists(conn, table_name: str, column_name: str) -> bool:
return bool(conn.execute(
text("""
SELECT COUNT(1)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = :table_name
AND column_name = :column_name
"""),
{"table_name": table_name, "column_name": column_name},
).scalar())
def main() -> None:
with engine.begin() as conn:
if not _column_exists(conn, "production_reports", "review_remark"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN review_remark VARCHAR(500) NULL AFTER reject_reason
"""))
print("report review remark column migrated")
if __name__ == "__main__":
main()
+31
View File
@@ -0,0 +1,31 @@
from sqlalchemy import text
from app.database import SessionLocal
def column_exists(db, table_name: str, column_name: str) -> bool:
return bool(db.execute(text(f"SHOW COLUMNS FROM {table_name} LIKE :column"), {"column": column_name}).first())
def main() -> None:
with SessionLocal() as db:
if not column_exists(db, "personnel", "is_temporary"):
db.execute(
text(
"ALTER TABLE personnel "
"ADD COLUMN is_temporary TINYINT(1) NOT NULL DEFAULT 0 AFTER name"
)
)
if not column_exists(db, "personnel", "temporary_expires_at"):
db.execute(
text(
"ALTER TABLE personnel "
"ADD COLUMN temporary_expires_at DATETIME NULL AFTER is_temporary"
)
)
db.commit()
print("temporary worker columns migrated")
if __name__ == "__main__":
main()
+46
View File
@@ -0,0 +1,46 @@
from sqlalchemy import inspect, text
from app.database import engine
def column_exists(inspector, table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def index_exists(inspector, table_name: str, index_name: str) -> bool:
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
def main() -> None:
with engine.begin() as conn:
inspector = inspect(conn)
if not column_exists(inspector, "production_reports", "is_voided"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN is_voided TINYINT(1) NOT NULL DEFAULT 0 AFTER multi_person_source_report_id
"""))
if not column_exists(inspector, "production_reports", "voided_at"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN voided_at DATETIME NULL AFTER is_voided
"""))
if not column_exists(inspector, "production_reports", "voided_by"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN voided_by VARCHAR(20) NULL AFTER voided_at
"""))
if not column_exists(inspector, "production_reports", "unvoid_deadline_at"):
conn.execute(text("""
ALTER TABLE production_reports
ADD COLUMN unvoid_deadline_at DATETIME NULL AFTER voided_by
"""))
inspector = inspect(conn)
if not index_exists(inspector, "production_reports", "idx_reports_voided"):
conn.execute(text("""
CREATE INDEX idx_reports_voided ON production_reports (is_voided, voided_at)
"""))
print("voided report columns migrated")
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
from pathlib import Path
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
from app.services.work_schedule import DEFAULT_WORK_SCHEDULE # noqa: E402
def main() -> None:
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS work_schedules (
id INT NOT NULL PRIMARY KEY,
day_start VARCHAR(5) NOT NULL,
day_end VARCHAR(5) NOT NULL,
lunch_start VARCHAR(5) NOT NULL,
lunch_end VARCHAR(5) NOT NULL,
dinner_start VARCHAR(5) NOT NULL,
dinner_end VARCHAR(5) NOT NULL,
overtime_start VARCHAR(5) NOT NULL,
overtime_end VARCHAR(5) NOT NULL,
night_start VARCHAR(5) NOT NULL,
night_end VARCHAR(5) NOT NULL,
attendance_latitude DECIMAL(10, 7) NULL,
attendance_longitude DECIMAL(10, 7) NULL,
attendance_radius_meters INT NOT NULL DEFAULT 500,
auto_submit_hours DECIMAL(10, 2) NOT NULL DEFAULT 15,
updated_by VARCHAR(20) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
exists = conn.execute(
text("SELECT COUNT(*) FROM work_schedules WHERE id = 1")
).scalar_one()
if not exists:
conn.execute(
text("""
INSERT INTO work_schedules (
id,
day_start,
day_end,
lunch_start,
lunch_end,
dinner_start,
dinner_end,
overtime_start,
overtime_end,
night_start,
night_end
) VALUES (
1,
:day_start,
:day_end,
:lunch_start,
:lunch_end,
:dinner_start,
:dinner_end,
:overtime_start,
:overtime_end,
:night_start,
:night_end
)
"""),
DEFAULT_WORK_SCHEDULE,
)
print("work_schedules migrated")
if __name__ == "__main__":
main()
@@ -0,0 +1,46 @@
from pathlib import Path
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
def column_exists(conn, column_name: str) -> bool:
return bool(conn.execute(
text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'work_schedules'
AND column_name = :column_name
"""),
{"column_name": column_name},
).scalar_one())
def main() -> None:
with engine.begin() as conn:
if not column_exists(conn, "attendance_latitude"):
conn.execute(text("""
ALTER TABLE work_schedules
ADD COLUMN attendance_latitude DECIMAL(10, 7) NULL AFTER night_end
"""))
if not column_exists(conn, "attendance_longitude"):
conn.execute(text("""
ALTER TABLE work_schedules
ADD COLUMN attendance_longitude DECIMAL(10, 7) NULL AFTER attendance_latitude
"""))
if not column_exists(conn, "attendance_radius_meters"):
conn.execute(text("""
ALTER TABLE work_schedules
ADD COLUMN attendance_radius_meters INT NOT NULL DEFAULT 500 AFTER attendance_longitude
"""))
print("work_schedules attendance location migrated")
if __name__ == "__main__":
main()
@@ -0,0 +1,41 @@
from pathlib import Path
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
def column_exists(conn, column_name: str) -> bool:
return bool(conn.execute(
text("""
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'work_schedules'
AND column_name = :column_name
"""),
{"column_name": column_name},
).scalar_one())
def main() -> None:
with engine.begin() as conn:
if not column_exists(conn, "auto_submit_hours"):
conn.execute(text("""
ALTER TABLE work_schedules
ADD COLUMN auto_submit_hours DECIMAL(10, 2) NOT NULL DEFAULT 15 AFTER attendance_radius_meters
"""))
conn.execute(text("""
UPDATE work_schedules
SET auto_submit_hours = 15
WHERE auto_submit_hours IS NULL OR auto_submit_hours <= 0
"""))
print("work_schedules auto_submit_hours migrated")
if __name__ == "__main__":
main()
@@ -0,0 +1,37 @@
from pathlib import Path
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
def main() -> None:
with engine.begin() as conn:
session_index_exists = conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'work_session_devices'
AND index_name = 'idx_session_devices_session'
""")).scalar()
if not session_index_exists:
conn.execute(text("CREATE INDEX idx_session_devices_session ON work_session_devices (session_id)"))
index_exists = conn.execute(text("""
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'work_session_devices'
AND index_name = 'uk_session_device'
""")).scalar()
if index_exists:
conn.execute(text("ALTER TABLE work_session_devices DROP INDEX uk_session_device"))
print("work session repeated molds migrated")
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
from argparse import ArgumentParser
from pathlib import Path
import sys
from sqlalchemy import select
from sqlalchemy.orm import selectinload
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal # noqa: E402
from app.models import ProductionReport, WorkSession # noqa: E402
from app.services.metrics import calculate_report_metrics # noqa: E402
from app.services.work_schedule import get_work_schedule_config # noqa: E402
def apply_metrics(report: ProductionReport, schedule) -> None:
metrics = calculate_report_metrics(
report.start_at,
report.end_at,
list(report.items),
devices=report.session.devices if report.session else None,
schedule=schedule,
)
report.duration_minutes = metrics["duration_minutes"]
report.effective_minutes = metrics["effective_minutes"]
report.total_good_qty = metrics["total_good_qty"]
report.total_output_qty = metrics["total_output_qty"]
report.actual_beat = metrics["actual_beat"]
report.standard_beat = metrics["standard_beat"]
report.expected_workload = metrics["expected_workload"]
report.pace_rate = metrics["pace_rate"]
report.workload_rate = metrics["workload_rate"]
def main() -> None:
parser = ArgumentParser(description="Recalculate production report metrics.")
parser.add_argument("--report-id", type=int, help="只重算指定报工 ID")
args = parser.parse_args()
query = select(ProductionReport).options(
selectinload(ProductionReport.items),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
)
if args.report_id:
query = query.where(ProductionReport.id == args.report_id)
with SessionLocal() as db:
reports = db.scalars(query).all()
for report in reports:
schedule = get_work_schedule_config(db, report.attendance_point_name)
apply_metrics(report, schedule)
db.commit()
print(f"recalculated reports: {len(reports)}")
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.database import SessionLocal # noqa: E402
from app.models import PersonRole, Personnel, Product, Role # noqa: E402
PEOPLE = [
{"phone": "13800000001", "name": "张三", "role": Role.worker},
{"phone": "13800000002", "name": "李四", "role": Role.admin},
{"phone": "13800000003", "name": "王经理", "role": Role.manager},
]
PRODUCTS = [
{
"project_no": "W02",
"profile_no": "XUS-157-6001-003",
"product_name": "LAC-56011041后防撞梁左吸能盒端板",
"material_code": "110101002022",
"material_name": "LAC-56011041-后防撞梁左吸能盒端板-半成品",
"device_no": "20#",
"process_name": "1序",
"stamping_method": "切边冲孔",
"operator_count": 1,
"standard_beat": 10.984,
"standard_workload": 2622,
},
{
"project_no": "W02",
"profile_no": "XUS-157-6009-001",
"product_name": "LAC-56010050/0014后防撞梁左连接板",
"material_code": "110501000186",
"material_name": "LAC-56010050-后防撞梁左连接板-半成品",
"device_no": "18#",
"process_name": "1序",
"stamping_method": "切边冲孔",
"operator_count": 1,
"standard_beat": 5.112,
"standard_workload": 5634,
},
]
def main() -> None:
with SessionLocal() as db:
for item in PEOPLE:
person = db.get(Personnel, item["phone"]) or Personnel(phone=item["phone"])
person.name = item["name"]
db.add(person)
if db.get(PersonRole, {"phone": item["phone"], "role": item["role"]}) is None:
db.add(PersonRole(phone=item["phone"], role=item["role"]))
for item in PRODUCTS:
product = db.get(
Product,
{
"project_no": item["project_no"],
"product_name": item["product_name"],
"device_no": item["device_no"],
"process_name": item["process_name"],
},
) or Product(
project_no=item["project_no"],
product_name=item["product_name"],
device_no=item["device_no"],
process_name=item["process_name"],
)
for key, value in item.items():
setattr(product, key, value)
db.add(product)
db.commit()
print("demo data seeded")
if __name__ == "__main__":
main()