From d405c1ba659b2050459fe4ba5f584acbb9b4d60a Mon Sep 17 00:00:00 2001 From: yuqei Date: Tue, 6 Oct 2026 11:42:45 +0800 Subject: [PATCH] feat(templates): enable all assets for local trial --- .../yuqei_contract_api/contract_templates.py | 11 ++- .../v1_contract_templates/source-catalog.json | 36 +++++++++- .../tests/test_contract_scene_assets.py | 10 +-- .../tests/test_contract_workflow.py | 3 +- .../test_scene_batch_a_content_closeout.py | 2 +- .../test_scene_batch_b_content_closeout.py | 2 +- .../tests/test_template_release_boundary.py | 71 +++++++++++++++++-- 7 files changed, 116 insertions(+), 19 deletions(-) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/contract_templates.py b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/contract_templates.py index 35cf7390..77206571 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/contract_templates.py +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/contract_templates.py @@ -793,7 +793,8 @@ def _load_templates() -> list[ContractTemplateItem]: item = ContractTemplateItem( id=scene["id"], name=scene["name"], version=scene["metadata"]["version"], contract_type=scene["contract_type"], body_preview=_preview_text(scene["body"]), - description="新增场景草稿 · " + scene["metadata"]["applicable"] + ";不适用:" + "、".join(scene["metadata"]["excluded"]), + description=("新增场景开发试用 · " if scene["id"] in trial_ids else "新增场景草稿 · ") + + scene["metadata"]["applicable"] + ";不适用:" + "、".join(scene["metadata"]["excluded"]), required_variables=[ContractTemplateVariable(name=name, label=name) for name in scene["required"]], optional_variables=[ContractTemplateVariable(name=name, label=name, required=False) for name in scene["optional"]], input_schema=scene["schema"], validation_rules=scene["metadata"]["review_scope_checklist"], @@ -801,9 +802,15 @@ def _load_templates() -> list[ContractTemplateItem]: # presented as an equipment-rental or logistics-specific check. clause_ids=["CLAUSE-COMMON-AUTHORIZATION-001", "CLAUSE-COMMON-FORMAT-001"], review_rule_ids=["RULE-AUTHORIZATION-SUBJECT-001", "RULE-FORMAT-CLAUSE-001"], - lifecycle_status="draft", source="scene-pack-draft-v1", + # The allowlist grants local/test drafting only; it is not a + # publication event and does not satisfy the release gate. + lifecycle_status="internal_trial" if scene["id"] in trial_ids else "draft", + source="scene-pack-draft-v1", ) templates.append(item.model_copy(update={"content_sha256": template_content_sha256(item, scene["body"])})) + unknown_trial_ids = set(trial_ids) - {item.id for item in templates} + if unknown_trial_ids: + raise ValueError("Internal-trial template catalog references unknown assets.") return sorted(templates, key=lambda item: (item.contract_type, item.name)) diff --git a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/source-catalog.json b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/source-catalog.json index 1d5c9b8d..15d19daa 100644 --- a/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/source-catalog.json +++ b/yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/source-catalog.json @@ -1,7 +1,7 @@ { "schema_version": "template-source-catalog.v1", - "recorded_on": "2026-08-28", - "scope": "Existing builtin assets for local/test evaluation only. This catalog is not a publication, legal approval or source verification. Unlisted assets default to draft.", + "recorded_on": "2026-10-06", + "scope": "All 60 builtin assets are explicitly allowlisted for local/test development trial only. This catalog is not a publication, legal approval or source verification. Unlisted assets default to draft.", "internal_trial_template_ids": [ "TPL-AGENCY-DISTRIBUTION-001", "TPL-COMMISSION-SERVICE-001", "TPL-COOPERATION-FRAMEWORK-001", "TPL-DATA-PROCESSING-001", "TPL-DEMAND-LETTER-001", "TPL-EQUITY-TRANSFER-001", "TPL-FINANCIAL-LEASE-001", @@ -10,6 +10,36 @@ "TPL-LEASE-HOUSE-001", "TPL-LEGAL-OPINION-001", "TPL-NDA-COMMERCIAL-001", "TPL-PARTNERSHIP-001", "TPL-POWER-OF-ATTORNEY-001", "TPL-PROPERTY-SERVICE-001", "TPL-PURCHASE-GOODS-001", "TPL-SALES-GOODS-001", "TPL-SERVICE-GENERAL-001", "TPL-SHAREHOLDER-RESOLUTION-001", "TPL-SOFTWARE-SAAS-001", "TPL-TECH-DEVELOPMENT-001", - "TPL-CONTRACT-TERMINATION-NOTICE-001", "TPL-VALUATION-ADJUSTMENT-001", "TPL-WORKS-001" + "TPL-CONTRACT-TERMINATION-NOTICE-001", "TPL-VALUATION-ADJUSTMENT-001", "TPL-WORKS-001", + "TPL-TRANSPORT-ORDINARY-001", + "TPL-WAREHOUSING-ORDINARY-001", + "TPL-LEASE-EQUIPMENT-001", + "TPL-SERVICE-EQUIPMENT-MAINTENANCE-001", + "TPL-WORKS-MANUFACTURING-001", + "TPL-SERVICE-ADVERTISEMENT-001", + "TPL-COMMISSION-MARKETING-001", + "TPL-SERVICE-BRAND-DESIGN-001", + "TPL-SERVICE-SOFTWARE-OPS-001", + "TPL-LEASE-RETAIL-SHOP-001", + "TPL-LABOR-AMENDMENT-001", + "TPL-AMENDMENT-GENERAL-001", + "TPL-TRANSPORT-COLD-CHAIN-001", + "TPL-WAREHOUSING-ECOMMERCE-FULFILLMENT-001", + "TPL-COMMISSION-ECOMMERCE-OPERATIONS-001", + "TPL-COOPERATION-LIVESTREAM-MARKETING-001", + "TPL-FRANCHISE-RESTAURANT-CHAIN-001", + "TPL-SERVICE-PRODUCT-TESTING-001", + "TPL-TECHNICAL-CONSULTING-001", + "TPL-TECHNOLOGY-TRANSFER-001", + "TPL-DATA-SUPPLY-001", + "TPL-DATA-ANNOTATION-PROCESSING-001", + "TPL-SERVICE-CONFERENCE-EVENT-001", + "TPL-SERVICE-ENTERPRISE-TRAINING-001", + "TPL-LABOR-HR-OUTSOURCING-001", + "TPL-PROPERTY-COMMERCIAL-MANAGEMENT-001", + "TPL-CONSTRUCTION-OFFICE-RENOVATION-001", + "TPL-CONSTRUCTION-PROJECT-001", + "TPL-INTERCOMPANY-SHAREHOLDER-LOAN-001", + "TPL-RECEIVABLES-ASSIGNMENT-001" ] } diff --git a/yuqei-contract-platform/services/contract-api/tests/test_contract_scene_assets.py b/yuqei-contract-platform/services/contract-api/tests/test_contract_scene_assets.py index 1e9b5318..d1fde955 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_contract_scene_assets.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_contract_scene_assets.py @@ -52,11 +52,11 @@ def test_every_exclusion_routes_away_from_generic_scene(template): compile_template_body(template, get_contract_template_body(template.id), values, allow_incomplete=True) -def test_scene_catalog_is_draft_only_and_cannot_be_published_by_stripping_metadata(): +def test_scene_catalog_is_development_trial_only_and_cannot_be_published_by_stripping_metadata(): assert len(SCENES) == 30 and len(CASES) == 300 repo = InMemoryContractRepository(contracts=[]) for template in SCENES: - assert template.lifecycle_status == "draft" and not repo.template_drafting_allowed(template) + assert template.lifecycle_status == "internal_trial" and not repo.template_drafting_allowed(template) assert template.input_schema.scene["origin"] == "platform_authored_draft" assert template.input_schema.scene["legal_approval"] is False assert template.input_schema.scene["source_references"] @@ -74,6 +74,8 @@ def test_scene_catalog_is_draft_only_and_cannot_be_published_by_stripping_metada with pytest.raises(ContractWorkflowError): repo.publish_template(template.id, ContractTemplateLifecycleRequest(version="unreviewed")) assert repo.list_template_lifecycle_events(template.id).total == 0 + repo.allow_internal_trial_templates = True + assert all(repo.template_drafting_allowed(template) for template in SCENES) target = SCENES[0] schema = target.input_schema.model_copy(update={"scene": None}) repo.save_template_edit_draft(target.id, ContractTemplateEditRequest(input_schema=schema)) @@ -118,7 +120,7 @@ def test_corrected_examples_are_versioned_and_complete_on_observed_gaps(): else "tp05-2-20260828-1") ) assert template.input_schema.scene["version"] == expected - assert template.lifecycle_status == "draft" + assert template.lifecycle_status == "internal_trial" equipment_values = equipment.input_schema.sample_values assert "各5000元" in equipment_values["付款与税费"] assert "TEST-OWN-001" in equipment_values["设备清单"] @@ -139,7 +141,7 @@ def test_second_batch_has_independent_boundaries_sources_fixtures_and_review_rou } for template in BATCH_B: scene = template.input_schema.scene - assert template.lifecycle_status == "draft" + assert template.lifecycle_status == "internal_trial" assert len(template.input_schema.fixtures) == 10 assert len(scene["excluded"]) >= 3 assert len(scene["review_scope_checklist"]) >= 5 diff --git a/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py b/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py index 25f0c344..cacabbaa 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_contract_workflow.py @@ -945,7 +945,8 @@ def test_v1_template_assets_have_complete_clause_and_rule_bindings(tmp_path: Pat assert templates_response.json()["total"] == 60 assert len(templates) == 60 - assert sum(item["lifecycle_status"] == "draft" for item in templates) == 30 + assert sum(item["lifecycle_status"] == "internal_trial" for item in templates) == 60 + assert all(item["drafting_available"] is True for item in templates) orphan_clause_ids: dict[str, list[str]] = {} orphan_rule_ids: dict[str, list[str]] = {} diff --git a/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_a_content_closeout.py b/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_a_content_closeout.py index 427a844d..51fe7faf 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_a_content_closeout.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_a_content_closeout.py @@ -54,7 +54,7 @@ def test_marketing_contact_data_has_bounded_use_access_retention_and_incident_ha pipl = next(item for item in references if "个人信息保护法" in item["title"]) assert pipl["current_effectiveness_verified"] is False assert pipl["accessed_on"] == "2026-09-10" - assert template.lifecycle_status == "draft" + assert template.lifecycle_status == "internal_trial" def test_maintenance_fixed_closeout_duties_survive_non_sample_commercial_terms(): diff --git a/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_b_content_closeout.py b/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_b_content_closeout.py index 6db708a6..b4ed31a2 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_b_content_closeout.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_scene_batch_b_content_closeout.py @@ -43,7 +43,7 @@ def test_batch_b_key_obligation_is_required_and_rendered(template_id, field, che rendered = compile_template_body(template, get_contract_template_body(template_id), values) assert values[field] in rendered.body_content assert all(marker in rendered.body_content for marker in markers) - assert template.lifecycle_status == "draft" + assert template.lifecycle_status == "internal_trial" assert template.input_schema.scene["legal_approval"] is False diff --git a/yuqei-contract-platform/services/contract-api/tests/test_template_release_boundary.py b/yuqei-contract-platform/services/contract-api/tests/test_template_release_boundary.py index daff8eda..42f40aed 100644 --- a/yuqei-contract-platform/services/contract-api/tests/test_template_release_boundary.py +++ b/yuqei-contract-platform/services/contract-api/tests/test_template_release_boundary.py @@ -18,8 +18,8 @@ from yuqei_contract_api.tenant_context import set_tenant_context, clear_tenant_c SCOPE = {"tenant": "release-test", "owner": "release-owner"} -def request_for(repository, *, incomplete=False): - template = repository.get_template("TPL-SERVICE-GENERAL-001") +def request_for(repository, *, incomplete=False, template_id="TPL-SERVICE-GENERAL-001"): + template = repository.get_template(template_id) values = {} if incomplete else deepcopy(template_sample_values(template.input_schema, [item.name for item in template.required_variables], [item.name for item in template.optional_variables], with_optional=False)) return DraftingCreateRequest(template_id=template.id, title="合成发布隔离验收", values=values, @@ -44,7 +44,8 @@ def test_builtin_catalog_is_not_publication_and_unknown_source_defaults_to_draft all_templates = assets.list_contract_templates().items baseline = [item for item in all_templates if item.source != "scene-pack-draft-v1"] scenes = [item for item in all_templates if item.source == "scene-pack-draft-v1"] - assert len(scenes) == 30 and all(item.lifecycle_status == "draft" for item in scenes) + assert len(scenes) == 30 + assert all(item.lifecycle_status == "internal_trial" and item.published_at is None for item in scenes) assert len(baseline) == 30 assert all(item.lifecycle_status == "internal_trial" and item.published_at is None for item in baseline) assert assets.ContractTemplateItem(id="new", name="new", version="dev", contract_type="test", body_preview="").lifecycle_status == "draft" @@ -62,24 +63,80 @@ def test_builtin_catalog_is_not_publication_and_unknown_source_defaults_to_draft assets._load_templates.cache_clear() +@pytest.mark.parametrize("template_id", ["TPL-SERVICE-GENERAL-001", "TPL-TRANSPORT-ORDINARY-001"]) @pytest.mark.parametrize("environment,available", [("local", True), ("test", True), ("staging", False), ("production", False)]) -def test_http_trial_capability_is_environment_bound(environment, available): +def test_http_trial_capability_is_environment_bound(environment, available, template_id): repo = InMemoryContractRepository(contracts=[]) client = TestClient(create_app(ContractApiSettings(environment=environment), repo)) - response = client.get("/api/v1/templates/TPL-SERVICE-GENERAL-001") + response = client.get(f"/api/v1/templates/{template_id}") assert response.status_code == 200, response.text item = response.json() assert item["lifecycle_status"] == "internal_trial" assert item["drafting_available"] is available - inputs = request_for(repo, incomplete=True).model_dump() + inputs = request_for(repo, incomplete=True, template_id=template_id).model_dump() created = client.post("/api/v1/drafting-workspaces", json=inputs, headers={"X-Tenant-Id": "release-test", "X-User-Id": "release-owner", "Idempotency-Key": "release-boundary"}) assert created.status_code == (201 if available else 409), created.text # Old direct draft and input-validation endpoints enforce the same policy. - validation = client.post("/api/v1/templates/TPL-SERVICE-GENERAL-001/validate-input", json={"values": {}, "partial": True}) + validation = client.post(f"/api/v1/templates/{template_id}/validate-input", json={"values": {}, "partial": True}) assert validation.status_code == (200 if available else 409), validation.text +def test_local_http_exposes_all_60_templates_as_draftable_without_publishing(): + repo = InMemoryContractRepository(contracts=[]) + client = TestClient(create_app(ContractApiSettings(environment="local"), repo)) + response = client.get("/api/v1/templates/tree") + assert response.status_code == 200, response.text + items = [template for node in response.json()["types"] for template in node["templates"]] + assert len(items) == 60 + assert all(item["lifecycle_status"] == "internal_trial" for item in items) + assert all(item["drafting_available"] is True for item in items) + assert all(item["published_at"] is None and item["lifecycle_event_count"] == 0 for item in items) + + +@pytest.mark.parametrize("environment", ["staging", "production"]) +def test_non_local_http_keeps_all_60_unpublished_trials_fail_closed(environment): + repo = InMemoryContractRepository(contracts=[]) + client = TestClient(create_app(ContractApiSettings(environment=environment), repo)) + response = client.get("/api/v1/templates/tree") + assert response.status_code == 200, response.text + items = [template for node in response.json()["types"] for template in node["templates"]] + assert len(items) == 60 + assert all(item["lifecycle_status"] == "internal_trial" for item in items) + assert all(item["drafting_available"] is False for item in items) + assert all(item["published_at"] is None and item["lifecycle_event_count"] == 0 for item in items) + + +def test_all_60_internal_trials_create_valid_local_drafting_workspaces(): + repo = InMemoryContractRepository(contracts=[]) + repo.allow_internal_trial_templates = True + store = DraftingWorkspaceStore(repo) + templates = repo.list_templates().items + assert len(templates) == 60 + for template in templates: + values = template_sample_values( + template.input_schema, + [item.name for item in template.required_variables], + [item.name for item in template.optional_variables], + with_optional=False, + ) + record = store.create( + DraftingCreateRequest( + template_id=template.id, + title=f"{template.name}开发试用起草", + values=values, + expected_template_content_sha256=template.content_sha256, + ), + tenant="release-test", + owner="release-owner", + key=f"all-templates-{template.id}", + ) + assert record.template.id == template.id + assert record.input_validation.valid, (template.id, record.input_validation.issues) + assert record.base_document.strip(), template.id + assert record.preview_error is None, (template.id, record.preview_error) + + def test_no_implicit_published_history_or_rollback_escape(store): template_id = "TPL-SERVICE-GENERAL-001" assert store.repository.get_template(template_id).lifecycle_status == "internal_trial"