aps-agent/tests/golden/test_schedule_exports.py

359 lines
15 KiB
Python
Raw Permalink Normal View History

# ============================================================
# 康尼柔性场景·调度方案导出契约(阶段 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 = "<mem>"):
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 # 保证持久化契约已随路由接线