Add exterior/interior pairing check and Ranger/CV series permutation support
- New check: exterior_interior_pairing_check — validates that every exterior base asset has a matching interior base asset (same feature set, excluding paint/trim codes), handles MEC 2d-background items separately - check_series_permutations: added Ranger/CV model detection via header.ptvl (TR[AB]## pattern), uses SE#/ABM codes instead of VS/ACM for those packs - unzip_and_verify_check: added fullLoad header validation (must be "Y") - profiles/ford_bnp.json: added exterior_interior_pairing_check to production profile - html_reporter.py: added friendly name mappings and HTML formatter for new pairing check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1002ebc61b
commit
44de833c45
5 changed files with 419 additions and 47 deletions
|
|
@ -1,17 +1,31 @@
|
|||
import os
|
||||
import re
|
||||
import json
|
||||
from typing import Dict, List, Set, Tuple, Any
|
||||
|
||||
|
||||
def _is_ranger_model(linking_data: dict) -> bool:
|
||||
"""
|
||||
Detect if this is a Ranger (CV) model pack by checking header.ptvl.
|
||||
Ranger packs use SE#/ABM codes instead of VS/ACM codes.
|
||||
|
||||
:param linking_data: Parsed linkingrecord.json data
|
||||
:return: True if this is a Ranger/CV model, False otherwise
|
||||
"""
|
||||
ptvl = linking_data.get('header', {}).get('ptvl', '')
|
||||
return bool(re.match(r'^TR[AB]\d{2}$', str(ptvl), re.IGNORECASE))
|
||||
|
||||
|
||||
def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate that series images exist for every unique VS/ACM combination found in exterior and showroom sections.
|
||||
Validate that series images exist for every unique VS/ACM (or SE#/ABM for Ranger) combination
|
||||
found in exterior and showroom sections.
|
||||
|
||||
This check validates that the "series" section contains an image for every unique combination of
|
||||
VS- (Visual Selection) and ACM (Accessory Content Management) codes that appears in the
|
||||
exterior and showroom sections. The validation creates a "source of truth" by extracting all
|
||||
unique [ACM, VS] pairs from exterior and showroom records, then ensures the series section
|
||||
provides an image for each combination.
|
||||
For standard packs: validates VS- (Visual Selection) and ACM (Accessory Content Management) pairs.
|
||||
For Ranger/CV packs (header.ptvl matches TR[AB]##): validates SE# and ABM code pairs instead.
|
||||
|
||||
The validation creates a "source of truth" by extracting all unique code pairs from exterior
|
||||
and showroom records, then ensures the series section provides an image for each combination.
|
||||
|
||||
:param config: Configuration dictionary containing:
|
||||
- working_dir: Directory where linkingrecord.json and extracted files are located
|
||||
|
|
@ -62,7 +76,34 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list."
|
||||
}
|
||||
|
||||
# STEP 1-3: Build "Source of Truth" - Extract unique [ACM, VS] pairs from exterior and showroom
|
||||
# Detect model type to determine which code prefixes to use
|
||||
is_ranger = _is_ranger_model(linkingrecord)
|
||||
|
||||
if is_ranger:
|
||||
# CV/Ranger model: uses SE# + ABM codes
|
||||
primary_prefix = "se#"
|
||||
secondary_prefix = "abm"
|
||||
code_label = "SE#/ABM"
|
||||
else:
|
||||
# Standard model: uses VS + ACM codes
|
||||
primary_prefix = "vs-"
|
||||
secondary_prefix = "acm"
|
||||
code_label = "VS/ACM"
|
||||
|
||||
def _extract_code_pair(features):
|
||||
"""Extract the primary (VS/SE#) and secondary (ACM/ABM) code from a feature list."""
|
||||
primary_code = None
|
||||
secondary_code = None
|
||||
for feature in features:
|
||||
if isinstance(feature, str):
|
||||
fl = feature.lower()
|
||||
if fl.startswith(primary_prefix):
|
||||
primary_code = feature
|
||||
elif fl.startswith(secondary_prefix):
|
||||
secondary_code = feature
|
||||
return primary_code, secondary_code
|
||||
|
||||
# STEP 1-3: Build "Source of Truth" - Extract unique code pairs from exterior and showroom
|
||||
source_of_truth = set()
|
||||
|
||||
for item in linkingrecord["items"]:
|
||||
|
|
@ -98,33 +139,26 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
if not isinstance(features, list):
|
||||
continue
|
||||
|
||||
# Extract the actual [ACM, VS] pair from this record's features
|
||||
# Extract the code pair from this record's features
|
||||
# Each record represents ONE combination, not a cross-product
|
||||
vs_code = None
|
||||
acm_code = None
|
||||
primary_code, secondary_code = _extract_code_pair(features)
|
||||
|
||||
for feature in features:
|
||||
if isinstance(feature, str):
|
||||
if feature.startswith("vs-"):
|
||||
vs_code = feature
|
||||
elif feature.startswith("acm"):
|
||||
acm_code = feature
|
||||
|
||||
# If this record has both ACM and VS, add the pair to source of truth
|
||||
if vs_code and acm_code:
|
||||
source_of_truth.add((acm_code, vs_code))
|
||||
# If this record has both codes, add the pair to source of truth
|
||||
if primary_code and secondary_code:
|
||||
source_of_truth.add((secondary_code, primary_code))
|
||||
|
||||
# Check if we found any valid combinations
|
||||
if not source_of_truth:
|
||||
return {
|
||||
"status": "passed",
|
||||
"details": {
|
||||
"message": "No VS/ACM combinations found in exterior or showroom sections. Check may not be applicable to this pack type.",
|
||||
"source_combinations_count": 0
|
||||
"message": f"No {code_label} combinations found in exterior or showroom sections. Check may not be applicable to this pack type.",
|
||||
"source_combinations_count": 0,
|
||||
"model_type": "Ranger/CV" if is_ranger else "Standard"
|
||||
}
|
||||
}
|
||||
|
||||
# STEP 4: Extract actual [ACM, VS] pairs from series section
|
||||
# STEP 4: Extract actual code pairs from series section
|
||||
series_combinations = set()
|
||||
series_section_found = False
|
||||
|
||||
|
|
@ -154,30 +188,22 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
if not isinstance(features, list):
|
||||
continue
|
||||
|
||||
# Extract the actual [ACM, VS] pair from this series record
|
||||
# Each series record represents ONE combination, not a cross-product
|
||||
vs_code = None
|
||||
acm_code = None
|
||||
# Extract the code pair from this series record
|
||||
primary_code, secondary_code = _extract_code_pair(features)
|
||||
|
||||
for feature in features:
|
||||
if isinstance(feature, str):
|
||||
if feature.startswith("vs-"):
|
||||
vs_code = feature
|
||||
elif feature.startswith("acm"):
|
||||
acm_code = feature
|
||||
|
||||
# If this record has both ACM and VS, add the pair
|
||||
if vs_code and acm_code:
|
||||
series_combinations.add((acm_code, vs_code))
|
||||
# If this record has both codes, add the pair
|
||||
if primary_code and secondary_code:
|
||||
series_combinations.add((secondary_code, primary_code))
|
||||
|
||||
# Check if series section exists
|
||||
if not series_section_found:
|
||||
return {
|
||||
"status": "failed",
|
||||
"details": {
|
||||
"message": "No series section found (viewtype='carousel', imagetype='series') in linkingrecord.",
|
||||
"message": f"No series section found (viewtype='carousel', imagetype='series') in linkingrecord.",
|
||||
"source_combinations_count": len(source_of_truth),
|
||||
"source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)]
|
||||
"model_type": "Ranger/CV" if is_ranger else "Standard",
|
||||
"source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,17 +214,18 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
# Generate detailed report
|
||||
if missing_combinations:
|
||||
error_details = {
|
||||
"message": "Series validation failed: Some VS/ACM combinations from exterior/showroom are missing series images.",
|
||||
"message": f"Series validation failed: Some {code_label} combinations from exterior/showroom are missing series images.",
|
||||
"model_type": "Ranger/CV" if is_ranger else "Standard",
|
||||
"source_combinations_count": len(source_of_truth),
|
||||
"series_combinations_count": len(series_combinations),
|
||||
"missing_count": len(missing_combinations),
|
||||
"missing_combinations": [f"({acm}, {vs})" for acm, vs in sorted(missing_combinations)]
|
||||
"missing_combinations": [f"({sec}, {pri})" for sec, pri in sorted(missing_combinations)]
|
||||
}
|
||||
|
||||
if extra_combinations:
|
||||
error_details["extra_count"] = len(extra_combinations)
|
||||
error_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)]
|
||||
error_details["extra_note"] = "Series section contains combinations not found in exterior/showroom."
|
||||
error_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)]
|
||||
error_details["extra_note"] = f"Series section contains {code_label} combinations not found in exterior/showroom."
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
|
|
@ -207,14 +234,15 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
|
||||
# All required combinations are present in series
|
||||
success_details = {
|
||||
"message": "All VS/ACM combinations from exterior and showroom have corresponding series images.",
|
||||
"message": f"All {code_label} combinations from exterior and showroom have corresponding series images.",
|
||||
"model_type": "Ranger/CV" if is_ranger else "Standard",
|
||||
"validated_combinations_count": len(source_of_truth),
|
||||
"source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)]
|
||||
"source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)]
|
||||
}
|
||||
|
||||
if extra_combinations:
|
||||
success_details["note"] = f"{len(extra_combinations)} additional series combinations found that don't appear in exterior/showroom."
|
||||
success_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)]
|
||||
success_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)]
|
||||
|
||||
return {
|
||||
"status": "passed",
|
||||
|
|
|
|||
221
checks/exterior_interior_pairing_check.py
Normal file
221
checks/exterior_interior_pairing_check.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import os
|
||||
import json
|
||||
from typing import Dict, Any, FrozenSet, Set, List
|
||||
|
||||
|
||||
def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate that every exterior base asset has a matching interior base asset with the same
|
||||
feature set (excluding paint and trim codes), and vice versa.
|
||||
|
||||
For each exterior item (viewtype="exterior", base imagetype/layer), the set of features
|
||||
minus paint codes must match at least one interior item's feature set minus trim codes.
|
||||
Both directions are checked: exterior items without an interior partner, and interior items
|
||||
without an exterior partner.
|
||||
|
||||
For MEC packs, items with experienceCondition="2d-background" are validated separately.
|
||||
|
||||
:param config: Configuration dictionary containing:
|
||||
- working_dir: Directory where linkingrecord.json is located
|
||||
- linkingrecord_filename: The linking record filename (default: 'linkingrecord.json')
|
||||
- paint_code_prefix: Prefix for paint codes to exclude from exterior features (default: 'pn')
|
||||
- trim_code_prefix: Prefix for trim codes to exclude from interior features (default: 'tr-')
|
||||
|
||||
:return: Dictionary with validation results:
|
||||
- status: "passed", "failed", or "error"
|
||||
- details: Breakdown of mismatches by direction and MEC condition
|
||||
"""
|
||||
working_dir = config.get("working_dir", "working")
|
||||
linkingrecord_filename = config.get("linkingrecord_filename", "linkingrecord.json")
|
||||
paint_prefix = config.get("paint_code_prefix", "pn").lower()
|
||||
trim_prefix = config.get("trim_code_prefix", "tr-").lower()
|
||||
linkingrecord_path = os.path.join(working_dir, linkingrecord_filename)
|
||||
|
||||
if not os.path.exists(linkingrecord_path):
|
||||
return {
|
||||
"status": "error",
|
||||
"error_message": f"Linking record file '{linkingrecord_filename}' not found in {working_dir}."
|
||||
}
|
||||
|
||||
try:
|
||||
with open(linkingrecord_path, 'r', encoding='utf-8') as f:
|
||||
linkingrecord = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error_message": f"Malformed JSON in '{linkingrecord_filename}': {str(e)}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error_message": f"Error reading '{linkingrecord_filename}': {str(e)}"
|
||||
}
|
||||
|
||||
if not isinstance(linkingrecord, dict):
|
||||
return {
|
||||
"status": "error",
|
||||
"error_message": "Invalid linkingrecord.json structure: root is not an object."
|
||||
}
|
||||
|
||||
if "items" not in linkingrecord or not isinstance(linkingrecord["items"], list):
|
||||
return {
|
||||
"status": "error",
|
||||
"error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list."
|
||||
}
|
||||
|
||||
def _is_base_item(item: dict) -> bool:
|
||||
"""A base item has no imagetype (None) or imagetype='base', and layer=0 or no layer."""
|
||||
conditions = item.get("conditions", {})
|
||||
if not isinstance(conditions, dict):
|
||||
return False
|
||||
imagetype = conditions.get("imagetype")
|
||||
layer = conditions.get("layer", 0)
|
||||
return imagetype in (None, "base", "") and (layer == 0 or layer is None)
|
||||
|
||||
def _strip_paint_codes(features: List[str]) -> FrozenSet[str]:
|
||||
"""Remove paint codes (pn prefix) from exterior feature list."""
|
||||
return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(paint_prefix))
|
||||
|
||||
def _strip_trim_codes(features: List[str]) -> FrozenSet[str]:
|
||||
"""Remove trim codes (configurable prefix) from interior feature list."""
|
||||
return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(trim_prefix))
|
||||
|
||||
def _collect_feature_sets(items_list, viewtype_val: str, strip_fn, mec_only: bool = False) -> Set[FrozenSet[str]]:
|
||||
"""Collect normalised feature sets for all base records of the given viewtype."""
|
||||
result = set()
|
||||
for item in items_list:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
conditions = item.get("conditions", {})
|
||||
if not isinstance(conditions, dict):
|
||||
continue
|
||||
if conditions.get("viewtype") != viewtype_val:
|
||||
continue
|
||||
if not _is_base_item(item):
|
||||
continue
|
||||
exp_condition = conditions.get("experienceCondition", "")
|
||||
is_mec = exp_condition == "2d-background"
|
||||
if mec_only and not is_mec:
|
||||
continue
|
||||
if not mec_only and is_mec:
|
||||
continue
|
||||
for record in item.get("records", []):
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
features = record.get("features", [])
|
||||
if not isinstance(features, list):
|
||||
continue
|
||||
normalised = strip_fn(features)
|
||||
if normalised:
|
||||
result.add(normalised)
|
||||
return result
|
||||
|
||||
def _run_pairing_check(items_list, mec_only: bool = False) -> dict:
|
||||
"""Compare exterior and interior feature sets and return mismatch details."""
|
||||
ext_sets = _collect_feature_sets(items_list, "exterior", _strip_paint_codes, mec_only)
|
||||
int_sets = _collect_feature_sets(items_list, "interior", _strip_trim_codes, mec_only)
|
||||
|
||||
if not ext_sets and not int_sets:
|
||||
return {"applicable": False}
|
||||
|
||||
ext_only = ext_sets - int_sets
|
||||
int_only = int_sets - ext_sets
|
||||
|
||||
return {
|
||||
"applicable": True,
|
||||
"exterior_count": len(ext_sets),
|
||||
"interior_count": len(int_sets),
|
||||
"exterior_without_interior": len(ext_only),
|
||||
"interior_without_exterior": len(int_only),
|
||||
"ext_only_feature_sets": [sorted(list(s)) for s in sorted(ext_only, key=lambda x: sorted(x))],
|
||||
"int_only_feature_sets": [sorted(list(s)) for s in sorted(int_only, key=lambda x: sorted(x))],
|
||||
}
|
||||
|
||||
items = linkingrecord["items"]
|
||||
|
||||
# Standard pairing check (excluding 2d-background MEC items)
|
||||
standard = _run_pairing_check(items, mec_only=False)
|
||||
|
||||
# MEC 2d-background pairing check
|
||||
mec = _run_pairing_check(items, mec_only=True)
|
||||
|
||||
# Determine if any items had the MEC condition
|
||||
has_mec_items = mec.get("applicable", False)
|
||||
|
||||
# Evaluate overall pass/fail
|
||||
standard_pass = not standard.get("applicable") or (
|
||||
standard.get("exterior_without_interior", 0) == 0 and
|
||||
standard.get("interior_without_exterior", 0) == 0
|
||||
)
|
||||
mec_pass = not mec.get("applicable") or (
|
||||
mec.get("exterior_without_interior", 0) == 0 and
|
||||
mec.get("interior_without_exterior", 0) == 0
|
||||
)
|
||||
|
||||
if not standard.get("applicable") and not has_mec_items:
|
||||
return {
|
||||
"status": "passed",
|
||||
"details": {
|
||||
"message": "No exterior or interior base items found. Check not applicable to this pack.",
|
||||
"applicable": False
|
||||
}
|
||||
}
|
||||
|
||||
overall_pass = standard_pass and mec_pass
|
||||
status = "passed" if overall_pass else "failed"
|
||||
|
||||
details: Dict[str, Any] = {}
|
||||
|
||||
if standard.get("applicable"):
|
||||
details["standard"] = {
|
||||
"exterior_base_sets": standard["exterior_count"],
|
||||
"interior_base_sets": standard["interior_count"],
|
||||
"exterior_without_interior_partner": standard["exterior_without_interior"],
|
||||
"interior_without_exterior_partner": standard["interior_without_exterior"],
|
||||
}
|
||||
if standard["exterior_without_interior"] > 0:
|
||||
details["standard"]["exterior_only_feature_sets"] = standard["ext_only_feature_sets"]
|
||||
if standard["interior_without_exterior"] > 0:
|
||||
details["standard"]["interior_only_feature_sets"] = standard["int_only_feature_sets"]
|
||||
|
||||
if has_mec_items:
|
||||
details["mec_2d_background"] = {
|
||||
"exterior_base_sets": mec["exterior_count"],
|
||||
"interior_base_sets": mec["interior_count"],
|
||||
"exterior_without_interior_partner": mec["exterior_without_interior"],
|
||||
"interior_without_exterior_partner": mec["interior_without_exterior"],
|
||||
}
|
||||
if mec["exterior_without_interior"] > 0:
|
||||
details["mec_2d_background"]["exterior_only_feature_sets"] = mec["ext_only_feature_sets"]
|
||||
if mec["interior_without_exterior"] > 0:
|
||||
details["mec_2d_background"]["interior_only_feature_sets"] = mec["int_only_feature_sets"]
|
||||
|
||||
if overall_pass:
|
||||
msg_parts = []
|
||||
if standard.get("applicable"):
|
||||
msg_parts.append(f"all {standard['exterior_count']} exterior/interior base pairs matched")
|
||||
if has_mec_items:
|
||||
msg_parts.append(f"all {mec['exterior_count']} MEC 2d-background pairs matched")
|
||||
details["message"] = "Exterior/interior pairing passed: " + "; ".join(msg_parts) + "."
|
||||
else:
|
||||
msg_parts = []
|
||||
if standard.get("applicable") and not standard_pass:
|
||||
ext_miss = standard["exterior_without_interior"]
|
||||
int_miss = standard["interior_without_exterior"]
|
||||
if ext_miss:
|
||||
msg_parts.append(f"{ext_miss} exterior base set(s) with no interior partner")
|
||||
if int_miss:
|
||||
msg_parts.append(f"{int_miss} interior base set(s) with no exterior partner")
|
||||
if has_mec_items and not mec_pass:
|
||||
ext_miss = mec["exterior_without_interior"]
|
||||
int_miss = mec["interior_without_exterior"]
|
||||
if ext_miss:
|
||||
msg_parts.append(f"{ext_miss} MEC exterior base set(s) with no interior partner")
|
||||
if int_miss:
|
||||
msg_parts.append(f"{int_miss} MEC interior base set(s) with no exterior partner")
|
||||
details["message"] = "Exterior/interior pairing failed: " + "; ".join(msg_parts) + "."
|
||||
|
||||
details["paint_code_prefix"] = paint_prefix
|
||||
details["trim_code_prefix"] = trim_prefix
|
||||
|
||||
return {"status": status, "details": details}
|
||||
|
|
@ -172,7 +172,9 @@ class HTMLReporter:
|
|||
"image_linking_check": "Image Link References Check",
|
||||
"business_data_check": "Business Data Validation",
|
||||
"image_format_check": "Image Format Validation",
|
||||
"layer_depth_check": "Layer Depth Validation"
|
||||
"layer_depth_check": "Layer Depth Validation",
|
||||
"check_series_permutations": "Series Permutations Validation",
|
||||
"exterior_interior_pairing_check": "Exterior / Interior Base Asset Pairing"
|
||||
}
|
||||
|
||||
# Return the friendly name or format the module name if not in the mapping
|
||||
|
|
@ -326,6 +328,8 @@ class HTMLReporter:
|
|||
elif 'record_results' in details and 'overall_coverage' in details:
|
||||
# Extra carousel validation check
|
||||
formatted_parts.append(HTMLReporter._format_extra_carousel_validation(details))
|
||||
elif check_type == "exterior_interior_pairing_check" or 'standard' in details or 'mec_2d_background' in details:
|
||||
formatted_parts.append(HTMLReporter._format_exterior_interior_pairing(details))
|
||||
# If no special formatting applied so far, use generic formatting
|
||||
elif not formatted_parts:
|
||||
formatted_parts.append(f'''
|
||||
|
|
@ -1576,6 +1580,107 @@ class HTMLReporter:
|
|||
'''
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _format_exterior_interior_pairing(details: dict) -> str:
|
||||
"""Format exterior/interior base asset pairing check results."""
|
||||
try:
|
||||
def _feature_set_table(feature_sets: list, label: str, badge_class: str) -> str:
|
||||
if not feature_sets:
|
||||
return ""
|
||||
rows = "".join(
|
||||
f"<tr><td>{i+1}</td><td><code>{', '.join(fs)}</code></td></tr>"
|
||||
for i, fs in enumerate(feature_sets)
|
||||
)
|
||||
return f'''
|
||||
<div class="mb-3">
|
||||
<h6 class="text-danger">{label} ({len(feature_sets)})</h6>
|
||||
<table class="table table-sm table-hover table-striped">
|
||||
<thead class="{badge_class}">
|
||||
<tr><th>#</th><th>Feature Set (normalised)</th></tr>
|
||||
</thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
'''
|
||||
|
||||
def _render_section(section_data: dict, title: str) -> str:
|
||||
if not section_data:
|
||||
return ""
|
||||
ext_count = section_data.get("exterior_base_sets", 0)
|
||||
int_count = section_data.get("interior_base_sets", 0)
|
||||
ext_miss = section_data.get("exterior_without_interior_partner", 0)
|
||||
int_miss = section_data.get("interior_without_exterior_partner", 0)
|
||||
passed = ext_miss == 0 and int_miss == 0
|
||||
header_class = "table-success" if passed else "table-danger"
|
||||
|
||||
summary = f'''
|
||||
<div class="mb-4">
|
||||
<h6>{title}</h6>
|
||||
<table class="table table-sm table-bordered" style="width:auto">
|
||||
<thead class="{header_class}">
|
||||
<tr><th>Metric</th><th>Count</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Exterior base sets</td><td>{ext_count}</td></tr>
|
||||
<tr><td>Interior base sets</td><td>{int_count}</td></tr>
|
||||
<tr><td>Exterior without interior partner</td>
|
||||
<td><span class="badge {'bg-success' if ext_miss == 0 else 'bg-danger'}">{ext_miss}</span></td></tr>
|
||||
<tr><td>Interior without exterior partner</td>
|
||||
<td><span class="badge {'bg-success' if int_miss == 0 else 'bg-danger'}">{int_miss}</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
'''
|
||||
|
||||
tables = ""
|
||||
if "exterior_only_feature_sets" in section_data:
|
||||
tables += _feature_set_table(
|
||||
section_data["exterior_only_feature_sets"],
|
||||
"Exterior Sets Without an Interior Partner",
|
||||
"table-danger"
|
||||
)
|
||||
if "interior_only_feature_sets" in section_data:
|
||||
tables += _feature_set_table(
|
||||
section_data["interior_only_feature_sets"],
|
||||
"Interior Sets Without an Exterior Partner",
|
||||
"table-warning"
|
||||
)
|
||||
|
||||
return summary + tables + "</div>"
|
||||
|
||||
parts = []
|
||||
|
||||
standard = details.get("standard")
|
||||
if standard:
|
||||
parts.append(_render_section(standard, "Standard Base Items"))
|
||||
|
||||
mec = details.get("mec_2d_background")
|
||||
if mec:
|
||||
parts.append(_render_section(mec, "MEC 2D-Background Items"))
|
||||
|
||||
if not parts:
|
||||
return '<div class="alert alert-info">No pairing data available.</div>'
|
||||
|
||||
paint_prefix = details.get("paint_code_prefix", "pn")
|
||||
trim_prefix = details.get("trim_code_prefix", "tr-")
|
||||
parts.append(f'''
|
||||
<div class="alert alert-info mt-2">
|
||||
<small>Paint codes excluded from exterior features: prefix <code>{paint_prefix}</code> |
|
||||
Trim codes excluded from interior features: prefix <code>{trim_prefix}</code></small>
|
||||
</div>
|
||||
''')
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Error formatting exterior/interior pairing: {e}")
|
||||
return f'''
|
||||
<div class="alert alert-warning">
|
||||
Error formatting exterior/interior pairing: {str(e)}
|
||||
</div>
|
||||
<pre>{json.dumps(details, indent=2, default=str)}</pre>
|
||||
'''
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if len(sys.argv) != 3:
|
||||
|
|
|
|||
|
|
@ -107,6 +107,15 @@ def validate_linkingrecord_header(linkingrecord_data):
|
|||
"error": "Must be a string value"
|
||||
})
|
||||
|
||||
# Validate fullLoad (must always be "Y")
|
||||
full_load = header.get("fullLoad", "")
|
||||
if full_load != "Y":
|
||||
errors.append({
|
||||
"field": "fullLoad",
|
||||
"value": full_load,
|
||||
"error": "Must be exactly 'Y' (full load packs only)"
|
||||
})
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
def validate_linking_record_filetypes(linkingrecord_data):
|
||||
|
|
|
|||
|
|
@ -97,5 +97,14 @@
|
|||
"working_dir": "__WORKING_DIR__",
|
||||
"linkingrecord_filename": "linkingrecord.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"script": "checks.exterior_interior_pairing_check",
|
||||
"config": {
|
||||
"working_dir": "__WORKING_DIR__",
|
||||
"linkingrecord_filename": "linkingrecord.json",
|
||||
"paint_code_prefix": "pn",
|
||||
"trim_code_prefix": "tr-"
|
||||
}
|
||||
}
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue