1337 lines
55 KiB
Python
1337 lines
55 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import csv
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
import math
|
||
|
|
import shutil
|
||
|
|
from collections import Counter, defaultdict
|
||
|
|
from datetime import datetime, timedelta
|
||
|
|
from itertools import pairwise
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from server.shipyard_synthetic.alternatives import ALTERNATIVE_MODES
|
||
|
|
from server.shipyard_synthetic.config import DATA_DISCLAIMER, GeneratorConfig
|
||
|
|
from server.shipyard_synthetic.constraints import validate_schedule_constraints
|
||
|
|
from server.shipyard_synthetic.exporter import export_bundle
|
||
|
|
from server.shipyard_synthetic.models import DatasetBundle
|
||
|
|
from server.shipyard_synthetic.pipeline import build_synthetic_dataset
|
||
|
|
from server.shipyard_synthetic.rag_skills import RAG_CATEGORIES, SKILL_IDS
|
||
|
|
from server.shipyard_synthetic.registry import TABLE_ORDER, TABLE_SPECS
|
||
|
|
from server.shipyard_synthetic.scenarios import CANONICAL_FILES, EVENT_TYPES
|
||
|
|
from server.shipyard_synthetic.validation import validate_bundle
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||
|
|
REAL_DATA_FILES = (
|
||
|
|
REPO_ROOT / "server/data/sap_mirror.json",
|
||
|
|
REPO_ROOT / "server/data/approvals.json",
|
||
|
|
REPO_ROOT / "server/data/world.json",
|
||
|
|
REPO_ROOT / "server/data/world-proj_712276ba.json",
|
||
|
|
)
|
||
|
|
|
||
|
|
EXPECTED_TABLES = (
|
||
|
|
"organizations",
|
||
|
|
"workshops",
|
||
|
|
"workcenters",
|
||
|
|
"resources",
|
||
|
|
"resource-groups",
|
||
|
|
"calendars",
|
||
|
|
"shifts",
|
||
|
|
"skills",
|
||
|
|
"teams",
|
||
|
|
"employees",
|
||
|
|
"suppliers",
|
||
|
|
"warehouses",
|
||
|
|
"locations",
|
||
|
|
"contracts",
|
||
|
|
"ship-projects",
|
||
|
|
"milestones",
|
||
|
|
"wbs",
|
||
|
|
"blocks",
|
||
|
|
"zones",
|
||
|
|
"work-packages",
|
||
|
|
"drawings",
|
||
|
|
"ebom",
|
||
|
|
"pbom",
|
||
|
|
"mbom",
|
||
|
|
"routings",
|
||
|
|
"routing-operations",
|
||
|
|
"engineering-releases",
|
||
|
|
"materials",
|
||
|
|
"inventory",
|
||
|
|
"inventory-allocations",
|
||
|
|
"substitutes",
|
||
|
|
"planned-receipts",
|
||
|
|
"material-requirements",
|
||
|
|
"kit-readiness",
|
||
|
|
"production-orders",
|
||
|
|
"work-orders",
|
||
|
|
"operations",
|
||
|
|
"purchase-suggestions",
|
||
|
|
"outsource-suggestions",
|
||
|
|
"capacity-demands",
|
||
|
|
"schedule-versions",
|
||
|
|
"schedule-slots",
|
||
|
|
"resource-loads",
|
||
|
|
"conflicts",
|
||
|
|
"kpis",
|
||
|
|
"mes-orders",
|
||
|
|
"operation-reports",
|
||
|
|
"material-issues",
|
||
|
|
"quality-inspections",
|
||
|
|
"nonconformities",
|
||
|
|
"rework-orders",
|
||
|
|
)
|
||
|
|
|
||
|
|
EXPECTED_EVENT_TYPES = (
|
||
|
|
"KEY_STEEL_DELAY_14D",
|
||
|
|
"MAIN_ENGINE_DELAY_30D",
|
||
|
|
"GANTRY_CRANE_FAILURE_7D",
|
||
|
|
"PAINT_SHOP_OUTAGE_5D",
|
||
|
|
"STRONG_WIND_LIFT_SUSPENSION_3D",
|
||
|
|
"OUTSOURCE_CAPACITY_REDUCTION_40PCT",
|
||
|
|
"WELDER_TEAM_SHORTAGE_20PCT",
|
||
|
|
"CRITICAL_SECTION_REWORK",
|
||
|
|
"OWNER_DESIGN_CHANGE",
|
||
|
|
"SISTER_SHIP_PRIORITY_INCREASE",
|
||
|
|
"DOCK_RELEASE_DELAY_10D",
|
||
|
|
"SEA_TRIAL_WINDOW_DELAY",
|
||
|
|
"CABLE_SHORTAGE",
|
||
|
|
"PIPE_PRESSURE_TEST_FAILURE",
|
||
|
|
"URGENT_REPAIR_PROJECT_INSERT",
|
||
|
|
)
|
||
|
|
|
||
|
|
EXPECTED_SKILL_IDS = (
|
||
|
|
"ship-project-master-planning",
|
||
|
|
"ship-wbs-network-planning",
|
||
|
|
"ship-bom-explosion",
|
||
|
|
"ship-mrp-netting",
|
||
|
|
"ship-make-buy-outsource-decision",
|
||
|
|
"ship-material-readiness",
|
||
|
|
"ship-block-production-scheduling",
|
||
|
|
"ship-dock-erection-scheduling",
|
||
|
|
"ship-crane-lift-scheduling",
|
||
|
|
"ship-yard-space-scheduling",
|
||
|
|
"ship-outfitting-zone-scheduling",
|
||
|
|
"ship-workforce-scheduling",
|
||
|
|
"ship-procurement-planning",
|
||
|
|
"ship-outsourcing-planning",
|
||
|
|
"ship-quality-hold-planning",
|
||
|
|
"ship-scenario-simulation",
|
||
|
|
"ship-schedule-repair",
|
||
|
|
"ship-schedule-explanation",
|
||
|
|
"ship-bottleneck-detection",
|
||
|
|
"ship-risk-prediction",
|
||
|
|
)
|
||
|
|
|
||
|
|
EXPECTED_RAG_CATEGORIES = (
|
||
|
|
"船舶建造流程",
|
||
|
|
"分段制造工艺规则",
|
||
|
|
"总组和搭载规则",
|
||
|
|
"船坞使用规则",
|
||
|
|
"龙门吊吊装规则",
|
||
|
|
"分段运输规则",
|
||
|
|
"涂装环境规则",
|
||
|
|
"舾装前移规则",
|
||
|
|
"托盘集配规则",
|
||
|
|
"焊接工艺规则",
|
||
|
|
"无损检测规则",
|
||
|
|
"管系试压规则",
|
||
|
|
"电气调试规则",
|
||
|
|
"船级社检验规则",
|
||
|
|
"供应商交付经验",
|
||
|
|
"姊妹船历史工时",
|
||
|
|
"设备故障经验",
|
||
|
|
"典型延期案例",
|
||
|
|
"排产策略说明",
|
||
|
|
"异常重排处置规则",
|
||
|
|
)
|
||
|
|
|
||
|
|
REQUIRED_SKILL_FIELDS = {
|
||
|
|
"skillId",
|
||
|
|
"name",
|
||
|
|
"description",
|
||
|
|
"inputSchema",
|
||
|
|
"outputSchema",
|
||
|
|
"requiredData",
|
||
|
|
"algorithmCandidates",
|
||
|
|
"hardConstraints",
|
||
|
|
"softConstraints",
|
||
|
|
"fallbackAlgorithm",
|
||
|
|
"timeoutSeconds",
|
||
|
|
"validationRules",
|
||
|
|
"evidenceFields",
|
||
|
|
"version",
|
||
|
|
}
|
||
|
|
|
||
|
|
ZERO_VALIDATION_METRICS = {
|
||
|
|
"foreignKeyErrorCount",
|
||
|
|
"requiredFieldErrorCount",
|
||
|
|
"duplicatePrimaryKeyCount",
|
||
|
|
"missingTableCount",
|
||
|
|
"scaleCountMismatchCount",
|
||
|
|
"syntheticBoundaryViolationCount",
|
||
|
|
"missingMakeRoutingCount",
|
||
|
|
"makeDesignReleaseViolationCount",
|
||
|
|
"missingBuyOutsourceSupplierCount",
|
||
|
|
"sourcingModeViolationCount",
|
||
|
|
"fulfillmentModeViolationCount",
|
||
|
|
"nettingEquationViolationCount",
|
||
|
|
"purchaseTraceViolationCount",
|
||
|
|
"productionRoutingCoverageViolationCount",
|
||
|
|
"productionResourceCoverageViolationCount",
|
||
|
|
"activeOperationCoverageViolationCount",
|
||
|
|
"duplicateOperationScheduleCount",
|
||
|
|
"resourceOverlapCount",
|
||
|
|
"teamOverlapCount",
|
||
|
|
"precedenceViolationCount",
|
||
|
|
"materialReadinessViolationCount",
|
||
|
|
"illegalFrozenChangeCount",
|
||
|
|
"maintenanceCalendarViolationCount",
|
||
|
|
"calendarViolationCount",
|
||
|
|
"resourceTypeViolationCount",
|
||
|
|
"resourceCapabilityViolationCount",
|
||
|
|
"resourceWeightDimensionViolationCount",
|
||
|
|
"teamSkillViolationCount",
|
||
|
|
"teamCrewViolationCount",
|
||
|
|
"teamQualificationViolationCount",
|
||
|
|
"dockConflictCount",
|
||
|
|
"berthConflictCount",
|
||
|
|
"fixtureConflictCount",
|
||
|
|
"paintBoothConflictCount",
|
||
|
|
"liftingCapacityViolationCount",
|
||
|
|
"transportConstraintViolationCount",
|
||
|
|
"weatherWindowViolationCount",
|
||
|
|
"hotWorkDensityViolationCount",
|
||
|
|
"qualityHoldPointViolationCount",
|
||
|
|
"reworkClosureViolationCount",
|
||
|
|
"reinspectionViolationCount",
|
||
|
|
"scheduleVersionEvidenceViolationCount",
|
||
|
|
"unexplainedScheduleDecisionCount",
|
||
|
|
"unresolvedEvidenceRefCount",
|
||
|
|
"skillRegistryViolationCount",
|
||
|
|
"skillSchemaViolationCount",
|
||
|
|
"ragKnowledgeViolationCount",
|
||
|
|
"alternativeViolationCount",
|
||
|
|
"scenarioViolationCount",
|
||
|
|
"infeasibleCoreViolationCount",
|
||
|
|
"executionClosureViolationCount",
|
||
|
|
"supplierCapacityViolationCount",
|
||
|
|
"supplierBlackoutViolationCount",
|
||
|
|
"outsourceTimeChainViolationCount",
|
||
|
|
"outsourceSupplierEligibilityViolationCount",
|
||
|
|
"constraintCoverageViolationCount",
|
||
|
|
"alternativeScheduleViolationCount",
|
||
|
|
"scenarioDiffViolationCount",
|
||
|
|
"unmarkedHardConstraintViolationCount",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _sha256(path: Path) -> str:
|
||
|
|
digest = hashlib.sha256()
|
||
|
|
with path.open("rb") as stream:
|
||
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||
|
|
digest.update(chunk)
|
||
|
|
return digest.hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def _existing_hashes(paths: tuple[Path, ...]) -> dict[str, str]:
|
||
|
|
return {
|
||
|
|
str(path.relative_to(REPO_ROOT)): _sha256(path)
|
||
|
|
for path in paths
|
||
|
|
if path.exists()
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _payload_hashes(root: Path) -> dict[str, str]:
|
||
|
|
return {
|
||
|
|
path.relative_to(root).as_posix(): _sha256(path)
|
||
|
|
for path in sorted(root.rglob("*"))
|
||
|
|
if path.is_file()
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _assert_report_valid(report: dict[str, Any], label: str) -> None:
|
||
|
|
if report.get("valid"):
|
||
|
|
return
|
||
|
|
issues = (
|
||
|
|
report.get("blockingIssues")
|
||
|
|
or report.get("violations")
|
||
|
|
or ["unknown validation failure"]
|
||
|
|
)
|
||
|
|
pytest.fail(
|
||
|
|
f"{label} validation failed:\n"
|
||
|
|
+ json.dumps(list(issues)[:40], ensure_ascii=False, indent=2, default=str),
|
||
|
|
pytrace=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _csv_data_row_count(path: Path) -> int:
|
||
|
|
with path.open("r", encoding="utf-8", newline="") as stream:
|
||
|
|
return max(0, sum(1 for _ in csv.reader(stream)) - 1)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="session", autouse=True)
|
||
|
|
def preserve_real_runtime_data() -> Any:
|
||
|
|
before = _existing_hashes(REAL_DATA_FILES)
|
||
|
|
yield before
|
||
|
|
assert _existing_hashes(REAL_DATA_FILES) == before, (
|
||
|
|
"W69 synthetic tests modified real server/data runtime files"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="session")
|
||
|
|
def small_config() -> GeneratorConfig:
|
||
|
|
return GeneratorConfig.for_scale("small")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="session")
|
||
|
|
def small_bundle(small_config: GeneratorConfig) -> DatasetBundle:
|
||
|
|
return build_synthetic_dataset(small_config, validate=True)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="session")
|
||
|
|
def full_config() -> GeneratorConfig:
|
||
|
|
return GeneratorConfig.for_scale("full")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="session")
|
||
|
|
def full_bundle(full_config: GeneratorConfig) -> DatasetBundle:
|
||
|
|
# Full is intentionally generated once for this entire module.
|
||
|
|
return build_synthetic_dataset(full_config, validate=True)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="session")
|
||
|
|
def deterministic_small_exports(
|
||
|
|
tmp_path_factory: pytest.TempPathFactory,
|
||
|
|
small_config: GeneratorConfig,
|
||
|
|
small_bundle: DatasetBundle,
|
||
|
|
) -> tuple[Path, Path, dict[str, Any], dict[str, Any]]:
|
||
|
|
root = tmp_path_factory.mktemp("bh-syn-deterministic")
|
||
|
|
first = root / "first"
|
||
|
|
second = root / "second"
|
||
|
|
first_manifest = export_bundle(small_bundle, small_config, first)
|
||
|
|
same_bundle = build_synthetic_dataset(small_config, validate=True)
|
||
|
|
second_manifest = export_bundle(same_bundle, small_config, second)
|
||
|
|
return first, second, first_manifest, second_manifest
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="session")
|
||
|
|
def full_export(
|
||
|
|
tmp_path_factory: pytest.TempPathFactory,
|
||
|
|
full_config: GeneratorConfig,
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
) -> tuple[Path, dict[str, Any]]:
|
||
|
|
output = tmp_path_factory.mktemp("bh-syn-full") / "dataset"
|
||
|
|
manifest = export_bundle(full_bundle, full_config, output)
|
||
|
|
return output, manifest
|
||
|
|
|
||
|
|
|
||
|
|
def test_small_pipeline_validation_passes(small_config: GeneratorConfig) -> None:
|
||
|
|
bundle: DatasetBundle | None = None
|
||
|
|
try:
|
||
|
|
bundle = build_synthetic_dataset(small_config, validate=True)
|
||
|
|
except (TypeError, ValueError) as exc:
|
||
|
|
pytest.fail(f"small pipeline validate=True did not pass: {exc}", pytrace=False)
|
||
|
|
assert bundle is not None
|
||
|
|
report = bundle.artifacts["validation-report"]
|
||
|
|
_assert_report_valid(report, "small pipeline")
|
||
|
|
assert report["solveStatus"] == "FEASIBLE"
|
||
|
|
assert report["metrics"]["unmarkedHardConstraintViolationCount"] == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_small_pipeline_exports_complete_deterministic_payload(
|
||
|
|
deterministic_small_exports: tuple[Path, Path, dict[str, Any], dict[str, Any]],
|
||
|
|
) -> None:
|
||
|
|
first, second, first_manifest, second_manifest = deterministic_small_exports
|
||
|
|
assert first_manifest["datasetType"] == "SYNTHETIC"
|
||
|
|
assert first_manifest["organizationScenario"] == "BEIHAI_SHIPYARD_APS"
|
||
|
|
assert first_manifest["csvFileCount"] == 51
|
||
|
|
assert first_manifest["businessDigest"] == second_manifest["businessDigest"]
|
||
|
|
assert _payload_hashes(first) == _payload_hashes(second)
|
||
|
|
assert (first / "README.md").is_file()
|
||
|
|
assert (first / "data-dictionary.md").is_file()
|
||
|
|
assert (first / "world.json").is_file()
|
||
|
|
assert (first / "validation/validation-report.json").is_file()
|
||
|
|
|
||
|
|
|
||
|
|
def test_same_seed_business_digest_is_stable_and_different_seed_changes_it(
|
||
|
|
small_config: GeneratorConfig,
|
||
|
|
small_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
same = build_synthetic_dataset(small_config, validate=False)
|
||
|
|
different = build_synthetic_dataset(
|
||
|
|
GeneratorConfig.for_scale("small", seed=small_config.seed + 1),
|
||
|
|
validate=False,
|
||
|
|
)
|
||
|
|
assert small_bundle.metadata["businessDigest"] == same.metadata["businessDigest"]
|
||
|
|
assert (
|
||
|
|
small_bundle.metadata["businessDigest"] != different.metadata["businessDigest"]
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_full_profile_exact_counts(full_bundle: DatasetBundle) -> None:
|
||
|
|
counts = full_bundle.counts()
|
||
|
|
block_counts = Counter(str(row["blockType"]) for row in full_bundle.rows("blocks"))
|
||
|
|
wbs_task_count = sum(
|
||
|
|
row.get("wbsType") == "TASK" for row in full_bundle.rows("wbs")
|
||
|
|
)
|
||
|
|
assert counts["ship-projects"] == 4
|
||
|
|
assert counts["milestones"] == 72
|
||
|
|
assert block_counts == {"GRAND_BLOCK": 72, "SECTION": 280}
|
||
|
|
assert counts["work-packages"] == 1000
|
||
|
|
assert wbs_task_count == 4000
|
||
|
|
assert counts["materials"] == 12000
|
||
|
|
assert counts["ebom"] + counts["pbom"] + counts["mbom"] == 50000
|
||
|
|
assert counts["routings"] == 220
|
||
|
|
assert counts["production-orders"] == 2400
|
||
|
|
assert counts["operations"] == 12000
|
||
|
|
assert counts["resources"] == 150
|
||
|
|
assert counts["teams"] == 60
|
||
|
|
assert counts["suppliers"] == 40
|
||
|
|
assert counts["purchase-suggestions"] == 550
|
||
|
|
assert counts["outsource-suggestions"] == 140
|
||
|
|
assert counts["schedule-slots"] == 12000
|
||
|
|
assert counts["conflicts"] == 180
|
||
|
|
assert len(full_bundle.artifacts["knowledge-assets"]) == 120
|
||
|
|
|
||
|
|
|
||
|
|
def test_all_51_csv_and_manifest_rows_bytes_and_hashes(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
full_export: tuple[Path, dict[str, Any]],
|
||
|
|
) -> None:
|
||
|
|
output, manifest = full_export
|
||
|
|
assert EXPECTED_TABLES == tuple(TABLE_ORDER)
|
||
|
|
assert len(EXPECTED_TABLES) == 51
|
||
|
|
assert manifest["csvFileCount"] == 51
|
||
|
|
assert manifest["manifestSelfIncluded"] is False
|
||
|
|
assert manifest["generatedAt"] == "2026-08-04T00:00:00+08:00"
|
||
|
|
assert manifest["generatedAtBasis"] == "DETERMINISTIC_BUILD_DATE"
|
||
|
|
assert manifest["planningBaseDate"] == "2026-09-01"
|
||
|
|
assert datetime.fromisoformat(manifest["generatedAt"]).date() < datetime.fromisoformat(
|
||
|
|
manifest["planningBaseDate"]
|
||
|
|
).date()
|
||
|
|
entries = {str(row["path"]): row for row in manifest["files"]}
|
||
|
|
for table_name in EXPECTED_TABLES:
|
||
|
|
relative_path = TABLE_SPECS[table_name].path
|
||
|
|
path = output / relative_path
|
||
|
|
entry = entries[relative_path]
|
||
|
|
assert path.is_file(), relative_path
|
||
|
|
assert entry["rowCount"] == len(full_bundle.rows(table_name))
|
||
|
|
assert _csv_data_row_count(path) == len(full_bundle.rows(table_name))
|
||
|
|
assert entry["bytes"] == path.stat().st_size
|
||
|
|
assert entry["sha256"] == _sha256(path)
|
||
|
|
for relative_path, entry in entries.items():
|
||
|
|
path = output / relative_path
|
||
|
|
assert path.is_file(), relative_path
|
||
|
|
assert entry["bytes"] == path.stat().st_size
|
||
|
|
assert entry["sha256"] == _sha256(path)
|
||
|
|
validation_report = json.loads(
|
||
|
|
(output / "validation/validation-report.json").read_text(encoding="utf-8")
|
||
|
|
)
|
||
|
|
referential = json.loads(
|
||
|
|
(output / "validation/referential-integrity.json").read_text(encoding="utf-8")
|
||
|
|
)
|
||
|
|
constraints = json.loads(
|
||
|
|
(output / "validation/constraint-validation.json").read_text(encoding="utf-8")
|
||
|
|
)
|
||
|
|
quality_markdown = (output / "validation/data-quality-report.md").read_text(
|
||
|
|
encoding="utf-8"
|
||
|
|
)
|
||
|
|
assert validation_report["status"] == "PASS"
|
||
|
|
assert referential["valid"] is True
|
||
|
|
assert constraints["valid"] is True
|
||
|
|
assert constraints["summary"]["hardViolationCount"] == 0
|
||
|
|
assert "NOT_GENERATED" not in json.dumps(
|
||
|
|
{"referential": referential, "constraints": constraints}
|
||
|
|
)
|
||
|
|
assert "NOT_RUN" not in quality_markdown
|
||
|
|
assert len(
|
||
|
|
json.loads(
|
||
|
|
(output / "execution/rework-operations.json").read_text(encoding="utf-8")
|
||
|
|
)
|
||
|
|
) == 50
|
||
|
|
assert len(
|
||
|
|
json.loads(
|
||
|
|
(output / "execution/rework-schedule-slots.json").read_text(
|
||
|
|
encoding="utf-8"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
) == 50
|
||
|
|
|
||
|
|
|
||
|
|
def test_exact_20_skills_required_fields_and_schemas(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
full_export: tuple[Path, dict[str, Any]],
|
||
|
|
) -> None:
|
||
|
|
output, _ = full_export
|
||
|
|
registry = full_bundle.artifacts["skill-registry"]
|
||
|
|
schemas = full_bundle.artifacts["skill-schemas"]
|
||
|
|
skills = registry["skills"]
|
||
|
|
assert EXPECTED_SKILL_IDS == tuple(SKILL_IDS)
|
||
|
|
assert registry["skillCount"] == 20
|
||
|
|
assert tuple(row["skillId"] for row in skills) == EXPECTED_SKILL_IDS
|
||
|
|
assert set(schemas) == set(EXPECTED_SKILL_IDS)
|
||
|
|
for skill in skills:
|
||
|
|
skill_id = skill["skillId"]
|
||
|
|
assert REQUIRED_SKILL_FIELDS <= set(skill)
|
||
|
|
assert all(
|
||
|
|
skill[field] not in (None, "", [], {}) for field in REQUIRED_SKILL_FIELDS
|
||
|
|
)
|
||
|
|
assert skill["evidenceRef"] == f"skill:{skill_id}"
|
||
|
|
assert skill["inputSchema"].endswith(f"/{skill_id}.schema.json#/$defs/input")
|
||
|
|
assert skill["outputSchema"].endswith(f"/{skill_id}.schema.json#/$defs/output")
|
||
|
|
schema = schemas[skill_id]
|
||
|
|
assert schema["$defs"]["input"]["required"]
|
||
|
|
assert schema["$defs"]["output"]["required"]
|
||
|
|
exported = output / "skills/skill-schemas" / f"{skill_id}.schema.json"
|
||
|
|
assert exported.is_file()
|
||
|
|
assert json.loads(exported.read_text(encoding="utf-8")) == schema
|
||
|
|
|
||
|
|
|
||
|
|
def test_full_rag_has_20_categories_six_each_and_resolvable_evidence(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
assets = full_bundle.artifacts["knowledge-assets"]
|
||
|
|
category_counts = Counter(str(row["category"]) for row in assets)
|
||
|
|
skill_ids = set(EXPECTED_SKILL_IDS)
|
||
|
|
knowledge_ids = {str(row["knowledgeId"]) for row in assets}
|
||
|
|
assert EXPECTED_RAG_CATEGORIES == tuple(RAG_CATEGORIES)
|
||
|
|
assert category_counts == {category: 6 for category in EXPECTED_RAG_CATEGORIES}
|
||
|
|
assert len(knowledge_ids) == 120
|
||
|
|
for asset in assets:
|
||
|
|
knowledge_id = str(asset["knowledgeId"])
|
||
|
|
assert asset["evidenceRef"] == knowledge_id
|
||
|
|
assert asset["sourceType"] == "SYNTHETIC_KNOWLEDGE"
|
||
|
|
assert asset["relatedSkillId"] in skill_ids
|
||
|
|
assert asset["relatedSkillEvidenceRef"] == f"skill:{asset['relatedSkillId']}"
|
||
|
|
assert "not a real Beihai Shipyard" in asset["content"]
|
||
|
|
for artifact_name in ("schedule-alternatives", "scenario-events"):
|
||
|
|
artifact = full_bundle.artifacts[artifact_name]
|
||
|
|
rows = artifact["alternatives"] if isinstance(artifact, dict) else artifact
|
||
|
|
for row in rows:
|
||
|
|
for reference in row.get("evidenceRefs") or []:
|
||
|
|
assert (
|
||
|
|
reference in knowledge_ids
|
||
|
|
or reference.removeprefix("skill:") in skill_ids
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_sourcing_fulfillment_routing_supplier_and_netting_contracts(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
materials = full_bundle.rows("materials")
|
||
|
|
routings = {str(row["routingId"]) for row in full_bundle.rows("routings")}
|
||
|
|
suppliers = {str(row["supplierId"]) for row in full_bundle.rows("suppliers")}
|
||
|
|
sourcing_counts = Counter(str(row["sourcingMode"]) for row in materials)
|
||
|
|
fulfillment_counts = Counter(str(row["fulfillmentMode"]) for row in materials)
|
||
|
|
assert all(
|
||
|
|
sourcing_counts[mode] > 0
|
||
|
|
for mode in ("MAKE", "BUY", "OUTSOURCE", "OWNER_SUPPLIED")
|
||
|
|
)
|
||
|
|
assert all(
|
||
|
|
fulfillment_counts[mode] > 0
|
||
|
|
for mode in (
|
||
|
|
"STOCK",
|
||
|
|
"TRANSFER",
|
||
|
|
"PLANNED_RECEIPT",
|
||
|
|
"NEW_SUPPLY",
|
||
|
|
"DESIGN_PENDING",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
for material in materials:
|
||
|
|
if (
|
||
|
|
material["sourcingMode"] == "MAKE"
|
||
|
|
and material["fulfillmentMode"] != "DESIGN_PENDING"
|
||
|
|
):
|
||
|
|
assert material["routingId"] in routings
|
||
|
|
if material["sourcingMode"] in {"BUY", "OUTSOURCE"}:
|
||
|
|
assert material["primarySupplierId"] in suppliers
|
||
|
|
outsource_required = {
|
||
|
|
"outsourceOrderId",
|
||
|
|
"projectId",
|
||
|
|
"wbsId",
|
||
|
|
"materialId",
|
||
|
|
"operationId",
|
||
|
|
"supplierId",
|
||
|
|
"sendDate",
|
||
|
|
"expectedReturnDate",
|
||
|
|
"transportOutboundDays",
|
||
|
|
"processDays",
|
||
|
|
"inspectionDays",
|
||
|
|
"transportReturnDays",
|
||
|
|
"quantity",
|
||
|
|
"status",
|
||
|
|
"qualityStatus",
|
||
|
|
"previousOperationId",
|
||
|
|
"nextOperationId",
|
||
|
|
"requiredCertificate",
|
||
|
|
"riskLevel",
|
||
|
|
}
|
||
|
|
material_ids = {str(row["materialId"]) for row in materials}
|
||
|
|
for suggestion in full_bundle.rows("outsource-suggestions"):
|
||
|
|
assert outsource_required <= set(suggestion)
|
||
|
|
assert suggestion["outsourceOrderId"] == suggestion["outsourceSuggestionId"]
|
||
|
|
assert suggestion["recordType"] == "SUGGESTION"
|
||
|
|
assert suggestion["isActualOrder"] is False
|
||
|
|
assert suggestion["materialId"] in material_ids
|
||
|
|
assert suggestion["expectedReturnDate"] == suggestion["returnDate"]
|
||
|
|
assert float(suggestion["quantity"]) > 0
|
||
|
|
assert suggestion["qualityStatus"] == "PENDING_INSPECTION"
|
||
|
|
assert str(suggestion["requiredCertificate"]).startswith("SHIP-OUTSOURCE-")
|
||
|
|
assert suggestion["riskLevel"] in {"LOW", "MEDIUM", "HIGH"}
|
||
|
|
for requirement in full_bundle.rows("material-requirements"):
|
||
|
|
expected = (
|
||
|
|
float(requirement["grossRequirement"])
|
||
|
|
+ float(requirement["safetyStockRequirement"])
|
||
|
|
+ float(requirement["scrapRequirement"])
|
||
|
|
- float(requirement["stockUsed"])
|
||
|
|
- float(requirement["releasedAllocationUsed"])
|
||
|
|
- float(requirement["plannedReceiptUsed"])
|
||
|
|
- float(requirement["substituteUsed"])
|
||
|
|
)
|
||
|
|
assert math.isclose(
|
||
|
|
float(requirement["netRequirement"]), max(0.0, expected), abs_tol=0.002
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_production_orders_have_routings_and_operations_have_finite_resources(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
routings = {str(row["routingId"]) for row in full_bundle.rows("routings")}
|
||
|
|
resources = {str(row["resourceId"]) for row in full_bundle.rows("resources")}
|
||
|
|
teams = {str(row["teamId"]): row for row in full_bundle.rows("teams")}
|
||
|
|
work_orders = {
|
||
|
|
str(row["workOrderId"]): row for row in full_bundle.rows("work-orders")
|
||
|
|
}
|
||
|
|
demands = {
|
||
|
|
str(row["operationId"]): row for row in full_bundle.rows("capacity-demands")
|
||
|
|
}
|
||
|
|
slots = {str(row["operationId"]): row for row in full_bundle.rows("schedule-slots")}
|
||
|
|
operations_by_work_order: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
|
|
for order in full_bundle.rows("production-orders"):
|
||
|
|
assert order["routingId"] in routings
|
||
|
|
assert order["designReleaseStatus"] == "RELEASED"
|
||
|
|
for operation in full_bundle.rows("operations"):
|
||
|
|
operation_id = str(operation["operationId"])
|
||
|
|
work_order_id = str(operation["workOrderId"])
|
||
|
|
operations_by_work_order[work_order_id].append(operation)
|
||
|
|
assert work_order_id in work_orders
|
||
|
|
assert operation["routingId"] in routings
|
||
|
|
assert operation_id in demands
|
||
|
|
assert operation_id in slots
|
||
|
|
assert demands[operation_id]["capacityMode"] == "FINITE"
|
||
|
|
assert slots[operation_id]["resourceId"] in resources
|
||
|
|
assert slots[operation_id]["teamId"] in teams
|
||
|
|
assert set(operation["requiredSkillCodes"]) <= set(
|
||
|
|
teams[slots[operation_id]["teamId"]]["skillCodes"]
|
||
|
|
)
|
||
|
|
assert int(operation["crewSize"]) <= int(
|
||
|
|
teams[slots[operation_id]["teamId"]]["crewSize"]
|
||
|
|
)
|
||
|
|
assert set(operations_by_work_order) == set(work_orders)
|
||
|
|
assert {len(rows) for rows in operations_by_work_order.values()} == {5}
|
||
|
|
|
||
|
|
|
||
|
|
def test_full_schedule_constraints_and_validation_metrics_are_clean(
|
||
|
|
full_config: GeneratorConfig,
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
operations = full_bundle.rows("operations")
|
||
|
|
slots = full_bundle.rows("schedule-slots")
|
||
|
|
assert len(operations) == len(slots) == 12000
|
||
|
|
assert len({row["operationId"] for row in slots}) == len(operations)
|
||
|
|
schedule_report = validate_schedule_constraints(full_bundle, full_config)
|
||
|
|
_assert_report_valid(schedule_report, "full finite-capacity schedule")
|
||
|
|
assert schedule_report["hardViolationCount"] == 0
|
||
|
|
assert schedule_report["unmarkedHardViolationCount"] == 0
|
||
|
|
assert schedule_report["checks"]["activeOperationCoverage"] == 12000
|
||
|
|
assert schedule_report["checks"]["scheduleSlotCount"] == 12000
|
||
|
|
assert schedule_report["checks"]["precedenceArcCount"] > 0
|
||
|
|
assert schedule_report["checks"]["materialReadyCheckCount"] == 12000
|
||
|
|
assert schedule_report["checks"]["holdPointCheckCount"] > 0
|
||
|
|
report = validate_bundle(full_bundle, full_config)
|
||
|
|
_assert_report_valid(report, "full dataset")
|
||
|
|
metrics = report["metrics"]
|
||
|
|
assert {name: metrics[name] for name in ZERO_VALIDATION_METRICS} == {
|
||
|
|
name: 0 for name in ZERO_VALIDATION_METRICS
|
||
|
|
}
|
||
|
|
assert report["constraintValidation"]["hardViolationCount"] == 0
|
||
|
|
coverage_keys = {
|
||
|
|
"constraintCoverage.precedenceFS",
|
||
|
|
"constraintCoverage.precedenceSS",
|
||
|
|
"constraintCoverage.precedenceFF",
|
||
|
|
"constraintCoverage.precedenceSF",
|
||
|
|
"constraintCoverage.positiveLag",
|
||
|
|
"constraintCoverage.negativeLag",
|
||
|
|
"constraintCoverage.qualification",
|
||
|
|
"constraintCoverage.weightDimension",
|
||
|
|
"constraintCoverage.transport",
|
||
|
|
"constraintCoverage.hazard",
|
||
|
|
"constraintCoverage.areaDensity",
|
||
|
|
"constraintCoverage.maintenanceBlackout",
|
||
|
|
"constraintCoverage.supplierBlackout",
|
||
|
|
"constraintCoverage.supplierCapacityUnit",
|
||
|
|
"constraintCoverage.weatherWindow",
|
||
|
|
}
|
||
|
|
assert all(report["checks"][name] > 0 for name in coverage_keys)
|
||
|
|
relation_counts = Counter(
|
||
|
|
str(row["relationType"])
|
||
|
|
for row in operations
|
||
|
|
if row.get("predecessorOperationId")
|
||
|
|
)
|
||
|
|
assert all(relation_counts[name] > 0 for name in ("FS", "SS", "FF", "SF"))
|
||
|
|
assert any(float(row["lagHours"]) > 0 for row in operations)
|
||
|
|
assert any(float(row["lagHours"]) < 0 for row in operations)
|
||
|
|
assert any(row.get("requiredQualificationCodes") for row in operations)
|
||
|
|
assert any(row.get("transportWindowStatus") == "OPEN" for row in operations)
|
||
|
|
assert any(row.get("weatherSensitive") for row in operations)
|
||
|
|
assert any(row.get("hazardClass") for row in operations)
|
||
|
|
assert any(float(row.get("maxAreaDensityUnits") or 0) > 0 for row in operations)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("business_constraint_name", "case", "expected_code"),
|
||
|
|
(
|
||
|
|
("\u5de5\u5e8f\u524d\u540e\u5173\u7cfb", "PROCESS_PRECEDENCE", "FS_PRECEDENCE"),
|
||
|
|
("\u7269\u6599\u9f50\u5957", "MATERIAL_KIT", "MATERIAL_READY"),
|
||
|
|
("\u8bbe\u8ba1\u56fe\u7eb8\u5df2\u91ca\u653e", "DESIGN_RELEASE", "DESIGN_RELEASE"),
|
||
|
|
("\u8d44\u6e90\u6709\u9650\u80fd\u529b", "FINITE_RESOURCE", "RESOURCE_CAPABILITY"),
|
||
|
|
("\u8bbe\u5907\u7ef4\u62a4\u7a97\u53e3", "MAINTENANCE_WINDOW", "MAINTENANCE_BLACKOUT"),
|
||
|
|
("\u4eba\u5458\u6280\u80fd\u548c\u4eba\u6570", "TEAM_SKILL_CREW", "TEAM_SKILLS"),
|
||
|
|
("\u8239\u575e\u72ec\u5360", "DOCK_EXCLUSIVE", "DOCK_EXCLUSIVITY"),
|
||
|
|
("\u7801\u5934\u6cca\u4f4d\u72ec\u5360", "BERTH_EXCLUSIVE", "BERTH_EXCLUSIVITY"),
|
||
|
|
("\u80ce\u67b6\u548c\u573a\u5730\u5360\u7528", "SUPPORT_FRAME_SITE", "SUPPORT_FRAME_SITE_OCCUPANCY"),
|
||
|
|
("\u540a\u88c5\u80fd\u529b\u548c\u540a\u88c5\u7a97\u53e3", "LIFT_CAPACITY_WINDOW", "LIFT_WINDOW"),
|
||
|
|
("\u5206\u6bb5\u8fd0\u8f93\u8def\u5f84", "TRANSPORT_PATH", "TRANSPORT_PATH"),
|
||
|
|
("\u6d82\u88c5\u73af\u5883\u6761\u4ef6", "PAINT_ENVIRONMENT", "PAINT_ENVIRONMENT"),
|
||
|
|
("\u8d28\u91cf Hold Point", "QUALITY_HOLD", "QUALITY_HOLD"),
|
||
|
|
("\u59d4\u5916\u5230\u8d27", "OUTSOURCE_ARRIVAL", "OUTSOURCE_TIME_CHAIN"),
|
||
|
|
("\u4f9b\u5e94\u5546\u4ea7\u80fd", "SUPPLIER_CAPACITY", "SUPPLIER_MONTHLY_CAPACITY"),
|
||
|
|
("\u9879\u76ee\u51bb\u7ed3\u533a", "PROJECT_FREEZE_ZONE", "FROZEN_ZONE"),
|
||
|
|
("\u5df2\u5f00\u5de5\u4efb\u52a1\u4e0d\u53ef\u968f\u610f\u8fc1\u79fb", "STARTED_IMMUTABLE", "STARTED_TASK_IMMUTABLE"),
|
||
|
|
("\u5df2\u4e0b\u53d1 MES \u4efb\u52a1\u4e0d\u53ef\u81ea\u52a8\u5220\u9664", "MES_NO_DELETE", "MES_DISPATCH_DELETE"),
|
||
|
|
("\u51bb\u7ed3\u5de5\u5355\u4e0d\u5141\u8bb8\u81ea\u52a8\u6539\u671f", "FROZEN_ORDER_NO_RESCHEDULE", "FROZEN_WORK_ORDER_RESCHEDULE"),
|
||
|
|
("\u5206\u6bb5\u642d\u8f7d\u987a\u5e8f", "SECTION_ERECTION_SEQUENCE", "SECTION_ERECTION_SEQUENCE"),
|
||
|
|
("\u603b\u6bb5\u7a7a\u95f4\u548c\u5e72\u6d89\u5173\u7cfb", "GRAND_BLOCK_SPATIAL", "GRAND_BLOCK_SPATIAL_INTERFERENCE"),
|
||
|
|
("\u5371\u9669\u4f5c\u4e1a\u4e92\u65a5", "HAZARD_MUTEX", "HAZARD_INCOMPATIBILITY"),
|
||
|
|
("\u540c\u533a\u57df\u591a\u4e13\u4e1a\u65bd\u5de5\u5bc6\u5ea6", "AREA_DENSITY", "AREA_DENSITY"),
|
||
|
|
("\u5173\u952e\u8bbe\u5907\u5230\u8d27\u6761\u4ef6", "CRITICAL_EQUIPMENT", "CRITICAL_EQUIPMENT_ARRIVAL"),
|
||
|
|
("\u8239\u4e1c\u548c\u8239\u7ea7\u793e\u89c1\u8bc1\u65e5\u5386", "WITNESS_CALENDAR", "WITNESS_CALENDAR"),
|
||
|
|
("\u4e0b\u6c34\u548c\u8bd5\u822a\u5929\u6c14\u7a97\u53e3", "LAUNCH_TRIAL_WEATHER", "LAUNCH_TRIAL_WEATHER"),
|
||
|
|
),
|
||
|
|
ids=[f"{index:02d}-{name}" for index, name in enumerate((
|
||
|
|
"\u5de5\u5e8f\u524d\u540e\u5173\u7cfb",
|
||
|
|
"\u7269\u6599\u9f50\u5957",
|
||
|
|
"\u8bbe\u8ba1\u56fe\u7eb8\u5df2\u91ca\u653e",
|
||
|
|
"\u8d44\u6e90\u6709\u9650\u80fd\u529b",
|
||
|
|
"\u8bbe\u5907\u7ef4\u62a4\u7a97\u53e3",
|
||
|
|
"\u4eba\u5458\u6280\u80fd\u548c\u4eba\u6570",
|
||
|
|
"\u8239\u575e\u72ec\u5360",
|
||
|
|
"\u7801\u5934\u6cca\u4f4d\u72ec\u5360",
|
||
|
|
"\u80ce\u67b6\u548c\u573a\u5730\u5360\u7528",
|
||
|
|
"\u540a\u88c5\u80fd\u529b\u548c\u540a\u88c5\u7a97\u53e3",
|
||
|
|
"\u5206\u6bb5\u8fd0\u8f93\u8def\u5f84",
|
||
|
|
"\u6d82\u88c5\u73af\u5883\u6761\u4ef6",
|
||
|
|
"\u8d28\u91cf Hold Point",
|
||
|
|
"\u59d4\u5916\u5230\u8d27",
|
||
|
|
"\u4f9b\u5e94\u5546\u4ea7\u80fd",
|
||
|
|
"\u9879\u76ee\u51bb\u7ed3\u533a",
|
||
|
|
"\u5df2\u5f00\u5de5\u4efb\u52a1\u4e0d\u53ef\u968f\u610f\u8fc1\u79fb",
|
||
|
|
"\u5df2\u4e0b\u53d1 MES \u4efb\u52a1\u4e0d\u53ef\u81ea\u52a8\u5220\u9664",
|
||
|
|
"\u51bb\u7ed3\u5de5\u5355\u4e0d\u5141\u8bb8\u81ea\u52a8\u6539\u671f",
|
||
|
|
"\u5206\u6bb5\u642d\u8f7d\u987a\u5e8f",
|
||
|
|
"\u603b\u6bb5\u7a7a\u95f4\u548c\u5e72\u6d89\u5173\u7cfb",
|
||
|
|
"\u5371\u9669\u4f5c\u4e1a\u4e92\u65a5",
|
||
|
|
"\u540c\u533a\u57df\u591a\u4e13\u4e1a\u65bd\u5de5\u5bc6\u5ea6",
|
||
|
|
"\u5173\u952e\u8bbe\u5907\u5230\u8d27\u6761\u4ef6",
|
||
|
|
"\u8239\u4e1c\u548c\u8239\u7ea7\u793e\u89c1\u8bc1\u65e5\u5386",
|
||
|
|
"\u4e0b\u6c34\u548c\u8bd5\u822a\u5929\u6c14\u7a97\u53e3",
|
||
|
|
), 1)],
|
||
|
|
)
|
||
|
|
def test_hard_constraint_mutation_matrix_rejects_each_family(
|
||
|
|
small_config: GeneratorConfig,
|
||
|
|
small_bundle: DatasetBundle,
|
||
|
|
business_constraint_name: str,
|
||
|
|
case: str,
|
||
|
|
expected_code: str,
|
||
|
|
) -> None:
|
||
|
|
"""Reject one explicit illegal mutation for each of the 26 named business constraints."""
|
||
|
|
bundle = small_bundle.clone()
|
||
|
|
operations = {str(row["operationId"]): row for row in bundle.rows("operations")}
|
||
|
|
slots_by_operation = {
|
||
|
|
str(row["operationId"]): row for row in bundle.rows("schedule-slots")
|
||
|
|
}
|
||
|
|
resources = {str(row["resourceId"]): row for row in bundle.rows("resources")}
|
||
|
|
|
||
|
|
def overlap(first: dict[str, Any], second: dict[str, Any]) -> None:
|
||
|
|
duration = datetime.fromisoformat(str(second["end"])) - datetime.fromisoformat(
|
||
|
|
str(second["start"])
|
||
|
|
)
|
||
|
|
second["start"] = first["start"]
|
||
|
|
second["end"] = (
|
||
|
|
datetime.fromisoformat(str(first["start"])) + duration
|
||
|
|
).isoformat()
|
||
|
|
|
||
|
|
if case == "PROCESS_PRECEDENCE":
|
||
|
|
operation = next(row for row in operations.values() if row.get("predecessorOperationId"))
|
||
|
|
operation["relationType"] = "FS"
|
||
|
|
operation["lagHours"] = 100000.0
|
||
|
|
elif case == "MATERIAL_KIT":
|
||
|
|
next(iter(operations.values()))["materialReadyAt"] = "2099-12-31"
|
||
|
|
elif case == "DESIGN_RELEASE":
|
||
|
|
next(iter(operations.values()))["drawingReleased"] = False
|
||
|
|
elif case == "FINITE_RESOURCE":
|
||
|
|
next(iter(operations.values()))["requiredCapabilityTags"] = ["CAP-SYN-NOT-AVAILABLE"]
|
||
|
|
elif case == "MAINTENANCE_WINDOW":
|
||
|
|
slot = next(iter(slots_by_operation.values()))
|
||
|
|
resources[str(slot["resourceId"])].setdefault("maintenanceBlackoutDates", []).append(
|
||
|
|
str(slot["start"])[:10]
|
||
|
|
)
|
||
|
|
elif case == "TEAM_SKILL_CREW":
|
||
|
|
next(iter(operations.values()))["requiredSkillCodes"] = ["SK-SYN-NOT-AVAILABLE"]
|
||
|
|
elif case in {"DOCK_EXCLUSIVE", "BERTH_EXCLUSIVE", "SUPPORT_FRAME_SITE"}:
|
||
|
|
field = {
|
||
|
|
"DOCK_EXCLUSIVE": "dockId",
|
||
|
|
"BERTH_EXCLUSIVE": "berthId",
|
||
|
|
"SUPPORT_FRAME_SITE": "supportFrameSiteId",
|
||
|
|
}[case]
|
||
|
|
grouped: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
|
||
|
|
for operation in operations.values():
|
||
|
|
if operation.get(field):
|
||
|
|
grouped[str(operation[field])].append(slots_by_operation[str(operation["operationId"])])
|
||
|
|
first, second = next(rows[:2] for rows in grouped.values() if len(rows) >= 2)
|
||
|
|
overlap(first, second)
|
||
|
|
elif case == "LIFT_CAPACITY_WINDOW":
|
||
|
|
operation = next(row for row in operations.values() if row.get("liftingRequired"))
|
||
|
|
operation["liftWindowStatus"] = "CLOSED"
|
||
|
|
elif case == "TRANSPORT_PATH":
|
||
|
|
operation = next(row for row in operations.values() if row.get("transportPathRequired"))
|
||
|
|
operation["transportPathStatus"] = "BLOCKED"
|
||
|
|
elif case == "PAINT_ENVIRONMENT":
|
||
|
|
operation = next(row for row in operations.values() if row.get("paintingEnvironmentRequired"))
|
||
|
|
operation["paintingEnvironmentStatus"] = "UNACCEPTABLE"
|
||
|
|
elif case == "QUALITY_HOLD":
|
||
|
|
operation = next(row for row in operations.values() if row.get("holdPoint"))
|
||
|
|
operation["holdStatus"] = "HOLD"
|
||
|
|
elif case == "OUTSOURCE_ARRIVAL":
|
||
|
|
suggestion = bundle.rows("outsource-suggestions")[0]
|
||
|
|
suggestion["returnDate"] = suggestion["sendDate"]
|
||
|
|
elif case == "SUPPLIER_CAPACITY":
|
||
|
|
suggestion = bundle.rows("outsource-suggestions")[0]
|
||
|
|
supplier = next(
|
||
|
|
row for row in bundle.rows("suppliers") if row["supplierId"] == suggestion["supplierId"]
|
||
|
|
)
|
||
|
|
supplier["monthlyCapacityUnit"] = "OPERATION"
|
||
|
|
supplier["monthlyCapacity"] = 0.5
|
||
|
|
elif case == "PROJECT_FREEZE_ZONE":
|
||
|
|
slot = next(row for row in slots_by_operation.values() if row.get("timeFence") == "FROZEN")
|
||
|
|
slot["baselineStart"] = (
|
||
|
|
datetime.fromisoformat(str(slot["start"])) - timedelta(hours=1)
|
||
|
|
).isoformat()
|
||
|
|
slot["changeAuthorized"] = False
|
||
|
|
elif case == "STARTED_IMMUTABLE":
|
||
|
|
operation = next(row for row in operations.values() if row.get("startedTaskImmutable"))
|
||
|
|
slot = slots_by_operation[str(operation["operationId"])]
|
||
|
|
slot["start"] = (datetime.fromisoformat(str(slot["start"])) + timedelta(minutes=30)).isoformat()
|
||
|
|
slot["end"] = (datetime.fromisoformat(str(slot["end"])) + timedelta(minutes=30)).isoformat()
|
||
|
|
elif case == "MES_NO_DELETE":
|
||
|
|
work_order = next(row for row in bundle.rows("work-orders") if row.get("mesDispatchProtected"))
|
||
|
|
operation = next(
|
||
|
|
row for row in operations.values() if row.get("workOrderId") == work_order["workOrderId"]
|
||
|
|
)
|
||
|
|
bundle.set_rows(
|
||
|
|
"schedule-slots",
|
||
|
|
[row for row in bundle.rows("schedule-slots") if row["operationId"] != operation["operationId"]],
|
||
|
|
)
|
||
|
|
elif case == "FROZEN_ORDER_NO_RESCHEDULE":
|
||
|
|
operation = next(
|
||
|
|
row
|
||
|
|
for row in operations.values()
|
||
|
|
if row.get("frozenWorkOrderProtected") and not row.get("startedTaskImmutable")
|
||
|
|
)
|
||
|
|
slot = slots_by_operation[str(operation["operationId"])]
|
||
|
|
slot["end"] = (datetime.fromisoformat(str(slot["end"])) + timedelta(minutes=30)).isoformat()
|
||
|
|
elif case == "SECTION_ERECTION_SEQUENCE":
|
||
|
|
operation = next(row for row in operations.values() if row.get("erectionPredecessorOperationId"))
|
||
|
|
predecessor = slots_by_operation[str(operation["erectionPredecessorOperationId"])]
|
||
|
|
overlap(predecessor, slots_by_operation[str(operation["operationId"])])
|
||
|
|
elif case == "GRAND_BLOCK_SPATIAL":
|
||
|
|
grouped = defaultdict(list)
|
||
|
|
for operation in operations.values():
|
||
|
|
if operation.get("spatialInterferenceGroup"):
|
||
|
|
grouped[str(operation["spatialInterferenceGroup"])].append(
|
||
|
|
slots_by_operation[str(operation["operationId"])]
|
||
|
|
)
|
||
|
|
first, second = next(rows[:2] for rows in grouped.values() if len(rows) >= 2)
|
||
|
|
overlap(first, second)
|
||
|
|
elif case in {"HAZARD_MUTEX", "AREA_DENSITY"}:
|
||
|
|
first_operation, second_operation = list(operations.values())[:2]
|
||
|
|
first_operation["zoneId"] = "ZONE-SYN-MUTATION"
|
||
|
|
second_operation["zoneId"] = "ZONE-SYN-MUTATION"
|
||
|
|
first_operation["areaDensityUnits"] = 2
|
||
|
|
second_operation["areaDensityUnits"] = 2
|
||
|
|
first_operation["maxAreaDensityUnits"] = 2
|
||
|
|
second_operation["maxAreaDensityUnits"] = 2
|
||
|
|
if case == "HAZARD_MUTEX":
|
||
|
|
first_operation["hazardClass"] = "HOT_WORK"
|
||
|
|
second_operation["hazardClass"] = "PAINT_VOC"
|
||
|
|
else:
|
||
|
|
first_operation["hazardClass"] = None
|
||
|
|
second_operation["hazardClass"] = None
|
||
|
|
overlap(
|
||
|
|
slots_by_operation[str(first_operation["operationId"])],
|
||
|
|
slots_by_operation[str(second_operation["operationId"])],
|
||
|
|
)
|
||
|
|
elif case == "CRITICAL_EQUIPMENT":
|
||
|
|
operation = next(row for row in operations.values() if row.get("criticalEquipmentRequired"))
|
||
|
|
operation["criticalEquipmentArrivalAt"] = "2099-12-31"
|
||
|
|
elif case == "WITNESS_CALENDAR":
|
||
|
|
operation = next(row for row in operations.values() if row.get("witnessRequired"))
|
||
|
|
operation["witnessCalendarStatus"] = "CLOSED"
|
||
|
|
elif case == "LAUNCH_TRIAL_WEATHER":
|
||
|
|
operation = next(row for row in operations.values() if row.get("launchTrialWeatherRequired"))
|
||
|
|
operation["launchTrialWeatherStatus"] = "CLOSED"
|
||
|
|
else: # pragma: no cover - exhaustive parametrization guard
|
||
|
|
raise AssertionError((business_constraint_name, case))
|
||
|
|
|
||
|
|
report = validate_schedule_constraints(bundle, small_config)
|
||
|
|
codes = {str(row["constraint"]) for row in report["violations"]}
|
||
|
|
assert expected_code in codes, (business_constraint_name, expected_code, sorted(codes))
|
||
|
|
|
||
|
|
|
||
|
|
def test_exact_26_business_constraints_have_positive_baseline_coverage(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
full_config: GeneratorConfig,
|
||
|
|
) -> None:
|
||
|
|
expected_names = (
|
||
|
|
"\u5de5\u5e8f\u524d\u540e\u5173\u7cfb",
|
||
|
|
"\u7269\u6599\u9f50\u5957",
|
||
|
|
"\u8bbe\u8ba1\u56fe\u7eb8\u5df2\u91ca\u653e",
|
||
|
|
"\u8d44\u6e90\u6709\u9650\u80fd\u529b",
|
||
|
|
"\u8bbe\u5907\u7ef4\u62a4\u7a97\u53e3",
|
||
|
|
"\u4eba\u5458\u6280\u80fd\u548c\u4eba\u6570",
|
||
|
|
"\u8239\u575e\u72ec\u5360",
|
||
|
|
"\u7801\u5934\u6cca\u4f4d\u72ec\u5360",
|
||
|
|
"\u80ce\u67b6\u548c\u573a\u5730\u5360\u7528",
|
||
|
|
"\u540a\u88c5\u80fd\u529b\u548c\u540a\u88c5\u7a97\u53e3",
|
||
|
|
"\u5206\u6bb5\u8fd0\u8f93\u8def\u5f84",
|
||
|
|
"\u6d82\u88c5\u73af\u5883\u6761\u4ef6",
|
||
|
|
"\u8d28\u91cf Hold Point",
|
||
|
|
"\u59d4\u5916\u5230\u8d27",
|
||
|
|
"\u4f9b\u5e94\u5546\u4ea7\u80fd",
|
||
|
|
"\u9879\u76ee\u51bb\u7ed3\u533a",
|
||
|
|
"\u5df2\u5f00\u5de5\u4efb\u52a1\u4e0d\u53ef\u968f\u610f\u8fc1\u79fb",
|
||
|
|
"\u5df2\u4e0b\u53d1 MES \u4efb\u52a1\u4e0d\u53ef\u81ea\u52a8\u5220\u9664",
|
||
|
|
"\u51bb\u7ed3\u5de5\u5355\u4e0d\u5141\u8bb8\u81ea\u52a8\u6539\u671f",
|
||
|
|
"\u5206\u6bb5\u642d\u8f7d\u987a\u5e8f",
|
||
|
|
"\u603b\u6bb5\u7a7a\u95f4\u548c\u5e72\u6d89\u5173\u7cfb",
|
||
|
|
"\u5371\u9669\u4f5c\u4e1a\u4e92\u65a5",
|
||
|
|
"\u540c\u533a\u57df\u591a\u4e13\u4e1a\u65bd\u5de5\u5bc6\u5ea6",
|
||
|
|
"\u5173\u952e\u8bbe\u5907\u5230\u8d27\u6761\u4ef6",
|
||
|
|
"\u8239\u4e1c\u548c\u8239\u7ea7\u793e\u89c1\u8bc1\u65e5\u5386",
|
||
|
|
"\u4e0b\u6c34\u548c\u8bd5\u822a\u5929\u6c14\u7a97\u53e3",
|
||
|
|
)
|
||
|
|
report = full_bundle.artifacts["baseline-results"]["constraintReport"]
|
||
|
|
coverage = report["businessConstraintCoverage"]
|
||
|
|
assert tuple(coverage) == expected_names
|
||
|
|
assert len(coverage) == 26
|
||
|
|
assert all(row["positiveSampleCount"] > 0 for row in coverage.values())
|
||
|
|
assert all(row["violationCount"] == 0 for row in coverage.values())
|
||
|
|
frozen_slots = [
|
||
|
|
row for row in full_bundle.rows("schedule-slots") if row.get("timeFence") == "FROZEN"
|
||
|
|
]
|
||
|
|
assert frozen_slots
|
||
|
|
frozen_limit = full_config.planning_base_date + timedelta(days=13)
|
||
|
|
assert max(datetime.fromisoformat(str(row["start"])).date() for row in frozen_slots) <= frozen_limit
|
||
|
|
assert len(full_bundle.rows("schedule-slots")) == 12000
|
||
|
|
|
||
|
|
|
||
|
|
def test_shipyard_synthetic_text_fields_have_no_question_mark_corruption(
|
||
|
|
small_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
source_files = (
|
||
|
|
"orders.py",
|
||
|
|
"scheduler.py",
|
||
|
|
"constraints.py",
|
||
|
|
"alternatives.py",
|
||
|
|
"scenarios.py",
|
||
|
|
)
|
||
|
|
for filename in source_files:
|
||
|
|
source = (REPO_ROOT / "server/shipyard_synthetic" / filename).read_text(encoding="utf-8")
|
||
|
|
assert "????" not in source, filename
|
||
|
|
generated = json.dumps(
|
||
|
|
{"tables": small_bundle.tables, "artifacts": small_bundle.artifacts},
|
||
|
|
ensure_ascii=False,
|
||
|
|
sort_keys=True,
|
||
|
|
)
|
||
|
|
assert "????" not in generated
|
||
|
|
assert all(
|
||
|
|
row.get("explanation") and "????" not in str(row["explanation"])
|
||
|
|
for row in small_bundle.rows("schedule-slots")
|
||
|
|
)
|
||
|
|
assert all(name and "????" not in name for _mode, name, _weights in ALTERNATIVE_MODES)
|
||
|
|
assert all(
|
||
|
|
action and "????" not in action
|
||
|
|
for event in small_bundle.artifacts["scenario-events"]
|
||
|
|
for action in event["input"]["recommendedActions"]
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
|
||
|
|
def test_seven_alternatives_and_fifteen_scenarios_with_infeasible_cores(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
full_export: tuple[Path, dict[str, Any]],
|
||
|
|
) -> None:
|
||
|
|
"""Alternatives and events must contain real, revalidated schedule changes."""
|
||
|
|
output, _ = full_export
|
||
|
|
alternative_artifact = full_bundle.artifacts["schedule-alternatives"]
|
||
|
|
alternatives = alternative_artifact["alternatives"]
|
||
|
|
expected_modes = tuple(row[0] for row in ALTERNATIVE_MODES)
|
||
|
|
assert len(alternatives) == 7
|
||
|
|
assert tuple(row["mode"] for row in alternatives) == expected_modes
|
||
|
|
assert len({row["scheduleVersionId"] for row in alternatives}) == 7
|
||
|
|
recommended = [row for row in alternatives if row["mode"] == "RECOMMENDED"]
|
||
|
|
assert len(recommended) == 1
|
||
|
|
assert (
|
||
|
|
alternative_artifact["recommendedAlternativeId"]
|
||
|
|
== recommended[0]["alternativeId"]
|
||
|
|
)
|
||
|
|
for alternative in alternatives:
|
||
|
|
assert alternative["scheduleVersionId"] != alternative["baseScheduleVersionId"]
|
||
|
|
assert alternative["changedSlotCount"] > 0
|
||
|
|
assert alternative["changedSlotCount"] == len(alternative["slotDeltas"])
|
||
|
|
assert alternative["changedSlotCount"] == len(alternative["alternativeSlots"])
|
||
|
|
assert alternative["constraintValidation"]["valid"] is True
|
||
|
|
assert alternative["constraintValidation"]["hardViolationCount"] == 0
|
||
|
|
actual_counts: Counter[str] = Counter()
|
||
|
|
for delta in alternative["slotDeltas"]:
|
||
|
|
before, after = delta["before"], delta["after"]
|
||
|
|
changed = [
|
||
|
|
field
|
||
|
|
for field in ("start", "end", "resourceId", "teamId")
|
||
|
|
if before[field] != after[field]
|
||
|
|
]
|
||
|
|
assert changed
|
||
|
|
assert set(changed) == set(delta["changedFields"])
|
||
|
|
assert after["scheduleVersionId"] == alternative["scheduleVersionId"]
|
||
|
|
actual_counts.update(changed)
|
||
|
|
assert dict(actual_counts) == alternative["changedFieldCounts"]
|
||
|
|
|
||
|
|
events = full_bundle.artifacts["scenario-events"]
|
||
|
|
assert len(events) == 15
|
||
|
|
assert tuple(EVENT_TYPES) == EXPECTED_EVENT_TYPES
|
||
|
|
assert tuple(row["eventType"] for row in events) == EXPECTED_EVENT_TYPES
|
||
|
|
assert all(row["eventName"] for row in events)
|
||
|
|
assert len({row["scenarioId"] for row in events}) == 15
|
||
|
|
assert len({row["afterScheduleVersionId"] for row in events}) == 15
|
||
|
|
infeasible = []
|
||
|
|
required_input_fields = {
|
||
|
|
"occurredAt",
|
||
|
|
"affectedProjectIds",
|
||
|
|
"affectedWbsIds",
|
||
|
|
"affectedProductionOrderIds",
|
||
|
|
"affectedResourceIds",
|
||
|
|
"parameters",
|
||
|
|
"originalImpact",
|
||
|
|
"recommendedActions",
|
||
|
|
"requiresReschedule",
|
||
|
|
"frozenImpact",
|
||
|
|
}
|
||
|
|
for event in events:
|
||
|
|
assert required_input_fields <= set(event["input"])
|
||
|
|
assert event["input"]["synthetic"] is True
|
||
|
|
assert event["input"]["datasetType"] == "SYNTHETIC"
|
||
|
|
assert event["input"]["requiresReschedule"] is True
|
||
|
|
assert event["input"]["parameters"]
|
||
|
|
assert event["input"]["recommendedActions"]
|
||
|
|
assert event["input"]["frozenImpact"]["timeFenceField"] == "timeFence"
|
||
|
|
assert event["afterScheduleVersionId"] != event["baseScheduleVersionId"]
|
||
|
|
assert isinstance(event["before"], list)
|
||
|
|
assert isinstance(event["after"], list)
|
||
|
|
assert isinstance(event["diff"], dict)
|
||
|
|
assert event["diff"]["unauthorizedFrozenChangeCount"] == 0
|
||
|
|
if event["solveStatus"] == "INFEASIBLE":
|
||
|
|
infeasible.append(event)
|
||
|
|
assert event["conflictCore"]
|
||
|
|
assert event["reliefSuggestions"]
|
||
|
|
continue
|
||
|
|
assert event["solveStatus"] == "FEASIBLE"
|
||
|
|
assert event["constraintValidation"]["valid"] is True
|
||
|
|
assert event["constraintValidation"]["hardViolationCount"] == 0
|
||
|
|
before_by_operation = {row["operationId"]: row for row in event["before"]}
|
||
|
|
after_by_operation = {row["operationId"]: row for row in event["after"]}
|
||
|
|
changed_counts: Counter[str] = Counter()
|
||
|
|
actual_changed = 0
|
||
|
|
for operation_id, before in before_by_operation.items():
|
||
|
|
after = after_by_operation[operation_id]
|
||
|
|
changed = [
|
||
|
|
field
|
||
|
|
for field in ("start", "end", "resourceId", "teamId")
|
||
|
|
if before[field] != after[field]
|
||
|
|
]
|
||
|
|
if changed:
|
||
|
|
actual_changed += 1
|
||
|
|
changed_counts.update(changed)
|
||
|
|
assert after["scheduleVersionId"] == event["afterScheduleVersionId"]
|
||
|
|
assert actual_changed == event["diff"]["changedSlotCount"]
|
||
|
|
assert len(event["before"]) == len(event["after"]) == actual_changed
|
||
|
|
assert dict(changed_counts) == event["diff"]["changedFieldCounts"]
|
||
|
|
assert infeasible
|
||
|
|
for file_name, event_type in CANONICAL_FILES.items():
|
||
|
|
assert full_bundle.artifacts[file_name]["eventType"] == event_type
|
||
|
|
exported = output / "scenarios" / f"{file_name}.json"
|
||
|
|
assert exported.is_file()
|
||
|
|
assert (
|
||
|
|
json.loads(exported.read_text(encoding="utf-8"))["eventType"] == event_type
|
||
|
|
)
|
||
|
|
events_root = output / "scenarios/events"
|
||
|
|
assert events_root.is_dir()
|
||
|
|
event_dirs = sorted(path for path in events_root.iterdir() if path.is_dir())
|
||
|
|
assert len(event_dirs) == 15
|
||
|
|
assert all(
|
||
|
|
{"input.json", "before.json", "after.json", "diff.json"}
|
||
|
|
<= {path.name for path in event_dir.iterdir()}
|
||
|
|
for event_dir in event_dirs
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_execution_quality_six_tables_and_rework_reinspection_chain(
|
||
|
|
full_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
"""NCR rework must consume a distinct finite slot and obey its timeline."""
|
||
|
|
table_names = (
|
||
|
|
"mes-orders",
|
||
|
|
"operation-reports",
|
||
|
|
"material-issues",
|
||
|
|
"quality-inspections",
|
||
|
|
"nonconformities",
|
||
|
|
"rework-orders",
|
||
|
|
)
|
||
|
|
assert all(full_bundle.rows(name) for name in table_names)
|
||
|
|
operations = {str(row["operationId"]) for row in full_bundle.rows("operations")}
|
||
|
|
resources = {str(row["resourceId"]) for row in full_bundle.rows("resources")}
|
||
|
|
teams = {str(row["teamId"]) for row in full_bundle.rows("teams")}
|
||
|
|
baseline_slots = {
|
||
|
|
str(row["scheduleSlotId"]): row for row in full_bundle.rows("schedule-slots")
|
||
|
|
}
|
||
|
|
rework_operations = {
|
||
|
|
str(row["reworkOperationId"]): row
|
||
|
|
for row in full_bundle.rows("rework-operations")
|
||
|
|
}
|
||
|
|
rework_slots = {
|
||
|
|
str(row["reworkScheduleSlotId"]): row
|
||
|
|
for row in full_bundle.rows("rework-schedule-slots")
|
||
|
|
}
|
||
|
|
inspections = {
|
||
|
|
str(row["inspectionId"]): row
|
||
|
|
for row in full_bundle.rows("quality-inspections")
|
||
|
|
}
|
||
|
|
rework_orders = {
|
||
|
|
str(row["reworkOrderId"]): row
|
||
|
|
for row in full_bundle.rows("rework-orders")
|
||
|
|
}
|
||
|
|
nonconformities = full_bundle.rows("nonconformities")
|
||
|
|
assert len(nonconformities) == len(rework_orders) == len(rework_slots) == 50
|
||
|
|
assert len(rework_operations) == 50
|
||
|
|
assert any(
|
||
|
|
row.get("holdPoint")
|
||
|
|
and "HOLD" in row["stateHistory"]
|
||
|
|
and "RELEASED" in row["stateHistory"]
|
||
|
|
for row in inspections.values()
|
||
|
|
)
|
||
|
|
for nonconformity in nonconformities:
|
||
|
|
assert nonconformity["status"] == "CLOSED"
|
||
|
|
initial = inspections[nonconformity["inspectionId"]]
|
||
|
|
reinspection = inspections[nonconformity["reinspectionId"]]
|
||
|
|
rework = rework_orders[nonconformity["reworkOrderId"]]
|
||
|
|
rework_slot = rework_slots[rework["reworkScheduleSlotId"]]
|
||
|
|
assert initial["nonconformityId"] == nonconformity["nonconformityId"]
|
||
|
|
assert initial["reworkOrderId"] == rework["reworkOrderId"]
|
||
|
|
assert reinspection["inspectionType"] == "REINSPECTION"
|
||
|
|
assert reinspection["status"] == "CLOSED"
|
||
|
|
assert {"REINSPECTION", "PASSED", "CLOSED"} <= set(
|
||
|
|
reinspection["stateHistory"]
|
||
|
|
)
|
||
|
|
assert rework["status"] == "CLOSED"
|
||
|
|
assert rework["sourceOperationId"] in operations
|
||
|
|
assert rework["reworkOperationId"] in rework_operations
|
||
|
|
assert rework["resourceId"] in resources
|
||
|
|
assert rework["teamId"] in teams
|
||
|
|
assert rework["sourceScheduleSlotId"] in baseline_slots
|
||
|
|
assert rework["reworkScheduleSlotId"] not in baseline_slots
|
||
|
|
assert rework_slot["resourceId"] == rework["resourceId"]
|
||
|
|
assert rework_slot["teamId"] == rework["teamId"]
|
||
|
|
assert rework_slot["finiteCapacityReserved"] is True
|
||
|
|
assert math.isclose(
|
||
|
|
float(rework["plannedHours"]),
|
||
|
|
float(rework_slot["durationHours"]),
|
||
|
|
abs_tol=0.01,
|
||
|
|
)
|
||
|
|
assert rework["reinspectionRequired"] is True
|
||
|
|
assert "REINSPECTION_PASSED" in rework["stateHistory"]
|
||
|
|
timeline = [
|
||
|
|
initial["heldAt"],
|
||
|
|
initial["inspectedAt"],
|
||
|
|
nonconformity["openedAt"],
|
||
|
|
rework_slot["start"],
|
||
|
|
rework_slot["end"],
|
||
|
|
reinspection["inspectedAt"],
|
||
|
|
reinspection["releasedAt"],
|
||
|
|
nonconformity["closedAt"],
|
||
|
|
]
|
||
|
|
parsed = [datetime.fromisoformat(value) for value in timeline]
|
||
|
|
assert all(left <= right for left, right in pairwise(parsed))
|
||
|
|
|
||
|
|
baseline_intervals: defaultdict[tuple[str, str], list[tuple[datetime, datetime]]] = defaultdict(list)
|
||
|
|
for slot in baseline_slots.values():
|
||
|
|
interval = (datetime.fromisoformat(slot["start"]), datetime.fromisoformat(slot["end"]))
|
||
|
|
baseline_intervals[("resource", str(slot["resourceId"]))].append(interval)
|
||
|
|
baseline_intervals[("team", str(slot["teamId"]))].append(interval)
|
||
|
|
for slot in rework_slots.values():
|
||
|
|
start, end = datetime.fromisoformat(slot["start"]), datetime.fromisoformat(slot["end"])
|
||
|
|
for owner in (("resource", str(slot["resourceId"])), ("team", str(slot["teamId"]))):
|
||
|
|
assert all(end <= left or right <= start for left, right in baseline_intervals[owner])
|
||
|
|
|
||
|
|
|
||
|
|
def test_incremental_export_skips_unchanged_and_tombstones_removed_file(
|
||
|
|
tmp_path: Path,
|
||
|
|
small_config: GeneratorConfig,
|
||
|
|
small_bundle: DatasetBundle,
|
||
|
|
) -> None:
|
||
|
|
output = tmp_path / "dataset"
|
||
|
|
first_manifest = export_bundle(small_bundle, small_config, output)
|
||
|
|
obsolete = output / "obsolete-synthetic-artifact.txt"
|
||
|
|
obsolete.write_text("obsolete", encoding="utf-8")
|
||
|
|
manifest_path = output / "manifest.json"
|
||
|
|
persisted = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
|
|
persisted["files"].append(
|
||
|
|
{
|
||
|
|
"path": obsolete.name,
|
||
|
|
"kind": "text",
|
||
|
|
"bytes": obsolete.stat().st_size,
|
||
|
|
"rowCount": None,
|
||
|
|
"sha256": _sha256(obsolete),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
persisted["fileCount"] += 1
|
||
|
|
manifest_path.write_text(
|
||
|
|
json.dumps(persisted, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
second_manifest = export_bundle(
|
||
|
|
small_bundle, small_config, output, incremental=True
|
||
|
|
)
|
||
|
|
assert second_manifest["businessDigest"] == first_manifest["businessDigest"]
|
||
|
|
assert second_manifest["incremental"]["enabled"] is True
|
||
|
|
assert second_manifest["incremental"]["skippedFiles"]
|
||
|
|
assert obsolete.name in second_manifest["incremental"]["tombstones"]
|
||
|
|
assert not obsolete.exists()
|
||
|
|
|
||
|
|
|
||
|
|
def test_isolated_worldstore_loads_consumable_flex_projection_without_runtime_pollution(
|
||
|
|
tmp_path: Path,
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
deterministic_small_exports: tuple[Path, Path, dict[str, Any], dict[str, Any]],
|
||
|
|
preserve_real_runtime_data: dict[str, str],
|
||
|
|
) -> None:
|
||
|
|
first, _, _, _ = deterministic_small_exports
|
||
|
|
isolated_world = tmp_path / "world.json"
|
||
|
|
shutil.copy2(first / "world.json", isolated_world)
|
||
|
|
source_hash = _sha256(first / "world.json")
|
||
|
|
monkeypatch.setenv("APS_DB_DISABLED", "1")
|
||
|
|
from server.aps_domain.flex import flex_gantt_view, flex_overview
|
||
|
|
from server.aps_domain.mrp import list_mrp
|
||
|
|
from server.aps_domain.sourcing import infer_material_sourcing
|
||
|
|
from server.state.store import WorldStore
|
||
|
|
|
||
|
|
store = WorldStore(
|
||
|
|
path=str(isolated_world),
|
||
|
|
world_key="default",
|
||
|
|
tenant_uuid="synthetic",
|
||
|
|
)
|
||
|
|
world = store.data
|
||
|
|
for key in (
|
||
|
|
"flexMaterials",
|
||
|
|
"flexBom",
|
||
|
|
"flexOrders",
|
||
|
|
"flexRoutings",
|
||
|
|
"flexEquipment",
|
||
|
|
"flexScheduleVersions",
|
||
|
|
"flexWorkOrders",
|
||
|
|
):
|
||
|
|
assert world.get(key), key
|
||
|
|
overview = flex_overview(world)
|
||
|
|
assert overview["materials"]
|
||
|
|
assert overview["orders"]
|
||
|
|
assert overview["equipment"]
|
||
|
|
assert overview["latestVersion"]
|
||
|
|
assert overview["workOrders"]
|
||
|
|
gantt = flex_gantt_view(world)
|
||
|
|
assert gantt["versionNo"]
|
||
|
|
assert gantt["equipment"]
|
||
|
|
assert gantt["workOrders"]
|
||
|
|
assert all(
|
||
|
|
{
|
||
|
|
"productCode",
|
||
|
|
"quantity",
|
||
|
|
"operationName",
|
||
|
|
"seq",
|
||
|
|
"equipmentCode",
|
||
|
|
"equipmentName",
|
||
|
|
"start",
|
||
|
|
"end",
|
||
|
|
}
|
||
|
|
<= set(row)
|
||
|
|
for row in gantt["workOrders"]
|
||
|
|
)
|
||
|
|
mrp_projection = list_mrp(world)
|
||
|
|
assert mrp_projection["make"]
|
||
|
|
assert set(mrp_projection) == {
|
||
|
|
"make",
|
||
|
|
"purchaseOrders",
|
||
|
|
"outsourceOrders",
|
||
|
|
"productionOrders",
|
||
|
|
}
|
||
|
|
first_material = world["flexMaterials"][0]
|
||
|
|
assert infer_material_sourcing(first_material) == first_material["sourcingMode"]
|
||
|
|
material_ids = {str(row["id"]) for row in world["flexMaterials"]}
|
||
|
|
order_nos = {str(row["orderNo"]) for row in world["flexOrders"]}
|
||
|
|
version_ids = {str(row["id"]) for row in world["flexScheduleVersions"]}
|
||
|
|
assert all(str(row["childCode"]) in material_ids for row in world["flexBom"])
|
||
|
|
assert all(str(row["flexOrderNo"]) in order_nos for row in world["flexWorkOrders"])
|
||
|
|
assert all(str(row["versionId"]) in version_ids for row in world["flexWorkOrders"])
|
||
|
|
assert _sha256(first / "world.json") == source_hash
|
||
|
|
assert _existing_hashes(REAL_DATA_FILES) == preserve_real_runtime_data
|
||
|
|
|
||
|
|
|
||
|
|
def test_synthetic_disclaimer_and_naming_boundaries(full_bundle: DatasetBundle) -> None:
|
||
|
|
assert full_bundle.metadata["datasetType"] == "SYNTHETIC"
|
||
|
|
assert full_bundle.metadata["organizationScenario"] == "BEIHAI_SHIPYARD_APS"
|
||
|
|
assert full_bundle.metadata["timezone"] == "Asia/Shanghai"
|
||
|
|
assert full_bundle.metadata["generatedFor"] == "APS development and validation"
|
||
|
|
assert full_bundle.metadata["dataDisclaimer"] == DATA_DISCLAIMER
|
||
|
|
assert full_bundle.metadata["planningBaseDate"] == "2026-09-01"
|
||
|
|
assert full_bundle.metadata["planningHorizonEnd"] == "2027-12-31"
|
||
|
|
assert all(
|
||
|
|
str(row["projectCode"]).startswith("BH-SYN-")
|
||
|
|
for row in full_bundle.rows("ship-projects")
|
||
|
|
)
|
||
|
|
assert all(
|
||
|
|
str(row["shipOwner"]).startswith("船东-SYN-")
|
||
|
|
for row in full_bundle.rows("ship-projects")
|
||
|
|
)
|
||
|
|
assert all(
|
||
|
|
str(row["supplierName"]).startswith("供应商-SYN-")
|
||
|
|
for row in full_bundle.rows("suppliers")
|
||
|
|
)
|
||
|
|
assert all("模拟" in str(row["name"]) for row in full_bundle.rows("employees"))
|
||
|
|
assert all(
|
||
|
|
row.get("datasetType") == "SYNTHETIC"
|
||
|
|
for row in full_bundle.rows("ship-projects")
|
||
|
|
)
|