From cc843535f583e13ed9aaf6b7514c8df07e68302e Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 13 Oct 2026 15:18:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/market_mobile.py | 86 +- backend/app/api/mini_menu.py | 128 +- backend/app/crud/customer.py | 24 +- backend/app/crud/protocol_donation_convert.py | 21 +- backend/app/crud/sample_reception.py | 27 +- backend/app/schemas/market_mobile.py | 40 +- backend/app/services/market_freight.py | 58 +- backend/app/services/market_presign.py | 95 +- backend/app/services/market_sample.py | 114 +- backend/app/services/market_support.py | 368 +- backend/app/services/party_customer_sync.py | 34 +- .../app/services/qc_work_order_fee_summary.py | 32 +- backend/tests/test_market_freight_scope.py | 48 +- backend/tests/test_market_protocol_api.py | 278 +- backend/tests/test_market_sample_api.py | 150 + backend/tests/test_market_support_api.py | 297 +- backend/tests/test_mini_menu.py | 149 + backend/tests/test_presign_order.py | 112 + ..._protocol_payment_summary_transfer_time.py | 16 +- backend/tests/test_protocol_sample_eta.py | 100 + .../tests/test_sample_reception_list_sort.py | 45 + docs/clash-verge-10min-traffic-report.md | 58 + docs/clash-verge-rev-省流量配置.md | 163 + ...0-12-market-protocol-source-tabs-demo.html | 766 +++ .../2026-10-10-market-mobile-menus.sql | 12 +- .../2026-10-11-mobile-menu-market-oa-dirs.sql | 235 + .../2026-10-13-customer-service-tables.sql | 4 +- ...-10-13-hide-market-mobile-sample-menus.sql | 27 + .../2026-10-13-rename-customer-menu.sql | 21 + ...26-10-13-sample-storage-cert-insurance.sql | 25 +- ...2026-10-10-market-miniapp-role-features.md | 549 ++ ...market-attachment-parity-implementation.md | 273 + ...26-10-11-market-list-card-right-spacing.md | 117 + .../2026-10-11-market-reference-labels.md | 145 + ...-remaining-mobile-parity-implementation.md | 48 + .../plans/2026-10-11-mobile-h5-env-banner.md | 20 +- ...-11-remove-market-floating-back-buttons.md | 47 + ...ayment-status-and-paid-amount-semantics.md | 2 +- ...10-12-presign-protocol-no-auto-allocate.md | 54 + ...pp-role-features-implementation-summary.md | 137 + ...6-10-11-market-attachment-parity-design.md | 122 + .../2026-10-11-mobile-h5-env-banner-design.md | 10 +- ...resign-protocol-no-auto-allocate-design.md | 81 + docs/web-admin-ui-pattern-index.md | 2 +- mobile-h5/components/MenuGrid.vue | 10 +- mobile-h5/components/van-icon/van-icon.vue | 7 +- mobile-h5/pages.json | 8 + mobile-h5/pages/index/index.vue | 48 +- mobile-h5/pages/login/index.vue | 10 + mobile-h5/pages/market/webview/index.vue | 121 + .../static/menu-icons/market-freight.png | Bin 0 -> 4506 bytes .../menu-icons/market-payment-chase.png | Bin 0 -> 4950 bytes .../static/menu-icons/market-payment.png | Bin 0 -> 5025 bytes .../static/menu-icons/market-presign.png | Bin 0 -> 4286 bytes .../static/menu-icons/market-protocol.png | Bin 0 -> 3953 bytes mobile-h5/styles/components/login.scss | 39 +- mobile-h5/styles/components/menu-grid.scss | 87 + mobile-h5/utils/market-h5.js | 130 + mobile-h5/utils/market-h5.test.js | 170 + mobile-h5/utils/menu-icon-colors.js | 14 +- mobile-h5/utils/menu-regions.js | 75 + mobile-h5/utils/menu-regions.test.js | 70 + mobile-h5/utils/menu.js | 64 +- mobile-h5/utils/oa-navigate-back.js | 52 +- mobile-h5/utils/oa-navigate-back.test.js | 50 + mobile-oa-h5/src/api/market.js | 30 +- mobile-oa-h5/src/main.js | 8 +- mobile-oa-h5/src/market/action-policy.js | 4 +- mobile-oa-h5/src/market/api-boundary.test.js | 10 +- mobile-oa-h5/src/market/api-contract.test.js | 20 +- .../src/market/attachment-presenter.js | 80 + .../src/market/attachment-presenter.test.js | 89 + .../src/market/attachment-upload-policy.js | 16 + .../market/attachment-upload-policy.test.js | 22 + .../components/MarketAttachmentCards.vue | 375 ++ .../src/market/components/MarketBottomBar.vue | 2 - .../market/components/MarketDetailSection.vue | 119 + .../src/market/components/MarketFormField.vue | 97 + .../market/components/MarketFormSection.vue | 85 + .../src/market/components/MarketListCard.vue | 73 + .../src/market/components/MarketPageShell.vue | 43 + .../components/PresignAttachmentUploader.vue | 23 + .../market/components/PresignOrderCard.vue | 86 + .../market/components/PresignSelectField.vue | 215 + mobile-oa-h5/src/market/context-contract.js | 2 - .../market/form-field-accessibility.test.js | 17 + mobile-oa-h5/src/market/form-payload.js | 88 + mobile-oa-h5/src/market/form-payload.test.js | 115 + mobile-oa-h5/src/market/form-state.js | 57 + mobile-oa-h5/src/market/form-state.test.js | 47 + .../src/market/form-view-lifecycle.test.js | 16 + mobile-oa-h5/src/market/market-error.js | 6 + mobile-oa-h5/src/market/market-error.test.js | 10 + .../src/market/mobile-parity-ui.test.js | 348 + .../src/market/module-actions.test.js | 13 +- .../src/market/module-contracts.test.js | 59 +- mobile-oa-h5/src/market/module-registry.js | 3 +- mobile-oa-h5/src/market/modules/freight.js | 61 +- mobile-oa-h5/src/market/modules/protocol.js | 216 +- mobile-oa-h5/src/market/modules/sample.js | 30 - .../presign-components-contract.test.js | 41 + .../presign/presign-detail-actions.test.js | 22 + .../src/market/presign/presign-form-state.js | 17 +- .../market/presign/presign-form-state.test.js | 20 +- .../market/presign/presign-form-view.test.js | 54 + .../src/market/presign/presign-list-model.js | 94 + .../market/presign/presign-list-model.test.js | 91 + .../src/market/presign/presign-presenter.js | 3 +- .../market/presign/presign-presenter.test.js | 2 +- mobile-oa-h5/src/market/reference-options.js | 171 + .../src/market/reference-options.test.js | 115 + mobile-oa-h5/src/market/request-state.js | 112 + mobile-oa-h5/src/market/request-state.test.js | 120 + mobile-oa-h5/src/market/routes.js | 131 + mobile-oa-h5/src/market/routes.test.js | 160 + mobile-oa-h5/src/market/status-tone.js | 12 + mobile-oa-h5/src/market/status-tone.test.js | 14 + mobile-oa-h5/src/market/upload.js | 19 + mobile-oa-h5/src/market/upload.test.js | 16 + mobile-oa-h5/src/router.js | 32 +- mobile-oa-h5/src/styles/market.css | 980 +++ mobile-oa-h5/src/utils/download-attachment.js | 26 +- .../src/utils/download-attachment.test.js | 67 + mobile-oa-h5/src/utils/http-error.test.js | 9 + mobile-oa-h5/src/utils/http.js | 32 +- .../src/views/market/MarketModuleDetail.vue | 354 + .../src/views/market/MarketModuleForm.vue | 23 +- .../src/views/market/MarketModuleList.vue | 393 ++ .../views/market/presign/PresignOrderForm.vue | 764 +++ .../views/market/presign/PresignOrderList.vue | 295 + .../zkxw-mobile-home-menu-regions-demo.html | 459 ++ .../graphify-out/.graphify_analysis.json | 3463 +++++----- web-admin/graphify-out/.graphify_extract.json | 84 +- web-admin/graphify-out/GRAPH_REPORT.md | 292 +- web-admin/graphify-out/graph.html | 8 +- web-admin/graphify-out/graph.json | 5675 +++++++++-------- web-admin/graphify-out/manifest.json | 16 +- web-admin/src/api/qc-work-order.ts | 2 +- .../core/layouts/art-work-tab/index.vue | 4 +- web-admin/src/store/modules/worktab.ts | 22 +- web-admin/src/utils/navigation/index.ts | 1 + .../navigation/keep-alive-exclude.test.ts | 58 + .../utils/navigation/keep-alive-exclude.ts | 17 + .../src/views/basic-info/customer/index.vue | 2 +- .../presign-order/PresignOrderFormDialog.vue | 4 - .../protocol-audit/ProtocolAuditTab.vue | 6 +- .../protocol/ProtocolFormDialog.vue | 16 +- .../SampleInfoQuickRegisterDialog.vue | 12 + .../sample-info/UnifiedSampleInfoPage.vue | 56 +- .../src/views/dashboard/console/index.vue | 2 +- .../list/DonationFormDialog.vue | 10 + .../list/DonationViewDialog.vue | 12 +- 中科兴皖采集大表20260720.xlsx | Bin 0 -> 116332 bytes 干细胞储存证数据.xlsx | Bin 0 -> 39921 bytes 154 files changed, 17837 insertions(+), 5153 deletions(-) create mode 100644 backend/tests/test_mini_menu.py create mode 100644 backend/tests/test_protocol_sample_eta.py create mode 100644 backend/tests/test_sample_reception_list_sort.py create mode 100644 docs/clash-verge-10min-traffic-report.md create mode 100644 docs/clash-verge-rev-省流量配置.md create mode 100644 docs/demos/2026-10-12-market-protocol-source-tabs-demo.html create mode 100644 docs/sql/migrations/2026-10-11-mobile-menu-market-oa-dirs.sql create mode 100644 docs/sql/migrations/2026-10-13-hide-market-mobile-sample-menus.sql create mode 100644 docs/sql/migrations/2026-10-13-rename-customer-menu.sql create mode 100644 docs/superpowers/plans/2026-10-10-market-miniapp-role-features.md create mode 100644 docs/superpowers/plans/2026-10-11-market-attachment-parity-implementation.md create mode 100644 docs/superpowers/plans/2026-10-11-market-list-card-right-spacing.md create mode 100644 docs/superpowers/plans/2026-10-11-market-reference-labels.md create mode 100644 docs/superpowers/plans/2026-10-11-market-remaining-mobile-parity-implementation.md create mode 100644 docs/superpowers/plans/2026-10-11-remove-market-floating-back-buttons.md create mode 100644 docs/superpowers/plans/2026-10-12-presign-protocol-no-auto-allocate.md create mode 100644 docs/superpowers/specs/2026-10-10-market-miniapp-role-features-implementation-summary.md create mode 100644 docs/superpowers/specs/2026-10-11-market-attachment-parity-design.md create mode 100644 docs/superpowers/specs/2026-10-12-presign-protocol-no-auto-allocate-design.md create mode 100644 mobile-h5/pages/market/webview/index.vue create mode 100644 mobile-h5/static/menu-icons/market-freight.png create mode 100644 mobile-h5/static/menu-icons/market-payment-chase.png create mode 100644 mobile-h5/static/menu-icons/market-payment.png create mode 100644 mobile-h5/static/menu-icons/market-presign.png create mode 100644 mobile-h5/static/menu-icons/market-protocol.png create mode 100644 mobile-h5/utils/market-h5.js create mode 100644 mobile-h5/utils/market-h5.test.js create mode 100644 mobile-h5/utils/menu-regions.js create mode 100644 mobile-h5/utils/menu-regions.test.js create mode 100644 mobile-h5/utils/oa-navigate-back.test.js create mode 100644 mobile-oa-h5/src/market/attachment-presenter.js create mode 100644 mobile-oa-h5/src/market/attachment-presenter.test.js create mode 100644 mobile-oa-h5/src/market/attachment-upload-policy.js create mode 100644 mobile-oa-h5/src/market/attachment-upload-policy.test.js create mode 100644 mobile-oa-h5/src/market/components/MarketAttachmentCards.vue create mode 100644 mobile-oa-h5/src/market/components/MarketDetailSection.vue create mode 100644 mobile-oa-h5/src/market/components/MarketFormField.vue create mode 100644 mobile-oa-h5/src/market/components/MarketFormSection.vue create mode 100644 mobile-oa-h5/src/market/components/MarketListCard.vue create mode 100644 mobile-oa-h5/src/market/components/MarketPageShell.vue create mode 100644 mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue create mode 100644 mobile-oa-h5/src/market/components/PresignOrderCard.vue create mode 100644 mobile-oa-h5/src/market/components/PresignSelectField.vue create mode 100644 mobile-oa-h5/src/market/form-field-accessibility.test.js create mode 100644 mobile-oa-h5/src/market/form-payload.js create mode 100644 mobile-oa-h5/src/market/form-payload.test.js create mode 100644 mobile-oa-h5/src/market/form-state.js create mode 100644 mobile-oa-h5/src/market/form-state.test.js create mode 100644 mobile-oa-h5/src/market/form-view-lifecycle.test.js create mode 100644 mobile-oa-h5/src/market/market-error.js create mode 100644 mobile-oa-h5/src/market/market-error.test.js create mode 100644 mobile-oa-h5/src/market/mobile-parity-ui.test.js delete mode 100644 mobile-oa-h5/src/market/modules/sample.js create mode 100644 mobile-oa-h5/src/market/presign/presign-components-contract.test.js create mode 100644 mobile-oa-h5/src/market/presign/presign-detail-actions.test.js create mode 100644 mobile-oa-h5/src/market/presign/presign-form-view.test.js create mode 100644 mobile-oa-h5/src/market/presign/presign-list-model.js create mode 100644 mobile-oa-h5/src/market/presign/presign-list-model.test.js create mode 100644 mobile-oa-h5/src/market/reference-options.js create mode 100644 mobile-oa-h5/src/market/reference-options.test.js create mode 100644 mobile-oa-h5/src/market/request-state.js create mode 100644 mobile-oa-h5/src/market/request-state.test.js create mode 100644 mobile-oa-h5/src/market/routes.js create mode 100644 mobile-oa-h5/src/market/routes.test.js create mode 100644 mobile-oa-h5/src/market/status-tone.js create mode 100644 mobile-oa-h5/src/market/status-tone.test.js create mode 100644 mobile-oa-h5/src/market/upload.js create mode 100644 mobile-oa-h5/src/market/upload.test.js create mode 100644 mobile-oa-h5/src/styles/market.css create mode 100644 mobile-oa-h5/src/utils/download-attachment.test.js create mode 100644 mobile-oa-h5/src/utils/http-error.test.js create mode 100644 mobile-oa-h5/src/views/market/MarketModuleDetail.vue create mode 100644 mobile-oa-h5/src/views/market/MarketModuleList.vue create mode 100644 mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue create mode 100644 mobile-oa-h5/src/views/market/presign/PresignOrderList.vue create mode 100644 mobile-ui/zkxw-mobile-home-menu-regions-demo.html create mode 100644 web-admin/src/utils/navigation/keep-alive-exclude.test.ts create mode 100644 web-admin/src/utils/navigation/keep-alive-exclude.ts create mode 100644 中科兴皖采集大表20260720.xlsx create mode 100644 干细胞储存证数据.xlsx diff --git a/backend/app/api/market_mobile.py b/backend/app/api/market_mobile.py index ffa977dc..ffdb29b6 100644 --- a/backend/app/api/market_mobile.py +++ b/backend/app/api/market_mobile.py @@ -13,13 +13,16 @@ from app.core.mobile_menu_guard import ( ) from app.core.response import error_response, success_response from app.schemas.market_mobile import ( + MarketAttachmentDownloadBody, MarketFreightCreateBody, MarketFreightItemsBody, MarketFreightUpdateBody, MarketPaymentTransferBody, MarketPaymentTransferCreateBody, + MarketPresignOrderCreateBody, MarketPresignOrderBody, MarketPresignTransferBody, + MarketProtocolAuditBody, MarketProtocolBody, MarketSampleInsuranceBody, MarketSampleStorageCertBody, @@ -105,6 +108,7 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None: async def presign_form_options( region_id: Optional[str] = Query(None, alias="regionId"), business_id: Optional[str] = Query(None, alias="businessId"), + user_keyword: Optional[str] = Query(None, alias='userKeyword', max_length=64), user: dict = Depends(permission_guard), db: Session = Depends(get_db), ): @@ -117,6 +121,7 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None: user_info=user, region_id=region_id, business_id=business_id, + user_keyword=user_keyword, ) ) except (PermissionError, ValueError) as exc: @@ -144,7 +149,7 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None: @router.post(prefix) async def create_order( - body: MarketPresignOrderBody, + body: MarketPresignOrderCreateBody, user: dict = Depends(permission_guard), db: Session = Depends(get_db), ): @@ -442,6 +447,51 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None: except (PermissionError, ValueError) as exc: return error_response(400, str(exc)) + @router.post(f'{prefix}/{{protocol_id}}/audit') + async def audit_protocol( + protocol_id: str, + body: MarketProtocolAuditBody, + source: Literal['cell_storage', 'research_donation'] = Query(...), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_protocol.audit_protocol( + db, + source=source, + protocol_id=protocol_id, + audit_status=body.audit_status, + audit_remark=body.audit_remark, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message='审核已提交') + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + @router.delete(f'{prefix}/{{protocol_id}}') + async def delete_protocol( + protocol_id: str, + source: Literal['cell_storage', 'research_donation'] = Query(...), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + market_protocol.delete_protocol( + db, + source=source, + protocol_id=protocol_id, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(message='删除成功') + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + _register_protocol_routes('specialist') _register_protocol_routes('manager') @@ -682,6 +732,7 @@ def _register_sample_routes(scope_kind: ScopeKind) -> None: @router.get(f'{prefix}/form-options') async def sample_form_options( sample_id: str = Query(..., alias='sampleId'), + action: Optional[Literal['storage-cert', 'insurance']] = None, user: dict = Depends(permission_guard), db: Session = Depends(get_db), ): @@ -690,6 +741,7 @@ def _register_sample_routes(scope_kind: ScopeKind) -> None: market_sample.get_sample_form_options( db, sample_id=sample_id, + action=action, ctx=_context(user, scope_kind), user_info=user, ) @@ -973,6 +1025,7 @@ def _register_attachment_routes(scope_kind: ScopeKind) -> None: f'mini:market:{scope_kind}:{module}' ) path = f'/mini/market/{scope_kind}/{module}/attachments' + download_path = f'{path}/download-url' def build_upload_endpoint(bound_module, bound_guard): async def upload_market_attachment( @@ -1013,5 +1066,36 @@ def _register_attachment_routes(scope_kind: ScopeKind) -> None: name=f'upload_market_{scope_kind}_{module}_attachment', ) + def build_download_endpoint(bound_module, bound_guard): + async def download_market_attachment( + body: MarketAttachmentDownloadBody, + user: dict = Depends(bound_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_support.get_market_attachment_download_url( + db, + file_url=body.file_url, + file_name=body.file_name, + module=bound_module, + ctx=_context(user, scope_kind), + user_info=user, + ) + ) + except (PermissionError, ValueError): + return error_response(403, '附件不存在或无权下载') + except Exception: + return error_response(500, '获取下载链接失败') + + return download_market_attachment + + router.add_api_route( + download_path, + build_download_endpoint(module, permission_guard), + methods=['POST'], + name=f'download_market_{scope_kind}_{module}_attachment', + ) + _register_attachment_routes('specialist') _register_attachment_routes('manager') diff --git a/backend/app/api/mini_menu.py b/backend/app/api/mini_menu.py index dea52d7b..41de5551 100644 --- a/backend/app/api/mini_menu.py +++ b/backend/app/api/mini_menu.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session from app.core.auth import get_current_user_by_token -from app.core.config import settings from app.core.database import get_db from app.core.response import error_response, success_response from app.models.models import SysMenu, SysRoleMenu @@ -24,14 +23,13 @@ async def _current_user(request: Request, db: Session) -> dict: return await get_current_user_by_token(token=token, db=db) -def _find_mobile_root(db: Session) -> SysMenu | None: - tenant = settings.DEFAULT_TENANT_NO +def _find_mobile_root(db: Session, tenant_no: str) -> SysMenu | None: rows = ( db.query(SysMenu) .filter( SysMenu.del_flag == 0, SysMenu.parent_id == "0", - SysMenu.tenant_no == tenant, + SysMenu.tenant_no == tenant_no, ) .all() ) @@ -42,13 +40,14 @@ def _find_mobile_root(db: Session) -> SysMenu | None: return None -def _collect_descendants(db: Session, root_id: str) -> List[SysMenu]: - tenant = settings.DEFAULT_TENANT_NO +def _collect_descendants( + db: Session, root_id: str, tenant_no: str +) -> List[SysMenu]: all_menus = ( db.query(SysMenu) .filter( SysMenu.del_flag == 0, - SysMenu.tenant_no == tenant, + SysMenu.tenant_no == tenant_no, SysMenu.status == "1", SysMenu.visible == "1", ) @@ -71,16 +70,15 @@ def _collect_descendants(db: Session, root_id: str) -> List[SysMenu]: return out -def _user_menu_ids(db: Session, user_id: str) -> Set[str]: - role_ids = get_effective_role_ids(db, user_id) +def _user_menu_ids(db: Session, user_id: str, tenant_no: str) -> Set[str]: + role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no) if not role_ids: return set() - tenant = settings.DEFAULT_TENANT_NO rows = ( db.query(SysRoleMenu.menu_id) .filter( SysRoleMenu.del_flag == 0, - SysRoleMenu.tenant_no == tenant, + SysRoleMenu.tenant_no == tenant_no, SysRoleMenu.role_id.in_(role_ids), ) .all() @@ -91,6 +89,20 @@ def _user_menu_ids(db: Session, user_id: str) -> Set[str]: def _mobile_menu_icon(path: str) -> str: """按路由匹配移动端宫格图标(与 mobile-h5 van-icon 字形名对齐)。""" p = (path or "").lower() + # 兼容有无前导斜杠、query 分隔写法 + if "market/webview" in p and "entry=" in p: + # payment-chase 须先于 payment 匹配 + if "payment-chase" in p: + return "market-payment-chase" + if "presign" in p: + return "market-presign" + if "protocol" in p: + return "market-protocol" + if "payment" in p: + return "market-payment" + if "freight" in p: + return "market-freight" + return "" if "oa/start" in p: return "approve" if "oa/drafts" in p: @@ -115,7 +127,7 @@ def _mobile_menu_icon(path: str) -> str: def _mobile_menu_icon_colors(path: str) -> dict: """宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。""" p = (path or "").lower() - # OA 菜单 PNG 自带颜色,宫格不套色底 + # OA / 市场菜单 PNG 自带颜色,宫格不套色底 if any( x in p for x in ( @@ -128,13 +140,61 @@ def _mobile_menu_icon_colors(path: str) -> dict: "oa/mentions", "oa/wechat-notify", ) - ): + ) or ("market/webview" in p and "entry=" in p): return {"iconBg": "transparent", "iconFg": ""} if "form-sink" in p: return {"iconBg": "#eef2f1", "iconFg": "#3d524a"} return {"iconBg": "#eef2f1", "iconFg": "#3d524a"} +def list_mobile_menu_items( + db: Session, + *, + user_id: str, + tenant_no: str, + is_super_admin: bool = False, +) -> List[dict]: + """列出当前租户下已授权的移动端叶子入口。""" + tenant = (tenant_no or "").strip() + uid = (user_id or "").strip() + if not tenant or not uid: + return [] + + root = _find_mobile_root(db, tenant) + if not root: + return [] + + descendants = _collect_descendants(db, root.id, tenant) + by_id = {menu.id: menu for menu in descendants} + allowed_ids = None if is_super_admin else _user_menu_ids(db, uid, tenant) + items: List[dict] = [] + for menu in descendants: + if (menu.menu_type or "").upper() == "F": + continue + path = (menu.path or "").strip() + if not path: + continue + if "form-sink" in path.lower() or "水槽" in str(menu.menu_name or ""): + continue + if allowed_ids is not None and menu.id not in allowed_ids: + continue + colors = _mobile_menu_icon_colors(path) + parent = by_id.get((menu.parent_id or "").strip()) + item = { + "id": menu.id, + "title": menu.menu_name, + "path": path if path.startswith("/") else f"/{path}", + "icon": _mobile_menu_icon(path), + "iconBg": colors["iconBg"], + "iconFg": colors["iconFg"], + "perms": (menu.perms or "").strip() or None, + } + if parent is not None and parent.id != root.id: + item["groupTitle"] = parent.menu_name + items.append(item) + return items + + @router.get("/menus") async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)): try: @@ -143,38 +203,14 @@ async def list_my_mobile_menus(request: Request, db: Session = Depends(get_db)): return error_response(401, "未授权") user_id = str(user.get("user_id") or user.get("sub") or "").strip() - if not user_id: + tenant_no = str(user.get("tenant_no") or "").strip() + if not user_id or not tenant_no: return error_response(401, "未授权") - - root = _find_mobile_root(db) - if not root: - return success_response({"list": []}) - - descendants = _collect_descendants(db, root.id) - web_role = resolve_web_role_for_user(db, user_id) - allowed_ids = None if web_role == "super_admin" else _user_menu_ids(db, user_id) - - items = [] - for m in descendants: - if (m.menu_type or "").upper() == "F": - continue - path = (m.path or "").strip() - if not path: - continue - if "form-sink" in path.lower() or "水槽" in str(m.menu_name or ""): - continue - if allowed_ids is not None and m.id not in allowed_ids: - continue - colors = _mobile_menu_icon_colors(path) - items.append( - { - "id": m.id, - "title": m.menu_name, - "path": path if path.startswith("/") else f"/{path}", - "icon": _mobile_menu_icon(path), - "iconBg": colors["iconBg"], - "iconFg": colors["iconFg"], - "perms": (m.perms or "").strip() or None, - } - ) + web_role = resolve_web_role_for_user(db, user_id, tenant_no=tenant_no) + items = list_mobile_menu_items( + db, + user_id=user_id, + tenant_no=tenant_no, + is_super_admin=web_role == "super_admin", + ) return success_response({"list": items}) diff --git a/backend/app/crud/customer.py b/backend/app/crud/customer.py index fe4106fa..b8f8c9b9 100644 --- a/backend/app/crud/customer.py +++ b/backend/app/crud/customer.py @@ -124,6 +124,8 @@ def create_customer( order_num: Optional[int] = None, remark: Optional[str] = None, user_id: Optional[str] = None, + commit: bool = True, + create_dept_id: str = "", ) -> BizCustomer: name = (customer_name or "").strip() if not name: @@ -147,15 +149,18 @@ def create_customer( order_num=order_num if order_num is not None else 0, remark=(remark or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) db.add(obj) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -219,14 +224,23 @@ def update_customer( return obj -def soft_delete_customer(db: Session, customer_id: str, tenant_no: Optional[str] = None) -> None: +def soft_delete_customer( + db: Session, + customer_id: str, + tenant_no: Optional[str] = None, + *, + commit: bool = True, +) -> None: obj = get_customer(db, customer_id, tenant_no=tenant_no) if not obj: raise ValueError("客户不存在") obj.del_flag = 1 obj.update_time = datetime.now() db.add(obj) - db.commit() + if commit: + db.commit() + else: + db.flush() def total_pages(total: int, page_size: int) -> int: diff --git a/backend/app/crud/protocol_donation_convert.py b/backend/app/crud/protocol_donation_convert.py index 071d5adf..ee741f4d 100644 --- a/backend/app/crud/protocol_donation_convert.py +++ b/backend/app/crud/protocol_donation_convert.py @@ -8,7 +8,7 @@ from sqlalchemy import and_ from sqlalchemy.orm import Session from app.core.utils import generate_uuid -from app.models.models import BizCollectionProtocol, BizProtocolSample +from app.models.models import BizCollectionProtocol, BizPresignOrder, BizProtocolSample from app.models.research_donation import ( BizProtocolDonationConvert, BizResearchDonation, @@ -193,10 +193,11 @@ def assert_biz_no_available( biz_no: str, exclude_protocol_id: Optional[str] = None, exclude_donation_id: Optional[str] = None, + exclude_presign_order_id: Optional[str] = None, for_donation: bool = False, ) -> None: """ - 校验业务编号在采集协议与科研捐赠间可用。 + 校验业务编号在采集协议、科研捐赠与预签约订单间可用。 若当前单存在转移配对,则豁免配对单据上的编号占用。 """ pn = (biz_no or "").strip() @@ -259,6 +260,22 @@ def assert_biz_no_available( raise ValueError("捐赠协议号已存在") raise ValueError("协议编号已被捐赠协议单使用") + presign_rows = ( + db.query(BizPresignOrder) + .filter( + and_( + BizPresignOrder.protocol_no == pn, + BizPresignOrder.tenant_no == tenant_no, + BizPresignOrder.del_flag == 0, + ) + ) + .all() + ) + for row in presign_rows: + if exclude_presign_order_id and row.id == exclude_presign_order_id: + continue + raise ValueError("协议编号已存在") + def upsert_convert_link_for_to_storage( db: Session, diff --git a/backend/app/crud/sample_reception.py b/backend/app/crud/sample_reception.py index 896100fa..7bc25afc 100644 --- a/backend/app/crud/sample_reception.py +++ b/backend/app/crud/sample_reception.py @@ -48,6 +48,18 @@ def _fmt_dt(t: Optional[datetime]) -> str: return t.strftime("%Y-%m-%d %H:%M:%S") if t else "" +def _sort_pending_list_items(items: List[Dict[str, Any]]) -> None: + """待接收:etaTime 升序(空值最后),同 ETA 时 sampleCode 倒序。""" + items.sort(key=lambda x: (x.get("sampleCode") or ""), reverse=True) + items.sort(key=lambda x: (not x.get("etaTime"), x.get("etaTime") or "")) + + +def _sort_received_list_items(items: List[Dict[str, Any]]) -> None: + """已接收:receptionTime 倒序,同接收时间时 sampleCode 倒序。""" + items.sort(key=lambda x: (x.get("sampleCode") or ""), reverse=True) + items.sort(key=lambda x: (x.get("receptionTime") or ""), reverse=True) + + def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str: val = (value or "").strip() if len(val) == 10: @@ -501,6 +513,7 @@ def list_pending_protocol_samples( BizProtocolSampleTransport.eta_time.is_(None), BizProtocolSampleTransport.eta_time.asc(), BizProtocolSample.create_time.asc(), + BizProtocolSample.sample_code.desc(), ) .all() ) @@ -516,13 +529,14 @@ def list_pending_protocol_samples( BizResearchDonationSampleTransport.eta_time.is_(None), BizResearchDonationSampleTransport.eta_time.asc(), BizResearchDonationSample.create_time.asc(), + BizResearchDonationSample.sample_code.desc(), ) .all() ) all_items.extend(_donation_rows_to_sample_list_items(db, donation_rows, tenant_no)) - # 统一按 etaTime 排序(空值排后) - all_items.sort(key=lambda x: (not x.get("etaTime"), x.get("etaTime") or "")) + # 统一按 etaTime 升序(空值排后),同 ETA 时 sampleCode 倒序 + _sort_pending_list_items(all_items) start_eta = _normalize_date_bound(eta_time_start) if eta_time_start else "" end_eta = _normalize_date_bound(eta_time_end, end_of_day=True) if eta_time_end else "" @@ -628,7 +642,10 @@ def list_received_protocol_samples( page = max(1, page) page_size = min(max(1, page_size), 100) receptions = ( - q_rec.order_by(BizSampleReception.reception_time.desc()) + q_rec.order_by( + BizSampleReception.reception_time.desc(), + BizSampleReception.sample_code.desc(), + ) .offset((page - 1) * page_size) .limit(page_size) .all() @@ -681,8 +698,8 @@ def list_received_protocol_samples( donation_ordered = [donation_row_by_sample[sid] for sid in donation_sample_ids if sid in donation_row_by_sample] items.extend(_donation_rows_to_sample_list_items(db, donation_ordered, tenant_no, reception_map=donation_reception_map)) - # 按 reception_time DESC 排序 - items.sort(key=lambda x: x.get("receptionTime") or "", reverse=True) + # 按 receptionTime 倒序,同时间时 sampleCode 倒序 + _sort_received_list_items(items) return items, int(total) diff --git a/backend/app/schemas/market_mobile.py b/backend/app/schemas/market_mobile.py index 7231ae2e..25e53037 100644 --- a/backend/app/schemas/market_mobile.py +++ b/backend/app/schemas/market_mobile.py @@ -31,6 +31,13 @@ MarketContractAmount = condecimal( MarketSampleTypeId = constr(min_length=1, max_length=64) +class MarketAttachmentDownloadBody(BaseModel): + model_config = ConfigDict(extra='forbid', populate_by_name=True) + + file_url: str = Field(..., min_length=1, max_length=1000, alias='fileUrl') + file_name: Optional[str] = Field(None, max_length=200, alias='fileName') + + class MarketPresignTransferBody(BaseModel): model_config = ConfigDict(extra='forbid', populate_by_name=True) @@ -95,9 +102,6 @@ class MarketPresignOrderBody(BaseModel): collector_user_id: Optional[str] = Field( None, max_length=64, alias="collectorUserId" ) - sales_user_id: Optional[str] = Field( - None, max_length=64, alias="salesUserId" - ) region_manager_user_id: Optional[str] = Field( None, max_length=64, alias="regionManagerUserId" ) @@ -106,7 +110,7 @@ class MarketPresignOrderBody(BaseModel): ] = Field(None, alias='orderStatus') remark: Optional[str] = Field(None, max_length=512) sample_type_ids: Optional[List[MarketSampleTypeId]] = Field( - None, max_length=100, alias="sampleTypeIds" + None, min_length=1, max_length=100, alias="sampleTypeIds" ) transfers: Optional[List[MarketPresignTransferBody]] = None @@ -124,6 +128,16 @@ class MarketPresignOrderBody(BaseModel): raise ValueError('日期必须是有效的 YYYYMMDD') from exc return text + @field_validator('mother_name') + @classmethod + def validate_mother_name(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + if not text: + raise ValueError('母亲姓名不能为空') + return text + @field_validator('sample_collect_time') @classmethod def validate_sample_collect_time( @@ -139,6 +153,16 @@ class MarketPresignOrderBody(BaseModel): return text +class MarketPresignOrderCreateBody(MarketPresignOrderBody): + """新增预签单必填字段;销售专员始终由服务端身份决定。""" + + mother_name: str = Field(min_length=1, max_length=64, alias='motherName') + sample_type_ids: List[MarketSampleTypeId] = Field( + min_length=1, max_length=100, alias='sampleTypeIds' + ) + + + class MarketProtocolCustomerBody(BaseModel): model_config = ConfigDict(extra='forbid', populate_by_name=True) @@ -200,6 +224,7 @@ class MarketProtocolSampleBody(BaseModel): is_special: Optional[int] = Field(None, alias='isSpecial') special_remark: Optional[str] = Field(None, alias='specialRemark') remark: Optional[str] = None + eta_time: Optional[str] = Field(None, alias='etaTime') class MarketProtocolAttachmentsBody(BaseModel): @@ -243,6 +268,13 @@ class MarketProtocolBody(BaseModel): attachments: Optional[MarketProtocolAttachmentsBody] = None +class MarketProtocolAuditBody(BaseModel): + model_config = ConfigDict(extra='forbid', populate_by_name=True) + + audit_status: Literal['shtg', 'shth'] = Field(..., alias='auditStatus') + audit_remark: Optional[str] = Field(None, max_length=512, alias='auditRemark') + + class MarketPaymentTransferBody(BaseModel): """市场侧仅可登记/编辑业务转账草稿,不接收状态及财务字段。""" diff --git a/backend/app/services/market_freight.py b/backend/app/services/market_freight.py index 260e6a66..404da041 100644 --- a/backend/app/services/market_freight.py +++ b/backend/app/services/market_freight.py @@ -299,7 +299,26 @@ def _label_map(db: Session, tenant_no: str, values: List[str]) -> Dict[str, str] return {value: label or value for value, label in rows} -def _batch_dict(obj: BizSampleFreight, count: int, label: str) -> Dict[str, Any]: +def _creator_name_map( + db: Session, tenant_no: str, user_ids: List[str] +) -> Dict[str, str]: + wanted = list({str(user_id or '').strip() for user_id in user_ids if user_id}) + if not wanted: + return {} + rows = ( + db.query(SysUser.id, SysUser.nick_name) + .filter( + SysUser.id.in_(wanted), + SysUser.tenant_no == tenant_no, + ) + .all() + ) + return {user_id: nick_name or '' for user_id, nick_name in rows} + + +def _batch_dict( + obj: BizSampleFreight, count: int, label: str, creator_name: str = '' +) -> Dict[str, Any]: return { 'id': obj.id, 'freightNo': obj.freight_no or '', @@ -308,6 +327,8 @@ def _batch_dict(obj: BizSampleFreight, count: int, label: str) -> Dict[str, Any] 'freightAmount': str((obj.freight_amount or Decimal('0')).quantize(Decimal('0.01'))), 'visibleItemCount': int(count), 'remark': obj.remark or '', + 'createBy': obj.create_by or '', + 'createByName': creator_name, 'createTime': _fmt_dt(obj.create_time), 'updateTime': _fmt_dt(obj.update_time), } @@ -342,14 +363,39 @@ def list_freights( batches = q.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()).offset((page - 1) * page_size).limit(page_size).all() ids = [row.id for row in batches] visible_rows = _visible_rows(db, ids, ctx, user_info) + active_counts = { + freight_id: int(count) + for freight_id, count in db.query( + BizSampleFreightItem.freight_id, + func.count(BizSampleFreightItem.id), + ).filter( + BizSampleFreightItem.freight_id.in_(ids or ['']), + BizSampleFreightItem.tenant_no == ctx.tenant_no, + BizSampleFreightItem.del_flag == 0, + ).group_by(BizSampleFreightItem.freight_id).all() + } by_batch: Dict[str, list] = {} for row in visible_rows: by_batch.setdefault(row[0].freight_id, []).append(row) labels = _label_map(db, ctx.tenant_no, [row.transport_mode for row in batches]) + creator_names = _creator_name_map( + db, ctx.tenant_no, [row.create_by for row in batches] + ) result = [] for batch in batches: rows = by_batch.get(batch.id, []) - data = _batch_dict(batch, len(rows), labels.get(batch.transport_mode or '', '')) + data = _batch_dict( + batch, + len(rows), + labels.get(batch.transport_mode or '', ''), + creator_names.get(batch.create_by or '', ''), + ) + has_outside_scope_item = active_counts.get(batch.id, 0) > len(rows) + data['allowedActions'] = ( + ['link', 'unlink'] + if has_outside_scope_item + else ['edit', 'link', 'unlink'] + ) if include_items: data['items'] = [_item_dict(row) for row in rows] result.append(data) @@ -367,7 +413,13 @@ def get_freight_detail(db: Session, freight_id: str, *, ctx: MarketFieldContext, if not obj or not rows: raise HTTPException(status_code=404, detail='数据不存在或无权访问') labels = _label_map(db, ctx.tenant_no, [obj.transport_mode]) - data = _batch_dict(obj, len(rows), labels.get(obj.transport_mode or '', '')) + creator_names = _creator_name_map(db, ctx.tenant_no, [obj.create_by]) + data = _batch_dict( + obj, + len(rows), + labels.get(obj.transport_mode or '', ''), + creator_names.get(obj.create_by or '', ''), + ) data['items'] = [_item_dict(row) for row in rows] visible_item_ids = {row[0].id for row in rows} has_outside_scope_item = ( diff --git a/backend/app/services/market_presign.py b/backend/app/services/market_presign.py index 1e5ad132..7213cf57 100644 --- a/backend/app/services/market_presign.py +++ b/backend/app/services/market_presign.py @@ -16,6 +16,7 @@ from app.models.models import ( BizCollectionRegion, BizCollectionRegionStaff, BizPresignOrder, + BizPresignOrderSampleType, BizPresignTransfer, ) from app.schemas.market_mobile import ( @@ -47,6 +48,76 @@ def _mobile_transfer_to_dict( return data +def _reference_data( + db: Session, rows: List[BizPresignOrder], tenant_no: str +) -> dict: + order_ids = [row.id for row in rows] + region_ids = {row.region_id for row in rows if row.region_id} + hospital_ids = {row.hospital_id for row in rows if row.hospital_id} + region_names = {} + if region_ids: + region_names = { + str(row.id): str(row.region_name or '').strip() + for row in db.query(BizCollectionRegion).filter( + BizCollectionRegion.id.in_(region_ids), + BizCollectionRegion.tenant_no == tenant_no, + ) + } + hospital_names = {} + if hospital_ids: + hospital_names = { + str(row.id): str(row.hospital_name or '').strip() + for row in db.query(BizCollectionHospital).filter( + BizCollectionHospital.id.in_(hospital_ids), + BizCollectionHospital.tenant_no == tenant_no, + ) + } + type_ids_by_order = {order_id: [] for order_id in order_ids} + if order_ids: + links = ( + db.query(BizPresignOrderSampleType) + .filter( + BizPresignOrderSampleType.presign_order_id.in_(order_ids), + BizPresignOrderSampleType.tenant_no == tenant_no, + BizPresignOrderSampleType.del_flag == 0, + ) + .order_by( + BizPresignOrderSampleType.order_num.asc(), + BizPresignOrderSampleType.create_time.asc(), + BizPresignOrderSampleType.id.asc(), + ) + .all() + ) + for link in links: + if link.sample_type_id: + type_ids_by_order.setdefault(link.presign_order_id, []).append( + str(link.sample_type_id) + ) + type_ids = { + type_id + for order_type_ids in type_ids_by_order.values() + for type_id in order_type_ids + } + type_names = {} + if type_ids: + type_names = { + str(row.id): str(row.type_name or '').strip() + for row in db.query(BizCellSampleType).filter( + BizCellSampleType.id.in_(type_ids), + BizCellSampleType.tenant_no == tenant_no, + ) + } + return { + 'regionNames': region_names, + 'hospitalNames': hospital_names, + 'sampleTypeIds': type_ids_by_order, + 'sampleTypeNames': { + order_id: [type_names.get(type_id, '') for type_id in order_type_ids] + for order_id, order_type_ids in type_ids_by_order.items() + }, + } + + def _mobile_order_to_dict( db: Session, row: BizPresignOrder, @@ -55,6 +126,7 @@ def _mobile_order_to_dict( with_children: bool, deposit_amount: Optional[Decimal] = None, user_name_map: Optional[dict] = None, + reference_data: Optional[dict] = None, ) -> dict: deposit = ( deposit_amount @@ -73,10 +145,14 @@ def _mobile_order_to_dict( ) data['contractAmount'] = _money_text(row.contract_amount) data['confirmedDepositAmount'] = _money_text(deposit) + references = reference_data or _reference_data(db, [row], tenant_no) + data['regionName'] = references['regionNames'].get(row.region_id or '', '') + data['hospitalName'] = references['hospitalNames'].get( + row.hospital_id or '', '' + ) + data['sampleTypeIds'] = references['sampleTypeIds'].get(row.id, []) + data['sampleTypeNames'] = references['sampleTypeNames'].get(row.id, []) if with_children: - data['sampleTypeIds'] = presign_crud.list_sample_type_ids( - db, presign_order_id=row.id, tenant_no=tenant_no - ) transfer_rows = ( db.query(BizPresignTransfer) .filter( @@ -151,8 +227,6 @@ def _manager_assignment_allowed( region_id: Optional[str], region_manager_user_id: Optional[str], ) -> bool: - if (region_manager_user_id or "").strip() == ctx.user_id: - return True region = (region_id or "").strip() if not region: return False @@ -300,6 +374,7 @@ def list_presign_orders( user_name_map = presign_crud._user_nick_name_map( db, person_ids, ctx.tenant_no ) + references = _reference_data(db, rows, ctx.tenant_no) return [ _mobile_order_to_dict( db, @@ -308,6 +383,7 @@ def list_presign_orders( with_children=False, deposit_amount=deposit_map.get(row.id, 0), user_name_map=user_name_map, + reference_data=references, ) for row in rows ], int(total) @@ -323,8 +399,13 @@ def get_presign_order( row = assert_presign_access( db, order_id, ctx, user_info=user_info ) + references = _reference_data(db, [row], ctx.tenant_no) return _mobile_order_to_dict( - db, row, ctx.tenant_no, with_children=True + db, + row, + ctx.tenant_no, + with_children=True, + reference_data=references, ) @@ -446,7 +527,7 @@ def update_presign_order( ) if ctx.scope_kind == "specialist": fields["sales_user_id"] = ctx.user_id - else: + elif str(resulting_region_id or '').strip() != str(current.region_id or '').strip(): _ensure_manager_assignment( db, ctx, diff --git a/backend/app/services/market_sample.py b/backend/app/services/market_sample.py index 7be49f0e..db9c653f 100644 --- a/backend/app/services/market_sample.py +++ b/backend/app/services/market_sample.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from datetime import datetime -from typing import Any, Dict, Iterable, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple from fastapi import HTTPException from sqlalchemy import and_, literal, or_ @@ -51,6 +51,15 @@ def _fmt_dt(value: Optional[datetime]) -> str: return value.strftime('%Y-%m-%d %H:%M:%S') if value else '' +def _fmt_date(value: Any) -> str: + if not value: + return '' + if hasattr(value, 'strftime'): + return value.strftime('%Y-%m-%d') + text = str(value) + return f'{text[:4]}-{text[4:6]}-{text[6:8]}' if len(text) == 8 and text.isdigit() else text + + def _parse_dt(value: Any) -> Optional[datetime]: if value in (None, ''): return None @@ -325,11 +334,21 @@ def _raw_item(source: str, sample, parent, collection, party) -> Dict[str, Any]: 'sampleRowId': sample.id, 'parentId': parent.id, 'protocolNo': number or '', + 'protocolStatus': ( + parent.protocol_status + if source == CELL_SOURCE + else parent.donation_status + ) or '', 'sampleCode': sample.sample_code or '', 'sampleTypeId': sample.sample_type_id or '', 'sampleTypeName': '', 'sampleStatus': sample.sample_status or '', 'customerName': party.mother_name if party else '', + 'motherName': party.mother_name if party else '', + 'newbornName': party.newborn_name if party else '', + 'expectedDeliveryDate': _fmt_date( + party.expected_delivery_date if party else None + ), 'regionId': collection.region_id or '', 'hospitalId': collection.hospital_id or '', 'salesUserId': collection.sales_user_id or '', @@ -478,12 +497,15 @@ def _handle_maps(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> Tu return cert_map, todo_map -def _handle_summary(row, *, insurance: bool = False) -> Dict[str, Any]: +def _handle_summary( + row, *, insurance: bool = False, handler_user_name: str = '' +) -> Dict[str, Any]: if row is None: return { **_status_meta('pending', '未办理'), 'handleTime': '', 'handlerUserId': '', + 'handlerUserName': '', 'attachments': '', 'remark': '', } @@ -494,11 +516,29 @@ def _handle_summary(row, *, insurance: bool = False) -> Dict[str, Any]: **_status_meta('handled' if handled else 'pending', '已办理' if handled else '未办理'), 'handleTime': _fmt_dt(row.handle_time), 'handlerUserId': row.handler_user_id or '', + 'handlerUserName': handler_user_name, 'attachments': attachments or '', 'remark': remark or '', } +def _referenced_user_name_map( + db: Session, tenant_no: str, user_ids: List[str] +) -> Dict[str, str]: + wanted = {str(user_id or '').strip() for user_id in user_ids if user_id} + if not wanted: + return {} + return { + str(user_id): str(nick_name or '').strip() + for user_id, nick_name in db.query(SysUser.id, SysUser.nick_name) + .filter( + SysUser.id.in_(wanted), + SysUser.tenant_no == tenant_no, + ) + .all() + } + + def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None: type_ids = {item['sampleTypeId'] for item in items if item['sampleTypeId']} type_map = {} @@ -513,6 +553,15 @@ def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None: } reception_map, qc_map, storage_map = _latest_maps(db, tenant_no, items) cert_map, todo_map = _handle_maps(db, tenant_no, items) + handler_names = _referenced_user_name_map( + db, + tenant_no, + [ + row.handler_user_id + for row in (*cert_map.values(), *todo_map.values()) + if row.handler_user_id + ], + ) for item in items: item['sampleTypeName'] = type_map.get(item['sampleTypeId'], '') sample_status = _sample_status(item['sampleStatus']) @@ -557,10 +606,24 @@ def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None: 'time': _fmt_dt(storage.storage_time) if storage else '', }, } + item['labStorageCode'] = ( + getattr(reception, 'lab_storage_code', None) or '' + ) if item['source'] == CELL_SOURCE: - item['storageCert'] = _handle_summary(cert_map.get(item['parentId'])) + cert = cert_map.get(item['parentId']) + todo = todo_map.get(item['parentId']) + item['storageCert'] = _handle_summary( + cert, + handler_user_name=handler_names.get( + getattr(cert, 'handler_user_id', '') or '', '' + ), + ) item['insurance'] = _handle_summary( - todo_map.get(item['parentId']), insurance=True + todo, + insurance=True, + handler_user_name=handler_names.get( + getattr(todo, 'handler_user_id', '') or '', '' + ), ) @@ -702,6 +765,7 @@ def get_sample_detail( {'label': '协议编号', 'value': item['protocolNo']}, {'label': '样本编码', 'value': item['sampleCode']}, {'label': '样本类型', 'value': item['sampleTypeName']}, + {'label': '实验室入库编码', 'value': item['labStorageCode']}, ], }, { @@ -757,6 +821,7 @@ def get_sample_form_options( db: Session, *, sample_id: str, + action: Optional[Literal['storage-cert', 'insurance']] = None, ctx: MarketFieldContext, user_info: dict, ) -> Dict[str, List[Dict[str, str]]]: @@ -769,11 +834,44 @@ def get_sample_form_options( .order_by(SysUser.nick_name.asc(), SysUser.id.asc()) .all() ) + referenced_ids = [] + if action: + model = ( + BizProtocolStorageCert + if action == 'storage-cert' + else BizProtocolFullmoonTodo + ) + historical = ( + db.query(model) + .filter( + model.collection_protocol_id == item['parentId'], + model.tenant_no == ctx.tenant_no, + model.del_flag == 0, + ) + .order_by( + model.update_time.desc(), + model.create_time.desc(), + model.id.desc(), + ) + .first() + ) + if historical is not None and historical.handler_user_id: + referenced_ids.append(historical.handler_user_id) + referenced_names = _referenced_user_name_map( + db, ctx.tenant_no, referenced_ids + ) + staff = [ + {'value': str(user.id), 'label': str(user.nick_name or '')} + for user in users + ] + included = {option['value'] for option in staff} + for user_id in referenced_ids: + value = str(user_id) + if value not in included and value in referenced_names: + staff.append({'value': value, 'label': referenced_names[value]}) + included.add(value) return { - 'staff': [ - {'value': str(user.id), 'label': str(user.nick_name or '')} - for user in users - ] + 'staff': staff } diff --git a/backend/app/services/market_support.py b/backend/app/services/market_support.py index 0e65b6aa..5dd4f2a6 100644 --- a/backend/app/services/market_support.py +++ b/backend/app/services/market_support.py @@ -20,17 +20,31 @@ from app.core.storage import storage from app.models.models import ( BizAttachment, BizCellSampleType, + BizCollectionProtocol, BizCollectionHospital, BizProtocolCollectionInfo, BizCollectionRegion, BizCollectionRegionStaff, BizItemUnit, + BizPresignOrder, + BizPresignTransfer, + BizProtocolFullmoonTodo, + BizProtocolPaymentTransfer, + BizProtocolStorageCert, + BizSampleCode, SysDictData, + SysEthnic, SysUser, ) -from app.models.research_donation import BizResearchDonationCollection +from app.models.research_donation import ( + BizResearchDonation, + BizResearchDonationCollection, +) from app.services.market_field_scope import ( MarketFieldContext, + apply_cell_protocol_scope, + apply_donation_scope, + apply_presign_scope, assert_cell_protocol_access, assert_donation_access, assert_presign_access, @@ -177,7 +191,8 @@ def get_form_options( user_info: dict, region_id: Optional[str] = None, business_id: Optional[str] = None, -) -> Dict[str, List[dict]]: + user_keyword: Optional[str] = None, +) -> Dict[str, Any]: """返回移动表单最小选项集;不会返回联系方式或证件等 PII。""" _verify_identity(ctx, user_info) normalized_module = str(module or '').strip() @@ -279,13 +294,95 @@ def get_form_options( 'isLeader': bool(binding.is_leader), } ) + current_user = ( + db.query(SysUser) + .filter( + SysUser.id == ctx.user_id, + SysUser.tenant_no == ctx.tenant_no, + SysUser.del_flag == 0, + SysUser.status == '1', + ) + .first() + ) + searched_users = [] + if normalized_module == 'presign' and user_keyword is not None: + keyword = str(user_keyword or '').strip() + user_query = db.query(SysUser).filter( + SysUser.tenant_no == ctx.tenant_no, + SysUser.del_flag == 0, + SysUser.status == '1', + ) + if keyword: + like = f'%{keyword}%' + user_query = user_query.filter( + or_(SysUser.nick_name.like(like), SysUser.user_no.like(like)) + ) + searched_users = ( + user_query.order_by(SysUser.nick_name.asc(), SysUser.id.asc()) + .limit(50) + .all() + ) result = { 'regions': [_option(row.id, row.region_name) for row in regions], 'hospitals': [_option(row.id, row.hospital_name) for row in hospitals], 'staff': staff, 'sampleTypes': [_option(row.id, row.type_name) for row in sample_types], 'units': [_option(row.id, row.unit_name) for row in units], + 'currentUser': ( + _option(current_user.id, current_user.nick_name) + if current_user + else None + ), } + if normalized_module == 'protocol': + ethnicities = ( + db.query(SysEthnic) + .filter( + SysEthnic.del_flag == 0, + or_(SysEthnic.tenant_no == '', SysEthnic.tenant_no == ctx.tenant_no), + ) + .order_by(SysEthnic.order_num.asc(), SysEthnic.ethnic_code.asc()) + .all() + ) + # 多取候选再过滤已占用,尽量凑满可选列表 + sample_codes = ( + db.query(BizSampleCode) + .filter( + BizSampleCode.tenant_no == ctx.tenant_no, + BizSampleCode.del_flag == 0, + ) + .order_by(BizSampleCode.create_time.desc(), BizSampleCode.sample_code.desc()) + .limit(200) + .all() + ) + from app.crud import collection_protocol as cell_crud + + used_codes = cell_crud.get_sample_codes_used_in_protocol_samples( + db, + tenant_no=ctx.tenant_no, + sample_codes=[(row.sample_code or '').strip() for row in sample_codes], + ) + available_codes = [ + row + for row in sample_codes + if (row.sample_code or '').strip() + and (row.sample_code or '').strip() not in used_codes + ][:100] + result.update( + { + 'ethnicities': [ + _option(row.ethnic_name, row.ethnic_name) + for row in ethnicities + ], + 'sampleCodes': [ + { + **_option(row.id, row.sample_code), + 'sampleCode': row.sample_code or '', + } + for row in available_codes + ], + } + ) if normalized_module == 'presign': result.update( { @@ -300,6 +397,7 @@ def get_form_options( {'label': '已作废', 'value': 'void'}, ], 'transferTypes': _dict_options(db, ctx.tenant_no, 'zzfs'), + 'users': [_option(row.id, row.nick_name) for row in searched_users], } ) return result @@ -500,6 +598,241 @@ def _attachment_candidates(value: str) -> List[str]: return list(dict.fromkeys(candidates)) +def _attachment_value_matches(value: Any, candidates: Iterable[str]) -> bool: + expected = set(candidates) + for item in str(value or '').split(','): + normalized = str(item or '').strip() + if normalized and expected.intersection(_attachment_candidates(normalized)): + return True + return False + + +def _attachment_columns_filter(columns, candidates: Iterable[str]): + return or_( + *( + column.contains(candidate) + for column in columns + for candidate in candidates + ) + ) + + +def _rows_reference_attachment(rows, field_names, candidates) -> bool: + return any( + _attachment_value_matches(getattr(row, field_name, None), candidates) + for row in rows + for field_name in field_names + ) + + +def _has_accessible_market_attachment_reference( + db: Session, + *, + module: str, + candidates: List[str], + ctx: MarketFieldContext, + user_info: dict, +) -> bool: + """附件必须被当前模块中调用人可访问的业务记录引用。""" + if module == 'protocol': + cell_fields = ( + 'protocol_attachments', + 'consent_form_urls', + 'health_survey_form_urls', + ) + cell_columns = tuple( + getattr(BizCollectionProtocol, field) for field in cell_fields + ) + cell_rows = ( + apply_cell_protocol_scope( + db.query(BizCollectionProtocol), + db, + ctx, + user_info=user_info, + ) + .filter(_attachment_columns_filter(cell_columns, candidates)) + .all() + ) + if _rows_reference_attachment(cell_rows, cell_fields, candidates): + return True + donation_fields = ( + 'consent_form_urls', + 'attachment_urls', + 'infectious_disease_report_urls', + ) + donation_columns = tuple( + getattr(BizResearchDonation, field) for field in donation_fields + ) + donation_rows = ( + apply_donation_scope( + db.query(BizResearchDonation), + db, + ctx, + user_info=user_info, + ) + .filter(_attachment_columns_filter(donation_columns, candidates)) + .all() + ) + return _rows_reference_attachment( + donation_rows, donation_fields, candidates + ) + + if module == 'presign': + rows = ( + apply_presign_scope( + db.query(BizPresignTransfer).join( + BizPresignOrder, + BizPresignOrder.id == BizPresignTransfer.presign_order_id, + ), + db, + ctx, + user_info=user_info, + ) + .filter( + BizPresignTransfer.tenant_no == ctx.tenant_no, + BizPresignTransfer.del_flag == 0, + _attachment_columns_filter( + (BizPresignTransfer.transfer_attachments,), candidates + ), + ) + .all() + ) + return _rows_reference_attachment( + rows, ('transfer_attachments',), candidates + ) + + if module == 'payment': + rows = ( + apply_cell_protocol_scope( + db.query(BizProtocolPaymentTransfer).join( + BizCollectionProtocol, + BizCollectionProtocol.id + == BizProtocolPaymentTransfer.collection_protocol_id, + ), + db, + ctx, + user_info=user_info, + ) + .filter( + BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no, + BizProtocolPaymentTransfer.del_flag == 0, + _attachment_columns_filter( + (BizProtocolPaymentTransfer.transfer_attachments,), candidates + ), + ) + .all() + ) + return _rows_reference_attachment( + rows, ('transfer_attachments',), candidates + ) + + if module == 'sample': + sample_models = ( + (BizProtocolStorageCert, 'cert_image_urls'), + (BizProtocolFullmoonTodo, 'insurance_attachments'), + ) + for model, field_name in sample_models: + rows = ( + apply_cell_protocol_scope( + db.query(model).join( + BizCollectionProtocol, + BizCollectionProtocol.id == model.collection_protocol_id, + ), + db, + ctx, + user_info=user_info, + ) + .filter( + model.tenant_no == ctx.tenant_no, + model.del_flag == 0, + _attachment_columns_filter( + (getattr(model, field_name),), candidates + ), + ) + .all() + ) + if _rows_reference_attachment(rows, (field_name,), candidates): + return True + return False + + +def get_market_attachment_download_url( + db: Session, + *, + file_url: str, + file_name: Optional[str], + module: str, + ctx: MarketFieldContext, + user_info: dict, + storage_backend=storage, +) -> Dict[str, str]: + """为市场移动端返回附件下载地址。 + + 新附件按市场模块隔离;历史 Web 附件没有 business_type, + 但仍必须是当前租户已登记的 BizAttachment 记录。 + """ + _verify_identity(ctx, user_info) + normalized_module = str(module or '').strip() + if normalized_module not in UPLOAD_MODULES: + raise ValueError('不支持的附件模块') + normalized_url = str(file_url or '').strip() + if not normalized_url or len(normalized_url) > MAX_ATTACHMENT_REF_LENGTH: + raise ValueError('文件地址无效') + candidates = _attachment_candidates(normalized_url) + rows = ( + db.query(BizAttachment) + .filter( + BizAttachment.tenant_no == ctx.tenant_no, + BizAttachment.del_flag == 0, + BizAttachment.file_url.in_(candidates), + ) + .all() + ) + expected_staging = f'market:{ctx.scope_kind}:{normalized_module}:staging' + allowed_bound = { + f'market:specialist:{normalized_module}:bound', + f'market:manager:{normalized_module}:bound', + } + row = next( + ( + item + for item in rows + if ( + str(item.business_type or '').strip() == expected_staging + and str(item.create_by or '') == ctx.user_id + and not item.business_id + ) + ), + None, + ) + if row is None: + referenced_rows = [ + item + for item in rows + if str(item.business_type or '').strip() in ({''} | allowed_bound) + ] + if referenced_rows and _has_accessible_market_attachment_reference( + db, + module=normalized_module, + candidates=candidates, + ctx=ctx, + user_info=user_info, + ): + row = referenced_rows[0] + if row is None: + raise PermissionError('附件不存在或无权下载') + download_name = str(row.original_name or '').strip() + if not download_name: + download_name = str(file_name or '').strip() + if not download_name: + download_name = str(row.file_url or '').rsplit('/', 1)[-1] or 'download' + return { + 'downloadUrl': storage_backend.presigned_download_url( + str(row.file_url), download_name + ) + } + + def validate_attachment_refs( db: Session, refs: Any, @@ -617,7 +950,11 @@ def allowed_actions( return ['withdraw'] return [] if module == 'protocol': - return ['edit'] if state in {'', 'draft', 'dsx', 'dqr', 'sxz', 'ygq'} else [] + return ( + ['audit', 'edit', 'delete'] + if state in {'', 'draft', 'dsx', 'dqr', 'sxz', 'ygq'} + else [] + ) if module == 'payment': return [] if state in PAYMENT_TERMINAL_PROTOCOL_STATUSES else ['register-transfer'] if module == 'payment-transfer': @@ -662,13 +999,34 @@ def decorate_allowed_actions( card = data.get('card') if isinstance(data.get('card'), dict) else data source = str(card.get('source') or data.get('source') or '') sample_code = str(card.get('sampleCode') or data.get('sampleCode') or '') - data['allowedActions'] = allowed_actions( + candidates = allowed_actions( module, status=status, scope_kind=scope_kind, source=source, has_sample_code=bool(sample_code.strip()), ) + progress = data.get('progress') if isinstance(data.get('progress'), dict) else card.get('progress') or {} + storage_ready = str((progress.get('storage') or {}).get('code') or '') == 'yrk' + handles = next( + ( + section + for section in data.get('sections') or [] + if section.get('key') == 'market-handles' + ), + {}, + ) + storage_cert = card.get('storageCert') or handles.get('storageCert') or {} + insurance = card.get('insurance') or handles.get('insurance') or {} + data['allowedActions'] = [ + action + for action in candidates + if storage_ready + and ( + (action == 'storage-cert' and storage_cert.get('code') != 'handled') + or (action == 'insurance' and insurance.get('code') != 'handled') + ) + ] return data if module == 'payment': parent_writable = status.lower() not in PAYMENT_TERMINAL_PROTOCOL_STATUSES @@ -724,7 +1082,7 @@ def decorate_allowed_actions( ) transfers.append(transfer_data) data['transfers'] = transfers - if module == 'freight' and detail: + if module == 'freight' and (detail or isinstance(data.get('allowedActions'), list)): provided = data.get('allowedActions') safe_actions = {'edit', 'link', 'unlink'} data['allowedActions'] = ( diff --git a/backend/app/services/party_customer_sync.py b/backend/app/services/party_customer_sync.py index ee2832cd..4e6354e8 100644 --- a/backend/app/services/party_customer_sync.py +++ b/backend/app/services/party_customer_sync.py @@ -225,6 +225,8 @@ def _create_role_customer( profile: SimpleNamespace, tenant_no: str, user_id: str, + commit: bool = True, + create_dept_id: str = "", ) -> str: obj = customer_crud.create_customer( db, @@ -239,6 +241,8 @@ def _create_role_customer( phone_no=profile.phone_no or None, tenant_no=tenant_no, user_id=user_id, + commit=commit, + create_dept_id=create_dept_id, ) return obj.id @@ -250,6 +254,7 @@ def _merge_and_repoint( to_customer: Any, tenant_no: str, profile: Optional[SimpleNamespace] = None, + commit: bool = True, ) -> str: source = get_customer(db, from_id, tenant_no=tenant_no) if source is not None: @@ -259,7 +264,9 @@ def _merge_and_repoint( apply_role_profile_overwrite(to_customer, profile) db.add(to_customer) repoint_customer_refs(db, from_id, to_customer.id, tenant_no) - customer_crud.soft_delete_customer(db, from_id, tenant_no=tenant_no) + customer_crud.soft_delete_customer( + db, from_id, tenant_no=tenant_no, commit=commit + ) return to_customer.id @@ -269,6 +276,8 @@ def _sync_one_role( spec: Dict[str, Any], tenant_no: str, user_id: str, + commit: bool = True, + create_dept_id: str = "", ) -> str: profile = _build_role_profile(party, spec) if _is_empty(profile.customer_name): @@ -286,6 +295,7 @@ def _sync_one_role( to_customer=found, tenant_no=tenant_no, profile=profile, + commit=commit, ) apply_role_profile_overwrite(found, profile) db.add(found) @@ -303,13 +313,31 @@ def _sync_one_role( profile=profile, tenant_no=tenant_no, user_id=user_id, + commit=commit, + create_dept_id=create_dept_id, ) -def sync_party_customers(db: Session, party_dict: Dict[str, Any], tenant_no: str, user_id: str) -> Dict[str, Any]: +def sync_party_customers( + db: Session, + party_dict: Dict[str, Any], + tenant_no: str, + user_id: str, + *, + commit: bool = True, + create_dept_id: str = "", +) -> Dict[str, Any]: out = dict(party_dict or {}) for spec in _ROLE_SPECS: - role_id = _sync_one_role(db, out, spec, tenant_no, user_id) + role_id = _sync_one_role( + db, + out, + spec, + tenant_no, + user_id, + commit=commit, + create_dept_id=create_dept_id, + ) for key in spec["id_keys"]: out[key] = role_id return out diff --git a/backend/app/services/qc_work_order_fee_summary.py b/backend/app/services/qc_work_order_fee_summary.py index 9962603d..78aff2ac 100644 --- a/backend/app/services/qc_work_order_fee_summary.py +++ b/backend/app/services/qc_work_order_fee_summary.py @@ -3,7 +3,7 @@ from __future__ import annotations from decimal import Decimal from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence, Tuple from sqlalchemy.orm import Session @@ -171,12 +171,26 @@ def build_protocol_payment_summary( } -def _first_period_last_transfer_times( +def pick_earliest_transfer_time_by_protocol( + rows: Sequence[Tuple[Any, Any]], +) -> Dict[str, datetime]: + """从 (protocol_id, transfer_time) 行中按协议取最早 transfer_time。""" + earliest: Dict[str, datetime] = {} + for pid, t in rows: + if not pid or t is None: + continue + prev = earliest.get(pid) + if prev is None or t < prev: + earliest[pid] = t + return earliest + + +def _first_period_first_transfer_times( db: Session, protocol_ids: List[str], tenant_no: str, ) -> Dict[str, str]: - """协议 → 第 1 期(schedule_no=1)多笔转账中 transfer_time 最晚的一笔。""" + """协议 → 第 1 期(schedule_no=1)多笔转账中 transfer_time 最早的一笔。""" ids = [x.strip() for x in protocol_ids if (x or "").strip()] if not ids: return {} @@ -200,14 +214,8 @@ def _first_period_last_transfer_times( ) .all() ) - latest: Dict[str, datetime] = {} - for pid, t in rows: - if not pid or t is None: - continue - prev = latest.get(pid) - if prev is None or t > prev: - latest[pid] = t - return {pid: _fmt_dt(t) for pid, t in latest.items() if _fmt_dt(t)} + earliest = pick_earliest_transfer_time_by_protocol(rows) + return {pid: _fmt_dt(t) for pid, t in earliest.items() if _fmt_dt(t)} def build_protocol_payment_summaries( @@ -241,7 +249,7 @@ def build_protocol_payment_summaries( .all() ): details_by_payment.setdefault(d.payment_id, []).append(d) - first_pay_map = _first_period_last_transfer_times(db, ids, tenant_no) + first_pay_map = _first_period_first_transfer_times(db, ids, tenant_no) out: Dict[str, Dict[str, Any]] = {} for pid in ids: pay = pay_by_proto.get(pid) diff --git a/backend/tests/test_market_freight_scope.py b/backend/tests/test_market_freight_scope.py index 9415c448..928aa446 100644 --- a/backend/tests/test_market_freight_scope.py +++ b/backend/tests/test_market_freight_scope.py @@ -81,9 +81,9 @@ def _sample(db, key, *, source='cell_storage', sales='u1', manager='m1', region= return sample, parent -def _freight(db, key, samples, *, tenant='tenant-a', amount='12.30', deleted=0): +def _freight(db, key, samples, *, tenant='tenant-a', amount='12.30', deleted=0, create_by='u1'): now = datetime(2026, 10, 11, 8, 0) - freight = BizSampleFreight(id=f'f-{key}', freight_no=f'YF-{key}', transport_mode='sf', freight_amount=Decimal(amount), tenant_no=tenant, create_dept_id='', create_time=now, update_time=now, del_flag=deleted) + freight = BizSampleFreight(id=f'f-{key}', freight_no=f'YF-{key}', transport_mode='sf', freight_amount=Decimal(amount), tenant_no=tenant, create_by=create_by, create_dept_id='', create_time=now, update_time=now, del_flag=deleted) db.add(freight) for pos, (source, sample, parent) in enumerate(samples): db.add(BizSampleFreightItem(id=f'fi-{key}-{pos}', freight_id=freight.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=tenant, create_dept_id='', create_time=now, update_time=now, del_flag=0)) @@ -106,10 +106,54 @@ def test_specialist_sees_shared_batch_but_only_own_items_and_count(): assert total == 1 and rows[0]['visibleItemCount'] == 1 assert [item['sampleCode'] for item in rows[0]['items']] == ['C-own'] assert [item['sampleCode'] for item in detail['items']] == ['C-own'] + assert rows[0]['createBy'] == detail['createBy'] == 'u1' + assert rows[0]['createByName'] == detail['createByName'] == '专员' assert 'linkedSampleCount' not in rows[0] and 'linkedSampleCount' not in detail assert 'hasHiddenItems' not in detail +def test_freight_creator_name_uses_historical_nickname_without_id_fallback(): + from app.services.market_freight import get_freight_detail, list_freights + + db = _session() + now = datetime(2026, 10, 11, 8, 0) + db.add_all( + [ + SysUser(id='deleted-creator', dept_id='', user_no='deleted-no', nick_name='历史创建人', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=1), + SysUser(id='blank-creator', dept_id='', user_no='raw-creator-no', nick_name='', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=1), + ] + ) + first_sample, first_parent = _sample(db, 'creator-history') + second_sample, second_parent = _sample(db, 'creator-blank') + historical = _freight( + db, + 'creator-history', + [('cell_storage', first_sample, first_parent)], + create_by='deleted-creator', + ) + blank = _freight( + db, + 'creator-blank', + [('cell_storage', second_sample, second_parent)], + create_by='blank-creator', + ) + + items, total = list_freights(db, ctx=_ctx(), user_info=_user()) + by_id = {item['id']: item for item in items} + historical_detail = get_freight_detail( + db, historical.id, ctx=_ctx(), user_info=_user() + ) + blank_detail = get_freight_detail( + db, blank.id, ctx=_ctx(), user_info=_user() + ) + + assert total == 2 + assert by_id[historical.id]['createByName'] == '历史创建人' + assert historical_detail['createByName'] == '历史创建人' + assert by_id[blank.id]['createByName'] == '' + assert blank_detail['createByName'] == '' + + def test_batch_without_visible_item_is_not_listed_or_addressable(): from app.services.market_freight import get_freight_detail, list_freights db = _session() diff --git a/backend/tests/test_market_protocol_api.py b/backend/tests/test_market_protocol_api.py index 095ceec7..e3bbce3b 100644 --- a/backend/tests/test_market_protocol_api.py +++ b/backend/tests/test_market_protocol_api.py @@ -20,8 +20,13 @@ from app.models.models import ( BizProtocolCollectionInfo, BizProtocolPartyCustomer, BizProtocolPayment, + BizProtocolPaymentDetail, + BizProtocolPaymentSchedule, + BizProtocolPaymentTransfer, BizProtocolSample, + BizProtocolSampleTransport, BizSampleCode, + BizSampleReception, SysPost, SysPostRole, SysRole, @@ -49,8 +54,13 @@ def _session(): BizProtocolPartyCustomer.__table__, BizProtocolCollectionInfo.__table__, BizProtocolSample.__table__, + BizProtocolSampleTransport.__table__, BizProtocolPayment.__table__, + BizProtocolPaymentDetail.__table__, + BizProtocolPaymentSchedule.__table__, + BizProtocolPaymentTransfer.__table__, BizSampleCode.__table__, + BizSampleReception.__table__, BizResearchDonation.__table__, BizResearchDonationParty.__table__, BizResearchDonationCollection.__table__, @@ -394,6 +404,161 @@ def test_protocol_detail_can_resolve_source_from_scoped_record(): assert result['basic']['id'] == 'don-own' +def test_protocol_detail_resolves_web_display_names_for_mobile_fields(): + from app.services.market_protocol import get_protocol + + db = _session() + _masters(db) + row = _cell(db, 'named') + row.collector_user_id = 'u1' + collection = db.query(BizProtocolCollectionInfo).filter_by( + collection_protocol_id=row.id + ).one() + collection.hospital_id = 'hospital-a' + db.add( + BizProtocolSample( + id='sample-named', + collection_protocol_id=row.id, + sample_type_id='type-a', + sample_code='S-NAMED', + unit_id='unit-a', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + + result = get_protocol( + db, + source='cell_storage', + protocol_id=row.id, + ctx=_ctx('specialist'), + user_info=_user(), + ) + + assert result['basic']['collectorUserName'] == '当前用户' + assert result['collection']['regionName'] == 'A区' + assert result['collection']['hospitalName'] == 'A院' + assert result['collection']['salesUserName'] == '当前用户' + assert result['collection']['regionManagerUserName'] == '经理' + assert result['samples'][0]['sampleTypeName'] == '样本A' + assert result['samples'][0]['unitName'] == '毫升' + + +def test_protocol_detail_resolves_soft_deleted_master_names_with_tenant_scope(): + from app.services.market_protocol import get_protocol + + db = _session() + _masters(db) + row = _cell(db, 'historical-master') + collection = db.query(BizProtocolCollectionInfo).filter_by( + collection_protocol_id=row.id + ).one() + collection.hospital_id = 'hospital-a' + db.get(BizCollectionRegion, 'region-a').del_flag = 1 + db.get(BizCollectionHospital, 'hospital-a').del_flag = 1 + db.get(BizCellSampleType, 'type-a').del_flag = 1 + db.get(BizItemUnit, 'unit-a').del_flag = 1 + db.add_all( + [ + BizCellSampleType(id='type-global-history', type_code='GLOBAL', type_name='历史全局样本', tenant_no='', create_dept_id='', del_flag=1), + BizCellSampleType(id='type-cross-history', type_code='CROSS', type_name='跨租户样本名称', tenant_no='tenant-b', create_dept_id='', del_flag=1), + BizItemUnit(id='unit-cross-history', unit_code='cross', unit_name='跨租户单位名称', tenant_no='tenant-b', create_dept_id='', del_flag=1), + BizProtocolSample(id='sample-history-local', collection_protocol_id=row.id, sample_type_id='type-a', sample_code='S-HISTORY-LOCAL', unit_id='unit-a', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizProtocolSample(id='sample-history-global', collection_protocol_id=row.id, sample_type_id='type-global-history', sample_code='S-HISTORY-GLOBAL', unit_id='', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizProtocolSample(id='sample-history-cross', collection_protocol_id=row.id, sample_type_id='type-cross-history', sample_code='S-HISTORY-CROSS', unit_id='unit-cross-history', tenant_no='tenant-a', create_dept_id='', del_flag=0), + ] + ) + db.commit() + + result = get_protocol( + db, + source='cell_storage', + protocol_id=row.id, + ctx=_ctx('specialist'), + user_info=_user(), + ) + samples = {item['sampleCode']: item for item in result['samples']} + + assert result['collection']['regionName'] == 'A区' + assert result['collection']['hospitalName'] == 'A院' + assert samples['S-HISTORY-LOCAL']['sampleTypeName'] == '样本A' + assert samples['S-HISTORY-LOCAL']['unitName'] == '毫升' + assert samples['S-HISTORY-GLOBAL']['sampleTypeName'] == '历史全局样本' + assert samples['S-HISTORY-CROSS']['sampleTypeName'] == '' + assert samples['S-HISTORY-CROSS']['unitName'] == '' + + +def test_protocol_names_use_exact_historical_nicknames_without_raw_fallbacks(): + from app.services.market_protocol import get_protocol, list_protocols + + db = _session() + _masters(db) + row = _cell(db, 'historical-names') + row.collector_user_id = 'blank-name-user' + collection = db.query(BizProtocolCollectionInfo).filter_by( + collection_protocol_id=row.id + ).one() + collection.region_manager_user_id = 'manager-a' + current_user = db.get(SysUser, 'u1') + current_user.nick_name = '历史删除专员' + current_user.del_flag = 1 + manager = db.get(SysUser, 'manager-a') + manager.nick_name = '历史停用经理' + manager.status = '0' + db.add( + SysUser( + id='blank-name-user', + user_no='raw-collector-no', + nick_name='', + status='0', + tenant_no='tenant-a', + create_dept_id='', + del_flag=1, + ) + ) + sample_type = db.get(BizCellSampleType, 'type-a') + sample_type.type_name = '' + unit = db.get(BizItemUnit, 'unit-a') + unit.unit_name = '' + db.add( + BizProtocolSample( + id='sample-historical-names', + collection_protocol_id=row.id, + sample_type_id='type-a', + sample_code='S-HISTORY', + unit_id='unit-a', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + + items, total = list_protocols( + db, ctx=_ctx('specialist'), user_info=_user() + ) + detail = get_protocol( + db, + source='cell_storage', + protocol_id=row.id, + ctx=_ctx('specialist'), + user_info=_user(), + ) + + assert total == 1 + assert items[0]['salesUserName'] == '历史删除专员' + assert items[0]['collectorUserName'] == '' + assert items[0]['managerName'] == '历史停用经理' + assert items[0]['sampleTypeName'] == '' + assert detail['basic']['collectorUserName'] == '' + assert detail['collection']['salesUserName'] == '历史删除专员' + assert detail['collection']['regionManagerUserName'] == '历史停用经理' + assert detail['samples'][0]['sampleTypeName'] == '' + assert detail['samples'][0]['unitName'] == '' + + def test_specialist_cell_create_forces_current_collection_and_collector_users(): from app.services.market_protocol import create_protocol @@ -883,14 +1048,115 @@ def test_manager_can_update_donation_in_scope_but_cannot_move_it_out(): assert collection.region_manager_user_id == 'u1' -def test_protocol_routes_do_not_expose_audit_void_refund_or_conversion(): +def test_protocol_routes_expose_scoped_audit_and_delete_but_not_other_admin_mutations(): from app.api.market_mobile import router - paths = {route.path for route in router.routes} - disallowed = ('audit', 'void', 'refund', 'convert', 'to-research', 'to-storage') - protocol_paths = [path for path in paths if '/protocols' in path] - assert protocol_paths - assert all(not any(word in path for word in disallowed) for path in protocol_paths) + for scope in ('specialist', 'manager'): + prefix = f'/mini/market/{scope}/protocols/{{protocol_id}}' + routes = { + route.path: route.methods + for route in router.routes + if route.path.startswith(f'/mini/market/{scope}/protocols') + } + assert routes[f'{prefix}/audit'] == {'POST'} + assert 'DELETE' in routes[prefix] + assert all(not any(word in path for word in ('void', 'refund', 'convert', 'to-research', 'to-storage')) for path in routes) + + +def test_protocol_audit_and_delete_recheck_scoped_access_before_crud(monkeypatch): + from app.services.market_protocol import audit_protocol, delete_protocol + + db = _session() + _masters(db) + own = _cell(db, 'own', sales='u1') + other = _cell(db, 'other', sales='u2') + db.commit() + audit_calls = [] + delete_calls = [] + monkeypatch.setattr( + 'app.services.market_protocol.cell_crud.create_audit', + lambda *args, **kwargs: audit_calls.append(kwargs), + ) + monkeypatch.setattr( + 'app.services.market_protocol.cell_crud.soft_delete_collection_protocol', + lambda *args, **kwargs: delete_calls.append((args, kwargs)), + ) + + audit_protocol( + db, + source='cell_storage', + protocol_id=own.id, + audit_status='shtg', + audit_remark='通过', + ctx=_ctx('specialist'), + user_info=_user(), + ) + delete_protocol( + db, + source='cell_storage', + protocol_id=own.id, + ctx=_ctx('specialist'), + user_info=_user(), + ) + assert audit_calls[0]['collection_protocol_id'] == own.id + assert delete_calls[0][0][1] == own.id + + with pytest.raises(HTTPException, match='无权'): + audit_protocol( + db, + source='cell_storage', + protocol_id=other.id, + audit_status='shtg', + audit_remark='', + ctx=_ctx('specialist'), + user_info=_user(), + ) + + +def test_protocol_list_contains_mobile_card_fields(): + from app.services.market_protocol import list_protocols + + db = _session() + _masters(db) + row = _cell(db, 'card') + row.protocol_total_amount = 20000 + db.add_all([ + BizProtocolPartyCustomer( + id='party-card', collection_protocol_id=row.id, mother_name='李安然', + mother_phone='13800138000', mother_id_card='340100', father_name='王先生', + newborn_sex='1', delivery_mode='sc', newborn_weight_gram=3200, + tenant_no='tenant-a', create_dept_id='', del_flag=0, + ), + BizProtocolSample( + id='sample-card', collection_protocol_id=row.id, sample_type_id='type-a', + sample_code='S-CARD', tenant_no='tenant-a', create_dept_id='', del_flag=0, + ), + ]) + db.commit() + + items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user()) + + assert total == 1 + assert items[0]['motherName'] == '李安然' + assert items[0]['sampleCode'] == 'S-CARD' + assert items[0]['sampleTypeName'] == '样本A' + assert items[0]['regionName'] == 'A区' + assert items[0]['motherPhone'] == '13800138000' + assert items[0]['motherIdCard'] == '340100' + assert items[0]['fatherName'] == '王先生' + assert items[0]['newbornSex'] == '1' + assert items[0]['deliveryMode'] == 'sc' + assert items[0]['newbornWeightGram'] == 3200 + assert items[0]['salesUserName'] == '当前用户' + + by_customer, customer_total = list_protocols( + db, ctx=_ctx('specialist'), user_info=_user(), keyword='李安然' + ) + by_sample, sample_total = list_protocols( + db, ctx=_ctx('specialist'), user_info=_user(), keyword='S-CARD' + ) + assert customer_total == sample_total == 1 + assert by_customer[0]['id'] == by_sample[0]['id'] == row.id def test_cell_create_rolls_back_main_customer_and_children_on_child_failure(): diff --git a/backend/tests/test_market_sample_api.py b/backend/tests/test_market_sample_api.py index 1d458536..82e13c41 100644 --- a/backend/tests/test_market_sample_api.py +++ b/backend/tests/test_market_sample_api.py @@ -1,4 +1,6 @@ +import asyncio from datetime import datetime, timedelta +from typing import get_args import pytest from fastapi import HTTPException @@ -530,6 +532,154 @@ def test_sample_form_options_return_only_enabled_staff_in_scoped_document_region assert result == {'staff': [{'value': 'eligible', 'label': '可办理'}]} +def test_sample_detail_and_options_resolve_only_referenced_historical_handlers(): + from app.services.market_sample import get_sample_detail, get_sample_form_options + + db = _session() + protocol, _, sample = _cell_sample(db, 'historical-handlers', region='r1') + db.add_all( + [ + SysUser(id='eligible', user_no='eligible-no', nick_name='当前可办理', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='disabled-history', user_no='disabled-no', nick_name='历史停用人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='deleted-history', user_no='deleted-no', nick_name='历史删除人员', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=1), + SysUser(id='unrelated-history', user_no='unrelated-no', nick_name='无关历史人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1), + SysUser(id='cross-history', user_no='cross-no', nick_name='跨租户历史人员', email='', phone_no='', status='0', tenant_no='tenant-b', create_dept_id='', del_flag=1), + BizCollectionRegionStaff(id='region-eligible-history', region_id='r1', user_id='eligible', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizProtocolStorageCert(id='cert-history', collection_protocol_id=protocol.id, handler_user_id='disabled-history', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizProtocolFullmoonTodo(id='todo-history', collection_protocol_id=protocol.id, handler_user_id='deleted-history', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizSampleReception(id='reception-history', protocol_sample_id=sample.id, collection_protocol_id=protocol.id, reception_time=datetime(2026, 10, 2, 8, 0), accept_conclusion='js', reception_source='cell_storage', sample_code=sample.sample_code, lab_storage_code='LAB-2026-001', tenant_no='tenant-a', create_dept_id='', del_flag=0), + ] + ) + db.commit() + + detail = get_sample_detail( + db, + sample_id='cell_storage:C-historical-handlers', + ctx=_ctx(), + user_info=_user(), + ) + handles = next( + section for section in detail['sections'] if section['key'] == 'market-handles' + ) + basic = next(section for section in detail['sections'] if section['key'] == 'basic') + storage_options = get_sample_form_options( + db, + sample_id='cell_storage:C-historical-handlers', + action='storage-cert', + ctx=_ctx(), + user_info=_user(), + ) + insurance_options = get_sample_form_options( + db, + sample_id='cell_storage:C-historical-handlers', + action='insurance', + ctx=_ctx(), + user_info=_user(), + ) + default_options = get_sample_form_options( + db, + sample_id='cell_storage:C-historical-handlers', + ctx=_ctx(), + user_info=_user(), + ) + + assert handles['storageCert']['handlerUserName'] == '历史停用人员' + assert handles['insurance']['handlerUserName'] == '历史删除人员' + assert {'label': '实验室入库编码', 'value': 'LAB-2026-001'} in basic['fields'] + assert {item['value']: item['label'] for item in storage_options['staff']} == { + 'eligible': '当前可办理', + 'disabled-history': '历史停用人员', + } + assert {item['value']: item['label'] for item in insurance_options['staff']} == { + 'eligible': '当前可办理', + 'deleted-history': '历史删除人员', + } + assert default_options == { + 'staff': [{'value': 'eligible', 'label': '当前可办理'}] + } + + +def test_sample_form_options_route_validates_and_forwards_action(monkeypatch): + from app.api import market_mobile + + route = next( + route + for route in market_mobile.router.routes + if route.path == '/mini/market/specialist/samples/form-options' + ) + action_param = next( + param for param in route.dependant.query_params if param.name == 'action' + ) + literal_type = next( + value + for value in get_args(action_param.field_info.annotation) + if get_args(value) + ) + assert set(get_args(literal_type)) == { + 'storage-cert', + 'insurance', + } + captured = {} + + def fake_form_options(*args, **kwargs): + captured.update(kwargs) + return {'staff': []} + + monkeypatch.setattr( + market_mobile.market_sample, + 'get_sample_form_options', + fake_form_options, + ) + + result = asyncio.run( + route.endpoint( + sample_id='cell_storage:C-action', + action='insurance', + user=_user(), + db=object(), + ) + ) + + assert result['code'] == 200 + assert captured['action'] == 'insurance' + + +def test_sample_list_batches_referenced_handler_name_lookup(): + from app.services.market_sample import list_samples + + db = _session() + db.add( + SysUser(id='history-handler', user_no='history-no', nick_name='历史办理人', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1) + ) + for index in range(5): + protocol, _, _ = _cell_sample(db, f'handler-batch-{index}') + db.add( + BizProtocolStorageCert( + id=f'cert-handler-batch-{index}', + collection_protocol_id=protocol.id, + handler_user_id='history-handler', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + statements = [] + + def capture_statement(_conn, _cursor, statement, _parameters, _context, _many): + statements.append(statement) + + event.listen(db.get_bind(), 'before_cursor_execute', capture_statement) + try: + items, total = list_samples(db, ctx=_ctx(), user_info=_user(), page_size=10) + finally: + event.remove(db.get_bind(), 'before_cursor_execute', capture_statement) + + assert total == len(items) == 5 + assert {item['storageCert']['handlerUserName'] for item in items} == {'历史办理人'} + assert sum('FROM sys_user' in statement for statement in statements) == 1 + + def test_sample_handler_must_be_enabled_staff_in_current_document_region(): from app.services.market_sample import update_storage_cert diff --git a/backend/tests/test_market_support_api.py b/backend/tests/test_market_support_api.py index ed0e48bc..427a04af 100644 --- a/backend/tests/test_market_support_api.py +++ b/backend/tests/test_market_support_api.py @@ -10,12 +10,17 @@ from app.models.models import ( BizAttachment, BizCellSampleType, BizCollectionHospital, + BizCollectionProtocol, BizCollectionRegion, BizCollectionRegionStaff, BizItemUnit, + BizProtocolSample, + BizSampleCode, SysDictData, + SysEthnic, SysUser, ) +from app.models.research_donation import BizResearchDonationSample from app.services.market_field_scope import MarketFieldContext @@ -27,10 +32,15 @@ def _session(): BizAttachment.__table__, BizCellSampleType.__table__, BizCollectionHospital.__table__, + BizCollectionProtocol.__table__, BizCollectionRegion.__table__, BizCollectionRegionStaff.__table__, BizItemUnit.__table__, + BizProtocolSample.__table__, + BizResearchDonationSample.__table__, + BizSampleCode.__table__, SysDictData.__table__, + SysEthnic.__table__, SysUser.__table__, ], ) @@ -72,6 +82,8 @@ def _seed_options(db): BizCellSampleType(id='type-a', type_code='A', type_name='样本A', tenant_no='tenant-a', create_dept_id='', del_flag=0), BizCellSampleType(id='type-x', type_code='X', type_name='跨租户秘密样本', tenant_no='tenant-b', create_dept_id='', del_flag=0), BizItemUnit(id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizSampleCode(id='sample-code-a', sample_code='S-001', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysEthnic(id='ethnic-han', ethnic_name='汉族', ethnic_code='01', tenant_no='', create_dept_id='', del_flag=0), SysDictData(id='dict-pay', dict_label='微信', dict_value='wx', dict_type='zzfs', tenant_no='tenant-a', create_dept_id='', del_flag=0), SysDictData(id='dict-freight', dict_label='专线车', dict_value='zxc', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', del_flag=0), SysDictData(id='dict-secret', dict_label='不应泄漏', dict_value='secret', dict_type='unrelated', tenant_no='tenant-a', create_dept_id='', del_flag=0), @@ -108,6 +120,7 @@ def test_specialist_form_options_are_region_scoped_and_minimal(monkeypatch): {'label': '已作废', 'value': 'void'}, ] assert out['transferTypes'] == [{'value': 'wx', 'label': '微信'}] + assert out['currentUser'] == {'value': 'user-a', 'label': '本人'} assert '跨租户' not in str(out) monkeypatch.setattr( @@ -142,6 +155,10 @@ def test_manager_form_options_only_include_led_regions_and_requested_region_must ) assert out['regions'] == [{'value': 'rb', 'label': 'B区'}] assert out['hospitals'] == [{'value': 'hb', 'label': 'B院'}] + assert out['ethnicities'] == [{'value': '汉族', 'label': '汉族'}] + assert out['sampleCodes'] == [ + {'value': 'sample-code-a', 'label': 'S-001', 'sampleCode': 'S-001'} + ] with pytest.raises(PermissionError, match='地区'): get_form_options( @@ -153,6 +170,65 @@ def test_manager_form_options_only_include_led_regions_and_requested_region_must ) +def test_protocol_form_options_hide_occupied_sample_codes(): + from app.services.market_support import get_form_options + + db = _session() + _seed_options(db) + db.add(BizSampleCode(id='sample-code-b', sample_code='S-002', tenant_no='tenant-a', create_dept_id='', del_flag=0)) + db.add( + BizCollectionProtocol( + id='proto-used', + protocol_no='CP-USED', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.add( + BizProtocolSample( + id='ps-used', + collection_protocol_id='proto-used', + sample_code='S-001', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + + out = get_form_options( + db, + module='protocol', + ctx=_ctx('manager', 'user-b'), + user_info=_user('user-b'), + region_id='rb', + ) + assert out['sampleCodes'] == [ + {'value': 'sample-code-b', 'label': 'S-002', 'sampleCode': 'S-002'} + ] + + +def test_presign_form_options_search_active_tenant_users_without_pii(): + from app.services.market_support import get_form_options + + db = _session() + _seed_options(db) + + out = get_form_options( + db, + module='presign', + ctx=_ctx(), + user_info=_user(), + user_keyword='员工', + ) + + assert out['users'] == [{'value': 'staff-a', 'label': 'A员工'}] + assert '跨租户' not in str(out['users']) + assert '停用' not in str(out['users']) + assert 'userNo' not in str(out['users']) + + def test_payment_and_freight_options_use_fixed_dictionary_allowlist(): from app.services.market_support import get_form_options @@ -393,6 +469,219 @@ def _attachment(**overrides): return BizAttachment(**values) +class _DownloadStorage: + def __init__(self): + self.requests = [] + + def presigned_download_url(self, file_url, file_name): + self.requests.append((file_url, file_name)) + return 'https://files.test/signed-download' + + +def test_market_attachment_download_uses_registered_original_name(): + from app.services.market_support import get_market_attachment_download_url + + db = _session() + db.add(_attachment()) + db.commit() + backend = _DownloadStorage() + + result = get_market_attachment_download_url( + db, + file_url='market/specialist/payment/a.pdf', + file_name='spoofed.exe', + module='payment', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + + assert result == {'downloadUrl': 'https://files.test/signed-download'} + assert backend.requests == [ + ('market/specialist/payment/a.pdf', 'proof.pdf') + ] + + +def test_market_attachment_download_rejects_cross_tenant_module_and_other_users_staging(): + from app.services.market_support import get_market_attachment_download_url + + db = _session() + db.add_all( + [ + _attachment(id='cross', file_url='cross.pdf', tenant_no='tenant-b'), + _attachment( + id='wrong-module', + file_url='wrong-module.pdf', + business_type='market:specialist:sample:bound', + business_id='sample-a', + ), + _attachment( + id='other-staging', + file_url='other-staging.pdf', + create_by='user-b', + ), + ] + ) + db.commit() + + for file_url in ('cross.pdf', 'wrong-module.pdf', 'other-staging.pdf'): + with pytest.raises(PermissionError, match='附件'): + get_market_attachment_download_url( + db, + file_url=file_url, + file_name=None, + module='payment', + ctx=_ctx(), + user_info=_user(), + storage_backend=_DownloadStorage(), + ) + + +def test_market_attachment_download_rejects_unreferenced_bound_and_legacy_rows( + monkeypatch, +): + from app.services import market_support + + db = _session() + db.add_all( + [ + _attachment( + id='manager-bound', + file_url='manager-bound.pdf', + business_type='market:manager:payment:bound', + business_id='transfer-a', + ), + _attachment( + id='legacy', + file_url='legacy.pdf', + business_type=None, + create_by='admin', + ), + ] + ) + db.commit() + monkeypatch.setattr( + market_support, + '_has_accessible_market_attachment_reference', + lambda *args, **kwargs: False, + ) + + for file_url in ('manager-bound.pdf', 'legacy.pdf'): + with pytest.raises(PermissionError, match='附件'): + market_support.get_market_attachment_download_url( + db, + file_url=file_url, + file_name=None, + module='payment', + ctx=_ctx(), + user_info=_user(), + storage_backend=_DownloadStorage(), + ) + + +def test_market_attachment_download_allows_accessible_referenced_legacy_row(monkeypatch): + from app.services import market_support + + db = _session() + db.add( + _attachment( + id='legacy', + file_url='legacy.pdf', + business_type=None, + create_by='admin', + ) + ) + db.commit() + checked = [] + + def allow_reference(_db, *, module, candidates, ctx, user_info): + checked.append((module, candidates, ctx.user_id, user_info['user_id'])) + return True + + monkeypatch.setattr( + market_support, + '_has_accessible_market_attachment_reference', + allow_reference, + ) + + result = market_support.get_market_attachment_download_url( + db, + file_url='legacy.pdf', + file_name=None, + module='payment', + ctx=_ctx(), + user_info=_user(), + storage_backend=_DownloadStorage(), + ) + + assert result['downloadUrl'] == 'https://files.test/signed-download' + assert checked == [('payment', ['legacy.pdf'], 'user-a', 'user-a')] + + +def test_protocol_attachment_reference_respects_specialist_donation_scope(): + from app.models.models import ( + BizCollectionProtocol, + BizProtocolCollectionInfo, + ) + from app.models.research_donation import ( + BizResearchDonation, + BizResearchDonationCollection, + ) + from app.services import market_support + + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all( + engine, + tables=[ + BizCollectionProtocol.__table__, + BizProtocolCollectionInfo.__table__, + BizResearchDonation.__table__, + BizResearchDonationCollection.__table__, + ], + ) + db = sessionmaker(bind=engine)() + db.add_all( + [ + BizResearchDonation( + id='donation-a', + donation_no='D-1', + donation_status='sxz', + source_type='direct', + attachment_urls='legacy.pdf', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizResearchDonationCollection( + id='collection-a', + research_donation_id='donation-a', + sales_user_id='user-a', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + ] + ) + db.commit() + super_admin_a = {**_user('user-a'), 'role': 'super_admin'} + super_admin_b = {**_user('user-b'), 'role': 'super_admin'} + + assert market_support._has_accessible_market_attachment_reference( + db, + module='protocol', + candidates=['legacy.pdf'], + ctx=_ctx(user_id='user-a'), + user_info=super_admin_a, + ) + assert not market_support._has_accessible_market_attachment_reference( + db, + module='protocol', + candidates=['legacy.pdf'], + ctx=_ctx(user_id='user-b'), + user_info=super_admin_b, + ) + + def test_create_attachment_refs_require_current_uploader_staging_exact_match(): from app.services.market_support import validate_attachment_refs @@ -673,7 +962,7 @@ def test_allowed_actions_are_state_scope_and_permission_aware(): assert allowed_actions('presign-transfer', status='dfj') == ['edit', 'delete', 'submit'] assert allowed_actions('presign-transfer', status='jfqrz') == ['withdraw'] assert allowed_actions('presign-transfer', status='yjf') == [] - assert allowed_actions('protocol', status='dsx') == ['edit'] + assert allowed_actions('protocol', status='dsx') == ['audit', 'edit', 'delete'] assert allowed_actions('protocol', status='yzz') == [] assert allowed_actions('payment-transfer', status='dfj') == ['edit-transfer', 'submit-finance'] assert allowed_actions('payment-transfer', status='jfqrz') == ['withdraw-transfer'] @@ -897,7 +1186,7 @@ def test_protocol_detail_action_reads_nested_basic_status(): terminal = decorate_allowed_actions( 'protocol', {'basic': {'protocolStatus': 'yzf'}}, scope_kind='specialist' ) - assert active['allowedActions'] == ['edit'] + assert active['allowedActions'] == ['audit', 'edit', 'delete'] assert terminal['allowedActions'] == [] @@ -916,9 +1205,13 @@ def test_market_support_routes_are_fixed_and_registered_before_dynamic_details() assert path in paths for module in ('presign', 'protocol', 'payment', 'sample'): upload_path = f'/mini/market/{scope}/{module}/attachments' + download_path = f'{upload_path}/download-url' assert upload_path in paths + assert download_path in paths upload_route = next(route for route in router.routes if route.path == upload_path) + download_route = next(route for route in router.routes if route.path == download_path) assert [param.name for param in upload_route.dependant.query_params] == [] + assert download_route.methods == {'POST'} assert paths.index(f'/mini/market/{scope}/presign-orders/form-options') < paths.index( f'/mini/market/{scope}/presign-orders/{{order_id}}' ) diff --git a/backend/tests/test_mini_menu.py b/backend/tests/test_mini_menu.py new file mode 100644 index 00000000..d1fa4bde --- /dev/null +++ b/backend/tests/test_mini_menu.py @@ -0,0 +1,149 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.api.mini_menu import list_mobile_menu_items +from app.models.base import Base +from app.models.models import ( + SysMenu, + SysPost, + SysPostRole, + SysRole, + SysRoleMenu, + SysUserPost, + SysUserRole, +) + + +def _session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + engine, + tables=[ + SysMenu.__table__, + SysPost.__table__, + SysPostRole.__table__, + SysRole.__table__, + SysRoleMenu.__table__, + SysUserPost.__table__, + SysUserRole.__table__, + ], + ) + return sessionmaker(bind=engine)() + + +def _menu(*, menu_id, tenant_no, name, parent_id, path="", perms=None, menu_type="M", order_num=1): + return SysMenu( + id=menu_id, + menu_name=name, + parent_id=parent_id, + menu_type=menu_type, + path=path, + perms=perms, + visible="1", + status="1", + tenant_no=tenant_no, + order_num=order_num, + create_dept_id="", + del_flag=0, + ) + + +def test_mobile_menu_query_uses_current_tenant_and_returns_leaf_group_title(): + db = _session() + db.add_all( + [ + _menu( + menu_id="tenant-a-root", + tenant_no="tenant-a", + name="移动端菜单", + parent_id="0", + ), + _menu( + menu_id="tenant-a-market", + tenant_no="tenant-a", + name="市场部", + parent_id="tenant-a-root", + ), + _menu( + menu_id="tenant-a-manager-presign", + tenant_no="tenant-a", + name="预签约订单(经理)", + parent_id="tenant-a-market", + path="/pages/market/webview/index?entry=manager-presign", + perms="mini:market:manager:presign", + menu_type="C", + ), + _menu( + menu_id="tenant-b-root", + tenant_no="tenant-b", + name="移动端菜单", + parent_id="0", + ), + _menu( + menu_id="tenant-b-market", + tenant_no="tenant-b", + name="市场部", + parent_id="tenant-b-root", + ), + _menu( + menu_id="tenant-b-specialist-presign", + tenant_no="tenant-b", + name="预签约订单(专员)", + parent_id="tenant-b-market", + path="/pages/market/webview/index?entry=specialist-presign", + perms="mini:market:specialist:presign", + menu_type="C", + ), + SysRole( + id="tenant-b-role", + role_name="市场专员", + status="1", + tenant_no="tenant-b", + create_dept_id="", + del_flag=0, + ), + SysUserRole( + id="tenant-b-user-role", + user_id="u1", + role_id="tenant-b-role", + tenant_no="tenant-b", + create_dept_id="", + del_flag=0, + ), + SysRoleMenu( + id="tenant-b-role-directory", + role_id="tenant-b-role", + menu_id="tenant-b-market", + tenant_no="tenant-b", + create_dept_id="", + del_flag=0, + ), + SysRoleMenu( + id="tenant-b-role-leaf", + role_id="tenant-b-role", + menu_id="tenant-b-specialist-presign", + tenant_no="tenant-b", + create_dept_id="", + del_flag=0, + ), + ] + ) + db.commit() + + items = list_mobile_menu_items(db, user_id="u1", tenant_no="tenant-b") + + assert {item["id"] for item in items} == {"tenant-b-specialist-presign"} + assert items[0]["groupTitle"] == "市场部" + assert items[0]["title"] == "预签约订单(专员)" + assert items[0]["path"] == ( + "/pages/market/webview/index?entry=specialist-presign" + ) + assert items[0]["icon"] == "market-presign" + assert items[0]["iconBg"] == "transparent" + + +def test_mobile_menu_query_requires_explicit_tenant(): + db = _session() + + assert list_mobile_menu_items(db, user_id="u1", tenant_no="") == [] + diff --git a/backend/tests/test_presign_order.py b/backend/tests/test_presign_order.py index 5d0672b6..1b03a993 100644 --- a/backend/tests/test_presign_order.py +++ b/backend/tests/test_presign_order.py @@ -153,5 +153,117 @@ class TestPresignWithdrawReceipt(unittest.TestCase): po._get_transfer_or_raise = orig +class TestPresignProtocolNoAllocate(unittest.TestCase): + """预签约协议编号:保存发号、忽略客户端改号、空号补发。""" + + def test_assert_biz_no_rejects_presign_occupied(self): + from types import SimpleNamespace + from unittest.mock import MagicMock + + from app.crud.protocol_donation_convert import assert_biz_no_available + from app.models.models import BizCollectionProtocol, BizPresignOrder + from app.models.research_donation import BizResearchDonation + + db = MagicMock() + + def query(model): + q = MagicMock() + if model is BizPresignOrder: + q.filter.return_value.all.return_value = [ + SimpleNamespace(id="presign-1") + ] + else: + q.filter.return_value.all.return_value = [] + return q + + db.query.side_effect = query + with self.assertRaises(ValueError) as ctx: + assert_biz_no_available(db, tenant_no="t1", biz_no="XBCC20261012000001") + self.assertIn("协议编号已存在", str(ctx.exception)) + + # 排除自身预签后可通过 + assert_biz_no_available( + db, + tenant_no="t1", + biz_no="XBCC20261012000001", + exclude_presign_order_id="presign-1", + ) + # 静默使用导入,避免 lint 误报 + _ = (BizCollectionProtocol, BizResearchDonation) + + def test_create_allocates_and_ignores_client_protocol_no(self): + from unittest.mock import MagicMock, patch + + from app.crud import presign_order as po + + db = MagicMock() + with patch.object( + po, "allocate_next_biz_no", return_value="XBCC20261012000009" + ) as alloc, patch.object(po, "replace_sample_types"): + obj = po.create_presign_order( + db, + tenant_no="t1", + user_id="u1", + fields={"protocolNo": "CLIENT-NO", "motherName": "张三"}, + commit=False, + ) + self.assertEqual(obj.protocol_no, "XBCC20261012000009") + alloc.assert_called_once() + self.assertEqual(alloc.call_args.kwargs.get("kind"), "collection") + + def test_update_ignores_client_change_and_backfills_empty(self): + from types import SimpleNamespace + from unittest.mock import MagicMock, patch + + from app.crud import presign_order as po + + kept = SimpleNamespace( + id="o1", + protocol_no="KEEP-NO", + order_status="active", + pay_mode=None, + contract_amount=None, + sample_collect_time=None, + update_by=None, + update_time=None, + ) + empty = SimpleNamespace( + id="o2", + protocol_no=None, + order_status="active", + pay_mode=None, + contract_amount=None, + sample_collect_time=None, + update_by=None, + update_time=None, + ) + db = MagicMock() + with patch.object(po, "_get_order_or_raise", return_value=kept): + po.update_presign_order( + db, + order_id="o1", + tenant_no="t1", + user_id="u1", + fields={"protocolNo": "HACK", "motherName": "李四"}, + commit=False, + ) + self.assertEqual(kept.protocol_no, "KEEP-NO") + self.assertEqual(kept.mother_name, "李四") + + with patch.object(po, "_get_order_or_raise", return_value=empty), patch.object( + po, "allocate_next_biz_no", return_value="XBCC20261012000010" + ) as alloc: + po.update_presign_order( + db, + order_id="o2", + tenant_no="t1", + user_id="u1", + fields={"motherName": "王五"}, + commit=False, + ) + self.assertEqual(empty.protocol_no, "XBCC20261012000010") + alloc.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_protocol_payment_summary_transfer_time.py b/backend/tests/test_protocol_payment_summary_transfer_time.py index fa032da6..15f927dd 100644 --- a/backend/tests/test_protocol_payment_summary_transfer_time.py +++ b/backend/tests/test_protocol_payment_summary_transfer_time.py @@ -3,7 +3,10 @@ from datetime import datetime from types import SimpleNamespace import unittest -from app.services.qc_work_order_fee_summary import build_protocol_payment_summary +from app.services.qc_work_order_fee_summary import ( + build_protocol_payment_summary, + pick_earliest_transfer_time_by_protocol, +) class TestPaymentSummaryTransferTime(unittest.TestCase): @@ -23,6 +26,17 @@ class TestPaymentSummaryTransferTime(unittest.TestCase): self.assertIsNotNone(summary) self.assertEqual(summary["paymentDetails"][0]["transferTime"], "2025-09-15 09:20:00") + def test_pick_earliest_transfer_time_by_protocol(self): + rows = [ + ("p1", datetime(2025, 9, 20, 14, 30, 0)), + ("p1", datetime(2025, 9, 15, 9, 20, 0)), + ("p1", datetime(2025, 9, 18, 8, 0, 0)), + ("p2", datetime(2025, 10, 1, 10, 0, 0)), + ] + got = pick_earliest_transfer_time_by_protocol(rows) + self.assertEqual(got["p1"], datetime(2025, 9, 15, 9, 20, 0)) + self.assertEqual(got["p2"], datetime(2025, 10, 1, 10, 0, 0)) + if __name__ == "__main__": unittest.main() diff --git a/backend/tests/test_protocol_sample_eta.py b/backend/tests/test_protocol_sample_eta.py new file mode 100644 index 00000000..198dceb7 --- /dev/null +++ b/backend/tests/test_protocol_sample_eta.py @@ -0,0 +1,100 @@ +"""协议单样本预计到达时间:运费批次改造后仍应可写回运输表。""" + +import unittest +from datetime import datetime +from unittest.mock import MagicMock, patch + +from app.crud.collection_protocol import save_sample_transports +from app.crud.research_donation import _create_transport + + +class TestSaveSampleTransportsEta(unittest.TestCase): + @patch("app.crud.collection_protocol.generate_uuid", return_value="tr-1") + @patch("app.crud.collection_protocol._soft_delete_transports_for_protocol") + def test_accepts_eta_only_without_transport_type(self, _soft_del, _uuid): + db = MagicMock() + sample = MagicMock() + sample.id = "s1" + sample.sample_code = "SC001" + + save_sample_transports( + db, + collection_protocol_id="p1", + tenant_no="t1", + user_id="u1", + saved_samples=[sample], + input_samples=[ + {"transport": {"etaTime": "2026-10-12 14:30:00"}}, + ], + ) + + self.assertTrue(db.add.called) + obj = db.add.call_args[0][0] + self.assertEqual(obj.sample_id, "s1") + self.assertEqual(obj.eta_time, datetime(2026, 10, 12, 14, 30, 0)) + self.assertEqual(obj.transport_type, "") + + @patch("app.crud.collection_protocol.generate_uuid", return_value="tr-2") + @patch("app.crud.collection_protocol._soft_delete_transports_for_protocol") + def test_accepts_top_level_eta_time_for_mobile(self, _soft_del, _uuid): + db = MagicMock() + sample = MagicMock() + sample.id = "s2" + sample.sample_code = "SC002" + + save_sample_transports( + db, + collection_protocol_id="p1", + tenant_no="t1", + user_id="u1", + saved_samples=[sample], + input_samples=[{"etaTime": "2026-10-13 09:00:00"}], + ) + + self.assertTrue(db.add.called) + obj = db.add.call_args[0][0] + self.assertEqual(obj.eta_time, datetime(2026, 10, 13, 9, 0, 0)) + + @patch("app.crud.collection_protocol._soft_delete_transports_for_protocol") + def test_skips_when_no_eta_and_no_transport_type(self, _soft_del): + db = MagicMock() + sample = MagicMock() + sample.id = "s3" + sample.sample_code = "SC003" + + save_sample_transports( + db, + collection_protocol_id="p1", + tenant_no="t1", + user_id="u1", + saved_samples=[sample], + input_samples=[{"transport": {"waybillNo": "SF123"}}], + ) + + db.add.assert_not_called() + + +class TestDonationCreateTransportEta(unittest.TestCase): + @patch("app.crud.research_donation.generate_uuid", return_value="dtr-1") + def test_create_transport_accepts_eta_only(self, _uuid): + db = MagicMock() + now = datetime(2026, 10, 12, 10, 0, 0) + obj = _create_transport( + db, + donation_id="d1", + sample_id="s1", + tenant_no="t1", + user_id="u1", + raw={"etaTime": "2026-10-15 16:00:00"}, + order_num=0, + now=now, + sample_code="SC001", + ) + self.assertIsNotNone(obj) + self.assertEqual(obj.eta_time, datetime(2026, 10, 15, 16, 0, 0)) + self.assertEqual(obj.transport_type, "") + db.add.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_sample_reception_list_sort.py b/backend/tests/test_sample_reception_list_sort.py new file mode 100644 index 00000000..c090bfeb --- /dev/null +++ b/backend/tests/test_sample_reception_list_sort.py @@ -0,0 +1,45 @@ +"""样本接收列表:待接收 / 已接收最终排序规则。""" + +import unittest + +from app.crud.sample_reception import ( + _sort_pending_list_items, + _sort_received_list_items, +) + + +class TestSampleReceptionListSort(unittest.TestCase): + def test_pending_sorts_by_eta_asc_then_sample_code_desc(self): + items = [ + {"etaTime": "2026-10-12 10:00:00", "sampleCode": "A001"}, + {"etaTime": "2026-10-12 10:00:00", "sampleCode": "A003"}, + {"etaTime": "2026-10-11 09:00:00", "sampleCode": "B001"}, + {"etaTime": "", "sampleCode": "Z999"}, + {"etaTime": None, "sampleCode": "C002"}, + ] + + _sort_pending_list_items(items) + + self.assertEqual( + [x["sampleCode"] for x in items], + ["B001", "A003", "A001", "Z999", "C002"], + ) + + def test_received_sorts_by_reception_time_desc_then_sample_code_desc(self): + items = [ + {"receptionTime": "2026-10-12 10:00:00", "sampleCode": "A001"}, + {"receptionTime": "2026-10-12 10:00:00", "sampleCode": "A003"}, + {"receptionTime": "2026-10-13 09:00:00", "sampleCode": "B001"}, + {"receptionTime": "", "sampleCode": "Z999"}, + ] + + _sort_received_list_items(items) + + self.assertEqual( + [x["sampleCode"] for x in items], + ["B001", "A003", "A001", "Z999"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/clash-verge-10min-traffic-report.md b/docs/clash-verge-10min-traffic-report.md new file mode 100644 index 00000000..93940794 --- /dev/null +++ b/docs/clash-verge-10min-traffic-report.md @@ -0,0 +1,58 @@ +# Clash Verge 流量监控(10 分钟) + +- 开始:2026-10-10T18:03:13.492312 +- 结束:2026-10-10T18:13:17.003010 +- 采样:每 5s,共 120 次(API 失败 0);跟踪连接 97 条 +- 期间累计(连接字节增量估算):上传 1.35 MB / 下载 1.47 MB / 合计 2.82 MB + +> 字节量来自 Clash `/connections` 采样增量;连接次数来自期间 sidecar 新建日志。 + +## 按流量 Top 域名 + +| 合计 | 上传 | 下载 | 域名 | 主要出站/节点 | 进程 | +|---:|---:|---:|---|---|---| +| 1.49 MB | 477.2 KB | 1.03 MB | `chatgpt.com` | 🇯🇵 JPN 日本 02 | Codex (Service) | +| 471.0 KB | 435.6 KB | 35.4 KB | `chat.openai.com` | 🇯🇵 JPN 日本 02 | Codex (Service) | +| 367.3 KB | 101.8 KB | 265.5 KB | `api2.cursor.sh` | DIRECT | Cursor Helper (Plugin) | +| 190.3 KB | 159.0 KB | 31.3 KB | `ab.chatgpt.com` | 🇯🇵 JPN 日本 02 | codex | +| 161.3 KB | 104.3 KB | 57.1 KB | `ws.chatgpt.com` | 🇯🇵 JPN 日本 02 | Codex (Service) | +| 79.6 KB | 57.0 KB | 22.6 KB | `gateway.icloud.com.cn` | DIRECT | cloudd | +| 41.1 KB | 29.2 KB | 11.9 KB | `api3.cursor.sh` | DIRECT | Cursor Helper | +| 27.0 KB | 6.6 KB | 20.5 KB | `mtalk.google.com` | 🇯🇵 JPN 日本 02 | Codex (Service) | +| 14.1 KB | 6.2 KB | 7.9 KB | `clients4.google.com` | 🇯🇵 JPN 日本 02 | Google Chrome Helper | +| 4.0 KB | 3.0 KB | 1.1 KB | `easylist-downloads.adblockplus.org` | DIRECT | Google Chrome Helper | + +## 新建连接:进程分布 + +- **127** × `?` +- **37** × `Cursor Helper` +- **6** × `codex` +- **4** × `Google Chrome Helper` +- **2** × `cloudd` +- **1** × `ChatGPT` + +## 新建连接:出站分布 + +- **164** × `DIRECT` +- **12** × `OpenAI[🇯🇵 JPN 日本 02]` +- **1** × `Google[🇯🇵 JPN 日本 02]` + +## 新建连接:走代理的域名 + +- **4** `chatgpt.com` → OpenAI[🇯🇵 JPN 日本 02] +- **4** `ab.chatgpt.com` → OpenAI[🇯🇵 JPN 日本 02] +- **3** `ws.chatgpt.com` → OpenAI[🇯🇵 JPN 日本 02] +- **1** `persistent.oaistatic.com` → OpenAI[🇯🇵 JPN 日本 02] +- **1** `clients4.google.com` → Google[🇯🇵 JPN 日本 02] + +## 新建连接:直连域名 + +- **152** `api2.cursor.sh` → DIRECT +- **10** `api3.cursor.sh` → DIRECT +- **2** `gateway.icloud.com.cn` → DIRECT + +## 简要结论 + +- 按连接出站估算:代理侧约 **2.34 MB**,直连约 **492.0 KB**。 +- 流量最大域名:`chatgpt.com`(1.49 MB), `chat.openai.com`(471.0 KB), `api2.cursor.sh`(367.3 KB)。 +- 期间最常走代理的域名:`chatgpt.com`, `ab.chatgpt.com`, `ws.chatgpt.com`, `persistent.oaistatic.com`, `clients4.google.com`。 diff --git a/docs/clash-verge-rev-省流量配置.md b/docs/clash-verge-rev-省流量配置.md new file mode 100644 index 00000000..0076768a --- /dev/null +++ b/docs/clash-verge-rev-省流量配置.md @@ -0,0 +1,163 @@ +# Clash Verge Rev 省流量配置备忘 + +适用版本:Clash Verge Rev **2.5.8**(其它相近版本界面类似) +订阅示例:LinkCube(策略组含 `Proxies` / `OpenAI` / `Apple` / `Microsoft` / `Final` 等) +整理日期:2026-10-10 + +本文记录本机排查后的结论与已生效改动,便于在另一台电脑上复现。 + +--- + +## 1. 推荐基础设置 + +| 项 | 建议 | +|---|---| +| 代理模式 | **规则**(不要长期用「全局」) | +| 系统代理 | 需要时开启 | +| TUN 模式 | **关闭**(不必须时不要开;开了更容易把几乎所有流量送进节点) | +| 局域网允许 | 不共享给其它设备时关闭 | + +--- + +## 2. 策略组改成直连(界面操作) + +打开左侧 **「代理」**(不是「设置」里的系统代理开关)。 + +找到下列策略组,展开后选中 **`DIRECT`**: + +| 策略组 | 改成 | 原因 | +|---|---|---| +| **Final** | `DIRECT` | 兜底规则 `MATCH,Final`;默认跟 `Proxies` 时,未命中规则的流量都会出国(IP 检测、广告列表、Raycast、订阅域名等) | +| **Apple** | `DIRECT` | iCloud / 系统服务等国内一般应直连 | +| **Microsoft** | `DIRECT` | 微软遥测等无需走代理 | + +保持需要翻墙的组继续走节点,例如: + +| 策略组 | 建议 | +|---|---| +| **Proxies** | 选延迟可用的节点(如日本 / 香港) | +| **OpenAI** | 继续走代理(ChatGPT 等) | +| **Google** | 需要访问 Google 时走代理 | + +### 界面找不到策略组时 + +1. 确认打开的是左侧 **「代理」**,不是「设置 / 配置」。 +2. 页面顶部搜索框输入 `Final` / `Apple` / `Microsoft`。 +3. 或从菜单栏 Clash 图标右键 → 策略组里切换。 + +### 不要只改 Proxies 节点 + +若 `Final` / `Apple` / `Microsoft` 仍指向 `Proxies`,只换日本/香港节点**不能**让这些流量直连,它们仍会出国。 + +--- + +## 3. Cursor 改直连(规则扩展) + +订阅里常见规则: + +```yaml +- DOMAIN-SUFFIX,cursor.sh,OpenAI +- DOMAIN-SUFFIX,cursorapi.com,OpenAI +``` + +需要 Cursor 不走机场流量时,在 **配置 → 对应订阅 → 规则(Rules)增强** 中写入(或编辑同名增强文件): + +```yaml +# Profile Enhancement Rules Template for Clash Verge + +prepend: + - DOMAIN-SUFFIX,cursor.sh,DIRECT + - DOMAIN-SUFFIX,cursorapi.com,DIRECT + - DOMAIN-SUFFIX,cursor.com,DIRECT + +append: [] + +delete: [] +``` + +说明: + +- `prepend` 会插到订阅规则**前面**,优先于原来的 `OpenAI` 规则。 +- 保存后需让配置重新加载(切换配置 / 重新启用订阅 / 重启内核,视版本而定)。 +- macOS 增强文件路径示例(UID 因机器而异,以界面「规则」增强为准): + + `~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/profiles/.yaml` + +### 注意 + +国内直连 Cursor 可能连不上或很慢。若异常,删掉上述三条,或改回: + +```yaml +prepend: + - DOMAIN-SUFFIX,cursor.sh,OpenAI + - DOMAIN-SUFFIX,cursorapi.com,OpenAI + - DOMAIN-SUFFIX,cursor.com,OpenAI +``` + +ChatGPT(`chatgpt.com` / `openai.com`)仍走 `OpenAI` 组,不受影响。 + +--- + +## 4. 另一台电脑快速 checklist + +1. [ ] 模式:**规则**;TUN:**关**;按需开系统代理 +2. [ ] 「代理」页:`Final` → **DIRECT** +3. [ ] 「代理」页:`Apple` → **DIRECT** +4. [ ] 「代理」页:`Microsoft` → **DIRECT** +5. [ ] (可选)规则增强 prepend:Cursor 三个域名 → **DIRECT** +6. [ ] 重载配置后,在「连接」里抽查: + - 百度 / QQ / Apple 国内 → `DIRECT` + - ChatGPT → `OpenAI` / 节点 + - Cursor(若已按 §3 配置)→ `DIRECT` + +--- + +## 5. 当时日志结论摘要(便于对照) + +在「系统代理 + 规则、TUN 关闭」下,sidecar 日志大致比例:**大量走代理,少量直连**。 + +主要走代理且通常合理: + +- `chatgpt.com`、OpenAI 相关 → `OpenAI` +- `api2.cursor.sh` 等(改规则前)→ `OpenAI` +- Google 系 → `Google` + +建议改为直连的来源: + +- 命中 **Final / Match** 的杂项(IP 查询站、easylist、linkcube 订阅域名、Raycast 等) +- **Apple** 组(`setup.icloud.com`、`api.apple-cloudkit.com` 及部分系统进程) +- **Microsoft** 组(如 `mobile.events.data.microsoft.com`) + +国内域名(QQ / 微信 / 百度 / `*.cn` / GeoIP CN)多数已是 `DIRECT`,订阅国内规则基本正常;主要浪费来自 **Final/Apple/Microsoft 默认跟 Proxies**,以及 **ChatGPT + Cursor 长时间使用**。 + +--- + +## 6. 日志位置(macOS,排查用) + +```text +~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/latest.log +~/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/logs/sidecar/sidecar_latest.log +``` + +在 sidecar 日志中关注: + +```text +match ... using Final[...] # 兜底走代理 → 检查 Final 是否应为 DIRECT +match ... using Apple[...] # Apple 是否应直连 +match ... using Microsoft[...] # Microsoft 是否应直连 +match DomainSuffix(cursor.sh) using ... +``` + +--- + +## 7. 策略组结构参考(LinkCube 类订阅) + +```text +Final → Proxies | DIRECT (建议选 DIRECT) +Apple → Proxies | DIRECT | 各地区 (建议选 DIRECT) +Microsoft → Proxies | DIRECT | 各地区 (建议选 DIRECT) +OpenAI → Proxies | 各地区 (无 DIRECT,继续代理) +Google → Proxies | 各地区 (无 DIRECT,继续代理) +Proxies → 具体节点 +规则末尾 → GEOIP,CN,DIRECT 然后 MATCH,Final +``` diff --git a/docs/demos/2026-10-12-market-protocol-source-tabs-demo.html b/docs/demos/2026-10-12-market-protocol-source-tabs-demo.html new file mode 100644 index 00000000..78b98a69 --- /dev/null +++ b/docs/demos/2026-10-12-market-protocol-source-tabs-demo.html @@ -0,0 +1,766 @@ + + + + + + 市场协议单 · 来源双 Tab Demo + + + +
+ + +
+
+
+
+ +

市场专员 · 协议单

+ +
+
+
+ + +
+
+
+ +
+
+ +
+
+ + +
+
+
+ + + + diff --git a/docs/sql/migrations/2026-10-10-market-mobile-menus.sql b/docs/sql/migrations/2026-10-10-market-mobile-menus.sql index ce506701..e7640f4a 100644 --- a/docs/sql/migrations/2026-10-10-market-mobile-menus.sql +++ b/docs/sql/migrations/2026-10-10-market-mobile-menus.sql @@ -172,13 +172,9 @@ INNER JOIN ( '/pages/market/webview/index?entry=specialist-payment', 'mini:market:specialist:payment', 40 UNION ALL - SELECT 'specialist', '市场专员', 'sample', '样本信息', - '/pages/market/webview/index?entry=specialist-sample', - 'mini:market:specialist:sample', 50 - UNION ALL SELECT 'specialist', '市场专员', 'freight', '运费信息', '/pages/market/webview/index?entry=specialist-freight', - 'mini:market:specialist:freight', 60 + 'mini:market:specialist:freight', 50 UNION ALL SELECT 'manager', '地区经理', 'presign', '预签约订单', '/pages/market/webview/index?entry=manager-presign', @@ -196,13 +192,9 @@ INNER JOIN ( '/pages/market/webview/index?entry=manager-payment', 'mini:market:manager:payment', 40 UNION ALL - SELECT 'manager', '地区经理', 'sample', '样本信息', - '/pages/market/webview/index?entry=manager-sample', - 'mini:market:manager:sample', 50 - UNION ALL SELECT 'manager', '地区经理', 'freight', '运费信息', '/pages/market/webview/index?entry=manager-freight', - 'mini:market:manager:freight', 60 + 'mini:market:manager:freight', 50 ) entry ON 1 = 1 INNER JOIN sys_menu directory diff --git a/docs/sql/migrations/2026-10-11-mobile-menu-market-oa-dirs.sql b/docs/sql/migrations/2026-10-11-mobile-menu-market-oa-dirs.sql new file mode 100644 index 00000000..38a17d17 --- /dev/null +++ b/docs/sql/migrations/2026-10-11-mobile-menu-market-oa-dirs.sql @@ -0,0 +1,235 @@ +-- ============================================================================= +-- 移动端菜单重组:市场部 / OA工作流两大目录 +-- +-- 目标结构: +-- 移动端菜单 +-- ├─ 市场部 +-- │ ├─ 预签约订单(专员)…运费信息(专员) +-- │ └─ 预签约订单(经理)…运费信息(经理) +-- └─ OA工作流 +-- └─ 发起审批…开启微信通知 +-- +-- 说明: +-- 1. 复用原「市场专员」目录 id,改名为「市场部」;原「地区经理」目录软删。 +-- 2. 叶子 id / path / perms 不变,仅调整 parent_id、menu_name、order_num。 +-- 3. 已授权 OA 叶子的角色自动补授「OA工作流」目录(幂等)。 +-- 4. MySQL 5.7 可执行。 +-- ============================================================================= + +-- 1) 「市场专员」→「市场部」 +UPDATE sys_menu market_dir +INNER JOIN sys_menu root + ON root.id = market_dir.parent_id + AND root.parent_id = '0' + AND root.menu_name = '移动端菜单' + AND root.del_flag = 0 +SET + market_dir.menu_name = '市场部', + market_dir.order_num = 10, + market_dir.remark = '小程序:市场部菜单目录', + market_dir.update_time = NOW() +WHERE market_dir.menu_type = 'M' + AND market_dir.path = '' + AND market_dir.del_flag = 0 + AND market_dir.menu_name IN ('市场专员', '市场部'); + +-- 2) 新建「OA工作流」目录(每个租户移动端根下一条) +INSERT INTO sys_menu ( + id, menu_name, parent_id, menu_type, path, component, perms, + is_frame, visible, status, tenant_no, order_num, remark, + create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + MD5(CONCAT( + 'mobile-oa-directory|', root.tenant_no, '|', root.id, '|M||OA工作流' + )), + 'OA工作流', + root.id, + 'M', + '', + NULL, + NULL, + 1, + '1', + '1', + root.tenant_no, + 20, + '小程序:OA工作流菜单目录', + NULL, + '', + NOW(), + NULL, + NOW(), + 0 +FROM sys_menu root +WHERE root.parent_id = '0' + AND root.menu_name = '移动端菜单' + AND root.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 + FROM sys_menu existing + WHERE existing.tenant_no = root.tenant_no + AND existing.parent_id = root.id + AND existing.menu_name = 'OA工作流' + AND existing.menu_type = 'M' + AND existing.path = '' + AND existing.del_flag = 0 + ) +ON DUPLICATE KEY UPDATE + tenant_no = VALUES(tenant_no), + parent_id = VALUES(parent_id), + menu_name = VALUES(menu_name), + menu_type = VALUES(menu_type), + path = VALUES(path), + order_num = VALUES(order_num), + remark = VALUES(remark), + update_time = VALUES(update_time), + del_flag = VALUES(del_flag); + +-- 3) 市场叶子:挂到「市场部」,名称加(专员)/(经理),调整排序 +UPDATE sys_menu leaf +INNER JOIN sys_menu root + ON root.parent_id = '0' + AND root.menu_name = '移动端菜单' + AND root.del_flag = 0 + AND root.tenant_no = leaf.tenant_no +INNER JOIN sys_menu market_dir + ON market_dir.tenant_no = root.tenant_no + AND market_dir.parent_id = root.id + AND market_dir.menu_name = '市场部' + AND market_dir.menu_type = 'M' + AND market_dir.path = '' + AND market_dir.del_flag = 0 +INNER JOIN ( + SELECT 'mini:market:specialist:presign' AS perms, '预签约订单(专员)' AS menu_name, 10 AS order_num + UNION ALL SELECT 'mini:market:specialist:protocol', '协议单(专员)', 20 + UNION ALL SELECT 'mini:market:specialist:payment-chase', '催缴信息(专员)', 30 + UNION ALL SELECT 'mini:market:specialist:payment', '缴费信息(专员)', 40 + UNION ALL SELECT 'mini:market:specialist:freight', '运费信息(专员)', 50 + UNION ALL SELECT 'mini:market:manager:presign', '预签约订单(经理)', 60 + UNION ALL SELECT 'mini:market:manager:protocol', '协议单(经理)', 70 + UNION ALL SELECT 'mini:market:manager:payment-chase', '催缴信息(经理)', 80 + UNION ALL SELECT 'mini:market:manager:payment', '缴费信息(经理)', 90 + UNION ALL SELECT 'mini:market:manager:freight', '运费信息(经理)', 100 +) map ON map.perms = leaf.perms +SET + leaf.parent_id = market_dir.id, + leaf.menu_name = map.menu_name, + leaf.order_num = map.order_num, + leaf.remark = CONCAT('小程序市场现场:', map.menu_name), + leaf.update_time = NOW() +WHERE leaf.menu_type = 'C' + AND leaf.del_flag = 0 + AND leaf.perms LIKE 'mini:market:%'; + +-- 4) OA 叶子:挂到「OA工作流」 +UPDATE sys_menu leaf +INNER JOIN sys_menu root + ON root.parent_id = '0' + AND root.menu_name = '移动端菜单' + AND root.del_flag = 0 + AND root.tenant_no = leaf.tenant_no +INNER JOIN sys_menu oa_dir + ON oa_dir.tenant_no = root.tenant_no + AND oa_dir.parent_id = root.id + AND oa_dir.menu_name = 'OA工作流' + AND oa_dir.menu_type = 'M' + AND oa_dir.path = '' + AND oa_dir.del_flag = 0 +SET + leaf.parent_id = oa_dir.id, + leaf.update_time = NOW() +WHERE leaf.del_flag = 0 + AND leaf.menu_type = 'C' + AND ( + leaf.perms IN ( + 'mini:oa:start', + 'mini:oa:drafts', + 'mini:oa:pending', + 'mini:oa:done', + 'mini:oa:mine', + 'mini:oa:cc', + 'oa:mention', + 'mini:oa:wechat-notify' + ) + OR leaf.path IN ( + '/pages/oa/start/index', + '/pages/oa/drafts/index', + '/pages/oa/pending/index', + '/pages/oa/done/index', + '/pages/oa/mine/index', + '/pages/oa/cc/index', + '/pages/oa/mentions/index', + '/pages/oa/wechat-notify/index' + ) + ); + +-- 5) 软删「地区经理」目录(步骤 3 已把子节点迁到市场部;避免同表子查询触发 1093) +UPDATE sys_menu manager_dir +INNER JOIN sys_menu root + ON root.id = manager_dir.parent_id + AND root.parent_id = '0' + AND root.menu_name = '移动端菜单' + AND root.del_flag = 0 +SET + manager_dir.del_flag = 1, + manager_dir.update_time = NOW(), + manager_dir.remark = '已重组至市场部,软删' +WHERE manager_dir.menu_type = 'M' + AND manager_dir.path = '' + AND manager_dir.menu_name = '地区经理' + AND manager_dir.del_flag = 0; + +-- 6) 已有 OA 叶子授权的角色补授「OA工作流」目录 +INSERT INTO sys_role_menu ( + id, role_id, menu_id, tenant_no, order_num, remark, + create_by, create_dept_id, create_time, update_by, update_time, del_flag +) +SELECT + MD5(CONCAT(r.id, '|', oa_dir.id)), + r.id, + oa_dir.id, + IFNULL(NULLIF(r.tenant_no, ''), oa_dir.tenant_no), + 0, + 'grant OA工作流 directory to roles with OA leaf menus', + NULL, + '', + NOW(), + NULL, + NOW(), + 0 +FROM sys_role r +INNER JOIN sys_role_menu rm + ON rm.role_id = r.id + AND rm.del_flag = 0 +INNER JOIN sys_menu leaf + ON leaf.id = rm.menu_id + AND leaf.del_flag = 0 + AND leaf.menu_type = 'C' + AND ( + leaf.perms IN ( + 'mini:oa:start', + 'mini:oa:drafts', + 'mini:oa:pending', + 'mini:oa:done', + 'mini:oa:mine', + 'mini:oa:cc', + 'oa:mention', + 'mini:oa:wechat-notify' + ) + OR leaf.path LIKE '/pages/oa/%' + ) +INNER JOIN sys_menu oa_dir + ON oa_dir.id = leaf.parent_id + AND oa_dir.menu_name = 'OA工作流' + AND oa_dir.menu_type = 'M' + AND oa_dir.del_flag = 0 +WHERE r.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 + FROM sys_role_menu x + WHERE x.del_flag = 0 + AND x.role_id = r.id + AND x.menu_id = oa_dir.id + ) +GROUP BY r.id, oa_dir.id, r.tenant_no, oa_dir.tenant_no; diff --git a/docs/sql/migrations/2026-10-13-customer-service-tables.sql b/docs/sql/migrations/2026-10-13-customer-service-tables.sql index 18bc6897..04889a7a 100644 --- a/docs/sql/migrations/2026-10-13-customer-service-tables.sql +++ b/docs/sql/migrations/2026-10-13-customer-service-tables.sql @@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS biz_sample_customer_service ( PRIMARY KEY (id), UNIQUE KEY uk_bscs_tenant_sample (tenant_no, sample_code), KEY idx_bscs_protocol (tenant_no, collection_protocol_id, del_flag) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本客户服务扩展'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本客户服务扩展'; -- 注:uk_bscs_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。 CREATE TABLE IF NOT EXISTS biz_customer_service_dict ( @@ -45,7 +45,7 @@ CREATE TABLE IF NOT EXISTS biz_customer_service_dict ( del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记', PRIMARY KEY (id), UNIQUE KEY uk_bcsd_tenant_type_value (tenant_no, dict_type, dict_value) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户服务字典'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户服务字典'; -- 注:uk_bcsd_tenant_type_value 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。 -- 预置字典种子(tenant_no=zkxw,is_system=1;幂等 NOT EXISTS) diff --git a/docs/sql/migrations/2026-10-13-hide-market-mobile-sample-menus.sql b/docs/sql/migrations/2026-10-13-hide-market-mobile-sample-menus.sql new file mode 100644 index 00000000..37098964 --- /dev/null +++ b/docs/sql/migrations/2026-10-13-hide-market-mobile-sample-menus.sql @@ -0,0 +1,27 @@ +-- ============================================================================= +-- 下线小程序「市场专员 / 地区经理 → 样本信息」菜单(2026-10-13) +-- ============================================================================= +-- +-- 样本信息(客服管理)不面向市场专员、地区经理;移动端入口与 OA H5 路由已移除。 +-- 本脚本将已入库的叶子菜单设为不可见(权限码保留,避免角色菜单残留脏数据时可另行清理)。 +-- +-- perms: +-- mini:market:specialist:sample +-- mini:market:manager:sample +-- visible: '0'=隐藏 +-- ============================================================================= + +UPDATE sys_menu +SET visible = '0', + update_time = NOW() +WHERE del_flag = 0 + AND ( + perms IN ( + 'mini:market:specialist:sample', + 'mini:market:manager:sample' + ) + OR path IN ( + '/pages/market/webview/index?entry=specialist-sample', + '/pages/market/webview/index?entry=manager-sample' + ) + ); diff --git a/docs/sql/migrations/2026-10-13-rename-customer-menu.sql b/docs/sql/migrations/2026-10-13-rename-customer-menu.sql new file mode 100644 index 00000000..ee59dbfb --- /dev/null +++ b/docs/sql/migrations/2026-10-13-rename-customer-menu.sql @@ -0,0 +1,21 @@ +-- ============================================================================= +-- 菜单改名:客户管理 → 客户基础信息 +-- id: 121 +-- perms: biz:customer:list +-- 父级:市场基础信息 +-- +-- 【前端固定菜单】同步修改: +-- - web-admin/src/router/modules/sqxp.ts +-- - backend/app/api/menu.py +-- ============================================================================= + +UPDATE sys_menu +SET menu_name = '客户基础信息', + update_time = NOW() +WHERE del_flag = 0 + AND ( + id = '121' + OR perms = 'biz:customer:list' + ) + AND menu_type = 'C' + AND menu_name <> '客户基础信息'; diff --git a/docs/sql/migrations/2026-10-13-sample-storage-cert-insurance.sql b/docs/sql/migrations/2026-10-13-sample-storage-cert-insurance.sql index 8af739ef..f964e8df 100644 --- a/docs/sql/migrations/2026-10-13-sample-storage-cert-insurance.sql +++ b/docs/sql/migrations/2026-10-13-sample-storage-cert-insurance.sql @@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS biz_sample_storage_cert ( PRIMARY KEY (id), UNIQUE KEY uk_bssc_tenant_sample (tenant_no, sample_code), KEY idx_bssc_protocol (tenant_no, collection_protocol_id, del_flag) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本储存证办理'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本储存证办理'; -- 注:uk_bssc_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。 CREATE TABLE IF NOT EXISTS biz_sample_insurance ( @@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS biz_sample_insurance ( PRIMARY KEY (id), UNIQUE KEY uk_bsi_tenant_sample (tenant_no, sample_code), KEY idx_bsi_protocol (tenant_no, collection_protocol_id, del_flag) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='样本保险办理'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='样本保险办理'; -- 注:uk_bsi_tenant_sample 不含 del_flag;del_flag=0 行唯一性由应用层保证(与 biz_sample_storage_cert_qc 一致)。 -- 迁移:协议级储存证 → 协议下每个未删样本一行(幂等 NOT EXISTS) @@ -69,7 +69,8 @@ INSERT INTO biz_sample_storage_cert ( update_by, update_time, del_flag ) SELECT - REPLACE(UUID(), '-', ''), + -- MySQL 5.7 同语句批量 UUID() 可能撞主键,用业务键派生稳定唯一 id + MD5(CONCAT('bssc#', c.tenant_no, '#', ps.sample_code, '#', c.id, '#', ps.id)), ps.sample_code, c.collection_protocol_id, c.make_status, @@ -89,16 +90,16 @@ SELECT 0 FROM biz_protocol_storage_cert c INNER JOIN biz_protocol_sample ps - ON ps.collection_protocol_id = c.collection_protocol_id - AND ps.tenant_no = c.tenant_no + ON (ps.collection_protocol_id COLLATE utf8mb4_unicode_ci) = (c.collection_protocol_id COLLATE utf8mb4_unicode_ci) + AND (ps.tenant_no COLLATE utf8mb4_unicode_ci) = (c.tenant_no COLLATE utf8mb4_unicode_ci) AND ps.del_flag = 0 WHERE c.del_flag = 0 AND ps.sample_code IS NOT NULL AND ps.sample_code <> '' AND NOT EXISTS ( SELECT 1 FROM biz_sample_storage_cert t - WHERE t.tenant_no = c.tenant_no - AND t.sample_code = ps.sample_code + WHERE (t.tenant_no COLLATE utf8mb4_unicode_ci) = (c.tenant_no COLLATE utf8mb4_unicode_ci) + AND (t.sample_code COLLATE utf8mb4_unicode_ci) = (ps.sample_code COLLATE utf8mb4_unicode_ci) AND t.del_flag = 0 ); @@ -114,7 +115,7 @@ INSERT INTO biz_sample_insurance ( update_by, update_time, del_flag ) SELECT - REPLACE(UUID(), '-', ''), + MD5(CONCAT('bsi#', t.tenant_no, '#', ps.sample_code, '#', t.id, '#', ps.id)), ps.sample_code, t.collection_protocol_id, t.todo_status, @@ -143,15 +144,15 @@ SELECT 0 FROM biz_protocol_fullmoon_todo t INNER JOIN biz_protocol_sample ps - ON ps.collection_protocol_id = t.collection_protocol_id - AND ps.tenant_no = t.tenant_no + ON (ps.collection_protocol_id COLLATE utf8mb4_unicode_ci) = (t.collection_protocol_id COLLATE utf8mb4_unicode_ci) + AND (ps.tenant_no COLLATE utf8mb4_unicode_ci) = (t.tenant_no COLLATE utf8mb4_unicode_ci) AND ps.del_flag = 0 WHERE t.del_flag = 0 AND ps.sample_code IS NOT NULL AND ps.sample_code <> '' AND NOT EXISTS ( SELECT 1 FROM biz_sample_insurance s - WHERE s.tenant_no = t.tenant_no - AND s.sample_code = ps.sample_code + WHERE (s.tenant_no COLLATE utf8mb4_unicode_ci) = (t.tenant_no COLLATE utf8mb4_unicode_ci) + AND (s.sample_code COLLATE utf8mb4_unicode_ci) = (ps.sample_code COLLATE utf8mb4_unicode_ci) AND s.del_flag = 0 ); diff --git a/docs/superpowers/plans/2026-10-10-market-miniapp-role-features.md b/docs/superpowers/plans/2026-10-10-market-miniapp-role-features.md new file mode 100644 index 00000000..321bdccb --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-market-miniapp-role-features.md @@ -0,0 +1,549 @@ +# 小程序市场专员 / 地区经理存储业务 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:** 在微信小程序中提供市场专员与地区经理两套独立的存储业务入口,由 `mobile-oa-h5` 承载六个业务模块,并由后端对租户、菜单权限、通用数据权限及现场 scope 做全链路强制校验。 + +**Architecture:** `mobile-h5` 只负责授权菜单与受控 `web-view`;`mobile-oa-h5` 使用十二个静态入口路由和共享移动业务组件;后端新增 `/api/mini/market/{specialist|manager}` 专用路由和集中 scope 服务。现有 Web CRUD 作为领域能力来源,但移动端通过白名单 DTO/动作调用,禁止直接暴露财务、审核、生产与质检能力。 + +**Tech Stack:** FastAPI、SQLAlchemy、Pydantic v2、Pytest、Vue 3、Vue Router、Vant、uni-app、Node `node:test`、MySQL 5.7。 + +--- + +## 任务地图 + +| 任务 | 交付物 | 主要验证 | +|---|---|---| +| 1 | 严格移动菜单权限与租户化菜单 | `test_market_mobile_permissions.py`、`test_mini_menu.py` | +| 2 | 集中 specialist/manager scope 服务 | `test_market_field_scope.py` | +| 3 | 预签移动 API | `test_market_presign_api.py` | +| 4 | 协议(细胞/科研)移动 API | `test_market_protocol_api.py` | +| 5 | 催缴/缴费移动 API | `test_market_payment_api.py` | +| 6 | 样本轻量移动 API | `test_market_sample_api.py` | +| 7 | 运费脱敏移动 API | `test_market_freight_scope.py` | +| 8 | 菜单 SQL 与小程序受控 web-view | Node 测试、`test_mini_menu.py` | +| 9 | H5 路由、公共组件与只读页面 | Node 路由测试、Vite build | +| 10 | 六模块写页面与动作 | Node 单测、Vite build、API 回归 | +| 11 | 全链路回归、知识图谱与总结文档 | Pytest、Node、build、字段一致性、图谱 | + +### Task 1: 严格移动菜单权限与租户化菜单 + +**Files:** +- Create: `backend/app/core/mobile_menu_guard.py` +- Create: `backend/tests/test_market_mobile_permissions.py` +- Create: `backend/tests/test_mini_menu.py` +- Modify: `backend/app/api/mini_menu.py` +- Modify: `backend/app/services/web_auth_roles.py` + +- [x] **Step 1: 写严格权限与租户隔离失败测试** + +```python +def test_specialist_permission_does_not_grant_manager_access(): + assert require_market_menu_permission( + user_id="u1", tenant_no="t1", required="mini:market:manager:presign", + granted={"mini:market:specialist:presign"}, + ) is False + +def test_mobile_menu_query_uses_current_tenant(): + items = list_mobile_menu_items(db, user_id="u1", tenant_no="tenant-b") + assert {item["id"] for item in items} == {"tenant-b-specialist-presign"} +``` + +- [x] **Step 2: 运行测试并确认因实现缺失失败** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_mobile_permissions.py tests/test_mini_menu.py -q` + +Expected: FAIL,提示 `mobile_menu_guard` 或租户化参数不存在。 + +- [x] **Step 3: 实现严格权限依赖** + +`mobile_menu_guard.py` 提供: + +```python +def require_market_menu_code(menu_code: str): + async def dependency(user=Depends(get_current_user), db=Depends(get_db)): + tenant_no = str(user.get("tenant_no") or "").strip() + user_id = str(user.get("user_id") or "").strip() + if not tenant_no or not user_id: + raise HTTPException(status_code=403, detail="用户租户或身份信息缺失") + if user.get("role") != "super_admin" and not user_has_tenant_menu_perm( + db, user_id=user_id, tenant_no=tenant_no, perm_code=menu_code + ): + raise HTTPException(status_code=403, detail="无移动端菜单权限") + return user + return dependency +``` + +把 `get_effective_role_ids()`、`iter_user_menu_perm_codes()` 扩展为显式接收 `tenant_no`,兼容原调用方但新移动链路禁止回退 `DEFAULT_TENANT_NO`。`mini_menu.py` 从认证结果读取当前租户,并返回直属目录的 `groupTitle`;目录无 path 时不输出。 + +- [x] **Step 4: 验证权限、职位继承角色和跨租户菜单** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_mobile_permissions.py tests/test_mini_menu.py tests/test_effective_roles.py -q` + +Expected: PASS。 + +### Task 2: 集中 specialist/manager 现场 scope 服务 + +**Files:** +- Create: `backend/app/services/market_field_scope.py` +- Create: `backend/tests/test_market_field_scope.py` +- Modify: `backend/app/core/data_scope.py`(仅在需要提供可组合 predicate 时修改) + +- [x] **Step 1: 写 scope 失败测试** + +覆盖以下最小事实: + +```python +def test_specialist_matches_sales_user_only(): ... +def test_manager_matches_leader_region_or_document_manager(): ... +def test_manager_ignores_non_leader_staff_rows(): ... +def test_soft_deleted_or_cross_tenant_region_never_matches(): ... +def test_dual_role_contexts_do_not_union_specialist_and_manager_scope(): ... +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_field_scope.py -q` + +Expected: FAIL,服务尚不存在。 + +- [x] **Step 3: 实现不可变上下文和查询组合器** + +```python +@dataclass(frozen=True) +class MarketFieldContext: + tenant_no: str + user_id: str + scope_kind: Literal["specialist", "manager"] + +def leader_region_ids_query(db: Session, ctx: MarketFieldContext): ... +def apply_presign_scope(query, db: Session, ctx: MarketFieldContext): ... +def apply_cell_protocol_scope(query, db: Session, ctx: MarketFieldContext): ... +def apply_donation_scope(query, db: Session, ctx: MarketFieldContext): ... +def assert_presign_access(...): ... +def assert_cell_protocol_access(...): ... +def assert_donation_access(...): ... +``` + +所有 predicate 必须包含 `tenant_no` 与 `del_flag=0`;manager 条件必须是 `leader region OR region_manager_user_id=current user`。服务同时提供将通用 `apply_data_scope()` 与现场 scope 组合的入口。 + +- [x] **Step 4: 验证 scope 单测** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_field_scope.py tests/test_basic_info_tenant_ownership.py -q` + +Expected: PASS。 + +### Task 3: 预签移动 API + +**Files:** +- Create: `backend/app/api/market_mobile.py` +- Create: `backend/app/schemas/market_mobile.py` +- Create: `backend/app/services/market_presign.py` +- Create: `backend/tests/test_market_presign_api.py` +- Modify: `backend/app/api/__init__.py` +- Modify: `backend/app/main.py` +- Reuse: `backend/app/crud/presign_order.py` + +- [x] **Step 1: 写列表、详情和 IDOR 失败测试** + +```python +def test_specialist_presign_list_only_returns_own_sales_orders(): ... +def test_manager_presign_list_uses_region_or_manager_union(): ... +def test_presign_detail_rejects_guessed_out_of_scope_id(): ... +def test_specialist_create_forces_current_sales_user(): ... +def test_update_rejects_resulting_assignment_outside_scope(): ... +def test_convert_rechecks_scope_and_keeps_expected_staff_mapping(): ... +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_presign_api.py -q` + +Expected: FAIL,移动路由不存在。 + +- [x] **Step 3: 实现两套固定路由和白名单 DTO** + +路由形态: + +```text +GET/POST /api/mini/market/specialist/presign-orders +GET/PUT /api/mini/market/specialist/presign-orders/{id} +POST /api/mini/market/specialist/presign-orders/{id}/convert +GET/POST /api/mini/market/manager/presign-orders +GET/PUT /api/mini/market/manager/presign-orders/{id} +POST /api/mini/market/manager/presign-orders/{id}/convert +``` + +共享 handler 只接收由路由闭包注入的 scope。专员创建强制 `sales_user_id=current user`;经理创建/修改后的 `region_id/region_manager_user_id` 必须仍命中 manager scope。状态只读,不开放删除和财务确认动作。 + +- [x] **Step 4: 验证预签与既有回归** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_presign_api.py tests/test_presign_order.py -q` + +Expected: PASS。 + +### Task 4: 协议(细胞存储 / 科研捐赠)移动 API + +**Files:** +- Create: `backend/app/services/market_protocol.py` +- Create: `backend/tests/test_market_protocol_api.py` +- Modify: `backend/app/api/market_mobile.py` +- Modify: `backend/app/schemas/market_mobile.py` +- Reuse: `backend/app/crud/collection_protocol.py` +- Reuse: `backend/app/crud/research_donation.py` + +- [x] **Step 1: 写双来源列表、详情、创建更新失败测试** + +```python +def test_protocol_list_combines_cell_and_donation_without_scope_leak(): ... +def test_cell_protocol_detail_rechecks_specialist_scope(): ... +def test_donation_detail_rechecks_manager_scope(): ... +def test_specialist_create_forces_sales_user_in_collection_info(): ... +def test_manager_update_cannot_move_document_out_of_scope(): ... +def test_mobile_protocol_api_does_not_expose_audit_void_refund_or_conversion(): ... +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_protocol_api.py -q` + +Expected: FAIL,协议移动服务不存在。 + +- [x] **Step 3: 实现统一移动 DTO 与两来源适配器** + +提供 `source=cell_storage|research_donation`,但 scope 固定由路由决定。列表返回移动卡片字段;详情返回基本、采集、客户、样本、缴费(仅细胞)、附件分区。写入只允许设计内字段;拒绝 `auditStatus/protocolStatus/delFlag/refund/finance*` 等越权字段。专员采集信息强制当前用户,经理写入结果须仍在 manager scope。 + +- [x] **Step 4: 验证协议回归** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_protocol_api.py tests/test_collection_protocol_save_bundle.py tests/test_collection_protocol_conventions.py -q` + +Expected: PASS。 + +### Task 5: 催缴与缴费移动 API + +**Files:** +- Create: `backend/app/services/market_payment.py` +- Create: `backend/tests/test_market_payment_api.py` +- Modify: `backend/app/api/market_mobile.py` +- Modify: `backend/app/schemas/market_mobile.py` +- Reuse: `backend/app/crud/protocol_sample_payment.py` +- Reuse: `backend/app/services/payment_chase.py` + +- [x] **Step 1: 写催缴/缴费范围与动作白名单失败测试** + +```python +def test_chase_is_overdue_union_next_30_days_with_parent_scope(): ... +def test_client_sales_user_filter_cannot_expand_scope(): ... +def test_payment_detail_rejects_out_of_scope_sample(): ... +def test_market_can_save_submit_and_withdraw_transfer(): ... +def test_market_cannot_confirm_reject_invoice_or_refund(): ... +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_payment_api.py -q` + +Expected: FAIL。 + +- [x] **Step 3: 实现父协议 scope 下的有限缴费动作** + +催缴只提供查询与跳转标识,不创建“催缴编辑实体”。缴费允许查看、登记转账草稿、提交财务确认、确认中撤回;财务确认/拒绝、退款确认和开票路由不存在。任何 `salesUserId/fieldScope/tenantNo` 客户端参数不得改变授权范围。 + +- [x] **Step 4: 验证缴费回归** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_payment_api.py tests/test_payment_chase.py tests/test_protocol_sample_payment.py tests/test_payment_transfer_delete.py -q` + +Expected: PASS。 + +### Task 6: 样本轻量移动 API + +**Files:** +- Create: `backend/app/services/market_sample.py` +- Create: `backend/tests/test_market_sample_api.py` +- Modify: `backend/app/api/market_mobile.py` +- Modify: `backend/app/schemas/market_mobile.py` +- Reuse: `backend/app/crud/protocol_sample_collection.py` +- Reuse: `backend/app/crud/research_donation_sample_collection.py` +- Reuse: `backend/app/services/sample_info_list_enrich.py` + +- [x] **Step 1: 写双来源样本与字段白名单失败测试** + +```python +def test_sample_list_resolves_parent_and_applies_field_scope(): ... +def test_sample_detail_uses_tenant_and_sample_code_for_downstream_match(): ... +def test_lightweight_update_accepts_market_fields_only(): ... +def test_lightweight_update_rejects_qc_production_lab_code_and_status(): ... +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_sample_api.py -q` + +Expected: FAIL。 + +- [x] **Step 3: 实现只读进度与轻量办理白名单** + +列表/详情统一返回细胞与科研来源、样本编码、采集/接收/质检/入库进度。跨表匹配遵循 `tenant_no + sample_code`。写 DTO 只开放市场侧时间、人员、附件和备注等已确认轻量字段;禁止修改质检结论、生产结果、实验室码和样本状态。 + +- [x] **Step 4: 验证样本回归** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_sample_api.py tests/test_sample_info_progress.py tests/test_sample_info_handle.py -q` + +Expected: PASS。 + +### Task 7: 运费批次可见性、明细脱敏与写校验 + +**Files:** +- Create: `backend/app/services/market_freight.py` +- Create: `backend/tests/test_market_freight_scope.py` +- Modify: `backend/app/api/market_mobile.py` +- Modify: `backend/app/schemas/market_mobile.py` +- Reuse: `backend/app/crud/sample_freight.py` + +- [x] **Step 1: 写混合批次隐私失败测试** + +```python +def test_specialist_sees_batch_when_own_item_exists_but_only_own_items(): ... +def test_specialist_response_omits_total_linked_sample_count(): ... +def test_keyword_matching_other_specialist_item_does_not_reveal_batch(): ... +def test_specialist_cannot_link_or_unlink_other_users_sample(): ... +def test_specialist_cannot_update_shared_batch_header(): ... +def test_manager_sees_only_manager_scope_items_with_full_fields(): ... +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_freight_scope.py -q` + +Expected: FAIL。 + +- [x] **Step 3: 实现 SQL 层过滤和有限写能力** + +专员批次存在性由“至少一个自己可见明细”决定;详情 SQL 只加载本人明细;响应使用 `visibleItemCount`,不返回全批次 `linkedSampleCount`。专员可新建、关联、解绑自己样本,但不可更新共享批次头。经理仅可查看和维护 manager scope 内明细;批次中其他地区明细不可泄露。 + +- [x] **Step 4: 验证运费与现有 CRUD 回归** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_freight_scope.py tests/test_sample_freight.py -q` + +Expected: PASS。 + +### Task 8: 菜单 SQL 与小程序受控市场 web-view + +**Files:** +- Create: `docs/sql/migrations/2026-10-10-market-mobile-menus.sql` +- Create: `mobile-h5/utils/market-h5.js` +- Create: `mobile-h5/utils/market-h5.test.js` +- Create: `mobile-h5/pages/market/webview/index.vue` +- Modify: `mobile-h5/pages.json` +- Modify: `mobile-h5/utils/menu.js` +- Modify: `mobile-h5/components/MenuGrid.vue` +- Modify: `mobile-h5/styles/components/menu-grid.scss` +- Modify: `mobile-h5/utils/oa-navigate-back.js` + +- [x] **Step 1: 写十二入口白名单与 icon 失败测试** + +```js +assert.equal(resolveMarketEntry('specialist-presign').path, '/market/specialist/presign') +assert.equal(resolveMarketEntry('manager-freight').path, '/market/manager/freight') +assert.equal(resolveMarketEntry('https://evil.example'), null) +assert.equal(resolveMarketEntry('../manager/freight'), null) +assert.equal(normalizeMenu({ icon: '' }).icon, '') +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd mobile-h5 && node --test utils/market-h5.test.js` + +Expected: FAIL,模块不存在。 + +- [x] **Step 3: 实现固定 entry 映射和 web-view** + +`market-h5.js` 只导出十二个固定 entry,使用 `OA_H5_ORIGIN`、token 与验证后的 `returnPath` 构造 URL。web-view 不接受任意 URL;无 token 走 `silentWechatLogin`;未知 entry 提示后返回首页。菜单保留显式空 icon,宫格不渲染 `VanIcon`,显示 `groupTitle` 角色标签。 + +SQL 使用幂等 `INSERT ... SELECT ... WHERE NOT EXISTS`,目录 path 为空,叶子 path 为固定 `entry`,权限码分别为 `mini:market:{role}:{module}`。菜单属于系统种子 DML;本仓库全量业务表基线不包含 `sys_*` 种子,因此只维护迁移脚本,不向 `docs/sql/zkxw.sql` 追加孤立菜单记录。 + +- [x] **Step 4: 验证 Node、菜单 API 与 SQL 静态检查** + +Run: `cd mobile-h5 && node --test utils/market-h5.test.js utils/oa-corner-index.test.js` + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_mini_menu.py -q` + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest tests/test_market_mobile_menu_sql.py -q` + +Expected: 测试 PASS,SQL 中十二个叶子权限码全部存在。 + +### Task 9: H5 十二路由、公共组件与只读页面 + +**Files:** +- Create: `mobile-oa-h5/src/market/routes.js` +- Create: `mobile-oa-h5/src/market/routes.test.js` +- Create: `mobile-oa-h5/src/api/market.js` +- Create: `mobile-oa-h5/src/market/components/MarketPageShell.vue` +- Create: `mobile-oa-h5/src/market/components/MarketListCard.vue` +- Create: `mobile-oa-h5/src/market/components/MarketDetailSection.vue` +- Create: `mobile-oa-h5/src/market/components/MarketBottomBar.vue` +- Create: `mobile-oa-h5/src/views/market/MarketModuleList.vue` +- Create: `mobile-oa-h5/src/views/market/MarketModuleDetail.vue` +- Create: `mobile-oa-h5/src/styles/market.css` +- Modify: `mobile-oa-h5/src/router.js` +- Modify: `mobile-oa-h5/src/main.js` + +- [x] **Step 1: 写十二静态入口与 scope 不串失败测试** + +```js +assert.equal(routes.length, 12) +assert.equal(find('/market/specialist/presign').meta.marketRole, 'specialist') +assert.equal(find('/market/manager/presign').meta.marketRole, 'manager') +assert.deepEqual(new Set(routes.map((r) => r.meta.module)), + new Set(['presign', 'protocol', 'payment-chase', 'payment', 'sample', 'freight'])) +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd mobile-oa-h5 && node --test src/market/routes.test.js` + +Expected: FAIL。 + +- [x] **Step 3: 实现公共移动列表与详情骨架** + +路由 meta 固定角色和模块,API 路径从 meta 映射,不读取 query 中的 scope。列表支持搜索、筛选、分页、加载/空/错误态;详情使用分区卡片和状态 Tag;底栏仅呈现后端返回的 `allowedActions`。市场样式局部使用三墙主色 `#0a9f6d`,不修改 OA 全局 token,也不引入 web-admin Fresh Mint 主色。 + +- [x] **Step 4: 验证路由和生产构建** + +Run: `cd mobile-oa-h5 && node --test src/market/routes.test.js` + +Run: `cd mobile-oa-h5 && npm run build` + +Expected: PASS,Vite build exit 0。 + +### Task 10: 六模块表单与有限业务动作 + +**Files:** +- Create: `mobile-oa-h5/src/views/market/MarketModuleForm.vue` +- Create: `mobile-oa-h5/src/market/modules/presign.js` +- Create: `mobile-oa-h5/src/market/modules/protocol.js` +- Create: `mobile-oa-h5/src/market/modules/payment-chase.js` +- Create: `mobile-oa-h5/src/market/modules/payment.js` +- Create: `mobile-oa-h5/src/market/modules/sample.js` +- Create: `mobile-oa-h5/src/market/modules/freight.js` +- Create: `mobile-oa-h5/src/market/module-actions.test.js` +- Modify: `mobile-oa-h5/src/market/routes.js` +- Modify: `mobile-oa-h5/src/api/market.js` + +- [x] **Step 1: 写每模块动作白名单失败测试** + +```js +assert.deepEqual(actions('payment'), ['view', 'register-transfer', 'submit-finance', 'withdraw']) +assert.equal(actions('payment').includes('finance-confirm'), false) +assert.equal(actions('protocol').includes('audit'), false) +assert.equal(actions('sample').includes('qc-conclusion'), false) +assert.equal(actions('freight', 'specialist').includes('edit-shared-header'), false) +``` + +- [x] **Step 2: 运行测试确认 RED** + +Run: `cd mobile-oa-h5 && node --test src/market/module-actions.test.js` + +Expected: FAIL。 + +- [x] **Step 3: 实现模块配置和单列移动表单** + +预签:分区表单、意向金子卡、转正式。协议:细胞/科研 Tab,采集→客户→样本→缴费(仅细胞)→附件/确认。催缴:只读卡片并跳登记缴费。缴费:应缴期与转账登记表单。样本:进度详情与轻量办理白名单。运费:批次卡片、可见样本选择、关联/解绑;经理显示后端允许的批次头编辑。 + +所有按钮防重复提交;附件立即上传;提交中禁用;长表单返回前提示未保存;后端 401 提示返回小程序重新登录,403/404 使用不泄露对象存在性的文案。 + +- [x] **Step 4: 验证动作和构建** + +Run: `cd mobile-oa-h5 && node --test src/market/module-actions.test.js src/market/routes.test.js` + +Run: `cd mobile-oa-h5 && npm run build` + +Expected: PASS。 + +### Task 11: 全链路回归、字段一致性、知识图谱与总结 + +**Files:** +- Create: `docs/superpowers/specs/2026-10-10-market-miniapp-role-features-implementation-summary.md` +- Modify: `docs/superpowers/plans/2026-10-10-market-miniapp-role-features.md`(勾选完成项并记录实测命令) +- Generated: `backend/graphify-out/*` + +- [x] **Step 1: 后端定向回归** + +Run: + +```bash +cd backend +PYTHONPATH=. venv/bin/python -m pytest \ + tests/test_market_mobile_permissions.py \ + tests/test_mini_menu.py \ + tests/test_market_field_scope.py \ + tests/test_market_presign_api.py \ + tests/test_market_protocol_api.py \ + tests/test_market_payment_api.py \ + tests/test_market_sample_api.py \ + tests/test_market_freight_scope.py -q +``` + +Expected: 全部 PASS。 + +2026-10-11 实测:计划内 8 个市场测试文件 `215 passed`;另补 3 个 SQL 测试文件 `11 passed`。 + +- [x] **Step 2: 后端全量测试与字段一致性** + +Run: `cd backend && PYTHONPATH=. venv/bin/python -m pytest -q` + +Run: `cd backend && PYTHONPATH=. venv/bin/python scripts/check_field_consistency.py` + +Expected: exit 0;若存在与本任务无关的基线失败,在总结中逐项记录,不得宣称全量通过。 + +2026-10-11 最终实测:全量 `912 passed, 4 failed`,4 项均为已确认既有基线;字段一致性 `0 errors, 5 warnings`,脚本 exit 1,且不覆盖本次 market DTO。详见实现总结。 + +- [x] **Step 3: 移动端测试与构建** + +Run: `cd mobile-h5 && node --test utils/*.test.js utils/*.test.mjs` + +Run: `cd mobile-oa-h5 && node --test $(find src -name '*.test.js' -print)` + +Run: `cd mobile-oa-h5 && npm run build` + +Expected: 全部 exit 0。 + +2026-10-11 最终实测:`mobile-h5 5/5 passed`;`mobile-oa-h5 62/62 passed`;Vite 构建 exit 0,仅有既有大 chunk 警告。 + +- [ ] **Step 4: 手工验收矩阵** + +2026-10-11:未执行真实浏览器多视口、微信开发者工具和真实账号/MinIO 联调;自动路由、入口白名单、测试与构建已通过。该步骤保留待测试环境手工验收,不宣称通过。 + +在 375px、390px、430px 视口以及微信开发者工具中验证: + +1. 只授权专员时只出现专员六项;只授权经理时反之;兼岗两组可辨。 +2. 十二入口均进入 `mobile-oa-h5`,未知 entry 无法打开任意 URL。 +3. 专员与经理列表、详情、编辑 scope 不串;猜测 ID 返回 404/403。 +4. 细胞/科研协议表单字段分流正确;催缴不出现虚假“编辑催缴”。 +5. 缴费无财务确认/开票/退款动作;样本无质检/生产动作。 +6. 混合运费批次不泄露他人明细或总数量;专员不能改共享批次头。 +7. 键盘、安全区、固定底栏、返回宿主、token 失效处理正常。 + +- [x] **Step 5: 更新代码知识图谱** + +Run: `./scripts/update-graph.sh backend` + +Expected: backend 图谱生成成功;本任务未修改 `web-admin`,无需更新 frontend 图谱。 + +2026-10-11 实测:exit 0,最终图谱为 `6122 nodes / 33427 edges / 40 communities`。 + +- [x] **Step 6: 写实现总结** + +总结文档必须包含:架构与安全边界、菜单与路由清单、六模块实际开放动作、scope 规则、运费脱敏、SQL 迁移步骤、部署/回滚、自动测试结果、手工验收结果、已知限制与后续 icon 任务。不得使用 TBD/TODO。 + +2026-10-11 已生成 `docs/superpowers/specs/2026-10-10-market-miniapp-role-features-implementation-summary.md`。 + +## 实施约束 + +- 所有生产代码遵循 RED → GREEN → REFACTOR;未观察到目标测试失败前不得写实现。 +- 子代理每次只负责一个任务域;完成后先做规格符合性审查,再做代码质量审查。 +- 不修改或提交当前工作区中与本任务无关的未跟踪文件。 +- 不直接放宽全局 `require_menu_code()`;移动市场权限使用专用严格守卫。 +- 不信任客户端的 tenant、scope、sales/manager 身份覆盖字段。 +- 不把桌面 Element Plus 页面复制进移动 H5。 +- 新增/修改后端文件、函数签名或 import 后必须执行 `./scripts/update-graph.sh backend`。 diff --git a/docs/superpowers/plans/2026-10-11-market-attachment-parity-implementation.md b/docs/superpowers/plans/2026-10-11-market-attachment-parity-implementation.md new file mode 100644 index 00000000..43ef0003 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-market-attachment-parity-implementation.md @@ -0,0 +1,273 @@ +# Market Attachment Parity 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:** Remove the floating round back button from every market detail page and render market attachments with the workflow-standard thumbnail, file-icon, download, and editable-delete experience. + +**Architecture:** Add a pure attachment normalizer and one shared market attachment-card component. Form fields and presign uploads retain their current upload APIs but delegate display, preview, download, and remove interactions to the shared component; detail adapters expose typed attachment fields consumed by `MarketDetailSection.vue`. + +**Tech Stack:** Vue 3 Composition API, Vant 4, native Node test runner, Vite 6. + +--- + +## File map + +- Create `mobile-oa-h5/src/market/attachment-presenter.js`: normalize supported attachment shapes, derive safe display names, image classification, and workflow file kinds. +- Create `mobile-oa-h5/src/market/attachment-presenter.test.js`: unit coverage for strings, arrays, object metadata, image detection, and file kinds. +- Create `mobile-oa-h5/src/market/components/MarketAttachmentCards.vue`: workflow-standard 84×84 rendering, upload trigger, image preview, file download, and editable remove event. +- Modify `mobile-oa-h5/src/market/components/MarketFormField.vue`: replace raw URL anchors with the shared component while preserving comma-separated form values. +- Modify `mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue`: reuse the shared renderer and preserve existing upload/remove events. +- Modify `mobile-oa-h5/src/market/components/MarketDetailSection.vue`: support `type: 'attachments'` in top-level and nested detail fields. +- Modify `mobile-oa-h5/src/market/modules/protocol.js`: expose protocol attachment values as typed attachments instead of joined path text. +- Modify `mobile-oa-h5/src/market/modules/payment.js`: expose transfer vouchers as typed attachments. +- Modify `mobile-oa-h5/src/market/modules/sample.js`: expose handling attachments as typed attachments. +- Modify `mobile-oa-h5/src/market/modules/presign.js`: expose presign transfer attachments as typed attachments. +- Modify `mobile-oa-h5/src/views/market/MarketModuleDetail.vue`: disable the detail-page back control. +- Modify `mobile-oa-h5/src/market/mobile-parity-ui.test.js`: lock the visual and detail attachment contracts. +- Modify `mobile-oa-h5/src/market/module-contracts.test.js`: lock typed attachment fields across adapters. + +### Task 1: Pure attachment normalization + +**Files:** +- Create: `mobile-oa-h5/src/market/attachment-presenter.js` +- Create: `mobile-oa-h5/src/market/attachment-presenter.test.js` + +- [ ] **Step 1: Write failing unit tests** + +Cover these public functions: + +```js +normalizeMarketAttachments('https://cdn/a%20b.pdf,https://cdn/photo.png') +// => [{ url: '...', name: 'a b.pdf', isImage: false, kind: 'pdf' }, ...] + +normalizeMarketAttachments([ + { fileUrl: '/minio/x/report.xlsx', fileName: '费用明细.xlsx' } +]) +// preserves fileName and raw URL + +marketAttachmentKind('archive.zip') // => 'zip' +isMarketAttachmentImage({ name: 'photo.webp' }) // => true +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cd mobile-oa-h5 +node --test src/market/attachment-presenter.test.js +``` + +Expected: FAIL because `attachment-presenter.js` does not exist. + +- [ ] **Step 3: Implement the minimal pure utility** + +Implement safe URL decoding, comma splitting, array/object normalization, these image extensions: + +```js +const IMAGE_RE = /\.(?:jpe?g|png|gif|bmp|webp|svg|jfif|avif|heic|heif)(?:\?|$)/i +``` + +and these file kinds: + +```js +pdf: pdf +xls: xls, xlsx, csv +doc: doc, docx, ppt, pptx +zip: zip, rar, 7z +other: every remaining file +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the command from Step 2. Expected: all tests pass. + +### Task 2: Shared workflow-standard attachment cards + +**Files:** +- Create: `mobile-oa-h5/src/market/components/MarketAttachmentCards.vue` +- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js` + +- [ ] **Step 1: Write failing component-contract assertions** + +Assert the component contains: + +```js +assert.match(source, /normalizeMarketAttachments/) +assert.match(source, /downloadAttachmentFile/) +assert.match(source, /showImagePreview/) +assert.match(source, /market-attachment-card__delete/) +assert.match(source, /@error=/) +assert.match(source, /84px/) +``` + +Also assert file-icon kinds `ft-pdf`, `ft-xls`, `ft-doc`, `ft-zip`, and `ft-other` exist. + +- [ ] **Step 2: Run the contract test and verify RED** + +```bash +cd mobile-oa-h5 +node --test src/market/mobile-parity-ui.test.js +``` + +Expected: FAIL because the component does not exist. + +- [ ] **Step 3: Implement the shared component** + +Required props and events: + +```js +defineProps({ + modelValue: { type: [Array, String, Object], default: '' }, + editable: { type: Boolean, default: false }, + uploading: { type: Boolean, default: false }, + maxSize: { type: Number, default: MARKET_ATTACHMENT_MAX_BYTES }, + accept: { type: String, default: MARKET_ATTACHMENT_ACCEPT } +}) +defineEmits(['upload', 'remove']) +``` + +Use `van-uploader` for the first add card. Match backend validation: 10 MiB by default, 20 MiB for presign, and only JPG/JPEG, PNG, WebP, HEIC/HEIF, PDF, DOC/DOCX, XLS/XLSX. Use image preview through `showImagePreview`, and non-image download through `downloadAttachmentFile`. Image `error` changes that card to the `other` file icon. Copy the approved 84×84 workflow styling and keep a visually small delete button with an unclipped 44×44px interactive hit area. When a name was inferred from a URL, omit `fileName` from the download request so the backend restores the stored original name. + +- [ ] **Step 4: Run the contract test and verify GREEN** + +Run the command from Step 2. Expected: all assertions pass. + +### Task 3: Form integration and editable deletion + +**Files:** +- Modify: `mobile-oa-h5/src/market/components/MarketFormField.vue` +- Modify: `mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue` +- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js` + +- [ ] **Step 1: Add failing integration contracts** + +Assert both form components import and render `MarketAttachmentCards`. Assert `MarketFormField.vue` updates a comma-separated URL list on remove, and `PresignAttachmentUploader.vue` forwards `upload` and `remove` events. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +cd mobile-oa-h5 +node --test src/market/mobile-parity-ui.test.js +``` + +Expected: FAIL because both components still use their separate raw-card implementations. + +- [ ] **Step 3: Integrate the shared component** + +For generic fields: + +```vue + +``` + +`removeAttachment(index)` must normalize the current value, remove the selected item, then emit a comma-separated URL string. For presign attachments, preserve the current array value and existing parent-owned `remove(index)` behavior. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the command from Step 2. Expected: all assertions pass. + +### Task 4: Read-only detail attachments and back-button removal + +**Files:** +- Modify: `mobile-oa-h5/src/market/components/MarketDetailSection.vue` +- Modify: `mobile-oa-h5/src/market/modules/protocol.js` +- Modify: `mobile-oa-h5/src/market/modules/payment.js` +- Modify: `mobile-oa-h5/src/market/modules/sample.js` +- Modify: `mobile-oa-h5/src/market/modules/presign.js` +- Modify: `mobile-oa-h5/src/views/market/MarketModuleDetail.vue` +- Modify: `mobile-oa-h5/src/market/module-contracts.test.js` +- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js` + +- [ ] **Step 1: Write failing detail contracts** + +Assert: + +```js +assert.match(detailView, /:backable="false"/) +assert.doesNotMatch(detailView, /@back="backToList"/) +``` + +For each adapter, build representative details and verify every attachment-bearing field uses: + +```js +{ label: '...', type: 'attachments', value: originalAttachmentValue } +``` + +Also assert `MarketDetailSection.vue` renders `MarketAttachmentCards` with `:editable="false"` for top-level, nested, and subcard fields. + +- [ ] **Step 2: Run both focused tests and verify RED** + +```bash +cd mobile-oa-h5 +node --test src/market/mobile-parity-ui.test.js src/market/module-contracts.test.js +``` + +Expected: FAIL because detail fields still display attachment paths and the detail view still enables the floating back button. + +- [ ] **Step 3: Implement typed attachment detail fields** + +Preserve the original attachment value in every adapter and add `type: 'attachments'`. Extend `normalizeCard()` and top-level field normalization in `MarketDetailSection.vue` to retain the type. Render the shared component when the type matches, otherwise keep the existing tag/text behavior. + +Change the detail shell to: + +```vue + +``` + +Remove the now-unused `backToList` handler only if no other code path calls it; otherwise retain the handler but remove the template listener. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run the command from Step 2. Expected: all assertions pass. + +### Task 5: Full verification and graph impact check + +**Files:** +- Verify all modified files above. + +- [ ] **Step 1: Run all mobile Node tests** + +```bash +cd mobile-oa-h5 +node --test $(rg --files src | rg '\.test\.js$') +``` + +Expected: zero failures. + +- [ ] **Step 2: Run the production build** + +```bash +cd mobile-oa-h5 +pnpm build +``` + +Expected: Vite build exits with code 0. + +- [ ] **Step 3: Check formatting and unintended whitespace** + +```bash +git diff --check +``` + +Expected: no output and exit code 0. + +- [ ] **Step 4: Inspect the final diff against the design** + +Verify all acceptance criteria from `docs/superpowers/specs/2026-10-11-market-attachment-parity-design.md`, especially that no raw attachment path is rendered in the four attachment-bearing detail adapters. + +- [ ] **Step 5: Skip knowledge-graph regeneration** + +This change modifies Vue templates, local CSS, and pure presentation helpers without changing function signatures or import relationships outside `mobile-oa-h5`; the repository knowledge-graph rule only has generated graphs for `backend` and `web-admin`, so no graph update command applies. diff --git a/docs/superpowers/plans/2026-10-11-market-list-card-right-spacing.md b/docs/superpowers/plans/2026-10-11-market-list-card-right-spacing.md new file mode 100644 index 00000000..3b4e0d96 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-market-list-card-right-spacing.md @@ -0,0 +1,117 @@ +# Market List Card Right Spacing 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:** 修复市场部通用列表卡片右侧溢出遮挡,并确认 12 个市场入口的卡片布局均受到回归保护。 + +**Architecture:** 保留现有通用卡片的 `width: 100%` 和间距,通过 `box-sizing: border-box` 将 padding 与 border 纳入宽度计算。预签约页面使用独立卡片,不修改其布局;回归测试同时校验通用卡片盒模型、预签约卡片宽度约束和 12 个路由覆盖。 + +**Tech Stack:** Vue 3, Vant 4, CSS, Node.js `node:test`, Vite + +--- + +### Task 1: Add a failing card-width regression test + +**Files:** +- Modify: `mobile-oa-h5/src/market/mobile-parity-ui.test.js` +- Test: `mobile-oa-h5/src/market/mobile-parity-ui.test.js` + +- [x] **Step 1: Write the failing test** + +Add a test that reads `styles/market.css` and `routes.js`, then asserts: + +```js +test('all twelve market list entries keep cards inside the horizontal content area', () => { + const styles = source('../styles/market.css') + const routes = source('./routes.js') + + assert.match(styles, /\.market-list-card\s*\{[\s\S]*?box-sizing:\s*border-box;[\s\S]*?width:\s*100%;/) + assert.match(styles, /\.presign-order-card\s*\{[\s\S]*?overflow:\s*hidden;/) + assert.match(routes, /export const MARKET_ROLES = \['specialist', 'manager'\]/) + assert.match(routes, /export const MARKET_MODULES = \[[\s\S]*?'presign'[\s\S]*?'protocol'[\s\S]*?'payment-chase'[\s\S]*?'payment'[\s\S]*?'sample'[\s\S]*?'freight'/) +}) +``` + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cd mobile-oa-h5 && node --test --test-name-pattern="all twelve market list entries" src/market/mobile-parity-ui.test.js +``` + +Expected: one failed test because `.market-list-card` does not contain `box-sizing: border-box` before `width: 100%`. + +### Task 2: Apply the minimal CSS fix + +**Files:** +- Modify: `mobile-oa-h5/src/styles/market.css` +- Test: `mobile-oa-h5/src/market/mobile-parity-ui.test.js` + +- [x] **Step 1: Add the width-safe box model** + +Update the existing rule to begin with: + +```css +.market-list-card { + box-sizing: border-box; + width: 100%; +``` + +- [x] **Step 2: Run the focused test and verify GREEN** + +Run: + +```bash +cd mobile-oa-h5 && node --test --test-name-pattern="all twelve market list entries" src/market/mobile-parity-ui.test.js +``` + +Expected: the focused test passes. + +### Task 3: Verify the market UI suite, build, and dependency graph + +**Files:** +- Verify: `mobile-oa-h5/src/market/*.test.js` +- Verify: `mobile-oa-h5/src/styles/market.css` +- Update generated graph: `web-admin/graphify-out/*` only if the project graph script maps `mobile-oa-h5` into the frontend graph + +- [x] **Step 1: Run all market UI tests** + +Run: + +```bash +cd mobile-oa-h5 && node --test src/market/*.test.js +``` + +Expected: zero failed tests. + +- [x] **Step 2: Run the production build** + +Run: + +```bash +cd mobile-oa-h5 && pnpm build +``` + +Expected: Vite exits with code 0. + +- [x] **Step 3: Update the frontend knowledge graph** + +Run: + +```bash +./scripts/update-graph.sh frontend +``` + +Expected: graph generation exits with code 0. Preserve generated changes only when the script includes the modified mobile frontend files. + +- [x] **Step 4: Check the final diff** + +Run: + +```bash +git diff --check -- mobile-oa-h5/src/styles/market.css mobile-oa-h5/src/market/mobile-parity-ui.test.js docs/superpowers/plans/2026-10-11-market-list-card-right-spacing.md +git status --short +``` + +Expected: no whitespace errors; only the intended incremental edits are attributed to this fix. diff --git a/docs/superpowers/plans/2026-10-11-market-reference-labels.md b/docs/superpowers/plans/2026-10-11-market-reference-labels.md new file mode 100644 index 00000000..4bc0e2e1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-market-reference-labels.md @@ -0,0 +1,145 @@ +# Market Reference Labels 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:** Ensure every Market mobile list, detail, create, and edit view displays Chinese business labels instead of internal user IDs, foreign-key IDs, or dictionary codes. + +**Architecture:** Keep backend permission-scoped option lists unchanged, but hydrate each edit form with the current record's authoritative `{ value, label }` snapshots so historical values remain readable. Centralize frontend display fallback rules so unresolved references render `未知人员` or `—`, never raw IDs/codes, and enrich sample handling responses with handler names at the backend boundary. + +**Tech Stack:** Vue 3, Vant, JavaScript Node test runner, FastAPI, SQLAlchemy, pytest. + +--- + +### Task 1: Add reference-label regression coverage + +**Files:** +- Create: `mobile-oa-h5/src/market/reference-options.test.js` +- Modify: `mobile-oa-h5/src/market/module-contracts.test.js` +- Modify: `mobile-oa-h5/src/market/list-presenter.test.js` +- Modify: `backend/tests/test_market_sample_api.py` + +- [ ] **Step 1: Write failing frontend tests** + +Add tests proving selected user, region, hospital, sample type, sample code, and unit values are merged into scoped option sets from detail label fields. Assert protocol/sample/freight detail presenters never fall back to IDs or dictionary codes. + +- [ ] **Step 2: Run the focused frontend tests and verify RED** + +Run: `node --test src/market/reference-options.test.js src/market/module-contracts.test.js src/market/list-presenter.test.js` + +Expected: FAIL because reference-option hydration and safe label fallbacks do not yet exist. + +- [ ] **Step 3: Write failing backend tests** + +Extend sample API tests so `get_sample_detail` returns `handlerUserName` for storage-certificate and insurance summaries and `get_sample_form_options` retains the currently selected handler as a readable option without widening normal region staff choices. + +- [ ] **Step 4: Run the focused backend tests and verify RED** + +Run: `backend/venv/bin/pytest backend/tests/test_market_sample_api.py -q` + +Expected: FAIL on missing handler display names/current selected options. + +### Task 2: Centralize form reference-option hydration + +**Files:** +- Create: `mobile-oa-h5/src/market/reference-options.js` +- Modify: `mobile-oa-h5/src/views/market/MarketModuleForm.vue` +- Modify: `mobile-oa-h5/src/views/market/presign/PresignOrderForm.vue` + +- [ ] **Step 1: Implement minimal hydration helpers** + +Create pure helpers that merge scoped options with current detail snapshots, deduplicate by normalized value, and only add entries with a real human-readable label. + +- [ ] **Step 2: Hydrate generic Market forms after detail/options load** + +For protocol fields, merge current collector, sales specialist, manager, region, hospital, sample type, sample code, and unit labels. For sample handling, merge the current handler name. Reapply hydration after region cascade option refreshes. + +- [ ] **Step 3: Harden the dedicated pre-sign form** + +Reuse the shared person-option merge behavior and render `未知人员` rather than the raw selected ID if the backend cannot resolve a name. + +- [ ] **Step 4: Run focused frontend tests and verify GREEN** + +Run: `node --test src/market/reference-options.test.js src/market/module-contracts.test.js src/market/list-presenter.test.js src/market/presign/presign-form-state.test.js src/market/presign/presign-form-view.test.js` + +Expected: all tests pass. + +### Task 3: Enrich sample handler names without widening permissions + +**Files:** +- Modify: `backend/app/services/market_sample.py` +- Modify: `backend/tests/test_market_sample_api.py` + +- [ ] **Step 1: Resolve handler names in sample detail summaries** + +Batch-load tenant user display names for storage-certificate and insurance handler IDs and expose them as `handlerUserName`. + +- [ ] **Step 2: Preserve the current handler in edit options** + +Append only the handler already referenced by the accessible sample record when it is outside the active region-staff list; do not expose unrelated tenant users. + +- [ ] **Step 3: Run focused backend tests and verify GREEN** + +Run: `backend/venv/bin/pytest backend/tests/test_market_sample_api.py -q` + +Expected: all tests pass. + +### Task 4: Remove raw ID/code display fallbacks across Market presenters + +**Files:** +- Modify: `mobile-oa-h5/src/market/modules/protocol.js` +- Modify: `mobile-oa-h5/src/market/modules/sample.js` +- Modify: `mobile-oa-h5/src/market/modules/freight.js` +- Modify: `mobile-oa-h5/src/market/list-presenter.js` +- Modify: `backend/app/services/market_freight.py` + +- [ ] **Step 1: Replace person-ID fallbacks** + +Display authoritative name fields, `未知人员` for a non-empty unresolved person reference, and `—` when no reference exists. + +- [ ] **Step 2: Replace unresolved dictionary-code fallbacks** + +For status, source, payment mode, transport mode, sex, and delivery mode, use known Chinese mappings and render `未知` for an unrecognized non-empty code instead of exposing the code. + +- [ ] **Step 3: Keep identifiers that are business numbers** + +Do not alter protocol numbers, sample codes, freight batch numbers, laboratory storage codes, or other user-facing business identifiers. + +- [ ] **Step 4: Run presenter and contract tests** + +Run: `node --test src/market/list-presenter.test.js src/market/module-contracts.test.js src/market/mobile-parity-ui.test.js` + +Expected: all tests pass. + +### Task 5: Full verification and browser audit + +**Files:** +- Modify generated graph files under `backend/graphify-out/` only through the repository script. + +- [ ] **Step 1: Run all mobile Market tests** + +Run: `node --test src/market/*.test.js src/market/presign/*.test.js src/utils/download-attachment.test.js src/utils/http-error.test.js` + +Expected: zero failures. + +- [ ] **Step 2: Build mobile OA H5** + +Run: `npm run build` + +Expected: Vite exits with code 0. + +- [ ] **Step 3: Run affected backend suites** + +Run: `backend/venv/bin/pytest backend/tests/test_market_sample_api.py backend/tests/test_market_protocol_api.py backend/tests/test_market_support_api.py backend/tests/test_market_freight_scope.py -q` + +Expected: zero failures. + +- [ ] **Step 4: Update backend code graph** + +Run: `./scripts/update-graph.sh backend` + +Expected: graph update exits with code 0. + +- [ ] **Step 5: Verify representative pages in the browser** + +Open specialist and manager protocol edit, pre-sign edit, sample handling, payment/chase detail, and freight detail. Confirm reference fields show Chinese labels, no internal UUID/user ID/dictionary code is visible, and valid business identifiers remain unchanged. + diff --git a/docs/superpowers/plans/2026-10-11-market-remaining-mobile-parity-implementation.md b/docs/superpowers/plans/2026-10-11-market-remaining-mobile-parity-implementation.md new file mode 100644 index 00000000..461d4d8c --- /dev/null +++ b/docs/superpowers/plans/2026-10-11-market-remaining-mobile-parity-implementation.md @@ -0,0 +1,48 @@ +# 市场端剩余五模块移动端复刻实施计划 + +> 执行方式:当前会话内直接实施。视觉与交互以 `docs/demos/2026-10-11-market-remaining-mobile-parity-demo.html` 为准。 + +## 目标 + +将市场专员、地区经理的协议单、催缴信息、缴费信息、样本信息、运费信息共 10 个入口改造成与 Web 端字段、动作一致的移动端卡片界面;复用现有 `mobile-oa-h5` 市场模块路由和表单框架,不复制业务逻辑,不制造仅前端可见的假动作。 + +## 实施步骤 + +1. **锁定展示契约** + - 新增列表展示适配器及单元测试,覆盖五模块标题、状态、完整字段、金额格式、角色差异和动作标签。 + - 所有卡片动作与后端返回的 `allowedActions` 取交集,避免越权按钮。 + +2. **补齐后端移动端协议能力** + - 为协议列表返回卡片所需字段。 + - 新增带业务来源参数的协议审核、软删除接口;访问控制继续经过租户、角色、数据范围校验。 + - 扩展协议 `allowedActions`,并以测试覆盖状态限制和路由契约。 + +3. **补齐其余列表摘要** + - 缴费列表增加已缴、待缴、期次进度摘要。 + - 催缴列表补充协议状态。 + - 运费列表携带当前角色可见样本,并对市场专员只展示隐私提示,不泄露隐藏数量。 + +4. **重做移动端列表壳层** + - 去除绿色大头部和状态下拉,只保留白色导航标题与搜索框。 + - 新增动作改为右下角 56px 圆形悬浮按钮。 + - 使用 Vant `List` 滚动触底分页加载,保留首屏失败、追加失败和空状态处理。 + - 卡片展示 Web 端同等信息,并提供详情、编辑、审核、删除及各模块专属动作。 + +5. **表单组件移动化** + - 下拉字段统一改为 Vant Picker;日期时间统一改为 Vant DatePicker/TimePicker。 + - 采集地区变化后清空并重新加载采集医院,实现数据库选项级联。 + - 上传字段改为“上传卡片 + 已上传附件卡片”同排布局,选择后立即上传,支持预览和删除。 + +6. **验证与图谱** + - 运行 `mobile-oa-h5` 市场单测及生产构建。 + - 运行后端市场模块定向测试。 + - 更新后端代码知识图谱(移动端 H5 暂无独立图谱任务)。 + +## 完成标准 + +- 两类角色的五模块列表均采用模板卡片布局,无状态筛选下拉。 +- 有新增权限的页面只显示右下角圆形 `+` 按钮。 +- 列表滚动自动分页,查询会重置分页并重新请求。 +- 列表和详情动作由后端授权,协议审核/删除真实可用。 +- 地区/医院联动、数据库下拉、附件卡片、缴费/样本/运费专属动作均可用。 +- 测试与构建通过,无跨应用主色混用。 diff --git a/docs/superpowers/plans/2026-10-11-mobile-h5-env-banner.md b/docs/superpowers/plans/2026-10-11-mobile-h5-env-banner.md index b35ef640..ed55acf3 100644 --- a/docs/superpowers/plans/2026-10-11-mobile-h5-env-banner.md +++ b/docs/superpowers/plans/2026-10-11-mobile-h5-env-banner.md @@ -28,7 +28,7 @@ - Modify: `mobile-h5/utils/config.js` - Create: `mobile-h5/utils/env-banner.test.js` -- [ ] **Step 1: 先写失败单测** +- [x] **Step 1: 先写失败单测** 创建 `mobile-h5/utils/env-banner.test.js`: @@ -60,7 +60,7 @@ assert.deepEqual(buildEnvBanner('unknown'), { console.log('env-banner.test.js: ok') ``` -- [ ] **Step 2: 跑测试确认失败** +- [x] **Step 2: 跑测试确认失败** Run: @@ -70,7 +70,7 @@ cd mobile-h5 && node utils/env-banner.test.js Expected: FAIL(`buildEnvBanner` 未导出 / 未定义) -- [ ] **Step 3: 实现 `buildEnvBanner` 与 `ENV_BANNER`** +- [x] **Step 3: 实现 `buildEnvBanner` 与 `ENV_BANNER`** 在 `mobile-h5/utils/config.js` 末尾(现有导出之后)增加: @@ -91,7 +91,7 @@ export const ENV_BANNER = buildEnvBanner(APP_ENV) 不要改动 `ENV_PRESETS` 与现有 URL 导出逻辑。若当前 `APP_ENV` 为本地联调值可保持不动(本任务不强制改回 `prod`)。 -- [ ] **Step 4: 跑测试确认通过** +- [x] **Step 4: 跑测试确认通过** Run: @@ -101,7 +101,7 @@ cd mobile-h5 && node utils/env-banner.test.js Expected: 打印 `env-banner.test.js: ok`,exit code 0 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add mobile-h5/utils/config.js mobile-h5/utils/env-banner.test.js @@ -121,7 +121,7 @@ EOF **Files:** - Modify: `mobile-h5/pages/index/index.vue` -- [ ] **Step 1: 模板插入色条** +- [x] **Step 1: 模板插入色条** 在 `` 内、`` **之前**插入: @@ -135,7 +135,7 @@ EOF ``` -- [ ] **Step 2: script 引入并挂到 data/computed** +- [x] **Step 2: script 引入并挂到 data/computed** 在 script 顶部增加: @@ -149,7 +149,7 @@ EOF envBanner: ENV_BANNER, ``` -- [ ] **Step 3: scoped 样式** +- [x] **Step 3: scoped 样式** 在现有 ` diff --git a/mobile-oa-h5/src/market/components/MarketBottomBar.vue b/mobile-oa-h5/src/market/components/MarketBottomBar.vue index 006fb109..fb0845f4 100644 --- a/mobile-oa-h5/src/market/components/MarketBottomBar.vue +++ b/mobile-oa-h5/src/market/components/MarketBottomBar.vue @@ -33,8 +33,6 @@ const ACTION_LABELS = { 'delete-transfer': '删除意向金', 'submit-transfer': '提交财务确认', 'withdraw-transfer': '撤回意向金', - 'storage-cert': '办理储存证', - insurance: '办理保险', link: '关联样本', unlink: '取消运费' } diff --git a/mobile-oa-h5/src/market/components/MarketDetailSection.vue b/mobile-oa-h5/src/market/components/MarketDetailSection.vue new file mode 100644 index 00000000..26d7eb1c --- /dev/null +++ b/mobile-oa-h5/src/market/components/MarketDetailSection.vue @@ -0,0 +1,119 @@ + + + + diff --git a/mobile-oa-h5/src/market/components/MarketFormField.vue b/mobile-oa-h5/src/market/components/MarketFormField.vue new file mode 100644 index 00000000..16985963 --- /dev/null +++ b/mobile-oa-h5/src/market/components/MarketFormField.vue @@ -0,0 +1,97 @@ + + + diff --git a/mobile-oa-h5/src/market/components/MarketFormSection.vue b/mobile-oa-h5/src/market/components/MarketFormSection.vue new file mode 100644 index 00000000..2dcc5f00 --- /dev/null +++ b/mobile-oa-h5/src/market/components/MarketFormSection.vue @@ -0,0 +1,85 @@ + + + diff --git a/mobile-oa-h5/src/market/components/MarketListCard.vue b/mobile-oa-h5/src/market/components/MarketListCard.vue new file mode 100644 index 00000000..0e0f5820 --- /dev/null +++ b/mobile-oa-h5/src/market/components/MarketListCard.vue @@ -0,0 +1,73 @@ + + + diff --git a/mobile-oa-h5/src/market/components/MarketPageShell.vue b/mobile-oa-h5/src/market/components/MarketPageShell.vue new file mode 100644 index 00000000..32478271 --- /dev/null +++ b/mobile-oa-h5/src/market/components/MarketPageShell.vue @@ -0,0 +1,43 @@ + + + + diff --git a/mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue b/mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue new file mode 100644 index 00000000..17a3c740 --- /dev/null +++ b/mobile-oa-h5/src/market/components/PresignAttachmentUploader.vue @@ -0,0 +1,23 @@ + + + diff --git a/mobile-oa-h5/src/market/components/PresignOrderCard.vue b/mobile-oa-h5/src/market/components/PresignOrderCard.vue new file mode 100644 index 00000000..bc0e0839 --- /dev/null +++ b/mobile-oa-h5/src/market/components/PresignOrderCard.vue @@ -0,0 +1,86 @@ + + + diff --git a/mobile-oa-h5/src/market/components/PresignSelectField.vue b/mobile-oa-h5/src/market/components/PresignSelectField.vue new file mode 100644 index 00000000..64e1e8a2 --- /dev/null +++ b/mobile-oa-h5/src/market/components/PresignSelectField.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/mobile-oa-h5/src/market/context-contract.js b/mobile-oa-h5/src/market/context-contract.js index ee32dc47..b3ff39d7 100644 --- a/mobile-oa-h5/src/market/context-contract.js +++ b/mobile-oa-h5/src/market/context-contract.js @@ -3,7 +3,6 @@ export const MARKET_MODULES = Object.freeze([ 'protocol', 'payment-chase', 'payment', - 'sample', 'freight' ]) @@ -14,7 +13,6 @@ const RESOURCE_BY_MODULE = Object.freeze({ protocol: 'protocols', 'payment-chase': 'payment-chase', payment: 'payments', - sample: 'samples', freight: 'freights' }) diff --git a/mobile-oa-h5/src/market/form-field-accessibility.test.js b/mobile-oa-h5/src/market/form-field-accessibility.test.js new file mode 100644 index 00000000..ac5ddef8 --- /dev/null +++ b/mobile-oa-h5/src/market/form-field-accessibility.test.js @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./components/MarketFormField.vue', import.meta.url), 'utf8') + +test('form fields expose stable label associations and required state', () => { + assert.match(source, /:id="controlId"/) + assert.match(source, /:for="controlId"/) + assert.match(source, /:aria-labelledby="labelId"/) + assert.match(source, /:aria-required="field\.required \? 'true' : undefined"/) +}) + +test('form controls support a shared disabled state', () => { + assert.match(source, /disabled:/) + assert.match(source, /:disabled="disabled/) +}) diff --git a/mobile-oa-h5/src/market/form-payload.js b/mobile-oa-h5/src/market/form-payload.js new file mode 100644 index 00000000..d41b78fb --- /dev/null +++ b/mobile-oa-h5/src/market/form-payload.js @@ -0,0 +1,88 @@ +import { serializeMarketAttachments } from './attachment-presenter.js' + +const FORBIDDEN_KEYS = new Set([ + 'token', + 'scope', + 'fieldScope', + 'tenantNo', + 'tenant_no' +]) + +export function sanitizeMarketPayload(value) { + if (Array.isArray(value)) return value.map(sanitizeMarketPayload) + if (!value || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !FORBIDDEN_KEYS.has(key)) + .map(([key, item]) => [key, sanitizeMarketPayload(item)]) + ) +} + +function pickFields(fields, values, businessSource) { + const result = {} + for (const field of fields || []) { + if (field.source && field.source !== businessSource) continue + if (Object.prototype.hasOwnProperty.call(values || {}, field.key)) { + result[field.key] = field.type === 'upload' + ? serializeMarketAttachments(values[field.key]) + : values[field.key] + } + } + return result +} + +export function pickConfiguredPayload(sections, formData) { + const result = {} + const businessSource = formData?.source + for (const section of sections || []) { + if (section.readOnly || section.uiOnly) continue + if (section.repeat) { + const rows = Array.isArray(formData?.[section.key]) ? formData[section.key] : [] + result[section.key] = rows.map((row) => pickFields(section.fields, row, businessSource)) + } else if (section.nested) { + result[section.key] = pickFields(section.fields, formData?.[section.key] || {}, businessSource) + } else { + Object.assign(result, pickFields(section.fields, formData || {}, businessSource)) + } + } + return sanitizeMarketPayload(result) +} + +export function buildMarketActionPayload(module, action, payload) { + const clean = sanitizeMarketPayload(payload) + const pick = (keys) => Object.fromEntries( + keys.filter((key) => Object.prototype.hasOwnProperty.call(clean || {}, key)) + .map((key) => [key, clean[key]]) + ) + if (module === 'freight' && ['link', 'unlink'].includes(action)) { + return { items: clean.items || [] } + } + if (module === 'payment' && ['register-transfer', 'edit-transfer'].includes(action)) { + const transferKeys = ['transferAmount', 'transferTime', 'transferType', 'transferAttachments', 'remark'] + const result = pick(action === 'register-transfer' ? ['scheduleId', ...transferKeys] : transferKeys) + if (Object.prototype.hasOwnProperty.call(result, 'transferAttachments')) { + result.transferAttachments = serializeMarketAttachments(result.transferAttachments) + } + return result + } + return clean +} + +export function validateRequiredFields(sections, formData) { + const empty = (value) => value == null || value === '' || (Array.isArray(value) && value.length === 0) + for (const section of sections || []) { + const values = section.repeat + ? (Array.isArray(formData?.[section.key]) ? formData[section.key] : []) + : [section.nested ? formData?.[section.key] || {} : formData || {}] + for (const row of values) { + for (const field of section.fields || []) { + const value = row?.[field.key] + const missing = field.type === 'checkbox' ? value !== true : empty(value) + if (field.required && (!field.source || field.source === formData?.source) && missing) { + return `请填写${field.label}` + } + } + } + } + return '' +} diff --git a/mobile-oa-h5/src/market/form-payload.test.js b/mobile-oa-h5/src/market/form-payload.test.js new file mode 100644 index 00000000..77fdd8e8 --- /dev/null +++ b/mobile-oa-h5/src/market/form-payload.test.js @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildMarketActionPayload, + pickConfiguredPayload, + sanitizeMarketPayload, + validateRequiredFields +} from './form-payload.js' + +test('removes authorization and routing fields recursively from form payloads', () => { + assert.deepEqual( + sanitizeMarketPayload({ + token: 'secret', + scope: 'manager', + tenantNo: 'other', + motherName: '张三', + collection: { fieldScope: 'specialist', regionId: 'r1' }, + samples: [{ sampleCode: 'S1', salesUserId: 'u2' }] + }), + { + motherName: '张三', + collection: { regionId: 'r1' }, + samples: [{ sampleCode: 'S1', salesUserId: 'u2' }] + } + ) +}) + +test('validates required fields in root and nested form sections', () => { + const sections = [ + { key: 'base', fields: [{ key: 'name', label: '姓名', required: true }] }, + { key: 'transfer', nested: true, fields: [{ key: 'amount', label: '金额', required: true }] } + ] + assert.equal(validateRequiredFields(sections, { name: '', transfer: { amount: '1' } }), '请填写姓名') + assert.equal(validateRequiredFields(sections, { name: 'A', transfer: { amount: '' } }), '请填写金额') + assert.equal(validateRequiredFields(sections, { name: 'A', transfer: { amount: '1' } }), '') +}) + +test('requires a required checkbox to be strictly confirmed', () => { + const sections = [ + { + key: 'confirm', + nested: true, + fields: [{ key: 'confirmed', label: '我已核对以上信息', type: 'checkbox', required: true }] + } + ] + assert.equal( + validateRequiredFields(sections, { confirm: { confirmed: false } }), + '请填写我已核对以上信息' + ) + assert.equal(validateRequiredFields(sections, { confirm: { confirmed: true } }), '') +}) + +test('action payloads exclude fields rejected by strict backend DTOs', () => { + assert.deepEqual( + buildMarketActionPayload('payment', 'edit-transfer', { + id: 't1', scheduleId: 's1', transferAmount: '10', transferTime: '2026-10-11', + transferType: 'bank', transferAttachments: 'a.pdf', remark: '备注', payStatus: 'dfj', + statusLabel: '待提交', statusTone: 'warning' + }), + { + transferAmount: '10', transferTime: '2026-10-11', transferType: 'bank', + transferAttachments: 'a.pdf', remark: '备注' + } + ) + assert.deepEqual( + buildMarketActionPayload('payment', 'register-transfer', { + scheduleId: 's1', transferAmount: '10', id: 'forged', statusLabel: '待提交' + }), + { scheduleId: 's1', transferAmount: '10' } + ) + assert.deepEqual( + buildMarketActionPayload('freight', 'link', { + transportMode: 'air', items: [{ sampleSource: 'cell_storage', sampleId: 'x' }] + }), + { items: [{ sampleSource: 'cell_storage', sampleId: 'x' }] } + ) +}) + +test('keeps only configured writable fields including nested and repeated sections', () => { + const sections = [ + { key: 'base', fields: [{ key: 'name' }] }, + { key: 'customer', nested: true, fields: [{ key: 'phone' }] }, + { key: 'samples', repeat: true, fields: [{ key: 'sampleCode' }] } + ] + assert.deepEqual( + pickConfiguredPayload(sections, { + id: 'readonly', name: 'A', status: 'done', + customer: { phone: '1', tenantNo: 'x' }, + samples: [{ id: 's1', sampleCode: 'S1' }] + }), + { name: 'A', customer: { phone: '1' }, samples: [{ sampleCode: 'S1' }] } + ) +}) + +test('serializes attachment display metadata back to backend URL strings', () => { + const sections = [ + { + key: 'transfer', + nested: true, + fields: [{ key: 'transferAttachments', type: 'upload' }] + } + ] + assert.deepEqual( + pickConfiguredPayload(sections, { + transfer: { + transferAttachments: [ + { fileUrl: '/minio/a.pdf', fileName: '协议.pdf' }, + { url: '/minio/b.jpg', name: '照片.jpg' } + ] + } + }), + { transfer: { transferAttachments: '/minio/a.pdf,/minio/b.jpg' } } + ) +}) diff --git a/mobile-oa-h5/src/market/form-state.js b/mobile-oa-h5/src/market/form-state.js new file mode 100644 index 00000000..9f24a35b --- /dev/null +++ b/mobile-oa-h5/src/market/form-state.js @@ -0,0 +1,57 @@ +function clone(value) { + return JSON.parse(JSON.stringify(value || {})) +} + +export function createMarketFormState(value = {}) { + return { value: clone(value), dirty: false } +} + +export function updateFormValue(state, key, value) { + return { value: { ...state.value, [key]: value }, dirty: true } +} + +export function markFormClean(state) { + return { ...state, dirty: false } +} + +export function canLeaveMarketForm({ allowLeave, pending, dirty }) { + if (allowLeave) return true + if (pending) return false + return !dirty +} + +export function createSubmitGuard() { + let isPending = false + return { + get pending() { + return isPending + }, + async run(task) { + if (isPending) throw new Error('正在提交,请勿重复操作') + isPending = true + try { + return await task() + } finally { + isPending = false + } + } + } +} + +export function createAsyncGenerationGuard() { + let generation = 0 + let active = true + return { + begin() { + generation += 1 + return generation + }, + isCurrent(value) { + return active && value === generation + }, + dispose() { + active = false + generation += 1 + } + } +} diff --git a/mobile-oa-h5/src/market/form-state.test.js b/mobile-oa-h5/src/market/form-state.test.js new file mode 100644 index 00000000..0483479e --- /dev/null +++ b/mobile-oa-h5/src/market/form-state.test.js @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + canLeaveMarketForm, + createSubmitGuard, + createAsyncGenerationGuard, + createMarketFormState, + markFormClean, + updateFormValue +} from './form-state.js' + +test('blocks cancel and dirty navigation while a form request is pending', () => { + assert.equal(canLeaveMarketForm({ allowLeave: false, pending: true, dirty: false }), false) + assert.equal(canLeaveMarketForm({ allowLeave: false, pending: true, dirty: true }), false) + assert.equal(canLeaveMarketForm({ allowLeave: true, pending: true, dirty: true }), true) + assert.equal(canLeaveMarketForm({ allowLeave: false, pending: false, dirty: false }), true) +}) + +test('tracks dirty state until a successful save marks the form clean', () => { + const initial = createMarketFormState({ motherName: '张三' }) + const changed = updateFormValue(initial, 'motherName', '李四') + assert.equal(changed.dirty, true) + assert.equal(markFormClean(changed).dirty, false) +}) + +test('invalidates async completions after unmount or a newer request', () => { + const guard = createAsyncGenerationGuard() + const first = guard.begin() + assert.equal(guard.isCurrent(first), true) + const second = guard.begin() + assert.equal(guard.isCurrent(first), false) + assert.equal(guard.isCurrent(second), true) + guard.dispose() + assert.equal(guard.isCurrent(second), false) +}) + +test('submit guard rejects concurrent submissions and releases after completion', async () => { + const guard = createSubmitGuard() + let release + const first = guard.run(() => new Promise((resolve) => { release = resolve })) + assert.equal(guard.pending, true) + await assert.rejects(() => guard.run(async () => 'duplicate'), /正在提交/) + release('ok') + assert.equal(await first, 'ok') + assert.equal(guard.pending, false) +}) diff --git a/mobile-oa-h5/src/market/form-view-lifecycle.test.js b/mobile-oa-h5/src/market/form-view-lifecycle.test.js new file mode 100644 index 00000000..69e17445 --- /dev/null +++ b/mobile-oa-h5/src/market/form-view-lifecycle.test.js @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('../views/market/MarketModuleForm.vue', import.meta.url), 'utf8') + +test('freight option searches abort stale requests and ignore post-unmount completions', () => { + assert.match(source, /sampleOptionsController\?\.abort\(\)/) + assert.match(source, /fetchFreightSampleOptions\([\s\S]*\{ signal: sampleOptionsController\.signal \}/) + assert.match(source, /sampleOptionsGuard\.isCurrent\(requestToken\)/) + assert.match(source, /sampleOptionsGuard\.dispose\(\)/) +}) + +test('form option requests include the selected action when the contract needs it', () => { + assert.match(source, /formOptionsParams\?\.\(id\.value, action\.value\)/) +}) diff --git a/mobile-oa-h5/src/market/market-error.js b/mobile-oa-h5/src/market/market-error.js new file mode 100644 index 00000000..df529b4b --- /dev/null +++ b/mobile-oa-h5/src/market/market-error.js @@ -0,0 +1,6 @@ +export function marketErrorMessage(error, fallback = '操作失败,请稍后重试') { + const status = Number(error?.status || error?.statusCode || error?.response?.status || 0) + if (status === 401) return '登录已失效,请返回小程序重新登录' + if (status === 403 || status === 404) return '数据不存在或无权访问' + return String(error?.message || fallback) +} diff --git a/mobile-oa-h5/src/market/market-error.test.js b/mobile-oa-h5/src/market/market-error.test.js new file mode 100644 index 00000000..21e3a716 --- /dev/null +++ b/mobile-oa-h5/src/market/market-error.test.js @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { marketErrorMessage } from './market-error.js' + +test('maps authentication and object access errors without leaking existence', () => { + assert.equal(marketErrorMessage({ status: 401 }), '登录已失效,请返回小程序重新登录') + assert.equal(marketErrorMessage({ status: 403, message: '存在但无权限' }), '数据不存在或无权访问') + assert.equal(marketErrorMessage({ status: 404, message: '真实对象名称' }), '数据不存在或无权访问') +}) diff --git a/mobile-oa-h5/src/market/mobile-parity-ui.test.js b/mobile-oa-h5/src/market/mobile-parity-ui.test.js new file mode 100644 index 00000000..78cb790e --- /dev/null +++ b/mobile-oa-h5/src/market/mobile-parity-ui.test.js @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +import { getMarketModule } from './module-registry.js' +import { marketListComponentKey, MARKET_MODULES, MARKET_ROLES } from './routes.js' + +function source(relativePath) { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8') +} + +test('market list pages omit their in-page title bars', () => { + const genericList = source('../views/market/MarketModuleList.vue') + const genericDetail = source('../views/market/MarketModuleDetail.vue') + const genericForm = source('../views/market/MarketModuleForm.vue') + const presignList = source('../views/market/presign/PresignOrderList.vue') + const presignForm = source('../views/market/presign/PresignOrderForm.vue') + + assert.match(genericList, /:show-header="false"/) + assert.match(genericDetail, /:show-header="false"/) + assert.match(genericForm, /:show-header="false"/) + assert.doesNotMatch(genericList, /:backable=/) + assert.doesNotMatch(presignList, / { + const genericDetail = source('../views/market/MarketModuleDetail.vue') + + assert.doesNotMatch(genericDetail, /:backable=/) + assert.doesNotMatch(genericDetail, /@back="backToList"/) +}) + +test('market create and edit pages omit every floating round back button', () => { + const shell = source('./components/MarketPageShell.vue') + const styles = source('../styles/market.css') + const genericForm = source('../views/market/MarketModuleForm.vue') + const presignForm = source('../views/market/presign/PresignOrderForm.vue') + + assert.doesNotMatch(shell, /market-page__floating-back/) + assert.doesNotMatch(shell, /has-floating-back/) + assert.doesNotMatch(shell, /aria-label="返回"/) + assert.doesNotMatch(shell, /\bbackable\b/) + assert.doesNotMatch(genericForm, /:backable=/) + assert.doesNotMatch(genericForm, /@back="back"/) + assert.doesNotMatch(presignForm, /presign-form-page__back/) + assert.doesNotMatch(presignForm, /aria-label="返回"/) + assert.doesNotMatch(styles, /market-page__floating-back/) + assert.doesNotMatch(styles, /presign-form-page__back/) + assert.match(styles, /\.presign-form-page__content\s*\{[^}]*?padding:\s*calc\(12px \+ env\(safe-area-inset-top\)\)/) +}) + +test('market list visual contract keeps search white, tags filled and actions right aligned', () => { + const styles = source('../styles/market.css') + const card = source('./components/MarketListCard.vue') + + assert.match(styles, /\.market-list__filters\s*\{[\s\S]*?background:\s*#fff;/) + assert.match(styles, /\.presign-order-page__search\s*\{[\s\S]*?top:\s*0;/) + assert.match(styles, /\.market-list-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/) + assert.match(styles, /\.presign-order-card__actions\s*\{[\s\S]*?justify-content:\s*flex-end;/) + assert.match(styles, /\.market-tag\s*\{[\s\S]*?align-self:\s*flex-start;/) + assert.match(styles, /\.market-tag\s*\{[\s\S]*?max-width:\s*112px;/) + assert.match(card, /class="market-tag"/) + assert.doesNotMatch(card, /]*\bplain\b/) + for (const component of [ + './components/MarketDetailSection.vue', + './components/PresignTransferCard.vue', + '../views/market/MarketModuleDetail.vue' + ]) { + const content = source(component) + assert.doesNotMatch(content, /]*class="market-tag")/) + } +}) + +test('protocol lists wire dual source tabs into listParams and create navigation', () => { + const genericList = source('../views/market/MarketModuleList.vue') + const styles = source('../styles/market.css') + + assert.match(genericList, /market-source-tabs/) + assert.match(genericList, /market-source-tabs__btn/) + assert.match(genericList, /type="button"/) + assert.match(genericList, /@click="switchListStatus\(tab\.value\)"/) + assert.doesNotMatch(genericList, /\[market-protocol-tabs\]/) + assert.doesNotMatch(genericList, /bindNativeTabListeners/) + assert.doesNotMatch(genericList, /installGlobalPointerProbe/) + assert.doesNotMatch(genericList, / { + const styles = source('../styles/market.css') + const routes = source('./routes.js') + + assert.match(styles, /\.market-list-card\s*\{[^}]*?box-sizing:\s*border-box;[^}]*?width:\s*100%;/) + assert.match(styles, /\.presign-order-card\s*\{[^}]*?overflow:\s*hidden;/) + assert.match(routes, /export const MARKET_ROLES = \['specialist', 'manager'\]/) + assert.match(routes, /export const MARKET_MODULES = \[[\s\S]*?'presign'[\s\S]*?'protocol'[\s\S]*?'payment-chase'[\s\S]*?'payment'[\s\S]*?'freight'/) + assert.doesNotMatch(routes, /'sample'/) +}) + +test('all ten market list routes use templates with brand green create buttons and white icons', () => { + const styles = source('../styles/market.css') + const genericList = source('../views/market/MarketModuleList.vue') + const presignList = source('../views/market/presign/PresignOrderList.vue') + const templateKeys = MARKET_ROLES.flatMap(() => MARKET_MODULES.map(marketListComponentKey)) + + assert.equal(templateKeys.length, 10) + assert.equal(templateKeys.filter((key) => key === 'presign-list').length, 2) + assert.equal(templateKeys.filter((key) => key === 'generic-list').length, 8) + assert.match(genericList, /class="market-list__fab"/) + assert.match(presignList, /class="market-list__fab"/) + assert.doesNotMatch(presignList, /van-floating-bubble/) + assert.match(styles, /\.market-list__fab\s*\{[^}]*?background:\s*var\(--market-primary,\s*#0a9f6d\)\s*!important;[^}]*?color:\s*#fff;/) +}) + +test('market create and edit bottom actions are centered', () => { + const styles = source('../styles/market.css') + const genericForm = source('../views/market/MarketModuleForm.vue') + + assert.match(genericForm, /market-bottom-bar market-form-bottom/) + assert.match(styles, /\.market-form-bottom\s*\{[\s\S]*?justify-content:\s*center;/) + assert.match(styles, /\.presign-form-bottom\s*\{[\s\S]*?justify-content:\s*center;/) +}) + +test('market attachments use the workflow thumbnail and file-icon contract', () => { + const attachmentCards = source('./components/MarketAttachmentCards.vue') + + assert.match(attachmentCards, /normalizeMarketAttachments/) + assert.match(attachmentCards, /downloadAttachmentFile/) + assert.match(attachmentCards, /fetchMarketAttachmentDownloadUrl/) + assert.match(attachmentCards, /showImagePreview/) + assert.match(attachmentCards, /market-attachment-card__download-action/) + assert.match(attachmentCards, /@click\.stop="downloadAttachment\(file\)"/) + assert.match(attachmentCards, /fetchDownloadUrl:/) + assert.match(attachmentCards, /market-attachment-card__delete/) + assert.match(attachmentCards, /@error=/) + assert.match(attachmentCards, /84px/) + assert.match(attachmentCards, /overflow:\s*visible/) + assert.match(attachmentCards, /market-attachment-card__main[\s\S]*?overflow:\s*hidden/) + assert.match(attachmentCards, /\.market-attachment-card__download-action\s*\{[\s\S]*?width:\s*44px;[\s\S]*?height:\s*44px;/) + assert.match(attachmentCards, /file\.explicitName\s*\?\s*file\.name\s*:\s*undefined/) + assert.doesNotMatch(attachmentCards, /#10b981/i) + for (const kind of ['pdf', 'xls', 'doc', 'zip', 'other']) { + assert.match(attachmentCards, new RegExp(`ft-${kind}`)) + } +}) + +test('market forms share attachment cards and preserve their existing value shapes', () => { + const genericField = source('./components/MarketFormField.vue') + const presignUploader = source('./components/PresignAttachmentUploader.vue') + const genericForm = source('../views/market/MarketModuleForm.vue') + + for (const content of [genericField, presignUploader]) { + assert.match(content, /import MarketAttachmentCards/) + assert.match(content, / item\.fileUrl\)[\s\S]*?\.join\(','\)/) + assert.match(presignUploader, /@upload="\$emit\('upload', \$event\)"/) + assert.match(presignUploader, /@remove="\$emit\('remove', \$event\)"/) + assert.match(presignUploader, /PRESIGN_ATTACHMENT_MAX_BYTES/) + assert.match(genericForm, /appendMarketAttachment/) + assert.match(genericForm, /fileName:\s*uploaded\?\.fileName\s*\|\|\s*file\.name/) +}) + +test('payment detail presents every web field and nested transfer field with Chinese labels', () => { + const sections = getMarketModule('payment').detailSections({ + protocolNo: 'P-1', + motherName: '张三', + motherPhone: '13800138000', + sampleCode: 'S-1', + payMode: 'fq', + contractAmount: '10000.00', + paidAmount: '3000.00', + remainAmount: '7000.00', + clearedScheduleCount: 1, + activeScheduleCount: 3, + protocolStatus: 'sxz', + paymentProgress: '部分缴费', + schedules: [{ + id: 'schedule-1', + scheduleNo: 1, + dueAmount: '5000.00', + dueDate: '2026-12-01', + paidAmount: '3000.00', + arrearsAmount: '2000.00', + scheduleStatus: 'open', + transfers: [{ + id: 'transfer-1', + payStatus: 'jfqrz', + transferType: 'wx', + transferAmount: '3000.00', + transferTime: '2026-10-11 10:00:00', + transferAttachments: 'proof.pdf', + remark: '首笔' + }] + }] + }) + + assert.deepEqual(sections.map((section) => section.title), ['缴费概览', '应缴批次']) + assert.deepEqual( + sections[0].fields.map((field) => field.label), + ['协议号', '客户', '联系电话', '样本编码', '缴费方式', '合同额', '已缴 / 待缴', '整体进度', '协议状态', '缴费进度'] + ) + assert.deepEqual( + sections[1].cards[0].fields.map((field) => field.label), + ['应缴金额', '应缴日', '已缴金额', '欠缴金额'] + ) + assert.deepEqual( + sections[1].cards[0].cards[0].fields.map((field) => field.label), + ['转账方式', '转账金额', '转账时间', '转账凭证', '备注'] + ) +}) + +test('payment cards reproduce the Web Excel columns and operation labels', () => { + const presenter = source('./list-presenter.js') + for (const label of [ + '姓名', '手机号', '样本编码', '签署日期', '存储年限', '存储项目', '合同总额', + '缴费方式', '优惠金额', '应收总额', '应缴费金额(元)', '应缴费时间', + '已缴费金额', '已缴费时间', '欠缴费金额', '累计实缴', '待缴金额', '备注' + ]) assert.match(presenter, new RegExp(label)) + assert.match(presenter, /'view-payment-detail': '缴费明细'/) +}) + +test('nested market detail cards use explicit labels instead of API property names', () => { + const detailSection = source('./components/MarketDetailSection.vue') + + assert.match(detailSection, /cards:/) + assert.doesNotMatch(detailSection, /function flattenFields/) +}) + +test('market detail sections render typed attachments as readonly cards', () => { + const detailSection = source('./components/MarketDetailSection.vue') + + assert.match(detailSection, /import MarketAttachmentCards/) + assert.match(detailSection, /field\.type === 'attachments'/) + assert.match(detailSection, / { + const { buildMarketCard } = await import('./list-presenter.js') + const card = buildMarketCard('protocol', 'specialist', { + source: 'cell_storage', protocolNo: 'P-1', protocolStatus: 'sxz', registerDate: '20261011', + motherName: '张三', motherPhone: '13800138000', motherIdCard: '3401', fatherName: '李四', + newbornSex: '1', deliveryMode: 'sc', newbornWeightGram: 3200, regionName: '合肥', + hospitalName: '省妇幼', collectorUserName: '采集员', salesUserName: '市场员', + sampleCollectTime: '2026-10-11 10:00:00', createTime: '2026-10-11 11:00:00', + allowedActions: ['edit', 'delete'] + }) + + assert.deepEqual(card.fields.map((field) => field.label), [ + '状态', '登记日期', '母亲姓名', '母亲电话', '母亲身份证号', '父亲姓名', + '新生儿性别', '分娩方式', '新生儿体重(克)', '采集地区', '采集医院', + '采集专员', '市场专员', '样本采集时间', '创建时间' + ]) + assert.deepEqual(card.actions, ['view', 'edit', 'delete']) +}) + +test('protocol, freight and presign details define explicit Chinese nested cards', () => { + const protocol = getMarketModule('protocol').detailSections({ + source: 'cell_storage', + basic: { protocolNo: 'P-1', protocolStatus: 'sxz' }, + customer: { motherName: '张三' }, + samples: [{ id: 's1', sampleCode: 'S-1', sampleTypeName: '脐带血' }], + attachments: { protocolAttachments: 'a.pdf' } + }) + const freight = getMarketModule('freight').detailSections({ + freightNo: 'F-1', transportModeLabel: '冷链', freightAmount: '20', + items: [{ id: 'i1', sampleSource: 'cell_storage', protocolNo: 'P-1', sampleCode: 'S-1' }] + }) + const presign = getMarketModule('presign').detailSections({ + transfers: [{ id: 't1', transferType: 'wxzz', transferAmount: '20', payStatus: 'yjf' }] + }) + + for (const sections of [protocol, freight, presign]) { + for (const section of sections) { + assert.ok(Array.isArray(section.fields)) + assert.ok(Array.isArray(section.cards || [])) + for (const card of section.cards || []) { + assert.ok(card.title) + assert.ok(card.fields.every((field) => /[\u3400-\u9fff]/.test(field.label))) + } + } + } + assert.ok(protocol.find((section) => section.key === 'samples').cards.length) + assert.ok(freight.find((section) => section.key === 'items').cards.length) + assert.ok(presign.find((section) => section.key === 'transfers').cards.length) +}) + +test('protocol create and edit forms expose every writable web business field', () => { + const module = getMarketModule('protocol') + const bySection = Object.fromEntries( + module.formSections.map((section) => [section.key, section.fields.map((field) => field.key)]) + ) + + assert.deepEqual(bySection.protocol, [ + 'source', 'protocolNo', 'donationNo', 'registerDate', 'donationDate', + 'collectorUserId', 'protocolTotalAmount', 'remark' + ]) + assert.deepEqual(bySection.collection, [ + 'salesUserId', 'regionId', 'hospitalId', 'regionManagerUserId', 'medicalStaff', + 'sampleCollectTime', 'maternalBloodCollectTime', 'remark' + ]) + assert.deepEqual(bySection.customer, [ + 'motherName', 'motherPhone', 'motherIdCard', 'motherEmail', 'motherBirthYm', + 'motherEthnic', 'motherAddress', 'fatherName', 'fatherPhone', 'fatherIdCard', + 'newbornName', 'newbornSex', 'expectedDeliveryDate', 'newbornDeliveryTime', + 'newbornIdCard', 'deliveryMode', 'birthOrder', 'newbornWeightGram', + 'apgar1min', 'apgar5min', 'remark' + ]) + assert.deepEqual(bySection.samples, [ + 'sampleTypeId', 'sampleCodeId', 'sampleCode', 'collectAmount', 'unitId', + 'price', 'originalPrice', 'storageYears', 'etaTime', 'isSpecial', 'specialRemark', 'remark' + ]) + assert.deepEqual(bySection.attachments, [ + 'protocolAttachments', 'consentFormUrls', 'healthSurveyFormUrls', + 'attachmentUrls', 'infectiousDiseaseReportUrls' + ]) + const customer = module.formSections.find((section) => section.key === 'customer') + const samples = module.formSections.find((section) => section.key === 'samples') + assert.deepEqual( + customer.fields.find((field) => field.key === 'motherEthnic'), + { key: 'motherEthnic', label: '母亲民族', type: 'select', optionKey: 'ethnicities' } + ) + assert.deepEqual( + samples.fields.find((field) => field.key === 'sampleCodeId'), + { key: 'sampleCodeId', label: '样本编码记录', type: 'select', optionKey: 'sampleCodes' } + ) + const form = source('../views/market/MarketModuleForm.vue') + assert.match(form, /field\.key === 'sampleCodeId'/) + assert.match(form, /sampleCode:\s*option\?\.sampleCode/) +}) + +test('presign delete action calls the delete API exactly once', () => { + const list = source('../views/market/presign/PresignOrderList.vue') + const deleteBranch = list.match(/if \(code === 'delete'\) \{([\s\S]*?)\} else \{/) + + assert.ok(deleteBranch) + assert.equal((deleteBranch[1].match(/deletePresignOrder\(/g) || []).length, 1) +}) diff --git a/mobile-oa-h5/src/market/module-actions.test.js b/mobile-oa-h5/src/market/module-actions.test.js index 9f8dee5f..6a8d61b3 100644 --- a/mobile-oa-h5/src/market/module-actions.test.js +++ b/mobile-oa-h5/src/market/module-actions.test.js @@ -17,8 +17,7 @@ test('each module exposes only its static market action whitelist', () => { 'withdraw' ]) assert.equal(moduleActions('payment').includes('finance-confirm'), false) - assert.equal(moduleActions('protocol').includes('audit'), false) - assert.equal(moduleActions('sample').includes('qc-conclusion'), false) + assert.deepEqual(moduleActions('protocol'), ['view', 'create', 'audit', 'edit', 'delete']) assert.equal(moduleActions('freight', 'specialist').includes('edit'), true) assert.equal(moduleActions('freight', 'manager').includes('edit'), true) assert.deepEqual(moduleActions('presign'), [ @@ -107,15 +106,11 @@ test('backend actions are intersected with the module whitelist', () => { ['submit', { code: 'withdraw', label: '撤回登记' }] ) assert.deepEqual( - intersectAllowedActions('sample', 'manager', [ - { key: 'storageCert', label: '办理储存证' }, - { key: 'insurance', label: '办理保险' }, + intersectAllowedActions('protocol', 'manager', [ + { key: 'audit', label: '审核' }, 'qc-conclusion' ]), - [ - { code: 'storage-cert', label: '办理储存证' }, - { code: 'insurance', label: '办理保险' } - ] + [{ code: 'audit', label: '审核' }] ) }) diff --git a/mobile-oa-h5/src/market/module-contracts.test.js b/mobile-oa-h5/src/market/module-contracts.test.js index 4cccbdab..dde725f5 100644 --- a/mobile-oa-h5/src/market/module-contracts.test.js +++ b/mobile-oa-h5/src/market/module-contracts.test.js @@ -21,9 +21,6 @@ test('module adapters map list filters to the actual backend query names', () => assert.deepEqual(getMarketModule('protocol').listParams({ status: 'cell_storage' }), { source: 'cell_storage' }) - assert.deepEqual(getMarketModule('sample').listParams({ status: 'research_donation' }), { - source: 'research_donation' - }) assert.deepEqual(getMarketModule('freight').listParams({ status: 'air' }), { transportMode: 'air', includeItems: true @@ -43,22 +40,6 @@ test('protocol list tabs match Web dual-source tabs without an all-sources optio }) }) -test('sample list tabs match Web dual-source tabs without an all-sources option', () => { - assert.deepEqual(getMarketModule('sample').statusOptions, [ - { text: '细胞存储', value: 'cell_storage' }, - { text: '科研捐赠', value: 'research_donation' } - ]) - assert.deepEqual(getMarketModule('sample').listParams({}), { - source: 'cell_storage' - }) - assert.deepEqual(getMarketModule('sample').listParams({ status: '' }), { - source: 'cell_storage' - }) - assert.deepEqual(getMarketModule('sample').listParams({ status: 'research_donation' }), { - source: 'research_donation' - }) -}) - test('presign filters use the backend order status codes', () => { assert.deepEqual(getMarketModule('presign').statusOptions, [ { text: '全部状态', value: '' }, @@ -77,7 +58,7 @@ test('protocol summary status resolves real codes without exposing unknown codes assert.equal(protocol.statusLabel({ source: 'cell_storage', basic: { protocolStatus: '' } }), '—') }) -test('protocol and sample adapters preserve nested detail sections', () => { +test('protocol adapters preserve nested detail sections', () => { const protocol = getMarketModule('protocol').detailSections({ source: 'cell_storage', protocol: { protocolNo: 'P-1' }, @@ -90,11 +71,6 @@ test('protocol and sample adapters preserve nested detail sections', () => { assert.deepEqual(protocol.map((section) => section.key), [ 'protocol', 'collection', 'customer', 'samples', 'payment', 'attachments' ]) - - const sample = getMarketModule('sample').detailSections({ - sections: [{ key: 'market-handles', title: '市场办理', storageCert: { statusLabel: '已完成' } }] - }) - assert.equal(sample[0].storageCert.statusLabel, '已完成') }) test('attachment-bearing detail adapters expose typed values instead of path text', () => { @@ -122,15 +98,6 @@ test('attachment-bearing detail adapters expose typed values instead of path tex value: 'payment-proof.png' }) - const sample = getMarketModule('sample').detailSections({ - sections: [{ - key: 'market-handles', - storageCert: { attachments: 'certificate.pdf' }, - insurance: { attachments: 'insurance.jpg' } - }] - }) - assert.equal(sample[0].cards[0].fields.find((field) => field.label === '附件').type, 'attachments') - const presign = getMarketModule('presign').detailSections({ transfers: [{ transferAttachments: 'deposit-proof.png' }] }) @@ -171,7 +138,7 @@ test('presign detail keeps the Web order fields and transfer cards', () => { assert.equal(sections[1].raw.transfers[0].statusLabel, '付款完成') }) -test('payment and sample adapters initialize only editable fields from real detail shapes', () => { +test('payment adapters initialize only editable fields from real detail shapes', () => { const payment = getMarketModule('payment') const paymentDetail = { id: 'payment-1', @@ -183,22 +150,6 @@ test('payment and sample adapters initialize only editable fields from real deta assert.deepEqual(payment.initialForm(paymentDetail, { transferId: 'transfer-1' }), { transfer: { transferAmount: '20', transferType: 'bank' } }) - - const sample = getMarketModule('sample') - const sampleDetail = { sections: [{ key: 'market-handles', storageCert: { - handleTime: '2026-10-11', handlerUserId: 'u1', certCode: 'CERT-1', statusLabel: '已完成' - } }] } - assert.deepEqual(sample.initialForm(sampleDetail, 'storage-cert'), { - handle: { handleTime: '2026-10-11', handlerUserId: 'u1' } - }) -}) - -test('sample form options are scoped by the exact source-qualified sample id and action', () => { - const sample = getMarketModule('sample') - assert.deepEqual(sample.formOptionsParams('cell_storage:C-001', 'insurance'), { - sampleId: 'cell_storage:C-001', - action: 'insurance' - }) }) test('protocol personnel fields use isolated hydrated option collections', () => { @@ -286,12 +237,6 @@ test('detail adapters hide unresolved references and dictionary codes but preser } assert.equal(protocol.find((section) => section.key === 'samples').cards[0].statusText, '已采集') - const sample = getMarketModule('sample').detailSections({ - sections: [{ key: 'market-handles', storageCert: { handlerUserId: 'handler-raw', handlerUserName: 'handler-raw', statusLabel: 'raw-handle-status' } }] - }) - assert.equal(sample[0].cards[0].fields.find((field) => field.label === '办理人员').value, '未知人员') - assert.equal(sample[0].cards[0].statusText, '未知') - const freight = getMarketModule('freight').detailSections({ freightNo: 'F-RAW-1', transportMode: 'raw-transport', createBy: 'creator-raw', creatorName: 'creator-raw', items: [{ sampleSource: 'raw-source', sampleCode: 'S-RAW-2' }] diff --git a/mobile-oa-h5/src/market/module-registry.js b/mobile-oa-h5/src/market/module-registry.js index ce4ee1aa..8f1b3b0c 100644 --- a/mobile-oa-h5/src/market/module-registry.js +++ b/mobile-oa-h5/src/market/module-registry.js @@ -3,9 +3,8 @@ import paymentChase from './modules/payment-chase.js' import payment from './modules/payment.js' import presign from './modules/presign.js' import protocol from './modules/protocol.js' -import sample from './modules/sample.js' -const MODULES = Object.freeze({ presign, protocol, 'payment-chase': paymentChase, payment, sample, freight }) +const MODULES = Object.freeze({ presign, protocol, 'payment-chase': paymentChase, payment, freight }) export function getMarketModule(module) { const value = MODULES[module] diff --git a/mobile-oa-h5/src/market/modules/freight.js b/mobile-oa-h5/src/market/modules/freight.js index 7dc8a8c2..2c37f191 100644 --- a/mobile-oa-h5/src/market/modules/freight.js +++ b/mobile-oa-h5/src/market/modules/freight.js @@ -1,3 +1,25 @@ +const SOURCE_LABELS = Object.freeze({ cell_storage: '细胞存储', research_donation: '科研捐赠' }) + +function dictionaryLabel(map, code, providedLabel) { + const label = String(providedLabel || '').trim() + const key = String(code || '').trim() + if (label && label !== key) return label + return map[key] || (key ? '未知' : '—') +} + +function person(name, id) { + const displayName = String(name || '').trim() + const referenceId = String(id || '').trim() + if (displayName && displayName !== referenceId) return displayName + return referenceId ? '未知人员' : '—' +} + +function money(value) { + const amount = Number(value) + if (!Number.isFinite(amount)) return '—' + return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + export default { key: 'freight', resource: 'freights', @@ -5,8 +27,45 @@ export default { specialist: ['view', 'create', 'edit', 'link', 'unlink'], manager: ['view', 'create', 'edit', 'link', 'unlink'] }, - listParams: ({ status } = {}) => ({ transportMode: status || '' }), + listParams: ({ status } = {}) => ({ transportMode: status || '', includeItems: true }), statusOptions: [{ text: '全部运输方式', value: '' }], + detailSections(detail) { + return [ + { + key: 'base', + title: '运费信息', + fields: [ + { label: '批次号', value: detail?.freightNo || '—' }, + { label: '运输方式', value: dictionaryLabel({}, detail?.transportMode, detail?.transportModeLabel), tag: true, tagType: 'primary' }, + { label: '运费金额', value: money(detail?.freightAmount) }, + { label: '关联数', value: String(detail?.visibleItemCount ?? detail?.items?.length ?? 0) }, + { label: '创建人', value: person(detail?.creatorName || detail?.createByName, detail?.createBy) }, + { label: '创建时间', value: detail?.createTime || '—' }, + { label: '更新时间', value: detail?.updateTime || '—' }, + { label: '备注', value: detail?.remark || '—' } + ], + cards: [] + }, + { + key: 'items', + title: '关联样本', + fields: [], + cards: (detail?.items || []).map((item, index) => ({ + key: String(item?.id || `item-${index}`), + title: item?.sampleCode || `样本 ${index + 1}`, + statusText: dictionaryLabel(SOURCE_LABELS, item?.sampleSource), + fields: [ + { label: '来源', value: dictionaryLabel(SOURCE_LABELS, item?.sampleSource) }, + { label: '协议单号', value: item?.protocolNo || '—' }, + { label: '样本编码', value: item?.sampleCode || '—' }, + { label: '样本类型', value: item?.sampleTypeName || '—' }, + { label: '母亲姓名', value: item?.motherName || '—' }, + { label: '子女姓名', value: item?.newbornName || '—' } + ] + })) + } + ] + }, usesPagedSampleOptions(action) { return ['create', 'link'].includes(action) }, diff --git a/mobile-oa-h5/src/market/modules/protocol.js b/mobile-oa-h5/src/market/modules/protocol.js index adecf6f4..b2d91618 100644 --- a/mobile-oa-h5/src/market/modules/protocol.js +++ b/mobile-oa-h5/src/market/modules/protocol.js @@ -1,34 +1,170 @@ -import { section } from './shared.js' +const STATUS_LABELS = Object.freeze({ + dsx: '待生效', dqr: '待确认', sxz: '生效中', ygq: '已过期', + yzf: '已作废', sdfq: '属地废弃', zky: '转科研', zckc: '转细胞存储' +}) +const PAY_MODE_LABELS = Object.freeze({ dj: '趸交', fq: '分期', fgd: '分改趸', djfq: '趸交+分期' }) +const SEX_LABELS = Object.freeze({ '1': '男', '0': '女' }) +const DELIVERY_LABELS = Object.freeze({ sc: '顺产', pgc: '剖宫产' }) +const SAMPLE_STATUS_LABELS = Object.freeze({ + ycj: '已采集', jcz: '检测中', yrk: '已入库', yzf: '已作废' +}) -const labels = { - protocolNo: '协议编号', donationNo: '捐赠编号', registerDate: '登记日期', - donationDate: '捐赠日期', motherName: '母亲姓名', motherPhone: '联系电话', - regionName: '采集地区', hospitalName: '采集医院', sampleCode: '样本编码', - payMode: '缴费模式', protocolAttachments: '协议附件' +function value(source, key) { + const result = source?.[key] + return result == null || result === '' ? '—' : String(result) +} + +function label(map, source) { + const key = String(source || '').trim() + return map[key] || (key ? (/[㐀-鿿]/u.test(key) ? key : '未知') : '—') +} + +function person(name, id) { + const displayName = String(name || '').trim() + const referenceId = String(id || '').trim() + if (displayName && displayName !== referenceId) return displayName + return referenceId ? '未知人员' : '—' +} + +function referenceName(name, id) { + const displayName = String(name || '').trim() + const referenceId = String(id || '').trim() + if (displayName && displayName !== referenceId) return displayName + return referenceId ? '未知' : '—' +} + +function fields(source, definitions) { + return definitions.map(([key, text, options = {}]) => ({ + label: text, + value: options.map ? options.map(source?.[key]) : value(source, key), + ...options, + map: undefined + })) } export default { key: 'protocol', resource: 'protocols', actions: { specialist: ['view', 'create', 'edit'], manager: ['view', 'create', 'edit'] }, - listParams: ({ status } = {}) => ({ source: status || '' }), + listParams: ({ status } = {}) => ({ + source: ['cell_storage', 'research_donation'].includes(String(status || '').trim()) + ? String(status).trim() + : 'cell_storage' + }), statusOptions: [ - { text: '全部来源', value: '' }, { text: '细胞存储', value: 'cell_storage' }, { text: '科研捐赠', value: 'research_donation' } ], + statusLabel(detail) { + const basic = detail?.protocol || detail?.basic || detail || {} + const status = detail?.source === 'research_donation' + ? basic?.donationStatus + : basic?.protocolStatus + return label(STATUS_LABELS, status) + }, detailSections(detail) { - if (Array.isArray(detail?.sections)) return detail.sections + if (Array.isArray(detail?.sections)) { + return detail.sections.map((item) => ({ ...item, fields: item?.fields || [], cards: item?.cards || [] })) + } + const source = detail?.source + const basic = detail?.protocol || detail?.basic || detail || {} + const statusKey = source === 'research_donation' ? 'donationStatus' : 'protocolStatus' + const dateKey = source === 'research_donation' ? 'donationDate' : 'registerDate' + const numberKey = source === 'research_donation' ? 'donationNo' : 'protocolNo' const values = [ - section('protocol', '协议信息', detail?.protocol || detail?.basic || detail, labels), - section('collection', '采集信息', detail?.collection, labels), - section('customer', '客户信息', detail?.customer, labels), - section('samples', '样本信息', { samples: detail?.samples }, labels) + { + key: 'protocol', + title: source === 'research_donation' ? '捐赠信息' : '协议信息', + fields: [ + { label: source === 'research_donation' ? '捐赠编号' : '协议编号', value: value(basic, numberKey) }, + { label: source === 'research_donation' ? '捐赠状态' : '协议状态', value: label(STATUS_LABELS, basic?.[statusKey]), tag: true }, + { label: source === 'research_donation' ? '捐赠日期' : '登记日期', value: value(basic, dateKey) }, + { label: '采集专员', value: person(basic?.collectorUserName, basic?.collectorUserId) }, + ...(source === 'cell_storage' ? [{ label: '协议总金额', value: value(basic, 'protocolTotalAmount') }] : []), + { label: '备注', value: value(basic, 'remark') }, + { label: '创建时间', value: value(basic, 'createTime') }, + { label: '更新时间', value: value(basic, 'updateTime') } + ], + cards: [] + }, + { + key: 'collection', + title: '采集信息', + fields: fields(detail?.collection, [ + ['salesUserName', '市场专员', { map: () => person(detail?.collection?.salesUserName, detail?.collection?.salesUserId) }], + ['regionName', '采集地区', { map: () => referenceName(detail?.collection?.regionName, detail?.collection?.regionId) }], + ['hospitalName', '采集医院', { map: () => referenceName(detail?.collection?.hospitalName, detail?.collection?.hospitalId) }], + ['regionManagerUserName', '地区经理', { map: () => person(detail?.collection?.regionManagerUserName, detail?.collection?.regionManagerUserId) }], + ['medicalStaff', '采集医护'], + ['sampleCollectTime', '样本采集时间'], + ['maternalBloodCollectTime', '母血采集时间'], + ['remark', '备注'] + ]), + cards: [] + }, + { + key: 'customer', + title: '客户信息', + fields: fields(detail?.customer, [ + ['motherName', '母亲姓名'], ['motherPhone', '母亲联系电话'], ['motherIdCard', '母亲身份证号'], + ['motherEmail', '母亲邮箱'], ['motherBirthYm', '母亲出生年月'], ['motherEthnic', '母亲民族'], + ['motherAddress', '母亲家庭住址'], ['fatherName', '父亲姓名'], ['fatherPhone', '父亲电话'], + ['fatherIdCard', '父亲身份证号'], ['newbornName', '新生儿姓名'], + ['newbornSex', '新生儿性别', { map: (item) => label(SEX_LABELS, item) }], + ['expectedDeliveryDate', '预产期'], ['newbornDeliveryTime', '新生儿分娩时间'], + ['newbornIdCard', '新生儿身份证'], + ['deliveryMode', '分娩方式', { map: (item) => label(DELIVERY_LABELS, item) }], + ['birthOrder', '胎次'], ['newbornWeightGram', '新生儿体重(克)'], + ['apgar1min', '新生儿评分(1分钟)'], ['apgar5min', '新生儿评分(5分钟)'], ['remark', '备注'] + ]), + cards: [] + }, + { + key: 'samples', + title: '样本信息', + fields: [], + cards: (detail?.samples || []).map((item, index) => ({ + key: String(item?.id || `sample-${index}`), + title: item?.sampleCode || `样本 ${index + 1}`, + statusText: label(SAMPLE_STATUS_LABELS, item?.sampleStatusLabel || item?.sampleStatus), + fields: fields(item, [ + ['sampleTypeName', '样本类型', { map: () => referenceName(item?.sampleTypeName, item?.sampleTypeId) }], + ['sampleCode', '样本编码'], ['collectAmount', '采集量'], + ['unitName', '计量单位', { map: () => referenceName(item?.unitName, item?.unitId) }], + ['price', '单价'], ['originalPrice', '原价'], ['storageYears', '存储年限'], + ['etaTime', '预计到达时间', { map: () => item?.etaTime || item?.transport?.etaTime || '' }], + ['isSpecial', '是否特殊样本', { map: (itemValue) => Number(itemValue) === 1 ? '是' : '否' }], + ['specialRemark', '特殊说明'], ['remark', '备注'] + ]) + })) + } ] if (detail?.source === 'cell_storage' || detail?.payment) { - values.push(section('payment', '缴费信息', detail?.payment, labels)) + values.push({ + key: 'payment', + title: '缴费信息', + fields: fields(detail?.payment, [ + ['payMode', '缴费模式', { map: (item) => label(PAY_MODE_LABELS, item), tag: true }], + ['contractAmount', '合同金额'], ['installmentCount', '分期期数'], + ['installmentIntervalMonths', '每期间隔(月)'], ['lumpSumDeadline', '趸交截止时间'], + ['installmentStartDate', '分期开始时间'], ['installmentAdvanceDays', '提前催缴预警(天)'], + ['remark', '缴费备注'] + ]), + cards: [] + }) } - values.push(section('attachments', '附件信息', detail?.attachments, labels)) + values.push({ + key: 'attachments', + title: '附件信息', + fields: [ + { 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 || '' } + ], + cards: [] + }) return values }, formSections: [ @@ -38,26 +174,64 @@ export default { ] }, { key: 'protocolNo', label: '协议编号', source: 'cell_storage' }, { key: 'donationNo', label: '捐赠编号', source: 'research_donation' }, - { key: 'registerDate', label: '登记日期', type: 'date', source: 'cell_storage' }, - { key: 'donationDate', label: '捐赠日期', type: 'date', source: 'research_donation' }, + { key: 'registerDate', label: '登记日期', type: 'date', valueFormat: 'YYYYMMDD', source: 'cell_storage' }, + { key: 'donationDate', label: '捐赠日期', type: 'date', valueFormat: 'YYYYMMDD', source: 'research_donation' }, + { key: 'collectorUserId', label: '采集专员', type: 'select', optionKey: 'collectorStaff' }, { key: 'protocolTotalAmount', label: '协议总金额', type: 'number', source: 'cell_storage' }, { key: 'remark', label: '备注', type: 'textarea' } ] }, { key: 'collection', title: '采集信息', nested: true, fields: [ + { key: 'salesUserId', label: '市场专员', type: 'select', optionKey: 'salesStaff' }, { key: 'regionId', label: '采集地区', type: 'select', optionKey: 'regions' }, { key: 'hospitalId', label: '采集医院', type: 'select', optionKey: 'hospitals' }, - { key: 'sampleCollectTime', label: '采集时间', type: 'datetime' } + { key: 'regionManagerUserId', label: '地区经理', type: 'select', optionKey: 'managerStaff' }, + { key: 'medicalStaff', label: '采集医护' }, + { key: 'sampleCollectTime', label: '样本采集时间', type: 'datetime' }, + { key: 'maternalBloodCollectTime', label: '母血采集时间', type: 'datetime' }, + { key: 'remark', label: '备注', type: 'textarea' } ] }, { key: 'customer', title: '客户信息', nested: true, fields: [ { key: 'motherName', label: '母亲姓名', required: true }, { key: 'motherPhone', label: '联系电话', type: 'tel' }, - { key: 'motherIdCard', label: '证件号码' }, - { key: 'expectedDeliveryDate', label: '预产期', type: 'date' } + { key: 'motherIdCard', label: '母亲身份证号' }, + { key: 'motherEmail', label: '母亲邮箱' }, + { key: 'motherBirthYm', label: '母亲出生年月' }, + { key: 'motherEthnic', label: '母亲民族', type: 'select', optionKey: 'ethnicities' }, + { key: 'motherAddress', label: '母亲家庭住址', type: 'textarea' }, + { key: 'fatherName', label: '父亲姓名' }, + { key: 'fatherPhone', label: '父亲电话', type: 'tel' }, + { key: 'fatherIdCard', label: '父亲身份证号' }, + { key: 'newbornName', label: '新生儿姓名' }, + { key: 'newbornSex', label: '新生儿性别', type: 'tabs', options: [ + { label: '男', value: '1' }, { label: '女', value: '0' } + ] }, + { key: 'expectedDeliveryDate', label: '预产期', type: 'date', valueFormat: 'YYYYMMDD' }, + { key: 'newbornDeliveryTime', label: '新生儿分娩时间', type: 'datetime' }, + { key: 'newbornIdCard', label: '新生儿身份证' }, + { key: 'deliveryMode', label: '分娩方式', type: 'select', options: [ + { label: '顺产', value: 'sc' }, { label: '剖宫产', value: 'pgc' } + ] }, + { key: 'birthOrder', label: '胎次', type: 'number' }, + { key: 'newbornWeightGram', label: '新生儿体重(克)', type: 'number' }, + { key: 'apgar1min', label: '新生儿评分(1分钟)', type: 'number' }, + { key: 'apgar5min', label: '新生儿评分(5分钟)', type: 'number' }, + { key: 'remark', label: '备注', type: 'textarea' } ] }, { key: 'samples', title: '样本信息', repeat: true, fields: [ { key: 'sampleTypeId', label: '样本类型', type: 'select', optionKey: 'sampleTypes' }, + { key: 'sampleCodeId', label: '样本编码记录', type: 'select', optionKey: 'sampleCodes' }, { key: 'sampleCode', label: '样本编码' }, - { key: 'storageYears', label: '存储年限', type: 'number', source: 'cell_storage' } + { key: 'collectAmount', label: '采集量', type: 'number' }, + { key: 'unitId', label: '计量单位', type: 'select', optionKey: 'units' }, + { key: 'price', label: '单价', type: 'number', source: 'cell_storage' }, + { key: 'originalPrice', label: '原价', type: 'number', source: 'cell_storage' }, + { key: 'storageYears', label: '存储年限', type: 'number', source: 'cell_storage' }, + { key: 'etaTime', label: '预计到达时间', type: 'datetime' }, + { key: 'isSpecial', label: '是否特殊样本', type: 'tabs', options: [ + { label: '否', value: 0 }, { label: '是', value: 1 } + ] }, + { key: 'specialRemark', label: '特殊说明', type: 'textarea' }, + { key: 'remark', label: '备注', type: 'textarea' } ] }, { key: 'payment', title: '缴费信息(只读)', nested: true, readOnly: true, editOnly: true, source: 'cell_storage', fields: [ { key: 'payMode', label: '缴费模式', readOnly: true }, diff --git a/mobile-oa-h5/src/market/modules/sample.js b/mobile-oa-h5/src/market/modules/sample.js deleted file mode 100644 index 57019487..00000000 --- a/mobile-oa-h5/src/market/modules/sample.js +++ /dev/null @@ -1,30 +0,0 @@ -export default { - key: 'sample', - resource: 'samples', - readOnlySources: ['research_donation'], - actions: { specialist: ['view', 'storage-cert', 'insurance'], manager: ['view', 'storage-cert', 'insurance'] }, - listParams: ({ status } = {}) => ({ status: status || '' }), - statusOptions: [{ text: '全部状态', value: '' }], - formOptionsParams(sampleId) { - return { sampleId: String(sampleId || '').trim() } - }, - initialForm(detail, action) { - const handles = (detail?.sections || []).find((section) => section.key === 'market-handles') || {} - const handle = action === 'insurance' ? handles.insurance || {} : handles.storageCert || {} - const fields = ['handleTime', 'handlerUserId', 'attachments', 'remark'] - return { - handle: Object.fromEntries(fields - .filter((key) => Object.prototype.hasOwnProperty.call(handle, key)) - .map((key) => [key, handle[key]])) - } - }, - detailSections(detail) { - return Array.isArray(detail?.sections) ? detail.sections : [] - }, - handleFields: [ - { key: 'handleTime', label: '办理时间', type: 'datetime' }, - { key: 'handlerUserId', label: '办理人员', type: 'select', optionKey: 'users' }, - { key: 'attachments', label: '附件', type: 'upload' }, - { key: 'remark', label: '备注', type: 'textarea' } - ] -} diff --git a/mobile-oa-h5/src/market/presign/presign-components-contract.test.js b/mobile-oa-h5/src/market/presign/presign-components-contract.test.js new file mode 100644 index 00000000..bcf5662b --- /dev/null +++ b/mobile-oa-h5/src/market/presign/presign-components-contract.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const componentSource = async (name) => readFile( + new URL(`../components/${name}.vue`, import.meta.url), + 'utf8' +) + +test('presign select uses Vant picker and supports searchable multi-select', async () => { + const source = await componentSource('PresignSelectField') + assert.match(source, / { + const source = await componentSource('PresignAttachmentUploader') + const cards = await componentSource('MarketAttachmentCards') + + assert.match(source, / { + const source = await componentSource('PresignTransferCard') + assert.match(source, /allowedActions/) + assert.match(source, /submit-transfer/) + assert.match(source, /withdraw-transfer/) + assert.match(source, /min-height:\s*44px/) + assert.doesNotMatch(source, /]*>\s*\{\{\s*modelValue\.id\s*\}\}\s*<\/small>/) +}) diff --git a/mobile-oa-h5/src/market/presign/presign-detail-actions.test.js b/mobile-oa-h5/src/market/presign/presign-detail-actions.test.js new file mode 100644 index 00000000..797c600c --- /dev/null +++ b/mobile-oa-h5/src/market/presign/presign-detail-actions.test.js @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const source = await readFile( + new URL('../../views/market/MarketModuleDetail.vue', import.meta.url), + 'utf8' +) + +test('presign detail handles order deletion and every transfer workflow action', () => { + assert.match(source, /deletePresignOrder/) + assert.match(source, /deletePresignTransfer/) + assert.match(source, /submitPresignTransfer/) + assert.match(source, /withdrawPresignTransfer/) + assert.match(source, /edit-presign-transfer/) + assert.match(source, /create-transfer/) +}) + +test('generic detail summary recognizes presign title and status fields', () => { + assert.match(source, /detail\.value\?\.motherName/) + assert.match(source, /detail\.value\?\.orderStatus/) +}) diff --git a/mobile-oa-h5/src/market/presign/presign-form-state.js b/mobile-oa-h5/src/market/presign/presign-form-state.js index e3a4e58c..58807f26 100644 --- a/mobile-oa-h5/src/market/presign/presign-form-state.js +++ b/mobile-oa-h5/src/market/presign/presign-form-state.js @@ -1,6 +1,5 @@ const ORDER_WRITABLE_FIELDS = Object.freeze([ 'orderDate', - 'protocolNo', 'motherName', 'expectedDeliveryDate', 'regionId', @@ -77,6 +76,22 @@ export function changePresignRegion(form, regionId) { return next } +export function mergePersistedTransfer(transfers, target, persisted) { + const rows = Array.isArray(transfers) ? transfers : [] + const targetId = String(target?.id || '') + const targetKey = String(target?._localKey || '') + return rows.map((row) => { + const sameId = targetId && String(row?.id || '') === targetId + const sameKey = targetKey && String(row?._localKey || '') === targetKey + if (row !== target && !sameId && !sameKey) return row + return { + ...row, + ...(persisted && typeof persisted === 'object' ? persisted : {}), + _localKey: row?._localKey || targetKey + } + }) +} + export function isPresignLocked(form) { return String(form?.orderStatus || '').trim() === 'converted' } diff --git a/mobile-oa-h5/src/market/presign/presign-form-state.test.js b/mobile-oa-h5/src/market/presign/presign-form-state.test.js index 5c3ebff7..95b85a4e 100644 --- a/mobile-oa-h5/src/market/presign/presign-form-state.test.js +++ b/mobile-oa-h5/src/market/presign/presign-form-state.test.js @@ -7,7 +7,8 @@ import { changePresignRegion, createPresignForm, isPresignLocked, - isPresignTransferLocked + isPresignTransferLocked, + mergePersistedTransfer } from './presign-form-state.js' test('creates every Web-writable field with mobile defaults', () => { @@ -66,6 +67,22 @@ test('changing region clears stale hospital and manager selections', () => { ) }) +test('records a newly persisted transfer id so retries update instead of duplicate create', () => { + const transfers = [ + { _localKey: 'local-1', transferAmount: 100 }, + { _localKey: 'local-2', transferAmount: 200 } + ] + const result = mergePersistedTransfer(transfers, transfers[0], { + id: 'transfer-1', + payStatus: 'dfj', + allowedActions: ['edit', 'delete', 'submit'] + }) + + assert.equal(result[0].id, 'transfer-1') + assert.deepEqual(result[0].allowedActions, ['edit', 'delete', 'submit']) + assert.equal(result[1], transfers[1]) +}) + test('keeps void orders editable while converted orders are locked', () => { assert.equal(isPresignLocked({ orderStatus: 'converted' }), true) assert.equal(isPresignLocked({ orderStatus: 'active' }), false) @@ -125,7 +142,6 @@ test('builds an order payload from only backend-writable camelCase fields', () = assert.deepEqual(payload, { orderDate: '20261011', - protocolNo: '', motherName: '张女士', expectedDeliveryDate: '20270101', regionId: 'region-1', diff --git a/mobile-oa-h5/src/market/presign/presign-form-view.test.js b/mobile-oa-h5/src/market/presign/presign-form-view.test.js new file mode 100644 index 00000000..9fe53b8b --- /dev/null +++ b/mobile-oa-h5/src/market/presign/presign-form-view.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const source = await readFile( + new URL('../../views/market/presign/PresignOrderForm.vue', import.meta.url), + 'utf8' +) + +test('presign form contains every Web-writable order field', () => { + for (const key of [ + 'orderDate', + 'motherName', + 'expectedDeliveryDate', + 'regionId', + 'hospitalId', + 'collectorUserId', + 'regionManagerUserId', + 'sampleCollectTime', + 'payMode', + 'contractAmount', + 'orderStatus', + 'sampleTypeIds', + 'remark' + ]) { + assert.match(source, new RegExp(`form\\.${key}`), `${key} is missing`) + } + // 协议编号由服务端发号,表单不展示、不可编辑 + assert.doesNotMatch(source, /v-model="form\.protocolNo"/) + assert.doesNotMatch(source, /