aps-agent/tests/golden/test_schedule_template_cont...

432 lines
20 KiB
Python
Raw Permalink Normal View History

"""Versioned contracts for scheduling workbooks and the profile-driven intake template."""
from __future__ import annotations
import copy
import json
from io import BytesIO
import pytest
from fastapi.testclient import TestClient
from openpyxl import load_workbook
from server.aps_domain.report_contracts import (
PLAN_REPORT_CONTRACT,
REPORT_CONTRACTS,
SCHEDULE_BLOCKED_CONTRACT,
SCHEDULE_EQUIPMENT_CONTRACT,
SCHEDULE_ORDER_CONTRACT,
build_contract_template,
contract_digest,
contract_summary,
validate_workbook_contract,
)
from server.aps_domain.reports import (
build_plan_report,
build_schedule_equipment_export,
build_schedule_order_export,
)
from server.engines import PoolEngine
from server.importers.template_workbook import (
build_intake_template,
intake_definition_rows,
template_contract,
validate_intake_template,
)
from server.importers.workbook_profiles import builtin_compatibility_profile
from server.state.seed import empty_world, 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 _world_with_version() -> dict:
world = empty_world()
world["flexScheduleVersions"] = [{
"id": 5, "versionNo": "V-CONTRACT-001", "sortMode": "BOTTLENECK", "solveStatus": "SOLVED",
"demandCount": 2, "admittedDemandCount": 2, "unscheduledDemandCount": 0,
}]
world["flexOrders"] = [
{"orderNo": "SO-C1", "productCode": "P-C1", "quantity": 10, "dueDate": "2031-02-10"},
{"orderNo": "SO-C2", "productCode": "P-C2", "quantity": 5, "dueDate": "2031-02-12"},
]
world["flexVirtualLines"] = [
{"versionId": 5, "orderNo": "SO-C1", "productCode": "P-C1", "quantity": 10,
"plannedStart": "2031-02-06 08:00", "plannedEnd": "2031-02-07 12:00"},
{"versionId": 5, "orderNo": "SO-C2", "productCode": "P-C2", "quantity": 5,
"plannedStart": "2031-02-06 13:00", "plannedEnd": "2031-02-06 18:00"},
]
world["flexWorkOrders"] = [{
"versionId": 5, "flexOrderNo": "SO-C1", "orderNo": "WO-C1-1", "productCode": "P-C1", "seq": 1,
"operationCode": "CUT", "operationName": "下料", "equipmentCode": "EQ-C1",
"equipmentName": "切割机", "zone": "Z1", "moldCode": "M1", "changeoverMin": 5, "moveMin": 2,
"runMin": 30, "plannedStartTime": "2031-02-06 08:00", "plannedEndTime": "2031-02-06 08:37",
"isBottleneck": True, "status": "PLANNED", "quantity": 10,
}]
return world
def _scheduled_world() -> dict:
world = seed_world()
counters: dict[str, int] = {}
def next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
PoolEngine().solve(world, next_id, sort_mode="BOTTLENECK")
return world
def test_report_contracts_declare_every_sheet_and_column():
plan = PLAN_REPORT_CONTRACT.sheets["plan"]
assert plan.name == "工作计划"
assert (plan.title_row, plan.header_row, plan.data_start_row) == (1, 4, 5)
assert plan.freeze_panes == "A5" and plan.auto_filter
assert plan.headers == (
"订单号", "产品编码", "数量", "交期", "序", "工序编码", "工序名称",
"工位/设备", "设备编码", "区域", "模具", "换型(分)", "移栽(分)", "加工(分)",
"计划开始", "计划结束", "瓶颈", "状态",
)
assert PLAN_REPORT_CONTRACT.sheets["overview"].header_row == 12
assert PLAN_REPORT_CONTRACT.sheets["conflicts"].header_row == 3
assert SCHEDULE_ORDER_CONTRACT.sheets["orders"].headers == (
"订单号", "工单号", "产品编码", "工序编码", "工序名称", "资源编码", "资源名称",
"计划开始", "计划结束", "状态", "预计数量", "实际数量",
)
assert SCHEDULE_EQUIPMENT_CONTRACT.sheets["equipment"].headers == (
"设备编码", "设备名称", "工序编码", "工序名称", "占用开始", "占用结束", "负荷分钟", "利用率%",
)
assert SCHEDULE_BLOCKED_CONTRACT.sheets["blocked"].headers == (
"阻断类型", "对象", "严重度", "说明", "修复建议",
)
assert {contract.id for contract in REPORT_CONTRACTS.values()} == {
"schedule-plan.v1", "schedule-order.v1", "schedule-equipment.v1", "schedule-blocked.v1",
}
summary = {row["id"]: row for row in contract_summary()}
assert summary["schedule-plan.v1"]["schemaVersion"] == 1
assert len(contract_digest()) == 64
def test_generated_plan_report_matches_contract_and_is_deterministic():
report = build_plan_report(_world_with_version())
assert report["templateId"] == "schedule-plan.v1"
assert report["templateSchemaVersion"] == 1
assert validate_workbook_contract(report["xlsxBytes"], PLAN_REPORT_CONTRACT) == []
assert build_plan_report(_world_with_version())["xlsxBytes"] == report["xlsxBytes"]
workbook = load_workbook(BytesIO(report["xlsxBytes"]))
assert workbook.sheetnames == ["工作计划", "方案概览", "冲突"]
assert workbook["工作计划"].freeze_panes == "A5"
assert workbook["工作计划"].auto_filter.ref == "A4:R5"
workbook.close()
def test_plan_report_without_work_orders_uses_the_blocked_contract():
world = _world_with_version()
world["flexWorkOrders"] = []
report = build_plan_report(world)
assert report["blocked"] is True
assert report["templateId"] == "schedule-blocked.v1"
assert validate_workbook_contract(report["xlsxBytes"], SCHEDULE_BLOCKED_CONTRACT) == []
assert build_plan_report(world)["xlsxBytes"] == report["xlsxBytes"]
def test_plan_report_without_any_version_keeps_the_declared_empty_state():
report = build_plan_report(empty_world())
assert report["xlsxBytes"] is None and report["filename"] is None
assert report["contract"]["reason"] == "no-flexible-version"
def test_schedule_order_and_equipment_exports_match_their_contracts():
world = _scheduled_world()
before = copy.deepcopy(world)
order = build_schedule_order_export(world)
equipment = build_schedule_equipment_export(world)
assert order["templateId"] == "schedule-order.v1"
assert equipment["templateId"] == "schedule-equipment.v1"
assert validate_workbook_contract(order["xlsxBytes"], SCHEDULE_ORDER_CONTRACT) == []
assert validate_workbook_contract(equipment["xlsxBytes"], SCHEDULE_EQUIPMENT_CONTRACT) == []
assert world == before
def test_contract_validator_reports_missing_sheets_and_wrong_headers():
report = build_plan_report(_world_with_version())
workbook = load_workbook(BytesIO(report["xlsxBytes"]))
workbook.remove(workbook["方案概览"])
workbook["工作计划"].cell(4, 1, "订单号(被改动)")
buffer = BytesIO()
workbook.save(buffer)
workbook.close()
defects = validate_workbook_contract(buffer.getvalue(), PLAN_REPORT_CONTRACT)
assert any("方案概览" in defect for defect in defects)
assert any("订单号" in defect for defect in defects)
def test_intake_template_is_profile_driven_and_round_trips_through_preview():
profile = builtin_compatibility_profile()
template = build_intake_template()
assert template["profileId"] == profile["id"]
assert template["profileDigest"] == profile["profileDigest"]
assert template["intakeSheet"] == "数据采集说明"
assert template["sheetNames"] == [spec["name"] for spec in profile["sheets"].values()]
assert validate_intake_template(template["xlsxBytes"], profile) == []
workbook = load_workbook(BytesIO(template["xlsxBytes"]))
assert workbook.sheetnames[0] == "数据采集说明"
assert workbook.sheetnames[1:] == template["sheetNames"]
assert profile["id"] in str(workbook["数据采集说明"]["A2"].value)
for role, spec in profile["sheets"].items():
sheet = workbook[spec["name"]]
headers = [cell.value for cell in sheet[1]]
assert headers == list(spec["columns"].values()), role
assert sheet.freeze_panes == "A2"
workbook.close()
from server.aps_domain.importers import preview_file
preview = preview_file("blank-template.xlsx", template["xlsxBytes"], empty_world())
assert preview["totalOk"] == 0
assert [batch["sheet"] for batch in preview["batches"]] == template["sheetNames"]
assert all(batch["sourceProfile"] == profile["id"] for batch in preview["batches"])
assert all(batch["profileDigest"] == profile["profileDigest"] for batch in preview["batches"])
assert all(batch["fieldMap"] for batch in preview["batches"])
def test_intake_template_examples_come_from_the_supplied_world_and_stay_deterministic():
profile = builtin_compatibility_profile()
world = seed_world()
first = build_intake_template(include_examples=True, world=world)
second = build_intake_template(include_examples=True, world=world)
assert first["xlsxBytes"] == second["xlsxBytes"]
assert first["sha256"] == second["sha256"]
assert first["rows"]["equipment"] == min(3, len(world["flexEquipment"]))
workbook = load_workbook(BytesIO(first["xlsxBytes"]))
sheet = workbook[profile["sheets"]["equipment"]["name"]]
assert sheet["A2"].value == world["flexEquipment"][0]["code"]
workbook.close()
def test_intake_template_endpoint_requires_auth_and_serves_the_declared_profile(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-intake-template")
store = _MemStore(empty_world(), 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())
unauthenticated = client.get("/api/import/template")
assert unauthenticated.status_code == 401
assert "AUTH_REQUIRED" in unauthenticated.text
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
assert login.status_code == 200, login.text
response = client.get("/api/import/template")
assert response.status_code == 200, response.text
profile = builtin_compatibility_profile()
assert response.headers["x-aps-profile-id"] == profile["id"]
assert response.headers["x-aps-profile-digest"] == profile["profileDigest"]
assert response.headers["x-aps-template-id"] == f"intake-{profile['id']}.v1"
assert "%E9%87%87%E9%9B%86%E6%A8%A1%E6%9D%BF" in response.headers["content-disposition"]
assert response.content[:2] == b"PK"
assert validate_intake_template(response.content, profile) == []
# 条件请求:同一份模板不重复下载,换了配置摘要才重新拉取。
cached = client.get("/api/import/template",
headers={"If-None-Match": response.headers["etag"]})
assert cached.status_code == 304
assert cached.content == b""
assert cached.headers["x-aps-profile-id"] == profile["id"]
assert client.get("/api/import/template",
headers={"If-None-Match": '"stale-digest"'}).status_code == 200
unknown = client.get("/api/import/template", params={"profileId": "not-registered"})
assert unknown.status_code == 404
assert "not-registered" in unknown.json()["detail"]
def _deployment_profile(profile_id: str = "customer-b-planning-v1") -> dict:
"""一份改变了格式ID、表名和列名的部署配置,用于验证模板跟随配置目录。"""
profile = copy.deepcopy(builtin_compatibility_profile())
profile.pop("profileDigest")
profile["id"] = profile_id
profile["label"] = "客户B排产"
for spec in profile["sheets"].values():
spec["name"] = "B-" + spec["name"]
spec["columns"] = {field: "B_" + header for field, header in spec["columns"].items()}
return profile
def _write_profile(directory, profile: dict, filename: str = "deployment.json"):
directory.mkdir(exist_ok=True)
(directory / filename).write_text(json.dumps(profile, ensure_ascii=False), encoding="utf-8")
def test_template_and_contract_follow_the_configured_profile_directory(tmp_path, monkeypatch):
profile = _deployment_profile()
_write_profile(tmp_path, profile)
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
template = build_intake_template()
assert template["profileId"] == profile["id"]
assert template["templateId"] == f"intake-{profile['id']}.v1"
assert template["sheetNames"] == [spec["name"] for spec in profile["sheets"].values()]
assert validate_intake_template(template["xlsxBytes"]) == []
workbook = load_workbook(BytesIO(template["xlsxBytes"]))
equipment = profile["sheets"]["equipment"]
headers = [cell.value for cell in workbook[equipment["name"]][1]]
assert headers == list(equipment["columns"].values())
assert profile["id"] in str(workbook["数据采集说明"]["A2"].value)
workbook.close()
contract = template_contract()
assert contract["profileId"] == profile["id"]
assert [sheet["name"] for sheet in contract["sheets"]] == template["sheetNames"]
assert contract["sheets"][0]["requiredColumns"] == profile["sheets"]["sourceNotes"]["requiredColumns"]
def test_template_default_profile_fails_closed_when_the_directory_is_ambiguous(tmp_path, monkeypatch):
first = _deployment_profile("customer-b-planning-v1")
first["compatibilityDefault"] = False
_write_profile(tmp_path, first, "first.json")
monkeypatch.setenv("APS_WORKBOOK_PROFILE_DIR", str(tmp_path))
# 目录里只有一份配置:即使没有默认标记,也只有这一种格式可用。
assert build_intake_template()["profileId"] == first["id"]
second = _deployment_profile("customer-c-planning-v1")
second["compatibilityDefault"] = False
_write_profile(tmp_path, second, "second.json")
with pytest.raises(ValueError, match="compatibilityDefault"):
build_intake_template()
# 唯一标记为默认的配置胜出,不再依赖目录顺序。
second["compatibilityDefault"] = True
_write_profile(tmp_path, second, "second.json")
assert build_intake_template()["profileId"] == second["id"]
def test_builtin_profile_declares_required_columns_for_every_role():
profile = builtin_compatibility_profile()
for role, spec in profile["sheets"].items():
assert spec["requiredColumns"], role
assert set(spec["requiredColumns"]) <= set(spec["columns"]), role
def test_intake_template_contract_matches_the_authorized_workbook():
"""现场工作簿与模板必须逐表逐列同构,模板才代表真实导入格式。"""
from tests.workbook_acceptance import source_for_test
contract = template_contract()
workbook = load_workbook(source_for_test(), read_only=True)
try:
assert workbook.sheetnames == [sheet["name"] for sheet in contract["sheets"]]
for sheet in contract["sheets"]:
physical = [cell.value for cell in next(workbook[sheet["name"]].iter_rows())]
assert physical[:len(sheet["columns"])] == sheet["columns"], sheet["name"]
finally:
workbook.close()
def test_schedule_export_templates_are_blank_header_skeletons_of_every_contract():
"""排产导出模板必须是合同的表头骨架:无业务行,且生成后即通过合同复核。"""
for report_type, contract in (
("plan", PLAN_REPORT_CONTRACT),
("schedule-order", SCHEDULE_ORDER_CONTRACT),
("schedule-equipment", SCHEDULE_EQUIPMENT_CONTRACT),
("schedule-blocked.v1", SCHEDULE_BLOCKED_CONTRACT),
):
template = build_contract_template(report_type)
assert template["templateId"] == contract.id
assert template["contractDigest"] == contract_digest()
assert template["sheets"][0]["name"] == contract.primary_sheet
assert validate_workbook_contract(template["xlsxBytes"], contract) == []
workbook = load_workbook(BytesIO(template["xlsxBytes"]))
try:
for spec in contract.sheets.values():
sheet = workbook[spec.name]
if not spec.header_row:
continue
headers = [cell.value for cell in sheet[spec.header_row]]
assert headers[:len(spec.headers)] == list(spec.headers), spec.name
assert sheet.cell(spec.data_start_row, 1).value is None, spec.name
finally:
workbook.close()
with pytest.raises(ValueError, match="未注册"):
build_contract_template("schedule-nothing")
def test_schedule_template_endpoint_serves_the_contract_with_conditional_requests(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-template")
store = _MemStore(empty_world(), 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())
unauthenticated = client.get("/api/reports/schedule-template")
assert unauthenticated.status_code == 401
assert "AUTH_REQUIRED" in unauthenticated.text
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
assert login.status_code == 200, login.text
response = client.get("/api/reports/schedule-template")
assert response.status_code == 200, response.text
assert response.headers["x-aps-template-id"] == "schedule-plan.v1"
assert response.headers["x-aps-contract-digest"] == contract_digest()
assert response.content[:2] == b"PK"
assert validate_workbook_contract(response.content, PLAN_REPORT_CONTRACT) == []
cached = client.get("/api/reports/schedule-template",
headers={"If-None-Match": response.headers["etag"]})
assert cached.status_code == 304
assert cached.content == b""
assert cached.headers["x-aps-template-id"] == "schedule-plan.v1"
orders = client.get("/api/reports/schedule-template", params={"reportType": "schedule-order"})
assert orders.status_code == 200, orders.text
assert orders.headers["x-aps-template-id"] == "schedule-order.v1"
assert validate_workbook_contract(orders.content, SCHEDULE_ORDER_CONTRACT) == []
unknown = client.get("/api/reports/schedule-template", params={"reportType": "not-registered"})
assert unknown.status_code == 404
assert "not-registered" in unknown.json()["detail"]
def test_intake_definition_declares_required_state_and_allowed_values():
"""采集说明必须把必填、单位/格式和允许取值讲清楚,且全部来自同一份配置。"""
profile = builtin_compatibility_profile()
rows = intake_definition_rows(profile)
by_field = {(row[0], row[4]): row for row in rows}
order_type = by_field[("orders", "orderType")]
assert order_type[5] == "必填"
assert order_type[7].startswith("订单分类:")
assert "正式订单" in order_type[7] and "SANDBOX" in order_type[7]
assert by_field[("equipment", "availabilityRate")][7] == "数字,不写单位"
assert by_field[("inventory", "expectedArrivalDate")][7].startswith("日期")
assert by_field[("products", "stock")][5] == "可选"
template = build_intake_template()
workbook = load_workbook(BytesIO(template["xlsxBytes"]))
try:
definition = workbook["数据采集说明"]
assert [cell.value for cell in definition[6]] == list(template_contract()["definitionHeaders"])
assert definition.cell(7, 8).value
assert definition.cell(definition.max_row, 8).value
finally:
workbook.close()