zlwu 1 주 전
부모
커밋
03fcc01d6d
36개의 변경된 파일과 4866개의 추가작업 그리고 4451개의 파일을 삭제
  1. 2 0
      backend/app/api/market_mobile.py
  2. 1 1
      backend/app/crud/presign_order.py
  3. 4 0
      backend/app/services/market_protocol.py
  4. 88 5
      backend/app/services/market_support.py
  5. 3203 4036
      backend/graphify-out/.graphify_analysis.json
  6. 190 122
      backend/graphify-out/.graphify_extract.json
  7. 78 110
      backend/graphify-out/GRAPH_REPORT.md
  8. 112 112
      backend/graphify-out/graph.json
  9. 4 4
      backend/graphify-out/manifest.json
  10. 28 0
      backend/tests/test_market_presign_api.py
  11. 2 0
      backend/tests/test_market_protocol_api.py
  12. 239 0
      backend/tests/test_market_support_api.py
  13. 313 0
      docs/demos/2026-10-17-presign-deposit-parity-demo.html
  14. 41 0
      docs/superpowers/plans/2026-10-17-market-protocol-sample-type-code-filter.md
  15. 27 0
      docs/superpowers/plans/2026-10-17-mobile-protocol-deposit-parity.md
  16. 47 0
      docs/superpowers/specs/2026-10-17-market-protocol-sample-type-code-filter-design.md
  17. 34 0
      docs/superpowers/specs/2026-10-17-mobile-protocol-deposit-parity-design.md
  18. 1 1
      mobile-h5/utils/config.js
  19. 59 6
      mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue
  20. 57 9
      mobile-oa-h5/src/market/form-payload.js
  21. 66 0
      mobile-oa-h5/src/market/form-payload.test.js
  22. 10 15
      mobile-oa-h5/src/market/modules/protocol.js
  23. 18 0
      mobile-oa-h5/src/market/page-scroll.js
  24. 40 0
      mobile-oa-h5/src/market/page-scroll.test.js
  25. 18 4
      mobile-oa-h5/src/market/protocol-mobile-parity.test.js
  26. 10 0
      mobile-oa-h5/src/market/protocol-sample-transport.js
  27. 12 0
      mobile-oa-h5/src/market/protocol-sample-transport.test.js
  28. 3 0
      mobile-oa-h5/src/oa/components/OaDatePicker.vue
  29. 25 0
      mobile-oa-h5/src/oa/oa-date-picker-reopen.test.js
  30. 21 1
      mobile-oa-h5/src/utils/http-error.test.js
  31. 26 4
      mobile-oa-h5/src/utils/http.js
  32. 70 16
      mobile-oa-h5/src/views/market/MarketModuleForm.vue
  33. 8 0
      mobile-oa-h5/src/views/market/MarketModuleList.vue
  34. 5 1
      mobile-oa-h5/src/views/market/presign/PresignOrderList.vue
  35. 1 1
      web-admin/graphify-out/GRAPH_REPORT.md
  36. 3 3
      web-admin/graphify-out/manifest.json

+ 2 - 0
backend/app/api/market_mobile.py

@@ -383,6 +383,7 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None:
     async def protocol_form_options(
         region_id: Optional[str] = Query(None, alias='regionId'),
         business_id: Optional[str] = Query(None, alias='businessId'),
+        code_filter: Optional[str] = Query(None, alias='codeFilter'),
         user: dict = Depends(permission_guard),
         db: Session = Depends(get_db),
     ):
@@ -395,6 +396,7 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None:
                     user_info=user,
                     region_id=region_id,
                     business_id=business_id,
+                    code_filter=code_filter,
                 )
             )
         except (PermissionError, ValueError) as exc:

+ 1 - 1
backend/app/crud/presign_order.py

@@ -1086,7 +1086,7 @@ def convert_to_protocol(
         protocol = cp_svc.create_collection_protocol(
             db,
             protocol_no=order.protocol_no or "",
-            protocol_status="dsx",
+            protocol_status="sxz",
             register_date=order.order_date,
             collector_user_id=staff["collector_user_id"],
             source_presign_order_id=order.id,

+ 4 - 0
backend/app/services/market_protocol.py

@@ -1912,6 +1912,8 @@ def create_protocol(
             row = cell_crud.create_collection_protocol(
                 db,
                 protocol_no=body.protocol_no or '',
+                # 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
+                protocol_status='sxz',
                 protocol_attachments=attachments.get('protocol_attachments'),
                 consent_form_urls=attachments.get('consent_form_urls'),
                 health_survey_form_urls=attachments.get(
@@ -1984,6 +1986,8 @@ def create_protocol(
             raise ValueError('科研捐赠不支持样本缴费方案')
         data = {
             'donation_no': body.donation_no or '',
+            # 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
+            'donation_status': 'sxz',
             'donation_date': body.donation_date,
             'collector_user_id': ctx.user_id
             if ctx.scope_kind == 'specialist'

+ 88 - 5
backend/app/services/market_support.py

@@ -133,6 +133,7 @@ def _protocol_sample_type_option(row: BizCellSampleType) -> Dict[str, Any]:
         'storageYears': (
             int(row.storage_years) if row.storage_years is not None else None
         ),
+        'sampleCodeFilter': (row.sample_code_filter or '').strip(),
     }
 
 
@@ -307,6 +308,7 @@ def get_form_options(
     region_id: Optional[str] = None,
     business_id: Optional[str] = None,
     user_keyword: Optional[str] = None,
+    code_filter: Optional[str] = None,
 ) -> Dict[str, Any]:
     """返回移动表单最小选项集;不会返回联系方式或证件等 PII。"""
     _verify_identity(ctx, user_info)
@@ -477,13 +479,19 @@ def get_form_options(
             .all()
         )
         # 多取候选再过滤已占用,尽量凑满可选列表
+        sample_code_query = db.query(BizSampleCode).filter(
+            BizSampleCode.tenant_no == ctx.tenant_no,
+            BizSampleCode.del_flag == 0,
+        )
+        cf = (code_filter or '').strip()
+        if cf:
+            sample_code_query = sample_code_query.filter(
+                BizSampleCode.sample_code.like(f'%{cf}%')
+            )
         sample_codes = (
-            db.query(BizSampleCode)
-            .filter(
-                BizSampleCode.tenant_no == ctx.tenant_no,
-                BizSampleCode.del_flag == 0,
+            sample_code_query.order_by(
+                BizSampleCode.create_time.desc(), BizSampleCode.sample_code.desc()
             )
-            .order_by(BizSampleCode.create_time.desc(), BizSampleCode.sample_code.desc())
             .limit(200)
             .all()
         )
@@ -833,6 +841,54 @@ def _has_accessible_protocol_cert_or_insurance_reference(
     return False
 
 
+def _has_accessible_protocol_deposit_attachment_reference(
+    db: Session,
+    *,
+    candidates: List[str],
+    ctx: MarketFieldContext,
+    user_info: dict,
+) -> bool:
+    """正式协议意向金凭证挂在预签转账上,经 source_presign_order_id 关联授权。"""
+    transfer_rows = (
+        db.query(BizPresignTransfer)
+        .filter(
+            BizPresignTransfer.tenant_no == ctx.tenant_no,
+            BizPresignTransfer.del_flag == 0,
+            _attachment_columns_filter(
+                (BizPresignTransfer.transfer_attachments,), candidates
+            ),
+        )
+        .all()
+    )
+    if not _rows_reference_attachment(
+        transfer_rows, ('transfer_attachments',), candidates
+    ):
+        return False
+    presign_ids = {
+        str(row.presign_order_id or '').strip()
+        for row in transfer_rows
+        if str(row.presign_order_id or '').strip()
+    }
+    if not presign_ids:
+        return False
+    protocol_rows = (
+        apply_cell_protocol_scope(
+            db.query(BizCollectionProtocol),
+            db,
+            ctx,
+            user_info=user_info,
+        )
+        .filter(
+            BizCollectionProtocol.tenant_no == ctx.tenant_no,
+            BizCollectionProtocol.del_flag == 0,
+            BizCollectionProtocol.source_presign_order_id.in_(presign_ids),
+        )
+        .limit(1)
+        .all()
+    )
+    return bool(protocol_rows)
+
+
 def _has_accessible_market_attachment_reference(
     db: Session,
     *,
@@ -901,6 +957,13 @@ def _has_accessible_market_attachment_reference(
             user_info=user_info,
         ):
             return True
+        if _has_accessible_protocol_deposit_attachment_reference(
+            db,
+            candidates=candidates,
+            ctx=ctx,
+            user_info=user_info,
+        ):
+            return True
         donation_fields = (
             'consent_form_urls',
             'attachment_urls',
@@ -1023,6 +1086,10 @@ def get_market_attachment_download_url(
         'market:specialist:sample:bound',
         'market:manager:sample:bound',
     }
+    legacy_presign_bound = {
+        'market:specialist:presign:bound',
+        'market:manager:presign:bound',
+    }
     row = next(
         (
             item
@@ -1065,6 +1132,22 @@ def get_market_attachment_download_url(
             )
         ):
             row = sample_bound_rows[0]
+    if row is None and normalized_module == 'protocol':
+        deposit_bound_rows = [
+            item
+            for item in rows
+            if str(item.business_type or '').strip() in legacy_presign_bound
+        ]
+        if (
+            deposit_bound_rows
+            and _has_accessible_protocol_deposit_attachment_reference(
+                db,
+                candidates=candidates,
+                ctx=ctx,
+                user_info=user_info,
+            )
+        ):
+            row = deposit_bound_rows[0]
     if row is None:
         raise PermissionError('附件不存在或无权下载')
     download_name = str(row.original_name or '').strip()

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 3203 - 4036
backend/graphify-out/.graphify_analysis.json


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 190 - 122
backend/graphify-out/.graphify_extract.json


+ 78 - 110
backend/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
 # Graph Report - app  (2026-10-17)
 
 ## Corpus Check
-- Large corpus: 309 files · ~378,791 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 309 files · ~378,970 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 6357 nodes · 35250 edges · 46 communities detected
-- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24116 edges (avg confidence: 0.6)
+- 6359 nodes · 35281 edges · 40 communities detected
+- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 24141 edges (avg confidence: 0.6)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -43,22 +43,16 @@
 - [[_COMMUNITY_Community 31|Community 31]]
 - [[_COMMUNITY_Community 32|Community 32]]
 - [[_COMMUNITY_Community 33|Community 33]]
-- [[_COMMUNITY_Community 34|Community 34]]
-- [[_COMMUNITY_Community 35|Community 35]]
 - [[_COMMUNITY_Community 36|Community 36]]
 - [[_COMMUNITY_Community 37|Community 37]]
 - [[_COMMUNITY_Community 38|Community 38]]
 - [[_COMMUNITY_Community 39|Community 39]]
-- [[_COMMUNITY_Community 42|Community 42]]
-- [[_COMMUNITY_Community 43|Community 43]]
-- [[_COMMUNITY_Community 44|Community 44]]
-- [[_COMMUNITY_Community 45|Community 45]]
-- [[_COMMUNITY_Community 46|Community 46]]
-- [[_COMMUNITY_Community 47|Community 47]]
+- [[_COMMUNITY_Community 40|Community 40]]
+- [[_COMMUNITY_Community 41|Community 41]]
 
 ## God Nodes (most connected - your core abstractions)
 1. `success_response()` - 830 edges
-2. `SysUser` - 730 edges
+2. `SysUser` - 731 edges
 3. `error_response()` - 699 edges
 4. `BizQcTaskResult` - 388 edges
 5. `BizProductionMaterial` - 372 edges
@@ -71,245 +65,219 @@
 ## Surprising Connections (you probably didn't know these)
 - `统一记录 API 请求/响应出入参的中间件` --uses--> `SysApiRequestLog`  [INFERRED]
   core/api_request_log_middleware.py → models/models.py
-- `get_menu_list()` --calls--> `success_response()`  [INFERRED]
-  api/menu.py → core/response.py
 - `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` --uses--> `SysUser`  [INFERRED]
   core/menu_guard.py → models/models.py
 - `运营端角色守卫:admin / super_admin 可访问。` --uses--> `SysUser`  [INFERRED]
   core/menu_guard.py → models/models.py
 - `读取用户授权的菜单权限标识集合(与前端 meta.menuCode / perms 对齐)。` --uses--> `SysUser`  [INFERRED]
   core/menu_guard.py → models/models.py
+- `接口菜单权限守卫(已统一放行 menuCode 校验,仅保留运营端登录)。` --uses--> `SysUser`  [INFERRED]
+  core/menu_guard.py → models/models.py
 
 ## Communities
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (1172): _assert_user_tenant_access(), attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu() (+1164 more)
+Nodes (1149): create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post(), create_role_management() (+1141 more)
 
 ### Community 1 - "Community 1"
-Cohesion: 0.01
-Nodes (743): BaseSettings, Config, secret_key_must_be_strong(), Settings, format_date(), generate_uuid(), create_cell_sample_type(), get_cell_sample_type() (+735 more)
+Cohesion: 0.04
+Nodes (851): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+843 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.01
-Nodes (607): GarmentBatchDeleteBody, GarmentBatchDiscardBody, GarmentCreateBody, GarmentDiscardBody, GarmentUpdateBody, SterilizeLogBatchBody, SterilizeLogCreateBody, SterilizeLogUpdateBody (+599 more)
+Nodes (753): get_role_menus(), BaseSettings, Config, secret_key_must_be_strong(), Settings, generate_uuid(), create_cell_sample_type(), get_cell_sample_type() (+745 more)
 
 ### Community 3 - "Community 3"
-Cohesion: 0.04
-Nodes (482): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+474 more)
+Cohesion: 0.01
+Nodes (680): GarmentBatchDeleteBody, GarmentBatchDiscardBody, GarmentCreateBody, GarmentDiscardBody, GarmentUpdateBody, SterilizeLogBatchBody, SterilizeLogCreateBody, SterilizeLogUpdateBody (+672 more)
 
 ### Community 4 - "Community 4"
 Cohesion: 0.01
-Nodes (426): _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), _leave_local_callback(), assert_donation_sensitive_fields(), batch_delete_research_donations(), change_donation_status(), convert_from_storage() (+418 more)
+Nodes (617): list_users(), _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), _leave_local_callback(), assert_donation_sensitive_fields(), batch_delete_research_donations(), change_donation_status() (+609 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.06
-Nodes (448): 环境检测 — 检测项维护 + 登记管理 API, InsuranceCompanyCreateBody, InsuranceCompanyInvoiceCreateBody, InsuranceCompanyInvoiceUpdateBody, InsuranceCompanyUpdateBody, InsuranceProjectPriceCreateBody, InsuranceProjectPriceUpdateBody, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格) (+440 more)
+Cohesion: 0.02
+Nodes (274): _save_sample_payments(), get_protocol_nos_used_in_protocols(), deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。 (+266 more)
 
 ### Community 6 - "Community 6"
 Cohesion: 0.02
-Nodes (366): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+358 more)
+Nodes (261): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _error_json(), lifespan(), sqlalchemy_exception_handler(), unhandled_exception_handler(), _copied_app_name() (+253 more)
 
 ### Community 7 - "Community 7"
 Cohesion: 0.01
-Nodes (332): _body_to_payload(), _get_current_user_id(), get_production_reception_by_sample_code(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), ProductionReceptionBody, put_production_reception() (+324 more)
+Nodes (191): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), mp_bind(), mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。 (+183 more)
 
 ### Community 8 - "Community 8"
 Cohesion: 0.02
-Nodes (184): _register_attachment_routes(), _register_freight_routes(), _register_payment_routes(), _register_presign_routes(), _register_protocol_routes(), mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。 (+176 more)
+Nodes (192): _load_lineage_qc_for_nodes(), get_schedule_detail(), compare_lab_storage_then_create_time_desc(), inbound_lab_storage_order_by_clauses(), uses_inbound_lab_storage_list_sort(), _allocate_batch_name(), _apply_assignees_to_batch_tasks(), _assignee_names() (+184 more)
 
 ### Community 9 - "Community 9"
 Cohesion: 0.02
-Nodes (213): _save_sample_payments(), allocate_next_biz_no(), _allocate_sqlite_sequence(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号, SQLite 测试环境使用 ORM 分配;生产 MySQL 仍走原子 SQL。, 原子分配下一个业务协议号。     须在 create 事务内调用;使用 MySQL LAST_INSERT_ID 保证并发安全。 (+205 more)
+Nodes (172): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+164 more)
 
 ### Community 10 - "Community 10"
 Cohesion: 0.03
-Nodes (150): assert_protocol_status_transition(), format_protocol_status_label(), 状态流转被拒绝时的提示,状态码展示为中文名称。, resolve_void_type_label(), status_transition_denied_message(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change() (+142 more)
+Nodes (164): _assert_user_tenant_access(), attachment_download_url(), AttachmentDownloadUrlBody, delete_user(), DeptCreateBody, DeptUpdateBody, dict_data_options(), DictDataCreateBody (+156 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.03
-Nodes (143): api_detail(), api_get_outbound_handover(), api_list(), api_save_outbound_handover(), _get_tenant_no(), ProductItemBody, UpdateQcStatusBody, UpdateScheduleBody (+135 more)
+Cohesion: 0.05
+Nodes (111): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+103 more)
 
 ### Community 12 - "Community 12"
 Cohesion: 0.04
-Nodes (106): create_sample_freight(), delete_sample_freight(), _get_current_user_id(), get_sample_freight(), _get_tenant_no(), link_sample_freight(), list_sample_freight_options(), list_sample_freights() (+98 more)
+Nodes (97): get_customer_service_dict(), _get_tenant_no(), _get_current_user_id(), get_sample_insurance(), get_sample_storage_cert(), _get_tenant_no(), put_sample_insurance(), put_sample_storage_cert() (+89 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.03
-Nodes (98): get_customer_service_dict(), _get_tenant_no(), _get_current_user_id(), get_sample_insurance(), get_sample_storage_cert(), _get_tenant_no(), put_sample_insurance(), put_sample_storage_cert() (+90 more)
+Cohesion: 0.04
+Nodes (98): _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes(), create_cell_product() (+90 more)
 
 ### Community 14 - "Community 14"
 Cohesion: 0.04
-Nodes (75): get_designer_data(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, BaseHTTPMiddleware, ApiCryptoError, _b64url_decode(), _b64url_encode(), decrypt_json() (+67 more)
+Nodes (62): get_designer_data(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, BaseHTTPMiddleware, ApiCryptoError, _b64url_decode(), _b64url_encode(), decrypt_json() (+54 more)
 
 ### Community 15 - "Community 15"
-Cohesion: 0.09
-Nodes (50): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+42 more)
+Cohesion: 0.12
+Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
 
 ### Community 16 - "Community 16"
-Cohesion: 0.12
-Nodes (32): join_equipment_ids(), parse_equipment_ids(), 工艺/步骤设备多选:逗号分隔 ID 与名称快照解析, 校验设备列表,返回 (equipment_ids 逗号串, equipment_name 逗号串快照)。, resolve_equipment_list_snapshot(), add_step(), copy_step(), _create_step_consumable() (+24 more)
+Cohesion: 0.14
+Nodes (25): _assert_routes_exist(), create_set(), create_set_from_work_order(), create_tasks_from_set(), delete_set(), get_set(), _item_count_map(), _item_to_dict() (+17 more)
 
 ### Community 17 - "Community 17"
-Cohesion: 0.12
-Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
+Cohesion: 0.14
+Nodes (11): apply_formulas_and_linkages(), eval_formula(), _extract_rules(), preprocess_formula(), OA 表单公式与数据联动(禁止任意 eval), 按 schema 中字段 props.formula / props.linkage 回填 form_data。     linkage 示例: { "when, 将 {field} 转为合法标识符引用 field。, _SafeEval (+3 more)
 
 ### Community 18 - "Community 18"
-Cohesion: 0.12
-Nodes (23): lifespan(), _days_in_year(), _fetch_batch(), _is_rest_from_type(), is_year_calendar_complete(), iter_month_dates(), _loop_body(), map_ailcc_day_row() (+15 more)
-
-### Community 19 - "Community 19"
 Cohesion: 0.19
 Nodes (21): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+13 more)
 
-### Community 20 - "Community 20"
+### Community 19 - "Community 19"
 Cohesion: 0.17
 Nodes (12): BusinessException, InvalidStatusTransitionException, log_delivery_operation(), log_operation(), log_order_operation(), log_settlement_operation(), log_user_operation(), OrderLockedException (+4 more)
 
-### Community 21 - "Community 21"
+### Community 20 - "Community 20"
 Cohesion: 0.21
 Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
 
-### Community 22 - "Community 22"
+### Community 21 - "Community 21"
 Cohesion: 0.2
 Nodes (19): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+11 more)
 
-### Community 23 - "Community 23"
-Cohesion: 0.25
-Nodes (15): create_protocol_refund_record(), delete_protocol_refund_record(), _get_current_user_id(), _get_tenant_no(), list_protocol_refund_records(), _protocol_accessible(), update_protocol_refund_record(), create_refund_record() (+7 more)
-
-### Community 24 - "Community 24"
-Cohesion: 0.22
-Nodes (15): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐), 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。 (+7 more)
-
-### Community 25 - "Community 25"
-Cohesion: 0.26
-Nodes (14): apply_leave_kit_hours(), calc_leave_hours(), _extract_rules(), _kit_child_fields(), _overlap_minutes_on_day(), _parse_dt(), _parse_hm(), 请假套件:双段工时重叠小时(与前端 oa-leave-hours.ts 对齐) (+6 more)
-
-### Community 26 - "Community 26"
+### Community 22 - "Community 22"
 Cohesion: 0.27
-Nodes (12): _field_label(), _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props() (+4 more)
+Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
 
-### Community 27 - "Community 27"
+### Community 23 - "Community 23"
 Cohesion: 0.29
 Nodes (11): get_detail(), _load_sample_context(), _row_to_dict(), upsert(), dumps_payload(), empty_detail(), loads_payload(), normalize_payload() (+3 more)
 
-### Community 28 - "Community 28"
+### Community 24 - "Community 24"
 Cohesion: 0.24
 Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
 
-### Community 29 - "Community 29"
+### Community 25 - "Community 25"
 Cohesion: 0.31
 Nodes (10): create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), OA 收款账户 CRUD(oa_receive_account), row_to_dict(), soft_delete_account() (+2 more)
 
-### Community 30 - "Community 30"
-Cohesion: 0.31
-Nodes (10): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。, _row_to_dict() (+2 more)
-
-### Community 31 - "Community 31"
+### Community 26 - "Community 26"
 Cohesion: 0.29
 Nodes (2): BaseModel, SoftDeleteModel
 
-### Community 32 - "Community 32"
+### Community 27 - "Community 27"
 Cohesion: 0.38
 Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。     约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;     若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
 
-### Community 33 - "Community 33"
+### Community 28 - "Community 28"
 Cohesion: 0.4
 Nodes (5): batch_matches_stock_status(), normalize_batch_stock_status(), 质检物料入库批次:按剩余数量过滤的语义辅助。, 纯函数判定(供单测);list_batches 用 SQL 等价条件。, 返回 'available' | 'exhausted';全部 / 空 / 非法 → None(不加剩余数量条件)。
 
-### Community 34 - "Community 34"
+### Community 29 - "Community 29"
 Cohesion: 0.5
 Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
 
-### Community 35 - "Community 35"
-Cohesion: 0.67
-Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
-
-### Community 36 - "Community 36"
+### Community 30 - "Community 30"
 Cohesion: 0.67
 Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
 
-### Community 37 - "Community 37"
+### Community 31 - "Community 31"
 Cohesion: 1.0
 Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
 
-### Community 38 - "Community 38"
+### Community 32 - "Community 32"
 Cohesion: 1.0
 Nodes (1): 请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
 
-### Community 39 - "Community 39"
+### Community 33 - "Community 33"
 Cohesion: 1.0
 Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
 
-### Community 42 - "Community 42"
+### Community 36 - "Community 36"
 Cohesion: 1.0
 Nodes (1): 发送订单状态通知                  Args:             openid: 用户openid             ord
 
-### Community 43 - "Community 43"
+### Community 37 - "Community 37"
 Cohesion: 1.0
 Nodes (1): 发送支付成功通知                  Args:             openid: 用户openid             ord
 
-### Community 44 - "Community 44"
+### Community 38 - "Community 38"
 Cohesion: 1.0
 Nodes (1): 发送配送通知                  Args:             openid: 配送员openid             task
 
-### Community 45 - "Community 45"
+### Community 39 - "Community 39"
 Cohesion: 1.0
 Nodes (1): 发送配送时效预警                  Args:             openid: 配送员openid             ta
 
-### Community 46 - "Community 46"
+### Community 40 - "Community 40"
 Cohesion: 1.0
 Nodes (1): 发送退款结果通知                  Args:             openid: 用户openid             ord
 
-### Community 47 - "Community 47"
+### Community 41 - "Community 41"
 Cohesion: 1.0
 Nodes (1): 发送催单通知给配送员                  Args:             deliver_openid: 配送员openid
 
 ## Knowledge Gaps
 - **209 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `严格判断当前租户用户是否拥有指定移动市场权限码。`, `为移动市场 API 提供租户化、精确匹配的菜单权限依赖。` (+204 more)
   These have ≤1 connection - possible missing edges or undocumented components.
-- **Thin community `Community 31`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
-  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 34`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
+- **Thin community `Community 26`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 35`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
+- **Thin community `Community 29`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 36`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
+- **Thin community `Community 30`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 37`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
+- **Thin community `Community 31`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 38`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
+- **Thin community `Community 32`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 39`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
+- **Thin community `Community 33`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 42`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 36`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 43`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 37`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 44`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
+- **Thin community `Community 38`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 45`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
+- **Thin community `Community 39`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 46`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 40`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 47`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
+- **Thin community `Community 41`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `SysUser` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 11`, `Community 12`, `Community 14`, `Community 28`?**
-  _High betweenness centrality (0.117) - this node is a cross-community bridge._
-- **Why does `OaEngineError` connect `Community 6` to `Community 0`, `Community 1`, `Community 2`, `Community 3`?**
-  _High betweenness centrality (0.039) - this node is a cross-community bridge._
-- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 35`, `Community 4`, `Community 7`, `Community 8`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 23`?**
-  _High betweenness centrality (0.037) - this node is a cross-community bridge._
-- **Are the 1165 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
-  _`str` has 1165 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `SysUser` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 24`?**
+  _High betweenness centrality (0.126) - this node is a cross-community bridge._
+- **Why does `OaEngineError` connect `Community 6` to `Community 10`, `Community 1`, `Community 2`, `Community 3`?**
+  _High betweenness centrality (0.038) - this node is a cross-community bridge._
+- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 3`, `Community 4`, `Community 7`, `Community 10`, `Community 11`, `Community 12`, `Community 14`?**
+  _High betweenness centrality (0.038) - this node is a cross-community bridge._
+- **Are the 1166 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
+  _`str` has 1166 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 829 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
   _`success_response()` has 829 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 728 inferred relationships involving `SysUser` (e.g. with `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` and `运营端角色守卫:admin / super_admin 可访问。`) actually correct?**
-  _`SysUser` has 728 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 729 inferred relationships involving `SysUser` (e.g. with `菜单 code 权限守卫(V0.3:通过 sys_user_role → sys_role_menu → sys_menu.perms)` and `运营端角色守卫:admin / super_admin 可访问。`) actually correct?**
+  _`SysUser` has 729 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 698 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
   _`error_response()` has 698 INFERRED edges - model-reasoned connections that need verification._

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 112 - 112
backend/graphify-out/graph.json


+ 4 - 4
backend/graphify-out/manifest.json

@@ -120,7 +120,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/temp_qc_target.py": 1787193516.0555077,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1783428516.0973594,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/biz_no_generator.py": 1791892839.5616264,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/presign_order.py": 1792079244.3547823,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/presign_order.py": 1792223982.1589236,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_month_template.py": 1787115871.8303692,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_info_quick_register.py": 1791860152.5581493,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_infusion_confirm.py": 1779828818.7646794,
@@ -218,7 +218,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_confirm.py": 1779657043.1389203,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_schedule_mgmt.py": 1779940938.18027,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/__init__.py": 1791860598.0884578,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/market_mobile.py": 1791955213.844891,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/market_mobile.py": 1792218205.107924,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/storage_entry_sms_notification.py": 1780325524.9170487,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/customer_service_dict.py": 1791859621.7209744,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_application.py": 1781079904.0516074,
@@ -288,10 +288,10 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/sample_payment_progress.py": 1792202352.3417196,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_resignation_kit_sync.py": 1785405020.285242,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_auth.py": 1786615330.0733685,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/services/market_support.py": 1792071279.776183,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/market_support.py": 1792225523.0536156,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_assignee.py": 1790665524.6969597,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_login_service.py": 1786615952.6840453,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/services/market_protocol.py": 1792199706.9263096,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/market_protocol.py": 1792221825.4808888,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/market_field_scope.py": 1791651783.1531875,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/customer_relations.py": 1791383150.5354238,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_receive_account.py": 1785390254.4121296,

+ 28 - 0
backend/tests/test_market_presign_api.py

@@ -1107,6 +1107,34 @@ def test_convert_rolls_back_protocol_children_and_presign_status_on_midway_failu
     assert db.query(BizProtocolSample).count() == 0
 
 
+def test_convert_creates_protocol_in_effective_status():
+    """预签约转正式后,正式协议状态应为生效中(sxz)。"""
+    from app.services.market_presign import convert_presign_order
+
+    db = _session()
+    _add_master_data(db)
+    order = _order(
+        "own",
+        region_id="region-a",
+        sales_user_id="u1",
+        manager_user_id="manager-a",
+    )
+    order.protocol_no = "P-CONVERT-SXZ"
+    order.hospital_id = "hospital-a"
+    db.add(order)
+    db.commit()
+
+    result = convert_presign_order(
+        db,
+        order_id="own",
+        ctx=_ctx("specialist"),
+        user_info=_user(),
+    )
+
+    protocol = db.query(BizCollectionProtocol).filter_by(id=result["protocolId"]).one()
+    assert protocol.protocol_status == "sxz"
+
+
 def test_convert_rejects_invalid_person_before_creating_protocol():
     from app.services.market_presign import convert_presign_order
 

+ 2 - 0
backend/tests/test_market_protocol_api.py

@@ -567,6 +567,7 @@ def test_specialist_cell_create_forces_current_collection_and_collector_users():
     collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id=main.id, del_flag=0).one()
     party = db.query(BizProtocolPartyCustomer).filter_by(collection_protocol_id=main.id, del_flag=0).one()
     sample = db.query(BizProtocolSample).filter_by(collection_protocol_id=main.id, del_flag=0).one()
+    assert main.protocol_status == 'sxz'
     assert main.collector_user_id == 'u1'
     assert main.create_dept_id == 'dept-from-auth'
     assert collection.sales_user_id == 'u1'
@@ -594,6 +595,7 @@ def test_specialist_donation_create_forces_current_users_and_has_no_payment_sect
     collection = db.query(BizResearchDonationCollection).filter_by(research_donation_id=donation.id, del_flag=0).one()
     party = db.query(BizResearchDonationParty).filter_by(research_donation_id=donation.id, del_flag=0).one()
     sample = db.query(BizResearchDonationSample).filter_by(research_donation_id=donation.id, del_flag=0).one()
+    assert donation.donation_status == 'sxz'
     assert donation.collector_user_id == 'u1'
     assert donation.create_dept_id == 'dept-from-auth'
     assert collection.sales_user_id == 'u1'

+ 239 - 0
backend/tests/test_market_support_api.py

@@ -17,6 +17,8 @@ from app.models.models import (
     BizCollectionRegion,
     BizCollectionRegionStaff,
     BizItemUnit,
+    BizPresignOrder,
+    BizPresignTransfer,
     BizProtocolCollectionInfo,
     BizProtocolFullmoonTodo,
     BizProtocolPayment,
@@ -344,23 +346,102 @@ def test_protocol_sample_type_options_keep_exact_defaults_and_tenant_scope():
             'label': '样本A',
             'storagePrice': '25880.00',
             'storageYears': 20,
+            'sampleCodeFilter': '',
         },
         'type-max': {
             'value': 'type-max',
             'label': '最大金额样本',
             'storagePrice': '9999999999999999.99',
             'storageYears': 30,
+            'sampleCodeFilter': '',
         },
         'type-null': {
             'value': 'type-null',
             'label': '未配置样本',
             'storagePrice': None,
             'storageYears': None,
+            'sampleCodeFilter': '',
         },
     }
     assert 'type-x' not in sample_types
 
 
+def test_protocol_sample_type_options_expose_sample_code_filter():
+    from app.services.market_support import get_form_options
+
+    db = _session()
+    _seed_options(db)
+    db.query(BizCellSampleType).filter(BizCellSampleType.id == 'type-a').update(
+        {'sample_code_filter': 'ZKZX'}
+    )
+    db.commit()
+
+    out = get_form_options(
+        db,
+        module='protocol',
+        ctx=_ctx('manager', 'user-b'),
+        user_info=_user('user-b'),
+        region_id='rb',
+    )
+    type_a = next(item for item in out['sampleTypes'] if item['value'] == 'type-a')
+    assert type_a['sampleCodeFilter'] == 'ZKZX'
+
+
+def test_protocol_form_options_filter_sample_codes_by_code_filter():
+    from app.services.market_support import get_form_options
+
+    db = _session()
+    _seed_options(db)
+    db.add_all(
+        [
+            BizSampleCode(
+                id='sample-code-zk',
+                sample_code='ZKZX-001',
+                tenant_no='tenant-a',
+                create_dept_id='',
+                del_flag=0,
+            ),
+            BizSampleCode(
+                id='sample-code-other',
+                sample_code='OTHER-002',
+                tenant_no='tenant-a',
+                create_dept_id='',
+                del_flag=0,
+            ),
+        ]
+    )
+    db.commit()
+
+    filtered = get_form_options(
+        db,
+        module='protocol',
+        ctx=_ctx('manager', 'user-b'),
+        user_info=_user('user-b'),
+        region_id='rb',
+        code_filter='ZKZX',
+    )
+    assert filtered['sampleCodes'] == [
+        {
+            'value': 'sample-code-zk',
+            'label': 'ZKZX-001',
+            'sampleCode': 'ZKZX-001',
+        }
+    ]
+
+    unfiltered = get_form_options(
+        db,
+        module='protocol',
+        ctx=_ctx('manager', 'user-b'),
+        user_info=_user('user-b'),
+        region_id='rb',
+        code_filter='',
+    )
+    codes = {item['sampleCode'] for item in unfiltered['sampleCodes']}
+    assert 'S-001' in codes
+    assert 'ZKZX-001' in codes
+    assert 'OTHER-002' in codes
+
+
 def test_protocol_form_options_hide_occupied_sample_codes():
     from app.services.market_support import get_form_options
 
@@ -764,6 +845,8 @@ def _protocol_attachment_session():
             BizAttachment.__table__,
             BizCollectionProtocol.__table__,
             BizCollectionRegionStaff.__table__,
+            BizPresignOrder.__table__,
+            BizPresignTransfer.__table__,
             BizProtocolCollectionInfo.__table__,
             BizProtocolFullmoonTodo.__table__,
             BizProtocolPayment.__table__,
@@ -1060,6 +1143,162 @@ def test_protocol_download_allows_accessible_cert_and_insurance_attachment_histo
     assert result == {'downloadUrl': 'https://files.test/signed-download'}
 
 
+@pytest.mark.parametrize(
+    ('attachment_id', 'file_url', 'business_type'),
+    [
+        ('legacy-deposit', 'files/deposit-legacy.png', ''),
+        (
+            'presign-bound-deposit',
+            'files/deposit-presign.png',
+            'market:specialist:presign:bound',
+        ),
+        (
+            'manager-presign-deposit',
+            'files/deposit-manager.png',
+            'market:manager:presign:bound',
+        ),
+    ],
+)
+def test_protocol_download_allows_linked_presign_deposit_attachments(
+    attachment_id,
+    file_url,
+    business_type,
+):
+    """正式协议详情/编辑预览意向金凭证:附件归属预签转账,须经 source_presign 关联授权。"""
+    db = _protocol_attachment_session()
+    protocol = BizCollectionProtocol(
+        id=f'protocol-{attachment_id}',
+        protocol_no=f'P-{attachment_id}',
+        source_presign_order_id=f'presign-{attachment_id}',
+        tenant_no='tenant-a',
+        create_by='sales-a',
+        create_dept_id='dept-a',
+        del_flag=0,
+    )
+    db.add_all(
+        [
+            protocol,
+            BizProtocolCollectionInfo(
+                id=f'collection-{attachment_id}',
+                collection_protocol_id=protocol.id,
+                region_id='region-a',
+                sales_user_id='sales-a',
+                region_manager_user_id='manager-a',
+                tenant_no='tenant-a',
+                create_dept_id='dept-a',
+                del_flag=0,
+            ),
+            BizPresignOrder(
+                id=f'presign-{attachment_id}',
+                order_status='converted',
+                sales_user_id='sales-a',
+                tenant_no='tenant-a',
+                create_by='sales-a',
+                create_dept_id='dept-a',
+                del_flag=0,
+            ),
+            BizPresignTransfer(
+                id=f'transfer-{attachment_id}',
+                presign_order_id=f'presign-{attachment_id}',
+                transfer_amount=Decimal('1000.00'),
+                transfer_attachments=file_url,
+                pay_status='yjf',
+                tenant_no='tenant-a',
+                create_by='sales-a',
+                create_dept_id='dept-a',
+                del_flag=0,
+            ),
+            _attachment(
+                id=attachment_id,
+                file_url=file_url,
+                business_type=business_type,
+                business_id=f'presign-{attachment_id}',
+                create_by='historical-uploader',
+                original_name='抄送我的.png',
+            ),
+        ]
+    )
+    db.commit()
+
+    result = market_support.get_market_attachment_download_url(
+        db,
+        file_url=file_url,
+        file_name=None,
+        module='protocol',
+        ctx=_protocol_download_ctx('specialist', 'sales-a'),
+        user_info=_market_user('sales-a'),
+        storage_backend=_DownloadStorage(),
+    )
+
+    assert result == {'downloadUrl': 'https://files.test/signed-download'}
+
+
+def test_protocol_download_rejects_presign_deposit_attachment_outside_scope():
+    db = _protocol_attachment_session()
+    db.add_all(
+        [
+            BizCollectionProtocol(
+                id='protocol-other',
+                protocol_no='P-OTHER',
+                source_presign_order_id='presign-other',
+                tenant_no='tenant-a',
+                create_by='sales-b',
+                create_dept_id='dept-a',
+                del_flag=0,
+            ),
+            BizProtocolCollectionInfo(
+                id='collection-other',
+                collection_protocol_id='protocol-other',
+                region_id='region-b',
+                sales_user_id='sales-b',
+                region_manager_user_id='manager-b',
+                tenant_no='tenant-a',
+                create_dept_id='dept-a',
+                del_flag=0,
+            ),
+            BizPresignOrder(
+                id='presign-other',
+                order_status='converted',
+                sales_user_id='sales-b',
+                tenant_no='tenant-a',
+                create_by='sales-b',
+                create_dept_id='dept-a',
+                del_flag=0,
+            ),
+            BizPresignTransfer(
+                id='transfer-other',
+                presign_order_id='presign-other',
+                transfer_amount=Decimal('500.00'),
+                transfer_attachments='files/deposit-other.png',
+                pay_status='yjf',
+                tenant_no='tenant-a',
+                create_by='sales-b',
+                create_dept_id='dept-a',
+                del_flag=0,
+            ),
+            _attachment(
+                id='deposit-other',
+                file_url='files/deposit-other.png',
+                business_type='market:specialist:presign:bound',
+                business_id='presign-other',
+                create_by='sales-b',
+            ),
+        ]
+    )
+    db.commit()
+
+    with pytest.raises(PermissionError, match='附件'):
+        market_support.get_market_attachment_download_url(
+            db,
+            file_url='files/deposit-other.png',
+            file_name=None,
+            module='protocol',
+            ctx=_protocol_download_ctx('specialist', 'sales-a'),
+            user_info=_market_user('sales-a'),
+            storage_backend=_DownloadStorage(),
+        )
+
+
 def test_protocol_download_does_not_use_plain_protocol_reference_for_sample_bound():
     db = _protocol_attachment_session()
     _add_protocol_attachment_reference(

+ 313 - 0
docs/demos/2026-10-17-presign-deposit-parity-demo.html

@@ -0,0 +1,313 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>预签约转正式 · 意向金展示对照</title>
+  <style>
+    :root {
+      --brand: #0a9f6d;
+      --brand-dark: #087f57;
+      --brand-bg: #e9f9f2;
+      --mint: #10b981;
+      --mint-bg: #ecfdf5;
+      --text: #0f172a;
+      --muted: #64748b;
+      --line: #e2e8f0;
+      --warn: #d97706;
+      --warn-bg: #fff7e8;
+      --card: #fff;
+      --shadow: 0 8px 28px rgba(15, 23, 42, 0.08);
+      --radius: 14px;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
+      color: var(--text);
+      background:
+        radial-gradient(1200px 480px at 10% -10%, #d1fae5 0%, transparent 55%),
+        radial-gradient(900px 420px at 100% 0%, #ecfdf5 0%, transparent 50%),
+        #f8fafc;
+      line-height: 1.5;
+    }
+    .wrap { max-width: 1180px; margin: 0 auto; padding: 28px 20px 64px; }
+    .hero h1 { margin: 0 0 8px; font-size: 26px; letter-spacing: -0.02em; }
+    .hero p { margin: 0; color: var(--muted); max-width: 720px; }
+    .badge {
+      display: inline-flex; align-items: center; gap: 6px;
+      margin-bottom: 12px; padding: 4px 10px; border-radius: 999px;
+      background: var(--brand-bg); color: var(--brand-dark); font-size: 12px; font-weight: 600;
+    }
+    .grid {
+      display: grid;
+      grid-template-columns: 1.1fr 0.9fr;
+      gap: 18px;
+      margin-top: 24px;
+    }
+    @media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
+    .panel {
+      background: var(--card);
+      border: 1px solid var(--line);
+      border-radius: var(--radius);
+      box-shadow: var(--shadow);
+      overflow: hidden;
+    }
+    .panel-head {
+      display: flex; justify-content: space-between; align-items: center; gap: 12px;
+      padding: 14px 16px; border-bottom: 1px solid var(--line);
+      background: linear-gradient(180deg, #fff, #f8fafc);
+    }
+    .panel-head h2 { margin: 0; font-size: 15px; }
+    .panel-head .tag {
+      font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
+    }
+    .tag.web { background: var(--mint-bg); color: #047857; }
+    .tag.now { background: #fee2e2; color: #b91c1c; }
+    .tag.next { background: var(--brand-bg); color: var(--brand-dark); }
+    .panel-body { padding: 16px; }
+    .alert {
+      background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af;
+      border-radius: 10px; padding: 10px 12px; font-size: 13px; margin-bottom: 12px;
+    }
+    .subtitle {
+      font-size: 13px; font-weight: 700; color: #334155; margin: 0 0 10px;
+    }
+    table {
+      width: 100%; border-collapse: collapse; font-size: 12px;
+      border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
+    }
+    th, td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
+    th { background: #f1f5f9; color: #475569; font-weight: 600; }
+    tr:last-child td { border-bottom: 0; }
+    .money { color: var(--brand-dark); font-weight: 700; }
+    .chip {
+      display: inline-block; padding: 2px 8px; border-radius: 999px;
+      background: var(--mint-bg); color: #047857; font-size: 11px; font-weight: 700;
+    }
+    .phone {
+      max-width: 360px; margin: 0 auto;
+      border: 1px solid var(--line); border-radius: 22px; overflow: hidden;
+      background: #f3f4f6; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6);
+    }
+    .phone-bar {
+      height: 36px; background: linear-gradient(135deg, var(--brand), var(--brand-dark));
+      color: #fff; display: flex; align-items: center; justify-content: center;
+      font-size: 13px; font-weight: 600;
+    }
+    .phone-inner { padding: 12px; }
+    .section-card {
+      background: #fff; border-radius: 12px; padding: 12px; margin-bottom: 10px;
+      border: 1px solid #e5e7eb;
+    }
+    .section-card h3 { margin: 0 0 6px; font-size: 14px; }
+    .hint { margin: 0 0 10px; font-size: 12px; color: var(--muted); }
+    .mini {
+      display: flex; justify-content: space-between; gap: 8px;
+      padding: 10px; border: 1px dashed #fca5a5; border-radius: 10px;
+      background: #fff1f2; font-size: 12px;
+    }
+    .mini strong { color: #9f1239; }
+    .full-card {
+      border: 1px solid #a7f3d0; border-radius: 12px; background: #fff;
+      padding: 12px; margin-top: 8px;
+    }
+    .full-card header {
+      display: flex; justify-content: space-between; align-items: center;
+      margin-bottom: 10px;
+    }
+    .full-card header strong { font-size: 13px; }
+    .field { margin-bottom: 8px; }
+    .field label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 2px; }
+    .field .val {
+      font-size: 13px; padding: 8px 10px; background: #f8fafc;
+      border: 1px solid var(--line); border-radius: 8px;
+    }
+    .field.wide .val { min-height: 44px; }
+    .attach {
+      display: inline-flex; align-items: center; gap: 6px;
+      padding: 6px 10px; border-radius: 8px; background: var(--brand-bg);
+      color: var(--brand-dark); font-size: 12px; font-weight: 600;
+    }
+    .checklist {
+      margin-top: 22px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
+    }
+    @media (max-width: 860px) { .checklist { grid-template-columns: 1fr; } }
+    .choice {
+      cursor: pointer; user-select: none;
+      background: #fff; border: 2px solid var(--line); border-radius: 14px;
+      padding: 14px; transition: .15s ease; box-shadow: var(--shadow);
+    }
+    .choice:hover { border-color: #86efac; }
+    .choice[data-selected="true"], .choice:focus-visible {
+      border-color: var(--brand); outline: none;
+      box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.18);
+    }
+    .choice h3 { margin: 0 0 6px; font-size: 14px; }
+    .choice p { margin: 0; font-size: 12px; color: var(--muted); }
+    .choice .rec {
+      display: inline-block; margin-top: 8px; font-size: 11px; font-weight: 700;
+      color: var(--brand-dark); background: var(--brand-bg); padding: 2px 8px; border-radius: 999px;
+    }
+    .note {
+      margin-top: 18px; padding: 12px 14px; border-radius: 12px;
+      background: var(--warn-bg); color: #92400e; font-size: 13px; border: 1px solid #fcd34d;
+    }
+    .legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
+    .legend span {
+      font-size: 11px; padding: 3px 8px; border-radius: 999px; background: #fff; border: 1px solid var(--line);
+    }
+    .missing { background: #fef2f2; color: #b91c1c; text-decoration: line-through; }
+    .ok { background: #ecfdf5; color: #047857; }
+  </style>
+</head>
+<body>
+  <div class="wrap">
+    <div class="hero">
+      <div class="badge">mobile-oa-h5 · 市场专员 / 地区经理</div>
+      <h1>预签约转正式后 · 意向金展示对照 Demo</h1>
+      <p>
+        对照 web-admin「采集协议」详情/编辑中的意向金区块。当前缺口主要在
+        <strong>移动端编辑页</strong>:只露出金额与时间;详情页字段已较完整。
+      </p>
+      <div class="legend">
+        <span class="ok">✓ 已有</span>
+        <span class="missing">✗ 编辑页缺失</span>
+        <span>Web 权威列:转账方式 / 金额 / 时间 / 凭证 / 备注 / 财务备注 / 状态</span>
+      </div>
+    </div>
+
+    <div class="grid">
+      <!-- Web -->
+      <section class="panel">
+        <div class="panel-head">
+          <h2>Web 端(权威参考)· 详情 / 编辑同构</h2>
+          <span class="tag web">web-admin</span>
+        </div>
+        <div class="panel-body">
+          <div class="alert">
+            来源预签约已确认意向金合计 ¥5,000.00(1 笔),下方列表与预签同一数据,不自动写入样本缴费
+          </div>
+          <div class="subtitle">意向金转账(关联预签约订单)</div>
+          <div style="overflow-x:auto">
+            <table>
+              <thead>
+                <tr>
+                  <th>#</th>
+                  <th>转账方式</th>
+                  <th>转账金额(元)</th>
+                  <th>转账时间</th>
+                  <th>转账凭证</th>
+                  <th>备注</th>
+                  <th>财务备注</th>
+                  <th>状态</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr>
+                  <td>1</td>
+                  <td>微信支付</td>
+                  <td class="money">5,000.00</td>
+                  <td>2026-10-01 14:30:00</td>
+                  <td>凭证.pdf</td>
+                  <td>意向金</td>
+                  <td>已核对</td>
+                  <td><span class="chip">已缴费</span></td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+      </section>
+
+      <!-- Mobile now vs next -->
+      <section class="panel">
+        <div class="panel-head">
+          <h2>移动端编辑页 · 现状 vs 完善后</h2>
+          <span class="tag now">缺口</span>
+        </div>
+        <div class="panel-body">
+          <div class="phone">
+            <div class="phone-bar">正式协议编辑</div>
+            <div class="phone-inner">
+              <div class="section-card">
+                <h3>现状(不够全)</h3>
+                <p class="hint">来源预签约已确认意向金合计 ¥5,000.00(1 笔)…</p>
+                <div class="mini">
+                  <div>
+                    <strong>转账 1</strong><br />
+                    <span class="ok">¥5,000.00</span> · <span class="ok">2026-10-01</span>
+                  </div>
+                  <div style="text-align:right;font-size:11px;color:#9f1239;line-height:1.6">
+                    <span class="missing">转账方式</span><br />
+                    <span class="missing">转账凭证</span><br />
+                    <span class="missing">备注</span><br />
+                    <span class="missing">财务备注</span><br />
+                    <span class="missing">状态</span>
+                  </div>
+                </div>
+              </div>
+
+              <div class="section-card" style="border-color:#a7f3d0;background:#f0fdf4">
+                <h3>完善后(对齐 Web)</h3>
+                <p class="hint">来源预签约已确认意向金合计 ¥5,000.00(1 笔),与预签同一数据,不自动写入样本缴费</p>
+                <div class="full-card">
+                  <header>
+                    <strong>意向金转账 1</strong>
+                    <span class="chip">已缴费</span>
+                  </header>
+                  <div class="field"><label>转账方式</label><div class="val">微信支付</div></div>
+                  <div class="field"><label>转账金额(元)</label><div class="val money">¥5,000.00</div></div>
+                  <div class="field"><label>转账时间</label><div class="val">2026-10-01 14:30:00</div></div>
+                  <div class="field wide"><label>转账凭证</label><div class="val"><span class="attach">📎 凭证.pdf</span></div></div>
+                  <div class="field"><label>备注</label><div class="val">意向金</div></div>
+                  <div class="field"><label>财务备注</label><div class="val">已核对</div></div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </section>
+    </div>
+
+    <div class="note">
+      详情页 <code>protocol.js → detailSections</code> 已包含上述 7 项字段;本次 Demo 重点对齐
+      <strong>编辑页</strong> <code>MarketModuleForm.vue</code> 意向金区块,并确认详情页展示样式与 Web 信息量一致。
+      意向金为只读关联数据(不可在正式单里改预签转账)。
+    </div>
+
+    <h2 style="margin:28px 0 10px;font-size:16px">实现方式(请点选一种)</h2>
+    <div class="checklist">
+      <div class="choice" data-choice="reuse-card" tabindex="0" role="button" aria-pressed="false">
+        <h3>A. 复用 PresignTransferCard(只读)</h3>
+        <p>编辑页直接挂现有预签转账卡片,disabled 只读,字段天然对齐预签表单。</p>
+        <span class="rec">推荐 · 改动小、一致</span>
+      </div>
+      <div class="choice" data-choice="detail-card" tabindex="0" role="button" aria-pressed="false">
+        <h3>B. 复用详情嵌套卡片</h3>
+        <p>编辑页调用与详情相同的 deposit section 数据结构 + MarketDetailNestedCard。</p>
+      </div>
+      <div class="choice" data-choice="inline-fields" tabindex="0" role="button" aria-pressed="false">
+        <h3>C. 手写只读字段列表</h3>
+        <p>在 MarketModuleForm 内补全 label/value 行,不抽公共组件。</p>
+      </div>
+    </div>
+  </div>
+
+  <script>
+    document.querySelectorAll('.choice').forEach((el) => {
+      el.addEventListener('click', () => {
+        document.querySelectorAll('.choice').forEach((x) => {
+          x.dataset.selected = 'false'
+          x.setAttribute('aria-pressed', 'false')
+        })
+        el.dataset.selected = 'true'
+        el.setAttribute('aria-pressed', 'true')
+        if (window.parent !== window) {
+          window.parent.postMessage({ type: 'brainstorm-choice', value: el.dataset.choice }, '*')
+        }
+      })
+    })
+  </script>
+</body>
+</html>

+ 41 - 0
docs/superpowers/plans/2026-10-17-market-protocol-sample-type-code-filter.md

@@ -0,0 +1,41 @@
+# Market Protocol Sample Type ↔ Code Filter Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Align market H5 protocol sample type → sample code linkage with Web admin via `sampleCodeFilter` / `codeFilter`.
+
+**Architecture:** Backend `get_form_options` exposes `sampleCodeFilter` on sample types and accepts `codeFilter` when listing sample codes. Frontend clears code on type change and refetches codes with that filter.
+
+**Tech Stack:** FastAPI/SQLAlchemy (`backend`), Vue 3 + Vant (`mobile-oa-h5`)
+
+---
+
+### Task 1: Backend — sampleCodeFilter + codeFilter
+
+**Files:**
+- Modify: `backend/app/services/market_support.py`
+- Test: `backend/tests/test_market_support_api.py`
+
+- [x] Add failing tests for `sampleCodeFilter` on types and `codeFilter` on codes
+- [x] Extend `_protocol_sample_type_option` with `sampleCodeFilter`
+- [x] Add `code_filter` param to `get_form_options` and apply LIKE filter
+- [x] Wire query param from `market_mobile.py` form-options routes
+- [x] Run tests
+
+### Task 2: Frontend — refresh codes on type change
+
+**Files:**
+- Modify: `mobile-oa-h5/src/market/protocol-sample-transport.js` (helpers if needed)
+- Modify: `mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue`
+- Modify: `mobile-oa-h5/src/views/market/MarketModuleForm.vue`
+- Test: `mobile-oa-h5/src/market/protocol-sample-transport.test.js` and/or related UI tests
+
+- [x] Helper: resolve `sampleCodeFilter` from selected type
+- [x] Panel emits refresh with codeFilter on type change / open editor
+- [x] Form refetches form-options with codeFilter and merges sampleCodes
+- [x] Run frontend tests
+
+### Task 3: Verify + graphify
+
+- [x] Run targeted backend + frontend tests
+- [x] Update graphify if needed (`./scripts/update-graph.sh`)

+ 27 - 0
docs/superpowers/plans/2026-10-17-mobile-protocol-deposit-parity.md

@@ -0,0 +1,27 @@
+# 移动端正式协议意向金展示对齐 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 正式协议编辑页意向金区块对齐 Web 字段,附件使用 `MarketAttachmentCards` 标准样式。
+
+**Architecture:** `MarketModuleForm.vue` 用只读 `PresignTransferCard` 替换精简意向金行;详情已齐,仅补契约测试锁定。
+
+**Tech Stack:** Vue 3 + Vant(mobile-oa-h5)
+
+---
+
+### Task 1: 失败测试锁定编辑页契约
+
+- [x] 在 `mobile-parity-ui.test.js` 断言编辑页使用 `PresignTransferCard`、`disabled`、去掉精简 `market-deposit-transfer`
+- [x] 运行测试确认 RED
+
+### Task 2: 实现编辑页只读意向金卡片
+
+- [x] `MarketModuleForm.vue`:有 hint 或 transfers 时展示;循环 `PresignTransferCard` + `disabled` + `transferTypes`
+- [x] `PresignTransferCard` 只读时始终展示财务备注(空则 —);附件走 `MarketAttachmentCards`
+- [x] GREEN
+
+### Task 3: 详情回归
+
+- [x] 详情字段顺序对齐 Web;`protocol-mobile-parity.test.js` 通过
+- [x] 跑相关 node 测试 55 pass

+ 47 - 0
docs/superpowers/specs/2026-10-17-market-protocol-sample-type-code-filter-design.md

@@ -0,0 +1,47 @@
+# 市场 H5 协议单:样本类型 ↔ 样本编码联动(对齐 Web)
+
+> 日期:2026-10-17  
+> 状态:已批准(方案 B)
+
+## 背景
+
+Web 管理端采集协议单新增/编辑样本时:切换**样本类型**会清空已选**样本编码**,并按细胞样本类型上的 `sampleCodeFilter` 作为 `codeFilter` 重新拉取可选编码。
+
+市场专员 / 地区经理协议单表单在 `mobile-oa-h5`(`mobile-h5` 仅 WebView 壳)。当前已支持改类型清空编码并带出价格/年限,但**未**按 `sampleCodeFilter` 过滤编码候选。
+
+## 目标
+
+专员、经理协议单新增/编辑样本弹窗中,样本类型与样本编码联动与 Web 管理端一致。
+
+## 行为要求
+
+1. 切换样本类型 → 清空 `sampleCodeId` / `sampleCode`;用该类型 `sampleCodeFilter` 作为 `codeFilter` 重新拉取编码;同步原价/优惠价/存储年限。
+2. 打开或搜索样本编码 → 请求始终带上当前类型的 `codeFilter`。
+3. 选中编码 → 只回填编码字段,不反改样本类型。
+4. `sampleCodeFilter` 为空或未配置 → 不过滤(展示未占用编码候选)。
+5. 编码候选仍排除:已被协议占用、本单其它样本行已选;当前编辑行已选保留回显。
+
+## 方案(B)
+
+### 后端
+
+- `get_form_options`(协议模块):
+  - `sampleTypes` 增加 `sampleCodeFilter`(来自 `BizCellSampleType.sample_code_filter`)。
+  - 查询参数 `codeFilter`:非空时对 `BizSampleCode.sample_code` 做 `LIKE %codeFilter%`,再排除已占用,仍限约 100 条。
+- 专员 / 经理共用同一接口。
+
+### 前端(`mobile-oa-h5`)
+
+- `ProtocolSampleTransportPanel`:改类型时清空编码并通知父级按 `codeFilter` 刷新编码列表;打开/搜索编码同样带 filter。
+- `MarketModuleForm`:调用 `fetchMarketFormOptions` 传 `codeFilter`,合并更新 `sampleCodes`(避免整表闪烁)。
+- `mobile-h5` 不改。
+
+## 范围外
+
+- 不改 Web 管理端既有逻辑。
+- 不改捐赠侧自动生成编码路径(无类型筛编码需求)。
+
+## 测试
+
+- 后端:`sampleCodeFilter` 回传;`codeFilter` 过滤;空 filter 不过滤。
+- 前端:改类型清空并发刷新;有 filter 时候选匹配;空 filter 全量候选。

+ 34 - 0
docs/superpowers/specs/2026-10-17-mobile-protocol-deposit-parity-design.md

@@ -0,0 +1,34 @@
+# 移动端正式协议意向金展示对齐 Web
+
+## 背景
+
+预签约转正式后,市场专员/地区经理在 `mobile-oa-h5` 查看/编辑正式协议时,意向金(关联预签约)信息少于 web-admin。
+
+## 权威参考(web-admin)
+
+详情与编辑同构展示:
+
+1. 汇总提示:`presignDepositHint`(已确认合计金额 + 笔数 + 不自动写入样本缴费说明)
+2. 转账列表字段:转账方式、转账金额、转账时间、转账凭证、备注、财务备注、状态
+
+## 现状
+
+| 端 | 缺口 |
+|---|---|
+| 详情 `protocol.js detailSections` | 字段已齐全,附件走 `MarketAttachmentCards` |
+| 编辑 `MarketModuleForm.vue` | 仅金额+时间;缺方式/凭证/备注/财务备注/状态 |
+
+## 方案(已选 A)
+
+编辑页复用 `PresignTransferCard`,`disabled` 只读:
+
+- 字段与预签意向金卡片一致
+- 附件经 `PresignAttachmentUploader` → **`MarketAttachmentCards` 标准附件卡片**(预览/下载,不可增删)
+- 无提交/撤回/删除操作条
+- 有 hint 或 `depositTransfers.length > 0` 时展示区块
+
+## 非目标
+
+- 不在正式协议编辑页修改预签意向金数据
+- 不改后端接口字段
+- 不改 web-admin

+ 1 - 1
mobile-h5/utils/config.js

@@ -6,7 +6,7 @@
  *
  * 微信公众平台须为对应环境配置 request / 业务域名(HTTPS)
  */
-export const APP_ENV = 'local' // 'local' | 'test' | 'prod'
+export const APP_ENV = 'test' // 'local' | 'test' | 'prod'
 
 const ENV_PRESETS = {
   local: {

+ 59 - 6
mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue

@@ -103,7 +103,14 @@
       </div>
     </article>
 
-    <van-popup v-model:show="editorVisible" position="bottom" round :style="{ maxHeight: '88%' }">
+    <van-popup
+      ref="editorPopupRef"
+      v-model:show="editorVisible"
+      position="bottom"
+      round
+      :style="{ maxHeight: '88%' }"
+      @opened="() => resetPopupScroll(editorPopupRef)"
+    >
       <div class="protocol-sample-transport__editor">
         <h3>{{ editorIndex < 0 ? '添加样本' : '编辑样本' }}</h3>
         <div class="market-form-fields">
@@ -214,7 +221,14 @@
       </div>
     </van-popup>
 
-    <van-popup v-model:show="freightVisible" position="bottom" round :style="{ maxHeight: '80%' }">
+    <van-popup
+      ref="freightPopupRef"
+      v-model:show="freightVisible"
+      position="bottom"
+      round
+      :style="{ maxHeight: '80%' }"
+      @opened="() => resetPopupScroll(freightPopupRef)"
+    >
       <div class="protocol-sample-transport__editor">
         <h3>配置运费</h3>
         <van-tabs v-model:active="freightMode">
@@ -302,7 +316,14 @@
       </div>
     </van-popup>
 
-    <van-popup v-model:show="unlinkVisible" position="bottom" round :style="{ maxHeight: '80%' }">
+    <van-popup
+      ref="unlinkPopupRef"
+      v-model:show="unlinkVisible"
+      position="bottom"
+      round
+      :style="{ maxHeight: '80%' }"
+      @opened="() => resetPopupScroll(unlinkPopupRef)"
+    >
       <div class="protocol-sample-transport__editor">
         <h3>取消运费</h3>
         <p class="protocol-sample-transport__hint">勾选要取消运费的样本(同批其它协议样本也可勾选):</p>
@@ -333,7 +354,7 @@
 </template>
 
 <script setup>
-import { computed, reactive, ref, watch } from 'vue'
+import { computed, nextTick, reactive, ref, watch } from 'vue'
 import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
 import OaDatePicker from '../../oa/components/OaDatePicker.vue'
 import OaMoneyField from '../../oa/components/OaMoneyField.vue'
@@ -358,6 +379,7 @@ import {
   formatFreightLinkedSampleCodes,
   freightDisplay,
   partitionFreightConfigSamples,
+  resolveSampleCodeFilter,
   sampleHasFreight,
   validateFreightForm,
   validateSampleRow
@@ -373,7 +395,7 @@ const props = defineProps({
   transportModes: { type: Array, default: () => [] },
   disabled: { type: Boolean, default: false }
 })
-const emit = defineEmits(['update:modelValue'])
+const emit = defineEmits(['update:modelValue', 'refresh-sample-codes'])
 
 const samples = computed(() => props.modelValue || [])
 const showPrice = computed(() => props.source === 'cell_storage')
@@ -394,8 +416,19 @@ const priceSum = computed(() =>
 )
 
 const editorVisible = ref(false)
+const editorPopupRef = ref(null)
+const freightPopupRef = ref(null)
+const unlinkPopupRef = ref(null)
 const editorIndex = ref(-1)
 const draft = reactive(emptySampleRow())
+
+function resetPopupScroll(popupRef) {
+  nextTick(() => {
+    const exposed = popupRef?.value
+    const el = exposed?.popupRef?.value ?? exposed?.popupRef
+    if (el && typeof el.scrollTop === 'number') el.scrollTop = 0
+  })
+}
 const freightVisible = ref(false)
 const freightMode = ref('create')
 const freightBusy = ref(false)
@@ -416,9 +449,17 @@ const typeSelectOptions = computed(() =>
     label: opt.label || opt.typeName || opt.value,
     value: opt.value || opt.id,
     storagePrice: opt.storagePrice ?? opt.price,
-    storageYears: opt.storageYears
+    storageYears: opt.storageYears,
+    sampleCodeFilter: opt.sampleCodeFilter || ''
   }))
 )
+
+function emitSampleCodeRefresh(sampleTypeId) {
+  if (autoGenerateCode.value) return
+  emit('refresh-sample-codes', {
+    codeFilter: resolveSampleCodeFilter(props.sampleTypes, sampleTypeId)
+  })
+}
 const statusSelectOptions = SAMPLE_STATUS_OPTIONS.map((opt) => ({
   label: opt.label,
   value: opt.value
@@ -492,6 +533,8 @@ function openAdd() {
   editorIndex.value = -1
   Object.assign(draft, emptySampleRow({ source: props.source }))
   editorVisible.value = true
+  resetPopupScroll(editorPopupRef)
+  emitSampleCodeRefresh('')
 }
 
 function openEdit(index) {
@@ -502,11 +545,18 @@ function openEdit(index) {
     isSpecial: Number(row.isSpecial) === 1 ? 1 : 0
   })
   editorVisible.value = true
+  resetPopupScroll(editorPopupRef)
+  emitSampleCodeRefresh(draft.sampleTypeId)
 }
 
 function onTypeIdChange(value) {
   if (!value) {
     draft.sampleTypeId = ''
+    if (!autoGenerateCode.value) {
+      draft.sampleCodeId = ''
+      draft.sampleCode = ''
+    }
+    emitSampleCodeRefresh('')
     return
   }
   if (!typeSelectOptions.value.length) {
@@ -526,6 +576,7 @@ function onTypeIdChange(value) {
     )
   )
   if (autoGenerateCode.value) draft.sampleCode = ''
+  emitSampleCodeRefresh(value)
 }
 
 function onCodeIdChange(value) {
@@ -602,6 +653,7 @@ async function openFreightConfig() {
   selectedFreightId.value = ''
   existingFreightKeyword.value = ''
   freightVisible.value = true
+  resetPopupScroll(freightPopupRef)
   await loadExistingFreights()
 }
 
@@ -802,6 +854,7 @@ async function unlinkFreight() {
     unlinkSelectedKeys.value = defaultSelectedKeys
     unlinkSeedRows.value = [...persisted, ...draftsOnly]
     unlinkVisible.value = true
+    resetPopupScroll(unlinkPopupRef)
   } catch (error) {
     showToast(marketErrorMessage(error, '加载运费明细失败'))
   } finally {

+ 57 - 9
mobile-oa-h5/src/market/form-payload.js

@@ -1,5 +1,6 @@
 import { serializeMarketAttachments } from './attachment-presenter.js'
 import { normalizeMoney } from './money.js'
+import { parseInstallmentRatios } from './protocol-sample-payment.js'
 
 const FORBIDDEN_KEYS = new Set([
   'token',
@@ -69,6 +70,61 @@ export function buildMarketActionPayload(module, action, payload) {
   return clean
 }
 
+/** 与 MarketProtocolSamplePaymentBody / ScheduleBody 对齐;勿把详情只读字段原样回传(extra=forbid) */
+const SAMPLE_PAYMENT_WRITE_KEYS = Object.freeze([
+  'id',
+  'sampleCode',
+  'protocolSampleId',
+  'payMode',
+  'contractAmount',
+  'lumpSumDeadline',
+  'installmentCount',
+  'installmentIntervalMonths',
+  'installmentStartDate',
+  'installmentAdvanceDays',
+  'installmentRatios',
+  'remark',
+  'schedules'
+])
+
+const SAMPLE_SCHEDULE_WRITE_KEYS = Object.freeze([
+  'id',
+  'scheduleNo',
+  'dueAmount',
+  'dueDate',
+  'scheduleStatus'
+])
+
+function pickDefinedKeys(source, keys) {
+  const result = {}
+  for (const key of keys) {
+    if (Object.prototype.hasOwnProperty.call(source || {}, key) && source[key] !== undefined) {
+      result[key] = source[key]
+    }
+  }
+  return result
+}
+
+function serializeSamplePaymentForSave(payment) {
+  const clean = sanitizeMarketPayload(payment) || {}
+  const next = pickDefinedKeys(clean, SAMPLE_PAYMENT_WRITE_KEYS)
+  if (Object.prototype.hasOwnProperty.call(next, 'contractAmount')) {
+    next.contractAmount = normalizeMoney(next.contractAmount)
+  }
+  // 详情接口可能回传 JSON 字符串(installment_ratios 原样),须先 parse
+  next.installmentRatios = parseInstallmentRatios(clean.installmentRatios)
+  next.schedules = (clean.schedules || [])
+    .filter((schedule) => schedule && typeof schedule === 'object')
+    .map((schedule) => {
+      const row = pickDefinedKeys(schedule, SAMPLE_SCHEDULE_WRITE_KEYS)
+      if (Object.prototype.hasOwnProperty.call(row, 'dueAmount')) {
+        row.dueAmount = normalizeMoney(row.dueAmount)
+      }
+      return row
+    })
+  return next
+}
+
 /** 细胞存储协议保存时附加 samplePayments;科研捐赠忽略 */
 export function mergeProtocolSamplePayments(payload, samplePayments) {
   const next = { ...(payload || {}) }
@@ -95,15 +151,7 @@ export function mergeProtocolSamplePayments(payload, samplePayments) {
     }))
   }
   if (samplePayments == null) return next
-  next.samplePayments = sanitizeMarketPayload(samplePayments).map((payment) => ({
-    ...payment,
-    contractAmount: normalizeMoney(payment.contractAmount),
-    installmentRatios: (payment.installmentRatios || []).map(normalizeMoney),
-    schedules: (payment.schedules || []).map(({ transfers: _transfers, ...schedule }) => ({
-      ...schedule,
-      dueAmount: normalizeMoney(schedule.dueAmount)
-    }))
-  }))
+  next.samplePayments = samplePayments.map(serializeSamplePaymentForSave)
   return next
 }
 

+ 66 - 0
mobile-oa-h5/src/market/form-payload.test.js

@@ -56,6 +56,72 @@ test('protocol money payload uses fixed two-decimal strings without float coerci
   assert.equal(next.samplePayments[0].schedules[0].dueAmount, '0.10')
 })
 
+test('edit payload tolerates backend installmentRatios JSON string and skips null schedules', () => {
+  // 市场详情接口把 installment_ratios 原样回成字符串;编辑保存若直接 .map 会抛错且无 Toast
+  const next = mergeProtocolSamplePayments(
+    { source: 'cell_storage', samples: [] },
+    [{
+      sampleCode: 'S1',
+      payMode: 'fq',
+      contractAmount: '100.00',
+      installmentRatios: '["40.00","60.00"]',
+      schedules: [
+        null,
+        { scheduleNo: 1, dueAmount: '40', transfers: [{ id: 't1', payStatus: 'dfj' }] },
+        { scheduleNo: 2, dueAmount: '60.0', transfers: [] }
+      ]
+    }]
+  )
+  assert.deepEqual(next.samplePayments[0].installmentRatios, ['40.00', '60.00'])
+  assert.equal(next.samplePayments[0].schedules.length, 2)
+  assert.equal(next.samplePayments[0].schedules[0].dueAmount, '40.00')
+  assert.equal(Object.hasOwn(next.samplePayments[0].schedules[0], 'transfers'), false)
+})
+
+test('samplePayments edit payload only keeps MarketProtocolSamplePaymentBody writable fields', () => {
+  // 详情会带回 paidAmount/allowedActions 等只读字段;extra=forbid 时 422 详情数组曾被 Toast 打成 [object Object]
+  const next = mergeProtocolSamplePayments(
+    { source: 'cell_storage', samples: [] },
+    [{
+      id: 'pay-1',
+      sampleCode: 'S1',
+      protocolSampleId: 'ps-1',
+      payMode: 'dj',
+      contractAmount: '100',
+      paidAmount: '10',
+      arrears: '90',
+      allowedActions: ['register-transfer'],
+      createTime: '2026-01-01',
+      schedules: [{
+        id: 'sch-1',
+        scheduleNo: 1,
+        dueAmount: '100',
+        dueDate: '2026-10-17',
+        scheduleStatus: 'open',
+        paidAmount: '0',
+        allowedActions: ['register-transfer'],
+        transfers: [{ id: 't1' }]
+      }]
+    }]
+  )
+  assert.deepEqual(Object.keys(next.samplePayments[0]).sort(), [
+    'contractAmount',
+    'id',
+    'installmentRatios',
+    'payMode',
+    'protocolSampleId',
+    'sampleCode',
+    'schedules'
+  ].sort())
+  assert.deepEqual(Object.keys(next.samplePayments[0].schedules[0]).sort(), [
+    'dueAmount',
+    'dueDate',
+    'id',
+    'scheduleNo',
+    'scheduleStatus'
+  ].sort())
+})
+
 test('mergeProtocolCustomerLinks keeps parent customer ids and strips link meta', () => {
   const next = mergeProtocolCustomerLinks(
     { source: 'cell_storage', customer: { motherName: '张敏' } },

+ 10 - 15
mobile-oa-h5/src/market/modules/protocol.js

@@ -462,8 +462,7 @@ export default {
           ['regionManagerUserName', '地区经理', { map: () => person(detail?.collection?.regionManagerUserName, detail?.collection?.regionManagerUserId) }],
           ['medicalStaff', '采集医护'],
           ['sampleCollectTime', '样本采集时间'],
-          ['maternalBloodCollectTime', '母血采集时间'],
-          ['remark', '备注']
+          ['maternalBloodCollectTime', '母血采集时间']
         ]),
         cards: []
       },
@@ -479,8 +478,8 @@ export default {
           ['expectedDeliveryDate', '预产期'], ['newbornDeliveryTime', '新生儿分娩时间'],
           ['newbornIdCard', '新生儿身份证'],
           ['deliveryMode', '分娩方式', { map: (item) => label(DELIVERY_LABELS, item) }],
-          ['birthOrder', '胎次'], ['newbornWeightGram', '新生儿体重(克)'],
-          ['apgar1min', '新生儿评分(1分钟)'], ['remark', '备注']
+          ['birthOrder', '胎次'],           ['newbornWeightGram', '新生儿体重(克)'],
+          ['apgar1min', '新生儿评分(1分钟)']
         ]),
         cards: []
       },
@@ -522,8 +521,7 @@ export default {
             ],
             ['etaTime', '预计到达时间', { map: () => item?.etaTime || item?.transport?.etaTime || '' }],
             ['isSpecial', '是否特殊样本', { map: (itemValue) => Number(itemValue) === 1 ? '是' : '否' }],
-            ['specialRemark', '特殊说明'],
-            ['remark', '备注']
+            ['specialRemark', '特殊说明']
           ]),
           cards: sampleLifecycleCards(item, source)
         }))
@@ -540,7 +538,7 @@ export default {
         fields: depositHint
           ? [{
               label: '已确认意向金',
-              value: `¥${amt.toFixed(2)}(${cnt} 笔);与预签同一数据,不自动写入样本缴费`
+              value: `¥${amt.toFixed(2)}(${cnt} 笔)`
             }]
           : [],
         cards: depositTransfers.map((tr, index) => {
@@ -551,13 +549,12 @@ export default {
             statusText: pay.label,
             statusTone: pay.tagType,
             fields: [
+              { label: '转账方式', value: transferTypeText(tr) },
               { label: '转账金额', value: value(tr, 'transferAmount') },
               { label: '转账时间', value: value(tr, 'transferTime') },
-              { label: '转账方式', value: transferTypeText(tr) },
               { label: '转账凭证', value: transferAttachmentValue(tr), type: 'attachments' },
               { label: '备注', value: value(tr, 'remark') },
-              { label: '财务备注', value: value(tr, 'financeRemark') },
-              { label: '状态', value: pay.label, tag: true, tagType: pay.tagType }
+              { label: '财务备注', value: value(tr, 'financeRemark') }
             ]
           }
         })
@@ -653,9 +650,7 @@ export default {
         : [
             { label: '协议附件', type: 'attachments', value: detail?.attachments?.protocolAttachments || '' },
             { label: '知情同意书', type: 'attachments', value: detail?.attachments?.consentFormUrls || '' },
-            { label: '孕妇健康调查表', type: 'attachments', value: detail?.attachments?.healthSurveyFormUrls || '' },
-            { label: '传染病检测报告', type: 'attachments', value: detail?.attachments?.infectiousDiseaseReportUrls || '' },
-            { label: '其他附件', type: 'attachments', value: detail?.attachments?.attachmentUrls || '' }
+            { label: '孕妇健康调查表', type: 'attachments', value: detail?.attachments?.healthSurveyFormUrls || '' }
           ],
       cards: []
     })
@@ -733,8 +728,8 @@ export default {
       { key: 'protocolAttachments', label: '协议附件', type: 'upload', attachmentPolicy: 'protocol', source: 'cell_storage' },
       { key: 'consentFormUrls', label: '知情同意书', type: 'upload', attachmentPolicy: 'protocol' },
       { key: 'healthSurveyFormUrls', label: '健康调查表', type: 'upload', attachmentPolicy: 'protocol', source: 'cell_storage' },
-      { key: 'attachmentUrls', label: '其他附件', type: 'upload', attachmentPolicy: 'protocol', source: 'research_donation' },
-      { key: 'infectiousDiseaseReportUrls', label: '传染病报告', type: 'upload', attachmentPolicy: 'protocol', source: 'research_donation' }
+      { key: 'infectiousDiseaseReportUrls', label: '传染病报告', type: 'upload', attachmentPolicy: 'protocol', source: 'research_donation' },
+      { key: 'attachmentUrls', label: '其他附件', type: 'upload', attachmentPolicy: 'protocol', source: 'research_donation' }
     ] },
     // 渲染时须置于样本运输 / 缴费面板之后(见 MarketModuleForm)
     { key: 'confirm', title: '提交确认', nested: true, uiOnly: true, renderLast: true, fields: [

+ 18 - 0
mobile-oa-h5/src/market/page-scroll.js

@@ -0,0 +1,18 @@
+/**
+ * Reset window/document scroll after leaving a long market form/detail.
+ * Nested list routes remount content but keep the shared page scroll position.
+ */
+export function resetMarketWindowScroll(win = typeof window !== 'undefined' ? window : null) {
+  if (!win) return false
+  const doc = win.document
+  if (doc?.documentElement) doc.documentElement.scrollTop = 0
+  if (doc?.body) doc.body.scrollTop = 0
+  if (typeof win.scrollTo === 'function') {
+    try {
+      win.scrollTo({ top: 0, left: 0, behavior: 'auto' })
+    } catch {
+      win.scrollTo(0, 0)
+    }
+  }
+  return true
+}

+ 40 - 0
mobile-oa-h5/src/market/page-scroll.test.js

@@ -0,0 +1,40 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+import { resetMarketWindowScroll } from './page-scroll.js'
+
+function source(relativePath) {
+  return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
+}
+
+test('resetMarketWindowScroll clears document and window scroll position', () => {
+  const calls = []
+  const fakeWindow = {
+    document: {
+      documentElement: { scrollTop: 480 },
+      body: { scrollTop: 480 }
+    },
+    scrollTo(...args) {
+      calls.push(args)
+    }
+  }
+
+  assert.equal(resetMarketWindowScroll(fakeWindow), true)
+  assert.equal(fakeWindow.document.documentElement.scrollTop, 0)
+  assert.equal(fakeWindow.document.body.scrollTop, 0)
+  assert.deepEqual(calls, [[{ top: 0, left: 0, behavior: 'auto' }]])
+})
+
+test('resetMarketWindowScroll is a no-op without a window', () => {
+  assert.equal(resetMarketWindowScroll(null), false)
+})
+
+test('presign and market lists reset page scroll when returning from child routes', () => {
+  const presign = source('../views/market/presign/PresignOrderList.vue')
+  const market = source('../views/market/MarketModuleList.vue')
+  assert.match(presign, /resetMarketWindowScroll/)
+  assert.match(presign, /wasActive && !active[\s\S]*resetMarketWindowScroll/)
+  assert.match(market, /resetMarketWindowScroll/)
+  assert.match(market, /wasActive && !active[\s\S]*resetMarketWindowScroll/)
+})

+ 18 - 4
mobile-oa-h5/src/market/protocol-mobile-parity.test.js

@@ -60,7 +60,19 @@ test('protocol detail exposes reception and storage release without qc productio
     }]
   })
   const sample = sections.find((section) => section.key === 'samples').cards[0]
-  assert.equal(sample.fields.find((field) => field.label === '备注').value, '细胞存储样本备注')
+  assert.equal(sample.fields.find((field) => field.label === '备注'), undefined)
+  assert.equal(
+    sections.find((section) => section.key === 'collection').fields.find((field) => field.label === '备注'),
+    undefined
+  )
+  assert.equal(
+    sections.find((section) => section.key === 'customer').fields.find((field) => field.label === '备注'),
+    undefined
+  )
+  assert.deepEqual(
+    sections.find((section) => section.key === 'attachments').fields.map((field) => field.label),
+    ['协议附件', '知情同意书', '孕妇健康调查表']
+  )
   const labels = [...sample.fields.map((field) => field.label), ...sample.cards.flatMap(cardLabels)]
   for (const expected of [
     '质检接收', '接收时间', '接收附件', '生产接收', '处理时间',
@@ -198,7 +210,7 @@ test('v1 protocol detail renders readonly payment details with backend transfer
   }
 })
 
-test('sample card includes remark, full reception checks and source-specific production fields', () => {
+test('sample card hides remark and keeps reception checks and source-specific production fields', () => {
   const sections = getMarketModule('protocol').detailSections({
     source: 'research_donation',
     basic: { donationNo: 'D-1', donationStatus: 'sxz' },
@@ -217,7 +229,7 @@ test('sample card includes remark, full reception checks and source-specific pro
     }]
   })
   const sample = sections.find((section) => section.key === 'samples').cards[0]
-  assert.equal(sample.fields.find((field) => field.label === '备注').value, '样本备注')
+  assert.equal(sample.fields.find((field) => field.label === '备注'), undefined)
   const reception = sample.cards.find((card) => card.title === '质检接收')
   assert.equal(reception.fields.find((field) => field.label === '检查项').value, '包装完整:是、标签清晰:否')
   const production = sample.cards.find((card) => card.title === '生产接收')
@@ -250,9 +262,11 @@ test('protocol detail exposes complete deposit and installment transfer fields',
     }]
   })
   const deposit = sections.find((section) => section.key === 'deposit').cards[0]
+  assert.equal(deposit.statusText, '付款完成')
   assert.deepEqual(deposit.fields.map((field) => field.label), [
-    '转账金额', '转账时间', '转账方式', '转账凭证', '备注', '财务备注', '状态'
+    '转账方式', '转账金额', '转账时间', '转账凭证', '备注', '财务备注'
   ])
+  assert.ok(!deposit.fields.some((field) => field.label === '状态'))
   const payment = sections.find((section) => section.key === 'payment').cards[0]
   const schedule = payment.cards[0]
   assert.equal(schedule.variant, 'schedule')

+ 10 - 0
mobile-oa-h5/src/market/protocol-sample-transport.js

@@ -57,6 +57,16 @@ export function applySampleTypeDefaults(sample, sampleType, { showPrice = true }
   return next
 }
 
+/** 从样本类型选项解析编码过滤文本(对齐 Web sampleCodeFilter → codeFilter) */
+export function resolveSampleCodeFilter(sampleTypes, sampleTypeId) {
+  const id = String(sampleTypeId || '').trim()
+  if (!id) return ''
+  const hit = (Array.isArray(sampleTypes) ? sampleTypes : []).find(
+    (opt) => String(opt?.value ?? opt?.id) === id
+  )
+  return String(hit?.sampleCodeFilter || '').trim()
+}
+
 export function validateSampleRow(sample, { source = 'cell_storage', autoGenerateCode = false } = {}) {
   if (!String(sample?.sampleTypeId || '').trim()) return '请选择样本类型'
   if (!autoGenerateCode && !String(sample?.sampleCodeId || sample?.sampleCode || '').trim()) {

+ 12 - 0
mobile-oa-h5/src/market/protocol-sample-transport.test.js

@@ -79,6 +79,18 @@ test('sample type change fills price for cell storage and clears code', () => {
   assert.equal(next.sampleCodeId, '')
 })
 
+test('resolveSampleCodeFilter reads type sampleCodeFilter for code list refresh', () => {
+  const { resolveSampleCodeFilter } = sampleTransport
+  const types = [
+    { value: 't1', label: '脐带血', sampleCodeFilter: 'ZKZX' },
+    { value: 't2', label: '胎盘', sampleCodeFilter: '' }
+  ]
+  assert.equal(resolveSampleCodeFilter(types, 't1'), 'ZKZX')
+  assert.equal(resolveSampleCodeFilter(types, 't2'), '')
+  assert.equal(resolveSampleCodeFilter(types, ''), '')
+  assert.equal(resolveSampleCodeFilter(types, 'missing'), '')
+})
+
 test('sample type defaults preserve the maximum DECIMAL(18,2) value exactly', () => {
   const next = applySampleTypeDefaults(
     { sampleCodeId: 'c1', sampleCode: 'S-1' },

+ 3 - 0
mobile-oa-h5/src/oa/components/OaDatePicker.vue

@@ -29,6 +29,7 @@
       <!-- 日期时间:日期 Tab + 时间 Tab -->
       <van-picker-group
         v-else
+        v-model:active-tab="activeTab"
         :title="pickerTitle"
         :tabs="['选择日期', '选择时间']"
         next-step-text="下一步"
@@ -81,6 +82,7 @@ const isReadonly = computed(() => {
 
 const show = ref(false)
 const fieldRef = ref(null)
+const activeTab = ref(0)
 const dateParts = ref(['2026', '01', '01'])
 const timeParts = ref(['00', '00'])
 
@@ -239,6 +241,7 @@ function open() {
   const parsed = parseModelValue(props.modelValue)
   dateParts.value = parsed.date.slice(0, dateColumnsType.value.length)
   timeParts.value = parsed.time.slice(0, timeColumnsType.value.length)
+  activeTab.value = 0
   show.value = true
 }
 

+ 25 - 0
mobile-oa-h5/src/oa/oa-date-picker-reopen.test.js

@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+function source(relativePath) {
+  return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
+}
+
+test('OaDatePicker datetime reopen resets picker group to date tab', () => {
+  const src = source('./components/OaDatePicker.vue')
+  assert.match(src, /v-model:active-tab="activeTab"/)
+  assert.match(src, /const activeTab = ref\(0\)/)
+  assert.match(src, /function open\(\)[\s\S]*activeTab\.value = 0/)
+})
+
+test('protocol sample transport popups reset scroll to top on open', () => {
+  const src = source('../market/components/ProtocolSampleTransportPanel.vue')
+  assert.match(src, /function resetPopupScroll\(/)
+  assert.match(src, /scrollTop\s*=\s*0/)
+  assert.match(src, /@opened="\(\) => resetPopupScroll\(editorPopupRef\)"/)
+  assert.match(src, /@opened="\(\) => resetPopupScroll\(freightPopupRef\)"/)
+  assert.match(src, /@opened="\(\) => resetPopupScroll\(unlinkPopupRef\)"/)
+  assert.match(src, /resetPopupScroll\(freightPopupRef\)/)
+  assert.match(src, /resetPopupScroll\(unlinkPopupRef\)/)
+})

+ 21 - 1
mobile-oa-h5/src/utils/http-error.test.js

@@ -1,9 +1,29 @@
 import assert from 'node:assert/strict'
 import test from 'node:test'
 
-import { createHttpError } from './http.js'
+import { createHttpError, formatHttpErrorText } from './http.js'
 
 test('preserves HTTP or response envelope status for non-leaking market errors', () => {
   assert.equal(createHttpError({ code: 403, message: '禁止' }, 200).status, 403)
   assert.equal(createHttpError({ detail: 'missing' }, 404).status, 404)
 })
+
+test('formats FastAPI validation detail arrays into readable Chinese messages', () => {
+  assert.equal(
+    formatHttpErrorText([
+      { loc: ['body', 'samplePayments', 0, 'installmentRatios'], msg: 'Input should be a valid list', type: 'list_type' },
+      { loc: ['body', 'customer', 'motherName'], msg: 'Field required', type: 'missing' }
+    ]),
+    'Input should be a valid list;Field required'
+  )
+  assert.equal(
+    createHttpError({
+      detail: [
+        { loc: ['body', 'source'], msg: '协议来源无效', type: 'value_error' }
+      ]
+    }, 422).message,
+    '协议来源无效'
+  )
+  assert.equal(formatHttpErrorText('样本编码重复'), '样本编码重复')
+  assert.equal(formatHttpErrorText([{ message: '金额无效' }]), '金额无效')
+})

+ 26 - 4
mobile-oa-h5/src/utils/http.js

@@ -2,10 +2,31 @@ import { getToken } from './auth.js'
 
 const API_BASE = (import.meta.env?.VITE_API_BASE || '/api').replace(/\/$/, '')
 
+/** FastAPI 422 detail 常为对象数组;String(array) 会变成一串 [object Object] */
+export function formatHttpErrorText(value) {
+  if (value == null || value === '') return ''
+  if (typeof value === 'string') return value.trim()
+  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+  if (Array.isArray(value)) {
+    return value
+      .map((item) => formatHttpErrorText(item))
+      .filter(Boolean)
+      .join(';')
+  }
+  if (typeof value === 'object') {
+    const nested = value.msg ?? value.message ?? value.detail ?? value.reason
+    if (nested != null && nested !== value) {
+      const text = formatHttpErrorText(nested)
+      if (text) return text
+    }
+  }
+  return ''
+}
+
 export function createHttpError(body, httpStatus = 0, fallback = '请求失败') {
   const error = new Error(
-    (body?.message && String(body.message).trim()) ||
-    (body?.detail && String(body.detail).trim()) ||
+    formatHttpErrorText(body?.message) ||
+    formatHttpErrorText(body?.detail) ||
     fallback
   )
   const envelopeStatus = Number(body?.code)
@@ -57,11 +78,12 @@ export async function request({ url, method = 'GET', data, headers = {}, signal
     throw createHttpError(body, 401, '登录已失效,请返回小程序重新登录')
   }
   if (body && typeof body === 'object' && body.detail && body.code == null) {
+    const detailText = formatHttpErrorText(body.detail)
     throw createHttpError(
       {
-        detail: res.status === 401 || /not authenticated/i.test(String(body.detail))
+        detail: res.status === 401 || /not authenticated/i.test(detailText)
           ? '登录已失效,请返回小程序重新登录'
-          : String(body.detail)
+          : detailText || '请求参数有误'
       },
       res.status
     )

+ 70 - 16
mobile-oa-h5/src/views/market/MarketModuleForm.vue

@@ -30,19 +30,24 @@
       />
 
       <section
-        v-if="context.module === 'protocol' && formData.source === 'cell_storage' && depositHintText"
+        v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
         class="market-form-section"
       >
         <h2>意向金(关联预签约)</h2>
-        <p class="market-protocol-total-sync">{{ depositHintText }}</p>
-        <div
-          v-for="(tr, index) in formData.depositTransfers || []"
-          :key="tr.id || index"
-          class="market-deposit-transfer"
-        >
-          <strong>转账 {{ index + 1 }}</strong>
-          <span>{{ formatMoney(tr.transferAmount || 0) }}</span>
-          <span>{{ tr.transferTime || '—' }}</span>
+        <p v-if="depositHintText" class="market-protocol-total-sync">{{ depositHintText }}</p>
+        <div class="market-deposit-transfer-list">
+          <PresignTransferCard
+            v-for="(tr, index) in formData.depositTransfers || []"
+            :key="tr.id || `deposit-${index}`"
+            :model-value="tr"
+            :index="index"
+            :transfer-types="formOptions.transferTypes || []"
+            disabled
+          />
+          <van-empty
+            v-if="!(formData.depositTransfers || []).length"
+            description="暂无意向金转账"
+          />
         </div>
       </section>
 
@@ -57,6 +62,7 @@
         :transport-modes="formOptions.transportModes || []"
         :disabled="fieldsLocked"
         @update:model-value="onSamplesChange"
+        @refresh-sample-codes="refreshSampleCodes"
       />
 
       <ProtocolSamplePaymentPanel
@@ -226,6 +232,7 @@ import {
   shouldFollowProtocolTotal,
   sumSampleContractAmounts
 } from '../../market/protocol-sample-payment.js'
+import PresignTransferCard from '../../market/components/PresignTransferCard.vue'
 import ProtocolSamplePaymentPanel from '../../market/components/ProtocolSamplePaymentPanel.vue'
 import ProtocolV1PaymentPanel from '../../market/components/ProtocolV1PaymentPanel.vue'
 import ProtocolSampleTransportPanel from '../../market/components/ProtocolSampleTransportPanel.vue'
@@ -327,6 +334,7 @@ const sampleOptionsGuard = createAsyncGenerationGuard()
 let loadController = null
 let sampleOptionsController = null
 let cascadeOptionsController = null
+let sampleCodeOptionsController = null
 let submitController = null
 const uploadControllers = new Set()
 let allowLeave = false
@@ -388,8 +396,11 @@ const depositHintText = computed(() => {
   const h = formData.value.presignDepositHint
   if (!h) return ''
   const cnt = Number(h.confirmedTransferCount || 0)
-  return `来源预签约已确认意向金合计 ${formatMoney(h.confirmedDepositAmount || 0)}(${cnt} 笔),与预签同一数据,不自动写入样本缴费`
+  return `来源预签约已确认意向金合计 ${formatMoney(h.confirmedDepositAmount || 0)}(${cnt} 笔)`
 })
+const showDepositSection = computed(() =>
+  Boolean(depositHintText.value || (formData.value.depositTransfers || []).length)
+)
 
 function syncProtocolTotalFromSamples() {
   if (context.value.module !== 'protocol' || formData.value.source !== 'cell_storage') return
@@ -434,6 +445,41 @@ function onSamplesChange(value) {
   dirty.value = true
 }
 
+/** 按样本类型 sampleCodeFilter 重拉可选编码(对齐 Web codeFilter) */
+async function refreshSampleCodes({ codeFilter } = {}) {
+  if (context.value.module !== 'protocol') return
+  sampleCodeOptionsController?.abort()
+  const controller = new AbortController()
+  sampleCodeOptionsController = controller
+  const filter = String(codeFilter || '').trim()
+  try {
+    const options = await fetchMarketFormOptions(
+      context.value,
+      {
+        ...(id.value ? { businessId: id.value } : {}),
+        ...(filter ? { codeFilter: filter } : {})
+      },
+      { signal: controller.signal }
+    )
+    if (!mountGuard.isCurrent(mountToken) || sampleCodeOptionsController !== controller) return
+    formOptions.value = hydrateProtocolReferenceOptions(
+      {
+        ...formOptions.value,
+        sampleCodes: options?.sampleCodes || []
+      },
+      formData.value
+    )
+  } catch (error) {
+    if (
+      error?.name !== 'AbortError' &&
+      mountGuard.isCurrent(mountToken) &&
+      sampleCodeOptionsController === controller
+    ) {
+      showToast(marketErrorMessage(error, '样本编码加载失败'))
+    }
+  }
+}
+
 watch(sampleContractSum, () => {
   syncProtocolTotalFromSamples()
 })
@@ -723,6 +769,7 @@ async function load() {
   const requestToken = loadGuard.begin()
   loadController?.abort()
   sampleOptionsController?.abort()
+  sampleCodeOptionsController?.abort()
   sampleOptionsGuard.begin()
   loadController = new AbortController()
   loading.value = true
@@ -1028,11 +1075,17 @@ async function submit() {
       }
     }
   }
-  const payload = buildMarketActionPayload(
-    context.value.module,
-    action.value,
-    sanitizeMarketPayload(buildPayload())
-  )
+  let payload
+  try {
+    payload = buildMarketActionPayload(
+      context.value.module,
+      action.value,
+      sanitizeMarketPayload(buildPayload())
+    )
+  } catch (error) {
+    showToast(marketErrorMessage(error, '组装保存数据失败'))
+    return
+  }
   if (context.value.module === 'freight' && ['create', 'link', 'unlink'].includes(action.value) && !payload.items?.length) {
     showToast('请至少选择一个样本')
     return
@@ -1163,6 +1216,7 @@ onBeforeUnmount(() => {
   loadController?.abort()
   sampleOptionsController?.abort()
   cascadeOptionsController?.abort()
+  sampleCodeOptionsController?.abort()
   submitController?.abort()
   for (const controller of uploadControllers) controller.abort()
   uploadControllers.clear()

+ 8 - 0
mobile-oa-h5/src/views/market/MarketModuleList.vue

@@ -175,6 +175,7 @@ import {
   resolvePaymentChaseView,
   shouldLoadMarketListAfterDetail
 } from '../../market/routes.js'
+import { resetMarketWindowScroll } from '../../market/page-scroll.js'
 import {
   createMarketListState,
   failMarketListRequest,
@@ -621,6 +622,13 @@ watch(detailId, (currentDetailId, previousDetailId) => {
   }
 })
 
+watch(childActive, (active, wasActive) => {
+  if (wasActive && !active) {
+    resetMarketWindowScroll()
+    clearStalePointerLocks()
+  }
+})
+
 onMounted(() => {
   clearStalePointerLocks()
   if (!childActive.value) {

+ 5 - 1
mobile-oa-h5/src/views/market/presign/PresignOrderList.vue

@@ -97,6 +97,7 @@ import {
   marketRecordNavigationQuery,
   pickMarketNavigationQuery
 } from '../../../market/routes.js'
+import { resetMarketWindowScroll } from '../../../market/page-scroll.js'
 import {
   createMarketListState,
   failMarketListRequest,
@@ -297,7 +298,10 @@ watch(
 )
 
 watch(childActive, (active, wasActive) => {
-  if (wasActive && !active) reload()
+  if (wasActive && !active) {
+    resetMarketWindowScroll()
+    reload()
+  }
 })
 
 onMounted(() => {

+ 1 - 1
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,7 +1,7 @@
 # Graph Report - src  (2026-10-17)
 
 ## Corpus Check
-- Large corpus: 1036 files · ~687,735 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 1036 files · ~687,728 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
 - 3834 nodes · 5593 edges · 66 communities detected

+ 3 - 3
web-admin/graphify-out/manifest.json

@@ -17,8 +17,8 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/index.ts": 1781272556.3597305,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/directive/directive.d.ts": 1781272556.3601494,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api/api.d.ts": 1781272556.3606477,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1792207835.5380793,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1792206847.1791198,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1792226504.1699305,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1792225926.2998207,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1787887312.735774,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1787887320.8486114,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/composables/useAttachmentActions.ts": 1788162977.6691713,
@@ -500,7 +500,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/storage-entry-info/index.vue": 1791200163.9343994,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/index.vue": 1792208118.0859683,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/RefundConfirmTab.vue": 1792208127.1538856,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/InvoiceRequestTab.vue": 1792208128.4761107,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/payment-confirm/InvoiceRequestTab.vue": 1792208148.5210757,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material-stock-log/index.vue": 1782893853.8275082,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/outside/Iframe.vue": 1782893853.8888595,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/operations/demo/index.vue": 1791296731.28513,

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.