aps-agent/tests/golden/test_schedule_exports.py

359 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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