| {source.sourceLabel}{source.terminalLabel || source.sourceKind || '未维护终端'} |
{source.protocol}{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'} |
@@ -64,12 +65,12 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km |
{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}{source.selectionReason || '等待选举说明'} |
-
- setPriority(Number(event.target.value))} />
+
+ setPriority(Number(event.target.value))} />
setProviderName(event.target.value)} placeholder="提供方,如 G7s" />
setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
- setRemark(event.target.value)} placeholder="启停或优先级调整原因(可选)" />
-
+ setRemark(event.target.value)} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
+
{save.isError ? {save.error instanceof Error ? save.error.message : '保存失败'} : null}
|
;
diff --git a/vehicle-data-platform/deploy/import-source-providers.py b/vehicle-data-platform/deploy/import-source-providers.py
new file mode 100644
index 00000000..f147cd06
--- /dev/null
+++ b/vehicle-data-platform/deploy/import-source-providers.py
@@ -0,0 +1,379 @@
+#!/usr/bin/env python3
+
+from __future__ import print_function
+
+import argparse
+import csv
+import json
+import os
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+
+SOURCE_REF_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+VIN_PATTERN = re.compile(r"^[A-Z0-9]{6,64}$")
+MAX_ROWS = 500
+
+
+class ImportError(RuntimeError):
+ pass
+
+
+class Client:
+ def __init__(self, base_url, token, timeout):
+ self.base_url = base_url.rstrip("/")
+ self.token = token
+ self.timeout = timeout
+
+ def data(self, method, path, payload=None):
+ body = None
+ headers = {"Authorization": "Bearer " + self.token}
+ if payload is not None:
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ headers["Content-Type"] = "application/json"
+ request = urllib.request.Request(
+ self.base_url + path,
+ data=body,
+ headers=headers,
+ method=method
+ )
+ try:
+ response = urllib.request.urlopen(request, timeout=self.timeout)
+ status = response.getcode()
+ raw = response.read()
+ except urllib.error.HTTPError as error:
+ status = error.code
+ raw = error.read()
+ if status < 200 or status >= 300:
+ code = ""
+ message = "HTTP {0}".format(status)
+ try:
+ envelope = json.loads(raw.decode("utf-8"))
+ code = envelope.get("code") or envelope.get("errorCode") or ""
+ message = envelope.get("message") or envelope.get("error") or message
+ except (ValueError, UnicodeDecodeError):
+ pass
+ detail = "{0}: {1}".format(code, message) if code else message
+ raise ImportError("{0} {1} failed: {2}".format(method, path, detail))
+ try:
+ envelope = json.loads(raw.decode("utf-8"))
+ except (ValueError, UnicodeDecodeError):
+ raise ImportError("{0} {1} returned invalid JSON".format(method, path))
+ if not isinstance(envelope, dict) or "data" not in envelope:
+ raise ImportError("{0} {1} returned no data envelope".format(method, path))
+ return envelope["data"]
+
+
+def clean(value):
+ return (value or "").strip()
+
+
+def read_rows(path):
+ rows = []
+ seen = set()
+ try:
+ handle = open(path, "r", encoding="utf-8-sig", newline="")
+ except (IOError, OSError) as error:
+ raise ImportError("cannot open CSV: {0}".format(error))
+ with handle:
+ reader = csv.DictReader(handle)
+ required = {"vin", "source_ref", "provider_name", "evidence"}
+ fields = set(reader.fieldnames or [])
+ missing = sorted(required - fields)
+ if missing:
+ raise ImportError("CSV missing columns: " + ",".join(missing))
+ for line_number, raw in enumerate(reader, 2):
+ vin = clean(raw.get("vin")).upper()
+ source_ref = clean(raw.get("source_ref")).lower()
+ provider_name = clean(raw.get("provider_name"))
+ evidence = clean(raw.get("evidence"))
+ expected_protocol = clean(raw.get("expected_protocol")).upper()
+ expected_current_provider = clean(raw.get("expected_current_provider"))
+ if not any((vin, source_ref, provider_name, evidence, expected_protocol, expected_current_provider)):
+ continue
+ if not VIN_PATTERN.match(vin):
+ raise ImportError("line {0}: invalid VIN".format(line_number))
+ if not SOURCE_REF_PATTERN.match(source_ref):
+ raise ImportError("line {0}: source_ref must be a 64-character lowercase SHA-256 value".format(line_number))
+ if not provider_name:
+ raise ImportError("line {0}: provider_name is required".format(line_number))
+ if len(provider_name) > 128:
+ raise ImportError("line {0}: provider_name exceeds 128 characters".format(line_number))
+ if not evidence:
+ raise ImportError("line {0}: evidence is required".format(line_number))
+ if len(evidence) > 255:
+ raise ImportError("line {0}: evidence exceeds 255 characters".format(line_number))
+ key = (vin, source_ref)
+ if key in seen:
+ raise ImportError("line {0}: duplicate VIN/source_ref".format(line_number))
+ seen.add(key)
+ rows.append({
+ "line": line_number,
+ "vin": vin,
+ "sourceRef": source_ref,
+ "providerName": provider_name,
+ "evidence": evidence,
+ "expectedProtocol": expected_protocol,
+ "expectedCurrentProvider": expected_current_provider
+ })
+ if len(rows) > MAX_ROWS:
+ raise ImportError("CSV exceeds the {0}-row safety limit".format(MAX_ROWS))
+ if not rows:
+ raise ImportError("CSV contains no provider rows")
+ return rows
+
+
+def diagnostic_path(vin):
+ return "/api/v2/operations/vehicles/{0}/sources".format(urllib.parse.quote(vin, safe=""))
+
+
+def update_path(vin, source_ref):
+ return "/api/v2/operations/vehicles/{0}/sources/{1}".format(
+ urllib.parse.quote(vin, safe=""),
+ urllib.parse.quote(source_ref, safe="")
+ )
+
+
+def sources(diagnostic):
+ evidence = diagnostic.get("evidence") if isinstance(diagnostic, dict) else None
+ result = evidence.get("locationSources") if isinstance(evidence, dict) else None
+ return result if isinstance(result, list) else []
+
+
+def find_source(diagnostic, row):
+ for source in sources(diagnostic):
+ if clean(source.get("sourceRef")).lower() == row["sourceRef"]:
+ return source
+ raise ImportError(
+ "line {0}: source_ref no longer exists for VIN {1}".format(row["line"], row["vin"])
+ )
+
+
+def policy_version(diagnostic, row):
+ policy = diagnostic.get("policy") if isinstance(diagnostic, dict) else None
+ version = policy.get("version") if isinstance(policy, dict) else None
+ if not isinstance(version, int) or version < 1:
+ raise ImportError(
+ "line {0}: invalid source policy version for VIN {1}".format(row["line"], row["vin"])
+ )
+ return version
+
+
+def effective_provider(source):
+ return clean(source.get("providerOverride")) or clean(source.get("sourceLabel"))
+
+
+def validate_source(row, source):
+ source_protocol = clean(source.get("protocol")).upper()
+ if row["expectedProtocol"] and source_protocol != row["expectedProtocol"]:
+ raise ImportError(
+ "line {0}: protocol changed from {1} to {2}".format(
+ row["line"], row["expectedProtocol"], source_protocol or "-"
+ )
+ )
+ current_provider = effective_provider(source)
+ if row["expectedCurrentProvider"] and current_provider != row["expectedCurrentProvider"]:
+ raise ImportError(
+ "line {0}: current provider changed from {1} to {2}".format(
+ row["line"], row["expectedCurrentProvider"], current_provider or "-"
+ )
+ )
+ priority = source.get("priority")
+ if not isinstance(priority, int) or priority < 1 or priority > 1000:
+ raise ImportError("line {0}: current priority is invalid".format(row["line"]))
+ if not isinstance(source.get("enabled"), bool):
+ raise ImportError("line {0}: current enabled state is invalid".format(row["line"]))
+
+
+def preflight(client, rows):
+ diagnostics = {}
+ changes = 0
+ for row in rows:
+ vin = row["vin"]
+ if vin not in diagnostics:
+ diagnostics[vin] = client.data("GET", diagnostic_path(vin))
+ policy_version(diagnostics[vin], row)
+ source = find_source(diagnostics[vin], row)
+ validate_source(row, source)
+ if clean(source.get("providerOverride")) != row["providerName"]:
+ changes += 1
+ return diagnostics, changes
+
+
+def export_missing(client, path):
+ vehicles = []
+ offset = 0
+ total = None
+ while total is None or offset < total:
+ page = client.data("POST", "/api/v2/access/vehicles", payload={
+ "connectionState": "master_data",
+ "limit": 200,
+ "offset": offset
+ })
+ if not isinstance(page, dict) or not isinstance(page.get("items"), list):
+ raise ImportError("access vehicle query returned an invalid page")
+ if total is None:
+ total = page.get("total")
+ if not isinstance(total, int) or total < 0:
+ raise ImportError("access vehicle query returned an invalid total")
+ vehicles.extend(page["items"])
+ if not page["items"]:
+ break
+ offset += len(page["items"])
+ rows = []
+ for vehicle in vehicles:
+ issues = vehicle.get("masterDataIssues") or []
+ if "JT808 接入方未维护" not in issues:
+ continue
+ vin = clean(vehicle.get("vin")).upper()
+ if not VIN_PATTERN.match(vin):
+ raise ImportError("access vehicle query returned an invalid VIN")
+ diagnostic = client.data("GET", diagnostic_path(vin))
+ for source in sources(diagnostic):
+ if clean(source.get("protocol")).upper() != "JT808":
+ continue
+ if clean(source.get("providerOverride")):
+ continue
+ current_provider = effective_provider(source)
+ source_ref = clean(source.get("sourceRef")).lower()
+ if not SOURCE_REF_PATTERN.match(source_ref):
+ raise ImportError("diagnostic returned an invalid source_ref for VIN " + vin)
+ rows.append({
+ "vin": vin,
+ "plate": clean(vehicle.get("plate")),
+ "source_ref": source_ref,
+ "terminal_label": clean(source.get("terminalLabel")),
+ "expected_protocol": "JT808",
+ "expected_current_provider": current_provider,
+ "provider_name": "",
+ "evidence": ""
+ })
+ if len(rows) > MAX_ROWS:
+ raise ImportError("missing provider export exceeds the {0}-row safety limit".format(MAX_ROWS))
+ if not rows:
+ raise ImportError("no JT808 provider gaps were found")
+ fieldnames = [
+ "vin", "plate", "source_ref", "terminal_label",
+ "expected_protocol", "expected_current_provider", "provider_name", "evidence"
+ ]
+ try:
+ handle = open(path, "x", encoding="utf-8-sig", newline="")
+ except FileExistsError:
+ raise ImportError("export path already exists: " + path)
+ except (IOError, OSError) as error:
+ raise ImportError("cannot create export CSV: {0}".format(error))
+ with handle:
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
+ writer.writeheader()
+ writer.writerows(rows)
+ return len(rows), len(set(row["vin"] for row in rows))
+
+
+def apply_rows(client, rows, diagnostics):
+ updated = 0
+ unchanged = 0
+ for row in rows:
+ diagnostic = diagnostics[row["vin"]]
+ source = find_source(diagnostic, row)
+ validate_source(row, source)
+ if clean(source.get("providerOverride")) == row["providerName"]:
+ unchanged += 1
+ continue
+ payload = {
+ "version": policy_version(diagnostic, row),
+ "providerName": row["providerName"],
+ "providerEvidence": row["evidence"],
+ "enabled": source["enabled"],
+ "priority": source["priority"],
+ "remark": clean(source.get("policyRemark"))
+ }
+ diagnostic = client.data(
+ "PUT",
+ update_path(row["vin"], row["sourceRef"]),
+ payload=payload
+ )
+ diagnostics[row["vin"]] = diagnostic
+ saved = find_source(diagnostic, row)
+ if clean(saved.get("providerOverride")) != row["providerName"]:
+ raise ImportError(
+ "line {0}: server did not persist provider for VIN {1}".format(row["line"], row["vin"])
+ )
+ updated += 1
+ print(
+ "source_provider_row=updated vin={0} source_ref={1} provider={2}".format(
+ row["vin"], row["sourceRef"][:12], row["providerName"]
+ )
+ )
+ return updated, unchanged
+
+
+def parser():
+ value = argparse.ArgumentParser(
+ description="Preflight and apply an authoritative source-provider CSV through the audited admin API."
+ )
+ mode = value.add_mutually_exclusive_group(required=True)
+ mode.add_argument("--csv", help="UTF-8 CSV with vin,source_ref,provider_name,evidence")
+ mode.add_argument(
+ "--export-missing",
+ metavar="PATH",
+ help="Write a fillable CSV for current JT808 provider gaps; refuses to overwrite an existing file."
+ )
+ value.add_argument("--base-url", default="http://127.0.0.1:20300")
+ value.add_argument("--token-env", default="SOURCE_PROVIDER_ADMIN_TOKEN")
+ value.add_argument("--timeout", type=float, default=20.0)
+ value.add_argument(
+ "--apply",
+ action="store_true",
+ help="Apply changes after a full preflight. Without this flag the command is read-only."
+ )
+ return value
+
+
+def main(argv=None):
+ args = parser().parse_args(argv)
+ token = os.environ.get(args.token_env, "")
+ if not token:
+ print("source_provider_import=failed reason=token_environment_missing", file=sys.stderr)
+ return 2
+ try:
+ client = Client(args.base_url, token, args.timeout)
+ if args.export_missing:
+ if args.apply:
+ raise ImportError("--apply cannot be used with --export-missing")
+ row_count, vehicle_count = export_missing(client, args.export_missing)
+ print(
+ "source_provider_export=ok rows={0} vehicles={1} path={2}".format(
+ row_count, vehicle_count, args.export_missing
+ )
+ )
+ return 0
+ rows = read_rows(args.csv)
+ diagnostics, changes = preflight(client, rows)
+ unchanged = len(rows) - changes
+ if not args.apply:
+ print(
+ "source_provider_import=ready rows={0} vehicles={1} changes={2} unchanged={3} mode=dry-run".format(
+ len(rows), len(diagnostics), changes, unchanged
+ )
+ )
+ return 0
+ updated, unchanged = apply_rows(client, rows, diagnostics)
+ except ImportError as error:
+ print(
+ "source_provider_import=failed reason={0}".format(str(error).replace("\n", " ")),
+ file=sys.stderr
+ )
+ return 1
+ print(
+ "source_provider_import=ok rows={0} vehicles={1} updated={2} unchanged={3}".format(
+ len(rows), len(diagnostics), updated, unchanged
+ )
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/vehicle-data-platform/deploy/import-source-providers.test.py b/vehicle-data-platform/deploy/import-source-providers.test.py
new file mode 100644
index 00000000..58987464
--- /dev/null
+++ b/vehicle-data-platform/deploy/import-source-providers.test.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+
+from __future__ import print_function
+
+import csv
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import threading
+import unittest
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+from urllib.parse import urlparse
+
+
+VIN = "LB9A32A21R0LS1464"
+SOURCE_REF = "a" * 64
+
+
+def diagnostic(version=1, provider=""):
+ return {
+ "evidence": {
+ "vin": VIN,
+ "locationSources": [{
+ "protocol": "JT808",
+ "sourceLabel": provider or "接入方未维护",
+ "providerOverride": provider,
+ "sourceRef": SOURCE_REF,
+ "sourceKind": "PLATFORM",
+ "enabled": True,
+ "priority": 88,
+ "policyRemark": "保留策略备注"
+ }]
+ },
+ "policy": {"vin": VIN, "version": version, "audit": []}
+ }
+
+
+class Handler(BaseHTTPRequestHandler):
+ puts = []
+
+ def log_message(self, _format, *_args):
+ pass
+
+ def envelope(self, data, status=200):
+ body = json.dumps({"data": data, "traceId": "test", "timestamp": 1}).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ self.assert_auth()
+ if urlparse(self.path).path == "/api/v2/operations/vehicles/{0}/sources".format(VIN):
+ self.envelope(diagnostic())
+ return
+ self.envelope({}, 404)
+
+ def do_POST(self):
+ self.assert_auth()
+ if urlparse(self.path).path == "/api/v2/access/vehicles":
+ size = int(self.headers.get("Content-Length", "0"))
+ payload = json.loads(self.rfile.read(size).decode("utf-8"))
+ self.envelope({
+ "items": [{
+ "vin": VIN,
+ "plate": "粤AGR6863",
+ "masterDataIssues": ["JT808 接入方未维护"]
+ }],
+ "total": 1,
+ "limit": payload.get("limit"),
+ "offset": payload.get("offset")
+ })
+ return
+ self.envelope({}, 404)
+
+ def do_PUT(self):
+ self.assert_auth()
+ expected = "/api/v2/operations/vehicles/{0}/sources/{1}".format(VIN, SOURCE_REF)
+ if urlparse(self.path).path != expected:
+ self.envelope({}, 404)
+ return
+ size = int(self.headers.get("Content-Length", "0"))
+ payload = json.loads(self.rfile.read(size).decode("utf-8"))
+ Handler.puts.append(payload)
+ self.envelope(diagnostic(version=2, provider=payload.get("providerName", "")))
+
+ def assert_auth(self):
+ if self.headers.get("Authorization") != "Bearer admin-test-token":
+ self.envelope({}, 401)
+ raise RuntimeError("missing test authorization")
+
+
+class SourceProviderImportTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.server = HTTPServer(("127.0.0.1", 0), Handler)
+ cls.thread = threading.Thread(target=cls.server.serve_forever)
+ cls.thread.daemon = True
+ cls.thread.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown()
+ cls.thread.join()
+
+ def setUp(self):
+ Handler.puts = []
+ self.tempdir = tempfile.TemporaryDirectory()
+
+ def tearDown(self):
+ self.tempdir.cleanup()
+
+ def write_csv(self, evidence="GPS 运维终端清单 2026-07-16", expected_provider="接入方未维护"):
+ path = Path(self.tempdir.name) / "providers.csv"
+ with path.open("w", encoding="utf-8", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=[
+ "vin", "source_ref", "provider_name", "evidence",
+ "expected_protocol", "expected_current_provider"
+ ])
+ writer.writeheader()
+ writer.writerow({
+ "vin": VIN,
+ "source_ref": SOURCE_REF,
+ "provider_name": "G7s",
+ "evidence": evidence,
+ "expected_protocol": "JT808",
+ "expected_current_provider": expected_provider
+ })
+ return str(path)
+
+ def run_script(self, csv_path, apply=False):
+ script = str(Path(__file__).with_name("import-source-providers.py"))
+ environment = dict(os.environ)
+ environment["SOURCE_PROVIDER_ADMIN_TOKEN"] = "admin-test-token"
+ command = [
+ sys.executable,
+ script,
+ "--csv", csv_path,
+ "--base-url", "http://127.0.0.1:{0}".format(self.server.server_port)
+ ]
+ if apply:
+ command.append("--apply")
+ return subprocess.run(
+ command,
+ env=environment,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ universal_newlines=True
+ )
+
+ def test_dry_run_preflights_without_writing(self):
+ result = self.run_script(self.write_csv())
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("source_provider_import=ready", result.stdout)
+ self.assertIn("changes=1", result.stdout)
+ self.assertEqual(Handler.puts, [])
+
+ def test_apply_preserves_policy_and_sends_evidence(self):
+ result = self.run_script(self.write_csv(), apply=True)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("source_provider_import=ok", result.stdout)
+ self.assertEqual(len(Handler.puts), 1)
+ self.assertEqual(Handler.puts[0], {
+ "version": 1,
+ "providerName": "G7s",
+ "providerEvidence": "GPS 运维终端清单 2026-07-16",
+ "enabled": True,
+ "priority": 88,
+ "remark": "保留策略备注"
+ })
+
+ def test_missing_evidence_fails_before_any_write(self):
+ result = self.run_script(self.write_csv(evidence=""), apply=True)
+ self.assertEqual(result.returncode, 1)
+ self.assertIn("evidence is required", result.stderr)
+ self.assertEqual(Handler.puts, [])
+
+ def test_stale_expected_provider_fails_preflight(self):
+ result = self.run_script(self.write_csv(expected_provider="旧提供方"), apply=True)
+ self.assertEqual(result.returncode, 1)
+ self.assertIn("current provider changed", result.stderr)
+ self.assertEqual(Handler.puts, [])
+
+ def test_export_missing_writes_fillable_template(self):
+ script = str(Path(__file__).with_name("import-source-providers.py"))
+ environment = dict(os.environ)
+ environment["SOURCE_PROVIDER_ADMIN_TOKEN"] = "admin-test-token"
+ path = str(Path(self.tempdir.name) / "missing.csv")
+ result = subprocess.run([
+ sys.executable,
+ script,
+ "--export-missing", path,
+ "--base-url", "http://127.0.0.1:{0}".format(self.server.server_port)
+ ], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("source_provider_export=ok rows=1 vehicles=1", result.stdout)
+ with open(path, "r", encoding="utf-8-sig", newline="") as handle:
+ rows = list(csv.DictReader(handle))
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(rows[0]["vin"], VIN)
+ self.assertEqual(rows[0]["source_ref"], SOURCE_REF)
+ self.assertEqual(rows[0]["terminal_label"], "")
+ self.assertEqual(rows[0]["provider_name"], "")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/vehicle-data-platform/deploy/install-web-release.sh b/vehicle-data-platform/deploy/install-web-release.sh
index 86fe356f..7a4a0e41 100755
--- a/vehicle-data-platform/deploy/install-web-release.sh
+++ b/vehicle-data-platform/deploy/install-web-release.sh
@@ -32,6 +32,7 @@ fi
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/verify-customer-demo.py" || { printf 'customer demo gate is missing\n' >&2; exit 1; }
+test -f "$SCRIPT_DIR/import-source-providers.py" || { printf 'source provider import helper is missing\n' >&2; exit 1; }
old=$(readlink -f "$ROOT/current")
next="$ROOT/releases/$RELEASE_ID"
@@ -110,8 +111,9 @@ fi
cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh"
cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py"
cp "$SCRIPT_DIR/verify-customer-demo.py" "$next/deploy/verify-customer-demo.py"
+cp "$SCRIPT_DIR/import-source-providers.py" "$next/deploy/import-source-providers.py"
cp "$0" "$next/deploy/install-web-release.sh"
-chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py" "$next/deploy/verify-customer-demo.py"
+chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py" "$next/deploy/verify-customer-demo.py" "$next/deploy/import-source-providers.py"
for runtime_file in alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync reconciliation-evaluator alert-benchmark; do
test ! -f "$next/$runtime_file" || chmod +x "$next/$runtime_file"
done
diff --git a/vehicle-data-platform/deploy/install-web-release.test.sh b/vehicle-data-platform/deploy/install-web-release.test.sh
index 7fdf085e..0e4d3dde 100755
--- a/vehicle-data-platform/deploy/install-web-release.test.sh
+++ b/vehicle-data-platform/deploy/install-web-release.test.sh
@@ -46,6 +46,7 @@ test -f "$root/current/web/assets/new.js"
test -f "$root/current/web/assets/old.js"
test -f "$root/current/web/.compatibility-manifests/1.assets"
test -x "$root/current/deploy/verify-customer-demo.py"
+test -x "$root/current/deploy/import-source-providers.py"
test -f "$root/current/deploy/migrations/017_reconciliation_center.sql"
test -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer"
test ! -e "$root/current/lingniu-vehicle-platform.service"
diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md
index 80d33c64..a00c217b 100644
--- a/vehicle-data-platform/docs/api-contract.md
+++ b/vehicle-data-platform/docs/api-contract.md
@@ -341,7 +341,7 @@ Content-Type: application/json
}
```
-The diagnostic response exposes every current location candidate, including the current verified `providerOverride` and gateway-policy `policyRemark`, but never exposes the raw source key. `sourceRef` is a SHA-256 reference resolved only by the server. Operator principals may read this diagnostic; only administrators may write.
+The diagnostic response exposes every current location candidate, including the current verified `providerOverride` and gateway-policy `policyRemark`, but never exposes the raw source key. `sourceRef` is a SHA-256 reference resolved only by the server. A connected protocol that currently has no independent location candidate is represented by a `CANONICAL` protocol snapshot with an opaque `sourceRef`, so its verified provider can still be maintained. Canonical snapshots are provider-only targets: attempts to change `enabled`, `priority` or `remark` return `SOURCE_POLICY_CANONICAL_READ_ONLY`. Operator principals may read this diagnostic; only administrators may write.
`providerEvidence` is mandatory whenever `providerName` changes, including removal. It is written to the immutable provider audit and is deliberately separate from `remark`, which belongs only to source enable/priority policy. A provider-only update must preserve the existing policy remark. Both change types share the vehicle-level optimistic `version`; stale writes return `SOURCE_POLICY_VERSION_CONFLICT`.
diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md
index b4a48141..c0783f1c 100644
--- a/vehicle-data-platform/docs/deployment.md
+++ b/vehicle-data-platform/docs/deployment.md
@@ -158,6 +158,8 @@ Production smoke must create an export for an active VIN, wait for `completed`,
Before a customer demonstration, run `deploy/verify-customer-demo.py` with the password supplied only through `DEMO_PASSWORD`. The gate logs in as the actual customer, requires the exact four customer menus, compares the complete realtime VIN set with the approved cohort, verifies representative source/location states, confirms an out-of-Scope VIN cannot resolve, checks optional track and mileage evidence, loads the four customer SPA routes and logs out. The script never creates an account, changes grants or persists the password; account identity and VIN Scope must be approved separately. See `docs/customer-demo-acceptance.md`.
+For an authoritative JT808 provider list, use `deploy/import-source-providers.py`. The command reads its administrator credential only from `SOURCE_PROVIDER_ADMIN_TOKEN`. `--export-missing