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