# ============================================================ # 康尼柔性场景·调度方案导出契约(阶段 A:工单/设备角度 xlsx) # ============================================================ from __future__ import annotations import copy import time from io import BytesIO from urllib.parse import quote from fastapi.testclient import TestClient from openpyxl import load_workbook from server.aps_domain.reports import ( build_schedule_equipment_export, build_schedule_order_export, persist_report_xlsx, ) from server.engines import PoolEngine from server.state.seed import seed_world class _MemStore: def __init__(self, data: dict, path: str = ""): self.data = data self.path = path def next_id(self, kind: str) -> int: key = f"_c_{kind}" self.data[key] = self.data.get(key, 100) + 1 return self.data[key] def save(self): pass def _scheduled_world() -> tuple[dict, int]: world = seed_world() counters: dict[str, int] = {} def next_id(kind: str) -> int: counters[kind] = counters.get(kind, 0) + 1 return counters[kind] result = PoolEngine().solve(world, next_id, sort_mode="BOTTLENECK") return world, int(result["versionId"]) def _multi_version_world() -> tuple[dict, dict, dict]: world = seed_world() orders = [o for o in world["flexOrders"] if o["status"] in ("RELEASED", "CREATED")] for i, order in enumerate(orders): order["requiredSkillLevel"] = "L4" if i % 2 == 0 else "L2" counters: dict[str, int] = {} def next_id(kind: str) -> int: counters[kind] = counters.get(kind, 0) + 1 return counters[kind] first = PoolEngine().solve(world, next_id, sort_mode="ASC") latest = PoolEngine().solve(world, next_id, sort_mode="SKILL_FIRST") return world, first, latest def _sheet(payload: bytes, name: str): wb = load_workbook(BytesIO(payload), data_only=False) return wb[name] def test_schedule_order_export_has_chinese_headers_and_deterministic_rows(): world, vid = _scheduled_world() before = copy.deepcopy(world) report = build_schedule_order_export(world) assert report["reportId"] and report["xlsxBytes"] assert report["format"] == "xlsx" assert report["filename"].startswith("排产工单方案_") and report["filename"].endswith(".xlsx") assert report["snapshot"]["versionId"] == vid ws = _sheet(report["xlsxBytes"], "排产工单") headers = [ws.cell(4, c).value for c in range(1, 13)] assert headers == ["订单号", "工单号", "产品编码", "工序编码", "工序名称", "资源编码", "资源名称", "计划开始", "计划结束", "状态", "预计数量", "实际数量"] wos = [w for w in world["flexWorkOrders"] if w["versionId"] == vid] assert wos assert report["snapshot"]["orderCount"] == 10 assert report["snapshot"]["woCount"] == len(wos) assert ws.max_row == 4 + len(wos) assert ws.freeze_panes == "A5" actual_order = [ (ws.cell(row, 1).value, ws.cell(row, 2).value, ws.cell(row, 4).value, ws.cell(row, 8).value) for row in range(5, ws.max_row + 1) ] expected_order = [ (w.get("flexOrderNo") or w.get("orderNo"), w.get("orderNo"), w.get("operationCode"), w.get("plannedStartTime")) for w in sorted( wos, key=lambda item: ( str(item.get("flexOrderNo") or ""), int(item.get("seq") or 0), str(item.get("plannedStartTime") or ""), str(item.get("orderNo") or ""), ), ) ] assert actual_order == expected_order # 确定性:同一次排产重复导出,工作簿字节一致 again = build_schedule_order_export(world) assert again["xlsxBytes"] == report["xlsxBytes"] # 行按订单号 + 序号确定性排序;未报工实际数量为 0 first = [ws.cell(5, c).value for c in (1, 2, 4)] assert first[0] and first[1] and first[2] assert ws.cell(5, 12).value == 0 assert world == before def test_schedule_equipment_export_has_load_and_utilization_contract(): world, vid = _scheduled_world() before = copy.deepcopy(world) report = build_schedule_equipment_export(world) assert report["reportId"] and report["xlsxBytes"] assert report["filename"].startswith("排产设备方案_") and report["filename"].endswith(".xlsx") assert report["snapshot"]["versionId"] == vid ws = _sheet(report["xlsxBytes"], "设备负荷") headers = [ws.cell(4, c).value for c in range(1, 9)] assert headers == ["设备编码", "设备名称", "工序编码", "工序名称", "占用开始", "占用结束", "负荷分钟", "利用率%"] wos = [w for w in world["flexWorkOrders"] if w["versionId"] == vid] assert ws.max_row == 4 + len(wos) assert report["snapshot"]["woCount"] == len(wos) expected_load: dict[str, float] = {} for w in wos: duration = (float(w.get("changeoverMin") or 0) + float(w.get("moveMin") or 0) + float(w.get("runMin") or 0)) expected_load[w["equipmentCode"]] = expected_load.get(w["equipmentCode"], 0.0) + duration util_by_eq: dict[str, float] = {} seen_code = None for row_index in range(5, ws.max_row + 1): code = str(ws.cell(row_index, 1).value) seen_code = seen_code or code minutes = float(ws.cell(row_index, 7).value) utilization = float(ws.cell(row_index, 8).value) assert minutes > 0 assert utilization >= 0 if code in util_by_eq: assert util_by_eq[code] == utilization else: util_by_eq[code] = utilization assert seen_code is not None actual_equipment_order = [ (ws.cell(row, 1).value, ws.cell(row, 5).value, ws.cell(row, 3).value) for row in range(5, ws.max_row + 1) ] assert actual_equipment_order == sorted(actual_equipment_order) load_sum = sum(float(ws.cell(row_index, 7).value) for row_index in range(5, ws.max_row + 1) if str(ws.cell(row_index, 1).value) == seen_code) assert abs(load_sum - expected_load[seen_code]) < 0.11 workbook_load = round(sum(float(ws.cell(row, 7).value) for row in range(5, ws.max_row + 1)), 1) assert report["snapshot"]["loadMinutes"] == workbook_load assert report["snapshot"]["equipmentCount"] == len(expected_load) # 确定性:重复导出字节一致 again = build_schedule_equipment_export(world) assert again["xlsxBytes"] == report["xlsxBytes"] assert world == before def test_schedule_xlsx_exports_are_stable_across_zip_timestamp_ticks(): world, _ = _scheduled_world() before = copy.deepcopy(world) order = build_schedule_order_export(world)["xlsxBytes"] equipment = build_schedule_equipment_export(world)["xlsxBytes"] time.sleep(2.1) assert build_schedule_order_export(world)["xlsxBytes"] == order assert build_schedule_equipment_export(world)["xlsxBytes"] == equipment assert world == before def test_schedule_exports_follow_latest_adopted_version(): world = seed_world() orders = [o for o in world["flexOrders"] if o["status"] in ("RELEASED", "CREATED")] assert len(orders) == 10 for i, order in enumerate(orders): order["requiredSkillLevel"] = "L4" if i % 2 == 0 else "L2" counters: dict[str, int] = {} def next_id(kind: str) -> int: counters[kind] = counters.get(kind, 0) + 1 return counters[kind] first = PoolEngine().solve(world, next_id, sort_mode="ASC") adopted = PoolEngine().solve(world, next_id, sort_mode="SKILL_FIRST") assert adopted["versionId"] != first["versionId"] before = copy.deepcopy(world) order_report = build_schedule_order_export(world) equipment_report = build_schedule_equipment_export(world) latest_wos = [w for w in world["flexWorkOrders"] if w["versionId"] == adopted["versionId"]] for report in (order_report, equipment_report): assert report["snapshot"]["versionId"] == adopted["versionId"] assert report["snapshot"]["versionNo"] == adopted["versionNo"] assert report["snapshot"]["sortMode"] == "SKILL_FIRST" assert report["snapshot"]["woCount"] == len(latest_wos) assert order_report["snapshot"]["orderCount"] == 10 assert world == before def test_schedule_exports_bind_explicit_non_latest_version(): world, first, latest = _multi_version_world() assert first["versionId"] != latest["versionId"] before = copy.deepcopy(world) first_wos = [ w for w in world["flexWorkOrders"] if w["versionId"] == first["versionId"] ] latest_wos = [ w for w in world["flexWorkOrders"] if w["versionId"] == latest["versionId"] ] assert { (w["orderNo"], w["plannedStartTime"], w["equipmentCode"]) for w in first_wos } != { (w["orderNo"], w["plannedStartTime"], w["equipmentCode"]) for w in latest_wos } order_report = build_schedule_order_export(world, version_id=first["versionId"]) equipment_report = build_schedule_equipment_export(world, version_id=first["versionId"]) for report in (order_report, equipment_report): assert report["snapshot"]["versionId"] == first["versionId"] assert report["snapshot"]["versionNo"] == first["versionNo"] assert report["snapshot"]["sortMode"] == "ASC" assert report["snapshot"]["woCount"] == len(first_wos) assert report["filename"].endswith(f"_{first['versionNo']}.xlsx") order_ws = _sheet(order_report["xlsxBytes"], "排产工单") actual_order_rows = [ (order_ws.cell(row, 2).value, order_ws.cell(row, 8).value) for row in range(5, order_ws.max_row + 1) ] expected_order_rows = [ (w.get("orderNo"), w.get("plannedStartTime")) for w in sorted( first_wos, key=lambda item: ( str(item.get("flexOrderNo") or ""), int(item.get("seq") or 0), str(item.get("plannedStartTime") or ""), str(item.get("orderNo") or ""), ), ) ] assert actual_order_rows == expected_order_rows equipment_ws = _sheet(equipment_report["xlsxBytes"], "设备负荷") actual_equipment_rows = [ (equipment_ws.cell(row, 1).value, equipment_ws.cell(row, 5).value, equipment_ws.cell(row, 3).value) for row in range(5, equipment_ws.max_row + 1) ] expected_equipment_rows = sorted( (w.get("equipmentCode"), w.get("plannedStartTime"), w.get("operationCode")) for w in first_wos ) assert actual_equipment_rows == expected_equipment_rows assert build_schedule_order_export( world, version_id=first["versionId"], )["xlsxBytes"] == order_report["xlsxBytes"] assert build_schedule_equipment_export( world, version_id=first["versionId"], )["xlsxBytes"] == equipment_report["xlsxBytes"] assert world == before def test_schedule_export_api_binds_version_and_rejects_invalid_track(monkeypatch, tmp_path): import server.gateway.app as gateway_module import server.state.store as state_store from tests.auth_provider import install_test_auth install_test_auth(monkeypatch, "tenant-schedule-version-export") world, first, latest = _multi_version_world() fixed_version_id = 900_001 world["scheduleVersions"].append({ "id": fixed_version_id, "versionNo": "V-FIXED-001", "status": "DRAFT", }) before = copy.deepcopy(world) store = _MemStore(world, path=str(tmp_path / "world.json")) monkeypatch.setattr(gateway_module, "get_store", lambda: store) monkeypatch.setattr(state_store, "get_store", lambda: store) client = TestClient(gateway_module.create_app()) login = client.post("/api/auth/login", json={"username": "planner", "password": "test"}) assert login.status_code == 200, login.text for report_type, sheet_name in ( ("schedule-order", "排产工单"), ("schedule-equipment", "设备负荷"), ): explicit = client.get( f"/api/reports/{report_type}", params={"versionId": first["versionId"]}, ) assert explicit.status_code == 200, explicit.text assert first["versionNo"] in explicit.headers["content-disposition"] assert _sheet(explicit.content, sheet_name)["A2"].value.startswith( f"版本 {first['versionNo']}" ) default = client.get(f"/api/reports/{report_type}") assert default.status_code == 200, default.text assert latest["versionNo"] in default.headers["content-disposition"] assert _sheet(default.content, sheet_name)["A2"].value.startswith( f"版本 {latest['versionNo']}" ) missing = client.get( f"/api/reports/{report_type}", params={"versionId": 900_002}, ) assert missing.status_code == 404 assert "柔性轨排产版本 900002 不存在" in missing.json()["detail"] wrong_track = client.get( f"/api/reports/{report_type}", params={"versionId": fixed_version_id}, ) assert wrong_track.status_code == 404 assert "属于固定轨" in wrong_track.json()["detail"] assert world == before def test_schedule_exports_persist_and_gateway_return_xlsx(monkeypatch, tmp_path): import server.gateway.app as gateway_module import server.state.store as state_store from tests.auth_provider import install_test_auth install_test_auth(monkeypatch, "tenant-schedule-export") world, _ = _scheduled_world() store = _MemStore(world, path=str(tmp_path / "world.json")) monkeypatch.setattr(gateway_module, "get_store", lambda: store) monkeypatch.setattr(state_store, "get_store", lambda: store) client = TestClient(gateway_module.create_app()) login = client.post("/api/auth/login", json={"username": "planner", "password": "test"}) assert login.status_code == 200, login.text for report_type, prefix, sheet in (("schedule-order", "排产工单方案_", "排产工单"), ("schedule-equipment", "排产设备方案_", "设备负荷")): resp = client.get(f"/api/reports/{report_type}") assert resp.status_code == 200, resp.text assert resp.headers["content-type"].startswith( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") assert quote(prefix) in resp.headers["content-disposition"] assert resp.content[:2] == b"PK" assert _sheet(resp.content, sheet).max_row >= 5 exports = list((tmp_path / "exports").glob("*.xlsx")) assert len(exports) == 2 assert all("__" in path.name for path in exports) assert persist_report_xlsx # 保证持久化契约已随路由接线