zlwu hace 3 meses
padre
commit
6eea13370c
Se han modificado 100 ficheros con 4497 adiciones y 1909 borrados
  1. 54 1
      backend/app/api/collection_protocol.py
  2. 11 0
      backend/app/api/menu.py
  3. 152 9
      backend/app/api/qc.py
  4. 12 0
      backend/app/api/research_donation.py
  5. 7 0
      backend/app/core/qc_material_amount_auth_constants.py
  6. 16 0
      backend/app/crud/collection_protocol.py
  7. 4 0
      backend/app/crud/pre_transit_sample.py
  8. 226 0
      backend/app/crud/qc_material_amount_auth.py
  9. 18 0
      backend/app/crud/research_donation.py
  10. 6 0
      backend/app/crud/sample_reception.py
  11. 0 0
      backend/app/graphify-out/cache/ast/068deb832899a17291a690fc6bb3644fb86fe0e0965e2f2f7c59f00a0d3fee96.json
  12. 0 0
      backend/app/graphify-out/cache/ast/2f4f7f87c5078deceea7a22f467092910348541ce9e741b79fe058d6cb66cb7a.json
  13. 0 0
      backend/app/graphify-out/cache/ast/3c10cffbc9a3dc991dd9283ddb3d47a803de1e9c74933ad6546798d36368894b.json
  14. 0 0
      backend/app/graphify-out/cache/ast/4413154b65d012ece8e4bd73b9b6edaed289dab3848b63cdf9c072dc91c2e5d1.json
  15. 0 0
      backend/app/graphify-out/cache/ast/4878dc4ddf7f60575ad278dca173b55e9ed824f0182f714b588bff49e0c251c9.json
  16. 0 0
      backend/app/graphify-out/cache/ast/743a68af29f5fe9fd6323ddccf52422ae80f3eb9b9ecd29986b5f8157c6899af.json
  17. 1 0
      backend/app/graphify-out/cache/ast/92878e8a96a4cfa5028e490f6d15af91fdd9ddc2ae95f2e036e5ca9d4319c06a.json
  18. 1 0
      backend/app/graphify-out/cache/ast/93af82a4a5c3095363f54261bebaf04a90ba7ee2d4fb923c54489ce6a81f7f37.json
  19. 0 0
      backend/app/graphify-out/cache/ast/b15b83818b5879733ebca2be855e7aaada60a41c88c5fb73a699837c4ae235d9.json
  20. 0 0
      backend/app/graphify-out/cache/ast/b972eeaabd049fdac33d747722ea7ab59152d7c2158a7d8dd4906ffd3b5d9a31.json
  21. 0 0
      backend/app/graphify-out/cache/ast/c43e91deb2dad165b6cb23fd466de3395a54b0554c34faa9b4d0c26964f9b224.json
  22. 0 0
      backend/app/graphify-out/cache/ast/d0875303e05d604a88d274fc4bfb770700b6972c74ca6da31222442493eb6aa0.json
  23. 0 0
      backend/app/graphify-out/cache/ast/d56aa6ab4bb2fb67831e1953609437dda70977394df103c34c997a13a19261dd.json
  24. 0 0
      backend/app/graphify-out/cache/ast/e0eff33579bca3936785ededf4cafbdbbbc1027ede96684abc86635721658120.json
  25. 0 0
      backend/app/graphify-out/cache/ast/ea03ca8b10d84a7ec819a19f06b43fdff16aa0edf5b47b42611221cad4a0622b.json
  26. 0 0
      backend/app/graphify-out/cache/ast/f9325a12903e5930b9c1e99b8928658f20de1bc149e1f62435a54f961460c189.json
  27. 2 0
      backend/app/models/models.py
  28. 3 0
      backend/app/models/research_donation.py
  29. 479 339
      backend/graphify-out/.graphify_analysis.json
  30. 334 320
      backend/graphify-out/.graphify_extract.json
  31. 119 82
      backend/graphify-out/GRAPH_REPORT.md
  32. 1 1
      backend/graphify-out/graph.html
  33. 128 110
      backend/graphify-out/graph.json
  34. 13 11
      backend/graphify-out/manifest.json
  35. 5 0
      docs/sql/migrate-donation-infectious-disease-report.sql
  36. 6 0
      docs/sql/migrate-protocol-consent-health-survey.sql
  37. 82 0
      docs/sql/migrate-qc-material-amount-auth.sql
  38. 4 1
      docs/sql/zkxw.sql
  39. 51 0
      docs/superpowers/plans/2026-07-13-sample-card-protocol-attachment.md
  40. 61 0
      docs/superpowers/specs/2026-07-13-protocol-consent-health-survey-design.md
  41. 219 0
      docs/superpowers/specs/2026-07-13-sample-card-protocol-attachment-design.md
  42. 450 492
      web-admin/graphify-out/.graphify_analysis.json
  43. 279 89
      web-admin/graphify-out/.graphify_extract.json
  44. 152 134
      web-admin/graphify-out/GRAPH_REPORT.md
  45. 1 1
      web-admin/graphify-out/graph.html
  46. 136 109
      web-admin/graphify-out/graph.json
  47. 20 15
      web-admin/graphify-out/manifest.json
  48. 43 0
      web-admin/src/api/qc-material-amount-auth.ts
  49. 222 0
      web-admin/src/components/business/protocol-attachment-mini-readonly/index.vue
  50. 12 9
      web-admin/src/components/business/sample-code-with-special-display/index.vue
  51. 25 15
      web-admin/src/components/business/special-sample-display/index.vue
  52. 28 0
      web-admin/src/composables/useAttachmentActions.ts
  53. 0 0
      web-admin/src/graphify-out/cache/ast/04552f64934bfbb9a4aa11779cfc00792dbd444bb4b377c3a6f1f90aee6ea614.json
  54. 1 0
      web-admin/src/graphify-out/cache/ast/06ee05d39776487253211de6b0654506bf950ec692ba229fa435dd81a78ea339.json
  55. 1 0
      web-admin/src/graphify-out/cache/ast/14516ebd2a273c2aa98f94f75088e8e4c7fc6a95f7d487c72ca3b00341dd41cf.json
  56. 1 0
      web-admin/src/graphify-out/cache/ast/1a58caa72d2c7797db4ccefeb407d16efdaad8c8c203898f2cbd3cd35aa7a806.json
  57. 0 0
      web-admin/src/graphify-out/cache/ast/1bc1f23024a71aadc5f144c71e077f7c71497e2d219e5a8e22145bce1e81dd82.json
  58. 1 0
      web-admin/src/graphify-out/cache/ast/20b5c6f93b63479a33a1bf074fe65ed1ef9e732255adddf326a653483584ecd3.json
  59. 0 0
      web-admin/src/graphify-out/cache/ast/260a86725af577c4ee3bfa672b233307087310d8326e84653099c3beaea96999.json
  60. 0 0
      web-admin/src/graphify-out/cache/ast/29800c2dd6181b09ae104fa5958a3268c387cdee5c8c1f8be7e7f8c92966f132.json
  61. 1 0
      web-admin/src/graphify-out/cache/ast/31c120793a813993ffbc0655d8c1df464a392f5f1a1f898bbd77e629cbaf0784.json
  62. 1 0
      web-admin/src/graphify-out/cache/ast/39179fd2bc7fe4f8f423325bb7f056904fed40cee3b1d86f701baf6f2caffd63.json
  63. 1 0
      web-admin/src/graphify-out/cache/ast/3a8b693651d750a3e44e4d4327fe04d043bd5c990bc5b2fa52b46be26cd74c03.json
  64. 1 0
      web-admin/src/graphify-out/cache/ast/3d24c625eb0eb5339c622a6d57dc1bfe21395df861b5cdcddc9f44e0844ec5a9.json
  65. 0 0
      web-admin/src/graphify-out/cache/ast/3e2877646fbef7a12b2857d98581a724a09b5e5814fdc96d068698207fbdcbd3.json
  66. 0 0
      web-admin/src/graphify-out/cache/ast/3eb439ae9676d9ce2c45f2b5be9c02da6132b6fa2da16803525230d92744c514.json
  67. 1 0
      web-admin/src/graphify-out/cache/ast/55df8487ec42be1a1c45007b9fb500858f4ee9ce23a4fe10f76b5bf146b307b9.json
  68. 1 0
      web-admin/src/graphify-out/cache/ast/5cfee7cd694f792bbc2ad34f587de49176f4cb411e61377f424db9e6a7572180.json
  69. 1 0
      web-admin/src/graphify-out/cache/ast/684e1e42458eb8dc36fea98605ab068d46c21bd13029cbc88274e739b6211061.json
  70. 0 0
      web-admin/src/graphify-out/cache/ast/6ba54761673800740275cd23a829a4e665a0063b12c6d138264cd3b593fab556.json
  71. 0 0
      web-admin/src/graphify-out/cache/ast/72f5546a16450218fd4211f41bc79d92ffe2bc73558f5a25ebb0ae002068a6d7.json
  72. 0 0
      web-admin/src/graphify-out/cache/ast/7ad7f5c535844cca608724769662ef7c224bc06178ce8ff0c3cc86207705fcf9.json
  73. 0 0
      web-admin/src/graphify-out/cache/ast/7fc55960df9394d35c9ba3e75a35f64b41299d881cb62b8e524a7ce3325bc1c0.json
  74. 1 0
      web-admin/src/graphify-out/cache/ast/80a777aac98ff466fed629d733ff61332a1a0d242d7f8072f479ed182e85f76c.json
  75. 1 0
      web-admin/src/graphify-out/cache/ast/88a661bceb0c617756378da0418f4dea138398aa5633fa55bf12078f5e376acb.json
  76. 0 0
      web-admin/src/graphify-out/cache/ast/8ddbf33903b24278a000a8c8de8e58f8862ccd2b70044c7eb9f266abb3e73bfa.json
  77. 0 0
      web-admin/src/graphify-out/cache/ast/91637564c702f3ce7724bfb7f67dd44316a641767ce3212346ab5acdacc6bbd0.json
  78. 1 0
      web-admin/src/graphify-out/cache/ast/938ae0b61acf7d5fda5c3951ce27d53cfe38194a576cbf015d58daaa2f34e08d.json
  79. 1 0
      web-admin/src/graphify-out/cache/ast/94f792d188d7ae69bd0300daf0d6d4bee0a38da41199171c7a97a958a4917a5d.json
  80. 1 0
      web-admin/src/graphify-out/cache/ast/a164550a822f6c951bb23239b60e330d84670ded853bb2e82c06560d413d6e13.json
  81. 0 0
      web-admin/src/graphify-out/cache/ast/a7251ca9410680ac9ff9aebef287feb50c6239b367449d2f6f8be0c5fdee171d.json
  82. 1 0
      web-admin/src/graphify-out/cache/ast/ac34bbf3abcfb4d901f650f62976641be088126130d91d75abb67fe83c6f817e.json
  83. 1 0
      web-admin/src/graphify-out/cache/ast/b3b7c8be7ed841e4aacfa019fb843144cb4e0f52dcd116c35b7af757af9d76f0.json
  84. 1 0
      web-admin/src/graphify-out/cache/ast/b99ab5d6f7618ee24cb9a42cdc46c2bd07f1b44ac4703302f6bdcb4c77b4442b.json
  85. 0 0
      web-admin/src/graphify-out/cache/ast/c021f5253ced109729f52a4001b0dd0a8c039d346b62a9769ecf0b6cfca8ff1b.json
  86. 0 0
      web-admin/src/graphify-out/cache/ast/c3e90aa505646052f67eeec8554ff46ad7483ecde65a4dedd0d20446684cbd10.json
  87. 0 0
      web-admin/src/graphify-out/cache/ast/c648411917880b21b2e6bdb2c3dc21c7782ac2d94ae2ab927e1b78c39b66e404.json
  88. 1 0
      web-admin/src/graphify-out/cache/ast/cda47123ee59f216c4f6361d17c0fc07d29490e4bdd8edd83ddeb20ee30731c7.json
  89. 0 0
      web-admin/src/graphify-out/cache/ast/d11b497961394ab2cd52e7090931dfc3212c6a73bcb0c01f654b275081dbb375.json
  90. 0 0
      web-admin/src/graphify-out/cache/ast/dd842bb915df5161ee4307592a436f5cfe5ed5b655061a1438ce4f54ed7f493e.json
  91. 1 0
      web-admin/src/graphify-out/cache/ast/ddffbeb0bfe884d2239a7a27bd35dd8c1340b76d2276b6a9b6867eda23175a55.json
  92. 1 0
      web-admin/src/graphify-out/cache/ast/f19b346efc330959cd49caddc040db6e746523d27b0f03ceb96f9b7af77c7f95.json
  93. 11 0
      web-admin/src/router/modules/sqxp.ts
  94. 26 0
      web-admin/src/utils/protocol-source-attachment.ts
  95. 334 31
      web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue
  96. 42 6
      web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue
  97. 224 102
      web-admin/src/views/quality-inspection/pre-transit-sample/PreTransitProtocolCardList.vue
  98. 391 0
      web-admin/src/views/quality-inspection/qc-material-amount-auth/index.vue
  99. 35 27
      web-admin/src/views/quality-inspection/qc-material/index.vue
  100. 29 5
      web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue

+ 54 - 1
backend/app/api/collection_protocol.py

@@ -180,6 +180,8 @@ class ProtocolCreateBody(BaseModel):
     protocol_no_id: Optional[str] = Field(None, alias="protocolNoId")
     protocol_status: str = Field("dsx", alias="protocolStatus")
     protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments")
+    consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
+    health_survey_form_urls: Optional[str] = Field(None, alias="healthSurveyFormUrls")
     register_date: Optional[str] = Field(None, alias="registerDate")
     collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
     remark: Optional[str] = None
@@ -189,6 +191,8 @@ class ProtocolCreateBody(BaseModel):
     payment: Optional[dict] = Field(None, alias="payment")
     payment_details: Optional[list] = Field(None, alias="paymentDetails")
     attachments: Optional[list] = Field(None, alias="attachments")
+    consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
+    health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
     storage_cert: Optional[dict] = Field(None, alias="storageCert")
 
 
@@ -198,6 +202,8 @@ class ProtocolUpdateBody(BaseModel):
     protocol_no: Optional[str] = Field(None, alias="protocolNo")
     protocol_status: Optional[str] = Field(None, alias="protocolStatus")
     protocol_attachments: Optional[str] = Field(None, alias="protocolAttachments")
+    consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
+    health_survey_form_urls: Optional[str] = Field(None, alias="healthSurveyFormUrls")
     register_date: Optional[str] = Field(None, alias="registerDate")
     effective_time: Optional[str] = Field(None, alias="effectiveTime")
     effective_desc: Optional[str] = Field(None, alias="effectiveDesc")
@@ -216,6 +222,8 @@ class ProtocolUpdateBody(BaseModel):
     payment: Optional[dict] = Field(None, alias="payment")
     payment_details: Optional[list] = Field(None, alias="paymentDetails")
     attachments: Optional[list] = Field(None, alias="attachments")
+    consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
+    health_survey_attachments: Optional[list] = Field(None, alias="healthSurveyAttachments")
     storage_cert: Optional[dict] = Field(None, alias="storageCert")
 
 
@@ -229,6 +237,26 @@ def _resolve_protocol_attachments(
     return protocol_attachments
 
 
+def _resolve_consent_form_urls(
+    consent_form_urls: Optional[str],
+    consent_attachments: Optional[list],
+) -> Optional[str]:
+    from_urls = cp_svc.attachments_to_urls(consent_attachments)
+    if from_urls:
+        return from_urls
+    return consent_form_urls
+
+
+def _resolve_health_survey_form_urls(
+    health_survey_form_urls: Optional[str],
+    health_survey_attachments: Optional[list],
+) -> Optional[str]:
+    from_urls = cp_svc.attachments_to_urls(health_survey_attachments)
+    if from_urls:
+        return from_urls
+    return health_survey_form_urls
+
+
 def _persist_protocol_sub_resources(
     db: Session,
     protocol_id: str,
@@ -277,6 +305,14 @@ async def create_collection_protocol(
             body.protocol_attachments,
             body.attachments,
         )
+        consent_form_urls = _resolve_consent_form_urls(
+            body.consent_form_urls,
+            body.consent_attachments,
+        )
+        health_survey_form_urls = _resolve_health_survey_form_urls(
+            body.health_survey_form_urls,
+            body.health_survey_attachments,
+        )
         collector = (body.collector_user_id or "").strip()
         if not collector:
             collector = user_id
@@ -286,6 +322,8 @@ async def create_collection_protocol(
             protocol_no_id=body.protocol_no_id,
             protocol_status=body.protocol_status,
             protocol_attachments=protocol_attachments,
+            consent_form_urls=consent_form_urls,
+            health_survey_form_urls=health_survey_form_urls,
             register_date=body.register_date,
             collector_user_id=collector,
             remark=body.remark,
@@ -325,7 +363,8 @@ async def update_collection_protocol(
         payload = body.model_dump(exclude_unset=True, by_alias=False)
         sub_keys = {
             "party_customer", "collection_info", "samples",
-            "payment", "payment_details", "attachments", "storage_cert",
+            "payment", "payment_details", "attachments",
+            "consent_attachments", "health_survey_attachments", "storage_cert",
         }
         main_payload = {k: v for k, v in payload.items() if k not in sub_keys}
         if body.attachments is not None:
@@ -335,6 +374,20 @@ async def update_collection_protocol(
             )
             if resolved is not None:
                 main_payload["protocol_attachments"] = resolved
+        if body.consent_attachments is not None:
+            resolved = _resolve_consent_form_urls(
+                main_payload.get("consent_form_urls"),
+                body.consent_attachments,
+            )
+            if resolved is not None:
+                main_payload["consent_form_urls"] = resolved
+        if body.health_survey_attachments is not None:
+            resolved = _resolve_health_survey_form_urls(
+                main_payload.get("health_survey_form_urls"),
+                body.health_survey_attachments,
+            )
+            if resolved is not None:
+                main_payload["health_survey_form_urls"] = resolved
         obj = cp_svc.update_collection_protocol(
             db,
             protocol_id,

+ 11 - 0
backend/app/api/menu.py

@@ -661,6 +661,17 @@ STATIC_MENU_TREE = [
                         "component": "/quality-inspection/qc-material/index",
                         "meta": {"title": "质检物料库", "keepAlive": True, "menuCode": "biz:qcMaterial:list"},
                     },
+                    {
+                        "id": "95100000000000000000000000000001",
+                        "name": "QualityInspectionQcMaterialAmountAuth",
+                        "path": "qc-material-amount-auth",
+                        "component": "/quality-inspection/qc-material-amount-auth/index",
+                        "meta": {
+                            "title": "质检物料金额查看授权",
+                            "keepAlive": True,
+                            "menuCode": "biz:qcMaterialAmountAuth:list",
+                        },
+                    },
                 ],
             },
         ],

+ 152 - 9
backend/app/api/qc.py

@@ -34,6 +34,8 @@ from app.crud import qc_material as mat_svc
 from app.crud import qc_material_stock_batch as batch_svc
 from app.crud import qc_material_stock_check as stock_check_svc
 from app.crud import qc_material_usage as mu_svc
+from app.crud import qc_material_amount_auth as amount_auth_svc
+from app.core.qc_material_amount_auth_constants import QC_MATERIAL_AMOUNT_AUTH_LIST_PERM
 from app.crud import inbound_sample_special as inbound_sample_special_svc
 from app.crud import qc_work_order as wo_svc
 from app.crud.collection_protocol import total_pages
@@ -1280,6 +1282,25 @@ def _maybe_strip_qc_material_amount_fields(
     strip_row_fields(rows, *QC_MATERIAL_AMOUNT_FIELD_NAMES)
 
 
+def _user_can_view_qc_material_amount(db: Session, user: dict) -> bool:
+    return user_has_menu_perm(db, user, QC_MATERIAL_VIEW_AMOUNT_PERM)
+
+
+def _strip_amount_from_write_data(db: Session, user: dict, data: Dict[str, Any]) -> None:
+    if _user_can_view_qc_material_amount(db, user):
+        return
+    for key in ("unit_price", "unitPrice", "amount"):
+        data.pop(key, None)
+
+
+def _resolve_stock_in_amounts(
+    db: Session, user: dict, unit_price: Any, amount: Any
+) -> tuple:
+    if _user_can_view_qc_material_amount(db, user):
+        return unit_price, amount
+    return 0, 0
+
+
 def _maybe_strip_qc_third_party_fee_fields(
     db: Session, user: dict, data: Dict[str, Any]
 ) -> None:
@@ -1507,6 +1528,7 @@ async def update_qc_material_batch(
         tenant_no = _get_tenant_no(db, user)
         user_id = _get_current_user_id(user)
         data = body.model_dump(exclude_none=True)
+        _strip_amount_from_write_data(db, user, data)
         obj = batch_svc.update_batch(
             db,
             batch_id,
@@ -1669,8 +1691,13 @@ async def batch_stock_in_qc_material(
             if not rt:
                 return error_response(400, "入库时间格式无效")
             operate_time = rt
-        items = [
-            {
+        items = []
+        for x in body.items:
+            unit_price, amount = _resolve_stock_in_amounts(
+                db, user, x.unit_price, x.amount
+            )
+            items.append(
+                {
                 "material_id": x.material_id,
                 "materialId": x.material_id,
                 "stock_in_no": x.stock_in_no,
@@ -1684,14 +1711,13 @@ async def batch_stock_in_qc_material(
                 "batch_no": x.batch_no,
                 "batchNo": x.batch_no,
                 "manufacturer": x.manufacturer,
-                "unit_price": x.unit_price,
-                "unitPrice": x.unit_price,
-                "amount": x.amount,
+                "unit_price": unit_price,
+                "unitPrice": unit_price,
+                "amount": amount,
                 "qty": x.qty,
                 "remark": x.remark,
             }
-            for x in body.items
-        ]
+            )
         count = batch_svc.batch_stock_in(
             db,
             items=items,
@@ -1803,6 +1829,9 @@ async def stock_in_qc_material(
             operate_time = rt
         from decimal import Decimal
 
+        unit_price, amount = _resolve_stock_in_amounts(
+            db, user, body.unit_price, body.amount
+        )
         obj = batch_svc.stock_in_one(
             db,
             material_id,
@@ -1812,8 +1841,8 @@ async def stock_in_qc_material(
             catalog_no=body.catalog_no,
             batch_no=body.batch_no,
             manufacturer=body.manufacturer,
-            unit_price=body.unit_price,
-            amount=body.amount,
+            unit_price=unit_price,
+            amount=amount,
             qty=Decimal(str(body.qty)),
             remark=body.remark,
             tenant_no=tenant_no,
@@ -2181,3 +2210,117 @@ async def cancel_qc_material_stock_check(
         return error_response(400, str(e))
     except Exception as e:
         return error_response(400, str(e))
+
+
+# ---------- 质检物料金额查看授权 ----------
+
+
+class QcMaterialAmountAuthGrantBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    user_id: str = Field(..., alias="userId")
+
+
+class QcMaterialAmountAuthBatchBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    user_ids: List[str] = Field(..., alias="userIds", min_length=1)
+
+
+@router.get("/qc-material-amount-auth/users")
+async def list_qc_material_amount_auth_users(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100),
+    keyword: Optional[str] = None,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
+):
+    """已授权「质检物料金额查看」角色的用户列表。"""
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items, total = amount_auth_svc.list_authorized_users(
+            db,
+            tenant_no=tenant_no,
+            page=page,
+            page_size=pageSize,
+            keyword=keyword,
+        )
+        return success_response(_page_payload(items, total, page, pageSize))
+    except Exception as e:
+        return error_response(400, str(e))
+
+
+@router.post("/qc-material-amount-auth/users")
+async def grant_qc_material_amount_auth(
+    body: QcMaterialAmountAuthGrantBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
+):
+    """为用户绑定「质检物料金额查看」专用角色。"""
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        operator_id = _get_current_user_id(user)
+        created = amount_auth_svc.grant_amount_view_role(
+            db,
+            tenant_no=tenant_no,
+            user_id=body.user_id.strip(),
+            operator_id=operator_id,
+        )
+        msg = "授权成功" if created else "该用户已授权"
+        return success_response({"created": created}, message=msg)
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
+
+
+@router.delete("/qc-material-amount-auth/users/{user_id}")
+async def revoke_qc_material_amount_auth(
+    user_id: str,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
+):
+    """解除用户的「质检物料金额查看」专用角色。"""
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        operator_id = _get_current_user_id(user)
+        removed = amount_auth_svc.revoke_amount_view_role(
+            db,
+            tenant_no=tenant_no,
+            user_id=user_id.strip(),
+            operator_id=operator_id,
+        )
+        if not removed:
+            return error_response(400, "该用户未授权或已解除")
+        return success_response(None, message="已解除授权")
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
+
+
+@router.post("/qc-material-amount-auth/users/batch-revoke")
+async def batch_revoke_qc_material_amount_auth(
+    body: QcMaterialAmountAuthBatchBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
+):
+    """批量解除「质检物料金额查看」授权。"""
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        operator_id = _get_current_user_id(user)
+        count = 0
+        for uid in body.user_ids:
+            uid = (uid or "").strip()
+            if not uid:
+                continue
+            if amount_auth_svc.revoke_amount_view_role(
+                db,
+                tenant_no=tenant_no,
+                user_id=uid,
+                operator_id=operator_id,
+            ):
+                count += 1
+        return success_response({"count": count}, message=f"已解除 {count} 项授权")
+    except ValueError as e:
+        return error_response(400, str(e))
+    except Exception as e:
+        return error_response(400, str(e))

+ 12 - 0
backend/app/api/research_donation.py

@@ -49,6 +49,12 @@ class DonationCreateBody(BaseModel):
     consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
     attachment_urls: Optional[str] = Field(None, alias="attachmentUrls")
     other_attachments: Optional[list] = Field(None, alias="otherAttachments")
+    infectious_disease_report_urls: Optional[str] = Field(
+        None, alias="infectiousDiseaseReportUrls"
+    )
+    infectious_disease_report_attachments: Optional[list] = Field(
+        None, alias="infectiousDiseaseReportAttachments"
+    )
     remark: Optional[str] = None
     party: Optional[dict] = None
     party_customer: Optional[dict] = Field(None, alias="partyCustomer")
@@ -70,6 +76,12 @@ class DonationUpdateBody(BaseModel):
     consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
     attachment_urls: Optional[str] = Field(None, alias="attachmentUrls")
     other_attachments: Optional[list] = Field(None, alias="otherAttachments")
+    infectious_disease_report_urls: Optional[str] = Field(
+        None, alias="infectiousDiseaseReportUrls"
+    )
+    infectious_disease_report_attachments: Optional[list] = Field(
+        None, alias="infectiousDiseaseReportAttachments"
+    )
     remark: Optional[str] = None
     party: Optional[dict] = None
     party_customer: Optional[dict] = Field(None, alias="partyCustomer")

+ 7 - 0
backend/app/core/qc_material_amount_auth_constants.py

@@ -0,0 +1,7 @@
+"""质检物料金额查看授权:专用角色与菜单权限常量。"""
+
+QC_MATERIAL_AMOUNT_VIEW_ROLE_ID = "ROLE000000000000000000000000000011"
+QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME = "质检物料金额查看"
+
+QC_MATERIAL_AMOUNT_AUTH_LIST_PERM = "biz:qcMaterialAmountAuth:list"
+QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME = "质量安全部"

+ 16 - 0
backend/app/crud/collection_protocol.py

@@ -343,6 +343,8 @@ def _protocol_to_dict(p: BizCollectionProtocol) -> Dict[str, Any]:
         "protocolNo": p.protocol_no or "",
         "protocolStatus": p.protocol_status or "",
         "protocolAttachments": p.protocol_attachments or "",
+        "consentFormUrls": p.consent_form_urls or "",
+        "healthSurveyFormUrls": p.health_survey_form_urls or "",
         "registerDate": (p.register_date or "").strip() if p.register_date else "",
         "effectiveTime": _fmt_dt(p.effective_time),
         "effectiveDesc": p.effective_desc or "",
@@ -1296,6 +1298,10 @@ def get_protocol_detail_bundle(
     delivery_dt = pc.newborn_delivery_time if pc else None
     out["fullmoonTodo"] = _fullmoon_todo_detail_dict(ft, newborn_delivery_time=delivery_dt)
     out["attachments"] = urls_to_attachment_list(p.protocol_attachments, db, tenant_no)
+    out["consentAttachmentFiles"] = urls_to_attachment_list(p.consent_form_urls, db, tenant_no)
+    out["healthSurveyAttachmentFiles"] = urls_to_attachment_list(
+        p.health_survey_form_urls, db, tenant_no
+    )
     if pay:
         detail_rows = list_payment_details(db, pay.id, tenant_no)
         details_out: List[Dict[str, Any]] = []
@@ -2232,6 +2238,8 @@ def create_collection_protocol(
     protocol_no: str = "",
     protocol_status: str = "dsx",
     protocol_attachments: Optional[str] = None,
+    consent_form_urls: Optional[str] = None,
+    health_survey_form_urls: Optional[str] = None,
     register_date: Optional[str] = None,
     collector_user_id: Optional[str] = None,
     remark: Optional[str] = None,
@@ -2250,6 +2258,8 @@ def create_collection_protocol(
         protocol_no=pn,
         protocol_status=protocol_status.strip() or "dsx",
         protocol_attachments=(protocol_attachments or "").strip() or None,
+        consent_form_urls=(consent_form_urls or "").strip() or None,
+        health_survey_form_urls=(health_survey_form_urls or "").strip() or None,
         register_date=(register_date or "").strip() or None,
         collector_user_id=(collector_user_id or "").strip() or None,
         tenant_no=tenant_no,
@@ -2278,6 +2288,8 @@ def update_collection_protocol(
     protocol_no: Optional[str] = None,
     protocol_status: Optional[str] = None,
     protocol_attachments: Optional[str] = None,
+    consent_form_urls: Optional[str] = None,
+    health_survey_form_urls: Optional[str] = None,
     register_date: Optional[str] = None,
     effective_time: Optional[datetime] = None,
     effective_desc: Optional[str] = None,
@@ -2307,6 +2319,10 @@ def update_collection_protocol(
                 raise ValueError("请选择作废类型")
     if protocol_attachments is not None:
         obj.protocol_attachments = protocol_attachments.strip() or None
+    if consent_form_urls is not None:
+        obj.consent_form_urls = consent_form_urls.strip() or None
+    if health_survey_form_urls is not None:
+        obj.health_survey_form_urls = health_survey_form_urls.strip() or None
     if register_date is not None:
         obj.register_date = register_date.strip() or None
     if effective_time is not None:

+ 4 - 0
backend/app/crud/pre_transit_sample.py

@@ -17,6 +17,7 @@ from app.crud.collection_protocol import (
     _party_customer_to_dict,
     _protocol_to_dict,
     total_pages,
+    urls_to_attachment_list,
 )
 from app.utils.sample_special import sample_special_to_dict
 from app.crud.research_donation import (
@@ -668,6 +669,9 @@ def _hydrate_collection_rows(
         if not r:
             continue
         d = _protocol_to_dict(r)
+        d["healthSurveyAttachmentFiles"] = urls_to_attachment_list(
+            r.health_survey_form_urls, db, tenant_no
+        )
         d["sourceType"] = "cell_storage"
         d["sourceTypeLabel"] = "细胞存储"
         d["collectionProtocolId"] = r.id

+ 226 - 0
backend/app/crud/qc_material_amount_auth.py

@@ -0,0 +1,226 @@
+"""质检物料金额查看授权 CRUD(sys_user_role 绑定专用角色)。"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Tuple
+
+from sqlalchemy import and_, or_
+from sqlalchemy.orm import Session
+
+from app.core.qc_material_amount_auth_constants import (
+    QC_MATERIAL_AMOUNT_VIEW_ROLE_ID,
+    QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME,
+    QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME,
+)
+from app.core.data_scope import _get_child_dept_ids
+from app.core.utils import generate_uuid
+from app.models.models import SysDept, SysRole, SysUser, SysUserRole
+
+
+def get_amount_view_role(db: Session, tenant_no: str) -> Optional[SysRole]:
+    row = (
+        db.query(SysRole)
+        .filter(
+            SysRole.id == QC_MATERIAL_AMOUNT_VIEW_ROLE_ID,
+            SysRole.tenant_no == tenant_no,
+            SysRole.del_flag == 0,
+            SysRole.status == "1",
+        )
+        .first()
+    )
+    if row:
+        return row
+    return (
+        db.query(SysRole)
+        .filter(
+            SysRole.role_name == QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME,
+            SysRole.tenant_no == tenant_no,
+            SysRole.del_flag == 0,
+            SysRole.status == "1",
+        )
+        .first()
+    )
+
+
+def _get_auth_dept_id(db: Session, tenant_no: str) -> Optional[str]:
+    row = (
+        db.query(SysDept.id)
+        .filter(
+            SysDept.dept_name == QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME,
+            SysDept.tenant_no == tenant_no,
+            SysDept.del_flag == 0,
+            SysDept.status == "1",
+        )
+        .first()
+    )
+    return str(row[0]).strip() if row and row[0] else None
+
+
+def _assert_user_in_auth_dept(db: Session, user: SysUser, tenant_no: str) -> None:
+    dept_id = _get_auth_dept_id(db, tenant_no)
+    if not dept_id:
+        raise ValueError(f"未找到部门「{QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME}」")
+    user_dept_id = (user.dept_id or "").strip()
+    if not user_dept_id:
+        raise ValueError(f"仅可授权「{QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME}」下的用户")
+    allowed = set(_get_child_dept_ids(db, dept_id, tenant_no))
+    if user_dept_id not in allowed:
+        raise ValueError(f"仅可授权「{QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME}」下的用户")
+
+
+def _user_has_role_binding(db: Session, user_id: str, role_id: str, tenant_no: str) -> bool:
+    return (
+        db.query(SysUserRole)
+        .filter(
+            SysUserRole.user_id == user_id,
+            SysUserRole.role_id == role_id,
+            SysUserRole.tenant_no == tenant_no,
+            SysUserRole.del_flag == 0,
+        )
+        .first()
+        is not None
+    )
+
+
+def _row_to_dict(user: SysUser, dept_name: str, grant_time: Optional[datetime]) -> Dict[str, Any]:
+    return {
+        "id": user.id,
+        "userId": user.id,
+        "userNo": user.user_no,
+        "nickName": user.nick_name,
+        "phoneNo": user.phone_no or "",
+        "deptName": dept_name,
+        "status": user.status,
+        "grantTime": grant_time.strftime("%Y-%m-%d %H:%M:%S") if grant_time else "",
+    }
+
+
+def list_authorized_users(
+    db: Session,
+    *,
+    tenant_no: str,
+    page: int = 1,
+    page_size: int = 10,
+    keyword: Optional[str] = None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    role = get_amount_view_role(db, tenant_no)
+    if not role:
+        return [], 0
+
+    q = (
+        db.query(SysUser, SysUserRole.create_time)
+        .join(
+            SysUserRole,
+            and_(
+                SysUserRole.user_id == SysUser.id,
+                SysUserRole.role_id == role.id,
+                SysUserRole.tenant_no == tenant_no,
+                SysUserRole.del_flag == 0,
+            ),
+        )
+        .filter(SysUser.del_flag == 0, SysUser.tenant_no == tenant_no)
+    )
+    kw = (keyword or "").strip()
+    if kw:
+        like = f"%{kw}%"
+        q = q.filter(
+            or_(
+                SysUser.user_no.like(like),
+                SysUser.nick_name.like(like),
+                SysUser.phone_no.like(like),
+            )
+        )
+    total = q.count()
+    rows = (
+        q.order_by(SysUserRole.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    dept_ids = list({u.dept_id for u, _ in rows if u.dept_id})
+    dept_map: Dict[str, str] = {}
+    if dept_ids:
+        for d in db.query(SysDept).filter(SysDept.id.in_(dept_ids), SysDept.del_flag == 0).all():
+            dept_map[d.id] = d.dept_name or ""
+    items = [
+        _row_to_dict(u, dept_map.get(u.dept_id or "", ""), grant_time)
+        for u, grant_time in rows
+    ]
+    return items, total
+
+
+def grant_amount_view_role(
+    db: Session,
+    *,
+    tenant_no: str,
+    user_id: str,
+    operator_id: Optional[str],
+) -> bool:
+    """绑定专用角色;已绑定则幂等返回 False(表示未新增)。"""
+    role = get_amount_view_role(db, tenant_no)
+    if not role:
+        raise ValueError("未找到「质检物料金额查看」角色,请先执行 migrate-qc-material-amount-auth.sql")
+
+    user = (
+        db.query(SysUser)
+        .filter(SysUser.id == user_id, SysUser.tenant_no == tenant_no, SysUser.del_flag == 0)
+        .first()
+    )
+    if not user:
+        raise ValueError("用户不存在")
+    if user.status != "1":
+        raise ValueError("用户已停用,无法授权")
+
+    _assert_user_in_auth_dept(db, user, tenant_no)
+
+    if _user_has_role_binding(db, user_id, role.id, tenant_no):
+        return False
+
+    now = datetime.now()
+    db.add(
+        SysUserRole(
+            id=generate_uuid(),
+            user_id=user_id,
+            role_id=role.id,
+            tenant_no=tenant_no,
+            order_num=0,
+            remark=None,
+            create_by=operator_id,
+            create_dept_id="",
+            create_time=now,
+            update_by=operator_id,
+            update_time=now,
+            del_flag=0,
+        )
+    )
+    db.commit()
+    return True
+
+
+def revoke_amount_view_role(
+    db: Session,
+    *,
+    tenant_no: str,
+    user_id: str,
+    operator_id: Optional[str],
+) -> bool:
+    """解除专用角色绑定;未绑定则返回 False。"""
+    role = get_amount_view_role(db, tenant_no)
+    if not role:
+        raise ValueError("未找到「质检物料金额查看」角色")
+
+    now = datetime.now()
+    updated = (
+        db.query(SysUserRole)
+        .filter(
+            SysUserRole.user_id == user_id,
+            SysUserRole.role_id == role.id,
+            SysUserRole.tenant_no == tenant_no,
+            SysUserRole.del_flag == 0,
+        )
+        .update({"del_flag": 1, "update_by": operator_id, "update_time": now})
+    )
+    if updated:
+        db.commit()
+        return True
+    return False

+ 18 - 0
backend/app/crud/research_donation.py

@@ -213,6 +213,15 @@ def _resolve_donation_payload(raw: dict) -> None:
     elif (raw.get("attachment_urls") or "").strip():
         raw["attachment_urls"] = (raw["attachment_urls"] or "").strip()
 
+    if raw.get("infectious_disease_report_attachments") is not None:
+        raw["infectious_disease_report_urls"] = (
+            attachments_to_urls(raw["infectious_disease_report_attachments"]) or None
+        )
+    elif (raw.get("infectious_disease_report_urls") or "").strip():
+        raw["infectious_disease_report_urls"] = (
+            (raw["infectious_disease_report_urls"] or "").strip()
+        )
+
     if not raw.get("party") and raw.get("party_customer"):
         raw["party"] = raw["party_customer"]
     if not raw.get("collection") and raw.get("collection_info"):
@@ -261,8 +270,12 @@ def _donation_to_dict(
         "sourceProtocolId": d.source_protocol_id or "",
         "consentFormUrls": d.consent_form_urls or "",
         "attachmentUrls": d.attachment_urls or "",
+        "infectiousDiseaseReportUrls": d.infectious_disease_report_urls or "",
         "consentAttachmentFiles": urls_to_attachment_list(d.consent_form_urls, db, tn) if db else [],
         "attachmentFiles": urls_to_attachment_list(d.attachment_urls, db, tn) if db else [],
+        "infectiousDiseaseReportAttachmentFiles": (
+            urls_to_attachment_list(d.infectious_disease_report_urls, db, tn) if db else []
+        ),
         "collectorUserId": d.collector_user_id or "",
         "effectiveTime": _fmt_dt(d.effective_time),
         "effectiveDesc": d.effective_desc or "",
@@ -904,6 +917,9 @@ def create_donation(
         source_protocol_id=(raw.get("source_protocol_id") or "").strip() or None,
         consent_form_urls=consent or None,
         attachment_urls=(raw.get("attachment_urls") or "").strip() or None,
+        infectious_disease_report_urls=(
+            (raw.get("infectious_disease_report_urls") or "").strip() or None
+        ),
         collector_user_id=(raw.get("collector_user_id") or "").strip() or None,
         tenant_no=tenant_no,
         order_num=0,
@@ -973,6 +989,7 @@ def update_donation(
     _MAIN_STR_FIELDS = [
         "donation_status", "donation_date", "source_type",
         "source_protocol_id", "consent_form_urls", "attachment_urls",
+        "infectious_disease_report_urls",
         "collector_user_id", "effective_desc", "expire_desc",
         "void_desc", "to_storage_desc", "remark", "donation_no",
     ]
@@ -1655,6 +1672,7 @@ def convert_to_storage(
 
     protocol_attachment_urls = _merge_comma_separated_urls(
         donation.consent_form_urls,
+        donation.infectious_disease_report_urls,
         donation.attachment_urls,
     )
 

+ 6 - 0
backend/app/crud/sample_reception.py

@@ -275,6 +275,9 @@ def _donation_rows_to_sample_list_items(
             ),
             "sourceType": "research_donation",
             "sourceTypeLabel": "科研捐赠",
+            "infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list(
+                donation.infectious_disease_report_urls if donation else None, db, tenant_no
+            ),
             "etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
             "transportType": (transport.transport_type if transport else "") or "",
             "waybillNo": (transport.waybill_no if transport else "") or "",
@@ -447,6 +450,9 @@ def _rows_to_sample_list_items(
             **sample_special_to_dict(sample),
             "sourceType": "cell_storage",
             "sourceTypeLabel": "细胞存储",
+            "healthSurveyAttachmentFiles": urls_to_attachment_list(
+                protocol.health_survey_form_urls if protocol else None, db, tenant_no
+            ),
             "paymentSummary": payment_summary_map.get(protocol.id) if protocol else None,
         }
         rec = _map_rec(sample)

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/068deb832899a17291a690fc6bb3644fb86fe0e0965e2f2f7c59f00a0d3fee96.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/2f4f7f87c5078deceea7a22f467092910348541ce9e741b79fe058d6cb66cb7a.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/3c10cffbc9a3dc991dd9283ddb3d47a803de1e9c74933ad6546798d36368894b.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/4413154b65d012ece8e4bd73b9b6edaed289dab3848b63cdf9c072dc91c2e5d1.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/4878dc4ddf7f60575ad278dca173b55e9ed824f0182f714b588bff49e0c251c9.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/743a68af29f5fe9fd6323ddccf52422ae80f3eb9b9ecd29986b5f8157c6899af.json


+ 1 - 0
backend/app/graphify-out/cache/ast/92878e8a96a4cfa5028e490f6d15af91fdd9ddc2ae95f2e036e5ca9d4319c06a.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_backend_app_core_qc_material_amount_auth_constants_py", "label": "qc_material_amount_auth_constants.py", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py", "source_location": "L1"}, {"id": "core_qc_material_amount_auth_constants_rationale_1", "label": "\u8d28\u68c0\u7269\u6599\u91d1\u989d\u67e5\u770b\u6388\u6743\uff1a\u4e13\u7528\u89d2\u8272\u4e0e\u83dc\u5355\u6743\u9650\u5e38\u91cf\u3002", "file_type": "rationale", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py", "source_location": "L1"}], "edges": [{"source": "core_qc_material_amount_auth_constants_rationale_1", "target": "users_wuzilong_work_00code_zkxw_backend_app_core_qc_material_amount_auth_constants_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}

+ 1 - 0
backend/app/graphify-out/cache/ast/93af82a4a5c3095363f54261bebaf04a90ba7ee2d4fb923c54489ce6a81f7f37.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_backend_app_core_qc_material_amount_auth_constants_py", "label": "qc_material_amount_auth_constants.py", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py", "source_location": "L1"}, {"id": "core_qc_material_amount_auth_constants_rationale_1", "label": "\u8d28\u68c0\u7269\u6599\u91d1\u989d\u67e5\u770b\u6388\u6743\uff1a\u4e13\u7528\u89d2\u8272\u4e0e\u83dc\u5355\u6743\u9650\u5e38\u91cf\u3002", "file_type": "rationale", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py", "source_location": "L1"}], "edges": [{"source": "core_qc_material_amount_auth_constants_rationale_1", "target": "users_wuzilong_work_00code_zkxw_backend_app_core_qc_material_amount_auth_constants_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/b15b83818b5879733ebca2be855e7aaada60a41c88c5fb73a699837c4ae235d9.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/b972eeaabd049fdac33d747722ea7ab59152d7c2158a7d8dd4906ffd3b5d9a31.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/c43e91deb2dad165b6cb23fd466de3395a54b0554c34faa9b4d0c26964f9b224.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/d0875303e05d604a88d274fc4bfb770700b6972c74ca6da31222442493eb6aa0.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/d56aa6ab4bb2fb67831e1953609437dda70977394df103c34c997a13a19261dd.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/e0eff33579bca3936785ededf4cafbdbbbc1027ede96684abc86635721658120.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/ea03ca8b10d84a7ec819a19f06b43fdff16aa0edf5b47b42611221cad4a0622b.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
backend/app/graphify-out/cache/ast/f9325a12903e5930b9c1e99b8928658f20de1bc149e1f62435a54f961460c189.json


+ 2 - 0
backend/app/models/models.py

@@ -743,6 +743,8 @@ class BizCollectionProtocol(Base):
     protocol_no = Column(String(32), nullable=False, comment="采集协议号展示")
     protocol_status = Column(String(16), nullable=False, default="dsx", comment="协议状态")
     protocol_attachments = Column(String(4000), nullable=True, comment="协议附件")
+    consent_form_urls = Column(String(4000), nullable=True, comment="知情同意书附件URL(逗号分隔)")
+    health_survey_form_urls = Column(String(4000), nullable=True, comment="孕妇健康调查表附件URL(逗号分隔)")
     register_date = Column(CHAR(8), nullable=True, comment="登记日期YYYYMMDD")
     effective_time = Column(DateTime, nullable=True, comment="协议生效时间")
     effective_desc = Column(String(512), nullable=True, comment="协议生效描述")

+ 3 - 0
backend/app/models/research_donation.py

@@ -19,6 +19,9 @@ class BizResearchDonation(Base):
     source_protocol_id = Column(String(64), nullable=True, comment="来源协议ID")
     consent_form_urls = Column(String(4000), nullable=True, comment="知情同意书附件URL(逗号分隔)")
     attachment_urls = Column(String(4000), nullable=True, comment="其他附件URL(逗号分隔)")
+    infectious_disease_report_urls = Column(
+        String(4000), nullable=True, comment="传染病检测报告附件URL(逗号分隔)"
+    )
     collector_user_id = Column(String(64), nullable=True, comment="采集专员用户ID")
     effective_time = Column(DateTime, nullable=True, comment="生效时间")
     effective_desc = Column(String(512), nullable=True, comment="生效描述")

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 479 - 339
backend/graphify-out/.graphify_analysis.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 334 - 320
backend/graphify-out/.graphify_extract.json


+ 119 - 82
backend/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
-# Graph Report - app  (2026-07-07)
+# Graph Report - app  (2026-07-13)
 
 ## Corpus Check
-- Large corpus: 217 files · ~264,353 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 219 files · ~265,676 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 4501 nodes · 24339 edges · 45 communities detected
-- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 16613 edges (avg confidence: 0.59)
+- 4528 nodes · 24439 edges · 52 communities detected
+- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 16669 edges (avg confidence: 0.59)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -48,17 +48,24 @@
 - [[_COMMUNITY_Community 36|Community 36]]
 - [[_COMMUNITY_Community 37|Community 37]]
 - [[_COMMUNITY_Community 38|Community 38]]
+- [[_COMMUNITY_Community 39|Community 39]]
+- [[_COMMUNITY_Community 40|Community 40]]
 - [[_COMMUNITY_Community 41|Community 41]]
 - [[_COMMUNITY_Community 42|Community 42]]
 - [[_COMMUNITY_Community 43|Community 43]]
 - [[_COMMUNITY_Community 44|Community 44]]
 - [[_COMMUNITY_Community 45|Community 45]]
-- [[_COMMUNITY_Community 46|Community 46]]
+- [[_COMMUNITY_Community 48|Community 48]]
+- [[_COMMUNITY_Community 49|Community 49]]
+- [[_COMMUNITY_Community 50|Community 50]]
+- [[_COMMUNITY_Community 51|Community 51]]
+- [[_COMMUNITY_Community 52|Community 52]]
+- [[_COMMUNITY_Community 53|Community 53]]
 
 ## God Nodes (most connected - your core abstractions)
-1. `success_response()` - 673 edges
-2. `error_response()` - 557 edges
-3. `SysUser` - 503 edges
+1. `success_response()` - 677 edges
+2. `error_response()` - 561 edges
+3. `SysUser` - 506 edges
 4. `BizQcTaskResult` - 316 edges
 5. `BizProductionMaterial` - 312 edges
 6. `BizProductionMaterialUsage` - 306 edges
@@ -83,55 +90,55 @@
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (859): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+851 more)
+Nodes (879): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+871 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.01
-Nodes (564): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+556 more)
+Nodes (625): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+617 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.01
-Nodes (382): _normalize_date_bound(), _body_to_payload(), _get_current_user_id(), get_production_reception_detail(), _get_tenant_no(), post_production_reception(), put_production_reception(), _resolve_reception_id() (+374 more)
+Nodes (410): list_collection_regions(), _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id(), _resolve_user_role_scopes() (+402 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.09
-Nodes (340): 环境检测 — 检测项维护 + 登记管理 API, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody, TaskActionBody (+332 more)
+Nodes (350): 环境检测 — 检测项维护 + 登记管理 API, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody, TaskActionBody (+342 more)
 
 ### Community 4 - "Community 4"
 Cohesion: 0.01
-Nodes (280): generate_uuid(), batch_stock_in(), batch_stock_out(), consumable_code_exists(), create_consumable(), _dec(), get_consumable(), _is_enabled_status() (+272 more)
+Nodes (312): generate_uuid(), batch_stock_in(), batch_stock_out(), consumable_code_exists(), create_consumable(), _dec(), get_consumable(), _is_enabled_status() (+304 more)
 
 ### Community 5 - "Community 5"
 Cohesion: 0.02
-Nodes (256): _persist_protocol_sub_resources(), list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id(), _resolve_user_role_scopes(), ApiRequestLogCRUD, delete() (+248 more)
+Nodes (253): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), build_void_type_label_map(), format_protocol_status_label() (+245 more)
 
 ### Community 6 - "Community 6"
-Cohesion: 0.08
-Nodes (271): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+263 more)
+Cohesion: 0.1
+Nodes (217): 细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API, 与列表接口一致:按租户 + 角色数据权限判断是否可访问该协议单。, If mother info exists but no customerId, create a customer record and link it., 细胞产品 CRUD(biz_cell_product), 解析任务样本投入物,返回祖先链单段(样本终止节点)。, 管理端手动改状态:允许在合法枚举间任意变更,并同步库位占用。, 去掉末尾 -P{n}-XX 或 -XX,得到代次产品编码的基础前缀。, 查询 `{base_code}-{passage}-XX` 形式产品编码在库中的最大两位序号(无匹配则 0)。 (+209 more)
 
 ### Community 7 - "Community 7"
-Cohesion: 0.02
-Nodes (172): api_save_application(), map_payments_by_schedule_ids(), get_protocol_nos_used_in_protocols(), create_dict_type(), dict_type_code_exists(), get_dict_type(), list_dict_types(), 字典类型 CRUD(sys_dict_type) (+164 more)
+Cohesion: 0.03
+Nodes (176): batch_discard(), batch_options(), _batch_row_to_dict(), batch_stock_in(), batch_stock_out(), _create_batch_and_usage(), delete_batch(), _discard_batch() (+168 more)
 
 ### Community 8 - "Community 8"
-Cohesion: 0.03
-Nodes (161): batch_discard(), batch_options(), _batch_row_to_dict(), batch_stock_in(), batch_stock_out(), _create_batch_and_usage(), delete_batch(), _discard_batch() (+153 more)
+Cohesion: 0.02
+Nodes (112): get_designer_data(), _error_json(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+104 more)
 
 ### Community 9 - "Community 9"
-Cohesion: 0.02
-Nodes (103): get_designer_data(), _error_json(), 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware, ApiCryptoError (+95 more)
+Cohesion: 0.04
+Nodes (124): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+116 more)
 
 ### Community 10 - "Community 10"
 Cohesion: 0.03
-Nodes (148): get_next_cell_product_code(), build_void_type_label_map(), format_protocol_status_label(), resolve_void_type_label(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain() (+140 more)
+Nodes (118): get_next_cell_product_code(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain(), _build_descendant_chain(), _build_facility_path_label(), build_split_product_codes() (+110 more)
 
 ### Community 11 - "Community 11"
 Cohesion: 0.04
-Nodes (124): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+116 more)
+Nodes (75): _jwt_user_payload(), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。, refresh_token(), web_login(), web_login_rsa_public_key(), lifespan(), create_access_token() (+67 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.04
-Nodes (91): batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation(), _get_current_user_id(), get_research_donation_detail() (+83 more)
+Cohesion: 0.05
+Nodes (79): batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation(), _get_current_user_id(), get_research_donation_detail() (+71 more)
 
 ### Community 13 - "Community 13"
 Cohesion: 0.34
@@ -139,7 +146,7 @@ Nodes (74): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOut
 
 ### Community 14 - "Community 14"
 Cohesion: 0.06
-Nodes (54): _avatar_url_field(), _build_user_info_payload(), _jwt_user_payload(), Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致), Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。, 返回库中原始头像路径,由前端按 VITE_MINIO_BASE_URL 拼展示 URL。, 与 web-admin `userInfo` 字段约定一致。, refresh_token() (+46 more)
+Nodes (61): complete_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products(), _format_yyyy_mm_dd(), _generate_application_no(), get_application() (+53 more)
 
 ### Community 15 - "Community 15"
 Cohesion: 0.11
@@ -147,32 +154,32 @@ Nodes (35): batch_create_sterilize_logs(), batch_create_wash_logs(), batch_disca
 
 ### Community 16 - "Community 16"
 Cohesion: 0.12
-Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
+Nodes (10): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA), wechat_pay() (+2 more)
 
 ### Community 17 - "Community 17"
 Cohesion: 0.2
 Nodes (19): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+11 more)
 
 ### Community 18 - "Community 18"
-Cohesion: 0.21
-Nodes (15): batch_generate_protocol_nos(), batch_issue_protocol_nos(), batch_revoke_protocol_nos(), create_protocol_no(), get_protocol_no(), list_protocol_nos(), _parse_issued_time(), protocol_no_exists() (+7 more)
+Cohesion: 0.27
+Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
 
 ### Community 19 - "Community 19"
-Cohesion: 0.22
-Nodes (14): batch_generate_sample_codes(), batch_issue_sample_codes(), batch_revoke_sample_codes(), create_sample_code(), get_sample_code(), list_sample_codes(), _parse_issued_time(), 样本编码池 CRUD(biz_sample_code) (+6 more)
+Cohesion: 0.26
+Nodes (12): batch_issue_sample_codes(), batch_revoke_sample_codes(), create_sample_code(), get_sample_code(), list_sample_codes(), _parse_issued_time(), 样本编码池 CRUD(biz_sample_code), _resolve_issued_user_display_name() (+4 more)
 
 ### Community 20 - "Community 20"
-Cohesion: 0.22
-Nodes (13): create_collection_region(), create_hospital(), get_collection_region(), get_hospital(), _hospital_names_by_region_ids(), _hospital_to_dict(), list_collection_regions(), list_hospitals() (+5 more)
-
-### Community 21 - "Community 21"
 Cohesion: 0.24
 Nodes (9): 微信消息通知服务  支持微信小程序模板消息推送  使用方式:     from app.core.wechat_message import Wech, 发送模板消息                  Args:             openid: 用户openid             templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
 
-### Community 22 - "Community 22"
+### Community 21 - "Community 21"
 Cohesion: 0.24
 Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
 
+### Community 22 - "Community 22"
+Cohesion: 0.32
+Nodes (11): create_env_detect_record(), _generate_record_no(), get_env_detect_record(), _join_csv(), list_env_detect_records(), _resolve_inspector_snapshots(), _resolve_item_snapshot(), _row_to_dict() (+3 more)
+
 ### Community 23 - "Community 23"
 Cohesion: 0.24
 Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
@@ -182,125 +189,155 @@ Cohesion: 0.27
 Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
 
 ### Community 25 - "Community 25"
+Cohesion: 0.29
+Nodes (9): _build_tree(), _cascade_soft_delete(), create_menu(), get_menu(), list_menus(), list_menus_tree(), _row_to_dict(), soft_delete_menu() (+1 more)
+
+### Community 26 - "Community 26"
 Cohesion: 0.27
 Nodes (8): create_customer(), get_customer(), id_card_taken(), list_customers(), 客户 CRUD(biz_customer), _row_to_dict(), soft_delete_customer(), update_customer()
 
-### Community 26 - "Community 26"
+### Community 27 - "Community 27"
 Cohesion: 0.31
 Nodes (8): create_express_company(), express_code_exists(), get_express_company(), list_express_companies(), 快递公司 CRUD(biz_express_company), _row_to_dict(), soft_delete_express_company(), update_express_company()
 
-### Community 27 - "Community 27"
+### Community 28 - "Community 28"
 Cohesion: 0.38
 Nodes (9): create_check_item(), get_check_item(), item_code_exists(), list_active_check_items(), list_check_items(), _order_query(), _row_to_dict(), soft_delete_check_item() (+1 more)
 
-### Community 28 - "Community 28"
+### Community 29 - "Community 29"
 Cohesion: 0.38
 Nodes (9): create_env_detect_item(), get_env_detect_item(), item_code_exists(), list_active_env_detect_items(), list_env_detect_items(), _order_query(), _row_to_dict(), soft_delete_env_detect_item() (+1 more)
 
-### Community 29 - "Community 29"
+### Community 30 - "Community 30"
+Cohesion: 0.31
+Nodes (8): category_code_exists(), create_process_category(), get_process_category(), list_process_categories(), 工艺工序分类 CRUD(biz_process_category), _row_to_dict(), soft_delete_process_category(), update_process_category()
+
+### Community 31 - "Community 31"
 Cohesion: 0.31
 Nodes (8): create_item_unit(), get_item_unit(), list_item_units(), 物品单位 CRUD(biz_item_unit), _row_to_dict(), soft_delete_item_unit(), unit_code_exists(), update_item_unit()
 
-### Community 30 - "Community 30"
+### Community 32 - "Community 32"
+Cohesion: 0.31
+Nodes (8): create_sop_document(), get_sop_document(), list_sop_documents(), SOP文档 CRUD(biz_sop_document), _row_to_dict(), soft_delete_sop_document(), sop_code_exists(), update_sop_document()
+
+### Community 33 - "Community 33"
 Cohesion: 0.31
 Nodes (8): create_cell_sample_type(), get_cell_sample_type(), list_cell_sample_types(), 细胞样本类型 CRUD(biz_cell_sample_type), _row_to_dict(), soft_delete_cell_sample_type(), type_code_exists(), update_cell_sample_type()
 
-### Community 31 - "Community 31"
+### Community 34 - "Community 34"
 Cohesion: 0.31
 Nodes (8): create_output_type(), get_output_type(), list_output_types(), 工艺产出物类型 CRUD(biz_output_type), _row_to_dict(), soft_delete_output_type(), type_code_exists(), update_output_type()
 
-### Community 32 - "Community 32"
+### Community 35 - "Community 35"
+Cohesion: 0.36
+Nodes (7): create_post(), get_post(), list_posts(), post_code_exists(), _row_to_dict(), soft_delete_post(), update_post()
+
+### Community 36 - "Community 36"
+Cohesion: 0.36
+Nodes (7): create_tenant(), get_tenant(), list_tenants(), _row_to_dict(), soft_delete_tenant(), tenant_no_exists(), update_tenant()
+
+### Community 37 - "Community 37"
+Cohesion: 0.36
+Nodes (7): create_site(), get_site(), list_sites(), _row_to_dict(), site_code_exists(), soft_delete_site(), update_site()
+
+### Community 38 - "Community 38"
 Cohesion: 0.29
 Nodes (2): BaseModel, SoftDeleteModel
 
-### Community 33 - "Community 33"
+### Community 39 - "Community 39"
 Cohesion: 0.38
 Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。     约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;     若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
 
-### Community 34 - "Community 34"
+### Community 40 - "Community 40"
 Cohesion: 0.5
 Nodes (3): BaseSettings, Config, Settings
 
-### Community 35 - "Community 35"
+### Community 41 - "Community 41"
 Cohesion: 0.5
 Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
 
-### Community 36 - "Community 36"
+### Community 42 - "Community 42"
 Cohesion: 0.67
 Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
 
-### Community 37 - "Community 37"
+### Community 43 - "Community 43"
+Cohesion: 1.0
+Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
+
+### Community 44 - "Community 44"
 Cohesion: 1.0
 Nodes (1): 请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
 
-### Community 38 - "Community 38"
+### Community 45 - "Community 45"
 Cohesion: 1.0
 Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
 
-### Community 41 - "Community 41"
+### Community 48 - "Community 48"
 Cohesion: 1.0
 Nodes (1): 发送订单状态通知                  Args:             openid: 用户openid             ord
 
-### Community 42 - "Community 42"
+### Community 49 - "Community 49"
 Cohesion: 1.0
 Nodes (1): 发送支付成功通知                  Args:             openid: 用户openid             ord
 
-### Community 43 - "Community 43"
+### Community 50 - "Community 50"
 Cohesion: 1.0
 Nodes (1): 发送配送通知                  Args:             openid: 配送员openid             task
 
-### Community 44 - "Community 44"
+### Community 51 - "Community 51"
 Cohesion: 1.0
 Nodes (1): 发送配送时效预警                  Args:             openid: 配送员openid             ta
 
-### Community 45 - "Community 45"
+### Community 52 - "Community 52"
 Cohesion: 1.0
 Nodes (1): 发送退款结果通知                  Args:             openid: 用户openid             ord
 
-### Community 46 - "Community 46"
+### Community 53 - "Community 53"
 Cohesion: 1.0
 Nodes (1): 发送催单通知给配送员                  Args:             deliver_openid: 配送员openid
 
 ## Knowledge Gaps
-- **130 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+125 more)
+- **131 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+126 more)
   These have ≤1 connection - possible missing edges or undocumented components.
-- **Thin community `Community 32`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
+- **Thin community `Community 38`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
+  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
+- **Thin community `Community 41`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 35`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
+- **Thin community `Community 42`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 36`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
+- **Thin community `Community 43`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 37`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
+- **Thin community `Community 44`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 38`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
+- **Thin community `Community 45`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 41`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 48`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 42`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 49`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 43`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
+- **Thin community `Community 50`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 44`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
+- **Thin community `Community 51`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 45`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 52`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 46`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
+- **Thin community `Community 53`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `SysUser` connect `Community 1` to `Community 2`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 18`, `Community 19`, `Community 22`, `Community 23`?**
-  _High betweenness centrality (0.121) - this node is a cross-community bridge._
-- **Why does `generate_uuid()` connect `Community 4` to `Community 0`, `Community 2`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 20`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`?**
-  _High betweenness centrality (0.076) - this node is a cross-community bridge._
-- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 2`, `Community 36`, `Community 5`, `Community 7`, `Community 9`, `Community 10`, `Community 12`, `Community 14`?**
-  _High betweenness centrality (0.068) - this node is a cross-community bridge._
-- **Are the 741 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
-  _`str` has 741 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 672 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
-  _`success_response()` has 672 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 556 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
-  _`error_response()` has 556 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 501 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
-  _`SysUser` has 501 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `SysUser` connect `Community 1` to `Community 2`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 19`, `Community 21`, `Community 23`?**
+  _High betweenness centrality (0.119) - this node is a cross-community bridge._
+- **Why does `generate_uuid()` connect `Community 4` to `Community 0`, `Community 1`, `Community 2`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 15`, `Community 17`, `Community 18`, `Community 19`, `Community 21`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 29`, `Community 30`, `Community 31`, `Community 32`, `Community 33`, `Community 34`, `Community 35`, `Community 36`, `Community 37`?**
+  _High betweenness centrality (0.082) - this node is a cross-community bridge._
+- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 2`, `Community 8`, `Community 9`, `Community 42`, `Community 11`, `Community 12`, `Community 10`?**
+  _High betweenness centrality (0.057) - this node is a cross-community bridge._
+- **Are the 746 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
+  _`str` has 746 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 676 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
+  _`success_response()` has 676 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 560 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
+  _`error_response()` has 560 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 504 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
+  _`SysUser` has 504 INFERRED edges - model-reasoned connections that need verification._

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
backend/graphify-out/graph.html


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 128 - 110
backend/graphify-out/graph.json


+ 13 - 11
backend/graphify-out/manifest.json

@@ -16,6 +16,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/response.py": 1779928224.7450857,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/menu_guard.py": 1782120978.1821575,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/db_collation.py": 1779890253.2250955,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/core/qc_material_amount_auth_constants.py": 1783919118.6811988,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/context.py": 1779242263.6923044,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/utils.py": 1780305790.0009735,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/protocol_status_display.py": 1783303208.7618413,
@@ -30,10 +31,10 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/material.py": 1782894693.6389399,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/production_material.py": 1782404882.9262497,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_param.py": 1782379593.174925,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1782893591.4107118,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1783920040.737089,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/cleanroom_garment.py": 1781347080.6165943,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1781344070.5899634,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/models/research_donation.py": 1782894734.5364213,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/models/research_donation.py": 1783920516.0199852,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_material.py": 1781690312.7245836,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/base.py": 1778578258.0821667,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/clinical_payment.py": 1779834956.9588954,
@@ -104,7 +105,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_third_party_item.py": 1779265661.482352,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_production_reception.py": 1782722028.9202535,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/reagent.py": 1779512669.9847593,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1783329833.497812,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_protocol.py": 1783920051.9423196,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/cleanroom_garment.py": 1782264452.4901433,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_usage.py": 1783428516.0973594,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/biz_no_generator.py": 1779882385.4674172,
@@ -119,14 +120,15 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/express_company.py": 1781402091.5987046,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_application.py": 1781079895.8304482,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_lab_test_item.py": 1779265673.3888667,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pre_transit_sample.py": 1782891216.72219,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pre_transit_sample.py": 1783921714.0966582,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/role.py": 1779265616.3701816,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_batch.py": 1783428574.8936534,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_stock_batch.py": 1783429677.918878,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/material_stock_check.py": 1781427194.5434632,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_task_step_assignee.py": 1782469070.7380123,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_sms_notification.py": 1781590614.9654794,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_task_assignee.py": 1781233180.3766735,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation_sample_collection.py": 1782886678.892387,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_material_amount_auth.py": 1783919120.0033576,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/cell_product.py": 1782886679.0930216,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/menu.py": 1779265729.0381103,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/collection_region.py": 1782181887.2821388,
@@ -135,7 +137,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/site.py": 1781401290.6845422,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/api_request_log.py": 1778578258.0614743,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_data.py": 1782893595.9312406,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation.py": 1782894734.7683048,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/research_donation.py": 1783920527.417902,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/ethnic.py": 1779265849.547566,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_formula.py": 1781344175.5871844,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/pilot_checkup.py": 1779935785.233911,
@@ -151,7 +153,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/customer.py": 1779686149.7508218,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/workflow.py": 1779265993.677624,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_sms_notification.py": 1781063495.0317714,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1783389101.2508402,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception.py": 1783921714.096361,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/consumable_pack.py": 1779265715.416005,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/qc_inspection_batch.py": 1782984951.4695187,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_prep.py": 1781514953.3551314,
@@ -177,10 +179,10 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/public.py": 1782122639.96012,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/material.py": 1782894693.6582327,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/production_material.py": 1782404982.2474263,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1783428518.8598783,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/qc.py": 1783916949.4250708,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/market.py": 1781522283.6823432,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_production_reception.py": 1782718998.90251,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1782982337.6828277,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/collection_protocol.py": 1783920067.0560462,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/cleanroom_garment.py": 1781347256.2326367,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/basic_info.py": 1782709076.3968658,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_infusion_confirm.py": 1779657043.1389203,
@@ -192,11 +194,11 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/qc_sms_notification.py": 1780326875.7886882,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation_sample_collection.py": 1780457788.2998126,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/operations.py": 1782699385.46194,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1781749943.5843754,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/menu.py": 1783917044.543897,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_delivery.py": 1779655584.820777,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/admin.py": 1782469068.0578597,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/env_detect.py": 1782376501.8919773,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1782893614.5041575,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1783920522.841086,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/ethnic.py": 1779266243.9511197,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_checkup.py": 1779935751.2658868,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_service.py": 1781599314.0221174,

+ 5 - 0
docs/sql/migrate-donation-infectious-disease-report.sql

@@ -0,0 +1,5 @@
+-- 科研捐赠单:传染病检测报告附件字段
+-- 2026-07-13
+
+ALTER TABLE `biz_research_donation`
+  ADD COLUMN `infectious_disease_report_urls` text COLLATE utf8mb4_unicode_ci COMMENT '传染病检测报告附件URL(逗号分隔)' AFTER `attachment_urls`;

+ 6 - 0
docs/sql/migrate-protocol-consent-health-survey.sql

@@ -0,0 +1,6 @@
+-- 采集协议单:知情同意书、孕妇健康调查表附件字段
+-- 2026-07-13
+
+ALTER TABLE `biz_collection_protocol`
+  ADD COLUMN `consent_form_urls` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '知情同意书附件URL(逗号分隔)' AFTER `protocol_attachments`,
+  ADD COLUMN `health_survey_form_urls` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '孕妇健康调查表附件URL(逗号分隔)' AFTER `consent_form_urls`;

+ 82 - 0
docs/sql/migrate-qc-material-amount-auth.sql

@@ -0,0 +1,82 @@
+-- 质检物料金额查看授权:专用角色 + viewAmount 按钮菜单 + 授权管理页
+-- 2026-07-13
+
+-- 1. 按钮权限(F,visible=0,仅用于权限解析)
+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`)
+VALUES
+  ('95100000000000000000000000000002', '查看质检物料金额', '70000000000000000000000000000009', 'F', '', NULL, 'biz:qcMaterial:viewAmount', 1, '0', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
+  ('95100000000000000000000000000003', '查看我的物料领用金额', '92000000000000000000000000000001', 'F', '', NULL, 'biz:myQcMaterialUsage:viewAmount', 1, '0', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0)
+ON DUPLICATE KEY UPDATE
+  `menu_name` = VALUES(`menu_name`),
+  `parent_id` = VALUES(`parent_id`),
+  `menu_type` = VALUES(`menu_type`),
+  `perms` = VALUES(`perms`),
+  `visible` = VALUES(`visible`),
+  `status` = VALUES(`status`),
+  `del_flag` = 0;
+
+-- 2. 授权管理页(C,挂在质检基础信息下)
+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`)
+VALUES
+  ('95100000000000000000000000000001', '质检物料金额查看授权', 'QCB00000000000000000000000000001', 'C', 'qc-material-amount-auth', '/quality-inspection/qc-material-amount-auth/index', 'biz:qcMaterialAmountAuth:list', 1, '1', '1', 'zkxw', 10, NULL, NULL, '', NOW(), NULL, NOW(), 0)
+ON DUPLICATE KEY UPDATE
+  `menu_name` = VALUES(`menu_name`),
+  `parent_id` = VALUES(`parent_id`),
+  `menu_type` = VALUES(`menu_type`),
+  `path` = VALUES(`path`),
+  `component` = VALUES(`component`),
+  `perms` = VALUES(`perms`),
+  `visible` = VALUES(`visible`),
+  `status` = VALUES(`status`),
+  `order_num` = VALUES(`order_num`),
+  `del_flag` = 0;
+
+-- 3. 专用角色
+INSERT INTO `sys_role`
+  (`id`, `role_name`, `data_scope`, `status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`)
+VALUES
+  ('ROLE000000000000000000000000000011', '质检物料金额查看', '1', '1', 'zkxw', 0, '可查看/编辑质检物料库与我的物料领用记录中的单价与金额', NULL, '', NOW(), NULL, NOW(), 0)
+ON DUPLICATE KEY UPDATE
+  `role_name` = VALUES(`role_name`),
+  `status` = VALUES(`status`),
+  `remark` = VALUES(`remark`),
+  `del_flag` = 0;
+
+-- 4. 角色 ↔ 按钮权限
+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`)
+VALUES
+  ('RM000000000000000000000000000011', 'ROLE000000000000000000000000000011', '95100000000000000000000000000002', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),
+  ('RM000000000000000000000000000012', 'ROLE000000000000000000000000000011', '95100000000000000000000000000003', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0)
+ON DUPLICATE KEY UPDATE
+  `del_flag` = 0;
+
+-- 5. 已拥有「质检物料库」菜单的角色,自动获得授权管理页
+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
+  CONCAT('RMAA', SUBSTRING(MD5(CONCAT(rm.role_id, '95100000000000000000000000000001')), 1, 28)),
+  rm.role_id,
+  '95100000000000000000000000000001',
+  'zkxw',
+  0,
+  NULL,
+  NULL,
+  '',
+  NOW(),
+  NULL,
+  NOW(),
+  0
+FROM `sys_role_menu` rm
+WHERE rm.menu_id = '70000000000000000000000000000009'
+  AND rm.del_flag = 0
+  AND rm.tenant_no = 'zkxw'
+  AND NOT EXISTS (
+    SELECT 1
+    FROM `sys_role_menu` x
+    WHERE x.role_id = rm.role_id
+      AND x.menu_id = '95100000000000000000000000000001'
+      AND x.del_flag = 0
+  );

+ 4 - 1
docs/sql/zkxw.sql

@@ -5323,6 +5323,8 @@ CREATE TABLE `biz_collection_protocol` (
   `protocol_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采集协议号展示',
   `protocol_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'dsx' COMMENT 'dsx待生效 sxz生效中 ygq已过期 yzf已作废 zky转科研',
   `protocol_attachments` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '协议附件 minio 地址逗号分隔',
+  `consent_form_urls` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '知情同意书附件URL(逗号分隔)',
+  `health_survey_form_urls` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '孕妇健康调查表附件URL(逗号分隔)',
   `register_date` char(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记日期YYYYMMDD',
   `effective_time` datetime DEFAULT NULL COMMENT '协议生效时间',
   `effective_desc` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '协议生效描述',
@@ -11925,6 +11927,7 @@ CREATE TABLE `biz_research_donation` (
   `source_protocol_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源采集协议单ID(from_storage时关联)',
   `consent_form_urls` text COLLATE utf8mb4_unicode_ci COMMENT '知情同意书附件URL(逗号分隔)',
   `attachment_urls` text COLLATE utf8mb4_unicode_ci COMMENT '附件URL(逗号分隔)',
+  `infectious_disease_report_urls` text COLLATE utf8mb4_unicode_ci COMMENT '传染病检测报告附件URL(逗号分隔)',
   `collector_user_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '采集专员用户ID',
   `effective_time` datetime DEFAULT NULL COMMENT '生效时间',
   `effective_desc` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效描述',
@@ -17387,4 +17390,4 @@ INSERT INTO `biz_storage_facility_slot` (`id`, `facility_node_id`, `slot_code`,
 INSERT INTO `biz_storage_facility_slot` (`id`, `facility_node_id`, `slot_code`, `slot_status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`, `occupied_count`, `last_item_type`, `last_stored_at`) VALUES ('06DAE9C9391A42F18810921CFEF9053A', '38E4724959314DDFA1C26E7C8ADC1D08', '3', '空', 'zkxw', 1003, NULL, 'd3000000000000000000000000000001', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-26 04:20:32', 'd3000000000000000000000000000001', '2026-06-26 04:20:32', 0, 0, NULL, NULL);
 INSERT INTO `biz_storage_facility_slot` (`id`, `facility_node_id`, `slot_code`, `slot_status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`, `occupied_count`, `last_item_type`, `last_stored_at`) VALUES ('06DE851FAABD4E07928BF7FB73A0C807', 'C530BD59EE934CCCAE12411679D5AF9A', '26', '占用', 'zkxw', 3006, NULL, 'd3000000000000000000000000000001', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-26 04:12:30', 'd3000000000000000000000000000001', '2026-06-30 00:53:30', 0, 1, 'cell_product', '2025-08-20 00:00:00');
 INSERT INTO `biz_storage_facility_slot` (`id`, `facility_node_id`, `slot_code`, `slot_status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`, `occupied_count`, `last_item_type`, `last_stored_at`) VALUES ('06E019CD9A794C27842428ED073640B5', '22564DA268E44068AB159C551C815CE5', '97', '空', 'zkxw', 10007, NULL, 'd3000000000000000000000000000001', 'b13c2be2-4e71-11f1-9f47-00163e3bdd96', '2026-06-26 04:20:41', 'd3000000000000000000000000000001', '2026-06-26 04:20:41', 0, 0, NULL, NULL);
-INSERT INTO `biz_storage_facility_slot` (`id`, `facility_node_id`, `slot_code`, `slot_status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag`, `occupied_count`, `last_item_type`, `last_stored_at`) VALUES ('06E27C81FCE14EFB89B760E049D19505', 'EF5E0C22CE24494C87BD66064347B647', '31', '空', 'zkxw', 4004, NULL, 'd3000000000000000000000000000001', 'b13c2be2-4e71-
+INSERT INTO `biz_storage_facility_slot` (`id`, `facility_node_id`, `slot_code`, `slot_status`, `tenant_no`, `order_num`, `remark`, `create_by`, `create_dept

+ 51 - 0
docs/superpowers/plans/2026-07-13-sample-card-protocol-attachment.md

@@ -0,0 +1,51 @@
+# 样本卡片协议附件迷你缩略图 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:** 在预产及在途样本、样本接收卡片中按来源展示协议级附件(迷你缩略图),支持预览/下载,无附件显示「无」。
+
+**Architecture:** 列表 API 补齐 `healthSurveyAttachmentFiles` / `infectiousDiseaseReportAttachmentFiles`;前端新建 `ProtocolAttachmentMiniReadonly` + `getSourceProtocolAttachment` + `useAttachmentActions`,三处卡片复用。
+
+**Tech Stack:** Python FastAPI CRUD、`urls_to_attachment_list`;Vue 3 + Element Plus + SCSS
+
+**Spec:** `docs/superpowers/specs/2026-07-13-sample-card-protocol-attachment-design.md`
+
+---
+
+### Task 1: 后端预产列表补细胞存储附件
+
+**Files:**
+- Modify: `backend/app/crud/pre_transit_sample.py`
+
+- [ ] 在细胞存储行 `d = _protocol_to_dict(r)` 后增加 `healthSurveyAttachmentFiles`
+
+### Task 2: 后端样本接收列表补协议附件
+
+**Files:**
+- Modify: `backend/app/crud/sample_reception.py`
+
+- [ ] `_rows_to_sample_list_items` 增加 `healthSurveyAttachmentFiles`
+- [ ] `_donation_rows_to_sample_list_items` 增加 `infectiousDiseaseReportAttachmentFiles`
+
+### Task 3: 前端共享 util / composable / 迷你组件
+
+**Files:**
+- Create: `web-admin/src/utils/protocol-source-attachment.ts`
+- Create: `web-admin/src/composables/useAttachmentActions.ts`
+- Create: `web-admin/src/components/business/protocol-attachment-mini-readonly/index.vue`
+
+### Task 4: 预产及在途卡片接入
+
+**Files:**
+- Modify: `web-admin/src/views/quality-inspection/pre-transit-sample/PreTransitProtocolCardList.vue`
+- Modify: `web-admin/src/views/quality-inspection/pre-transit-sample/index.vue`
+
+### Task 5: 样本接收卡片接入
+
+**Files:**
+- Modify: `web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue`
+
+### Task 6: 验证
+
+- [ ] `pnpm lint` 相关前端文件
+- [ ] `./scripts/update-graph.sh frontend`(如有新建文件)

+ 61 - 0
docs/superpowers/specs/2026-07-13-protocol-consent-health-survey-design.md

@@ -0,0 +1,61 @@
+# 采集协议单 · 知情同意书与孕妇健康调查表附件
+
+> 日期:2026-07-13  
+> 状态:已确认,实现中  
+> 确认项:两类附件均为**选填**;查看/编辑布局为**三行堆叠**
+
+---
+
+## 1. 目标
+
+在采集协议单「基本信息」区域,除现有「协议附件」外,新增:
+
+- **知情同意书**(`consent_form_urls`)
+- **孕妇健康调查表**(`health_survey_form_urls`)
+
+新增/编辑弹窗支持上传;所有展示「协议附件」的协议单查看弹窗同步展示上述两类附件。
+
+## 2. 数据模型
+
+`biz_collection_protocol` 新增两列(VARCHAR 4000,逗号分隔 MinIO URL),命名与科研捐赠单 `consent_form_urls` 对齐。
+
+## 3. 后端
+
+- Model / CRUD `_protocol_to_dict` / `get_protocol_detail_bundle` / create / update 读写新字段
+- Bundle 输出:`consentAttachmentFiles`、`healthSurveyAttachmentFiles`(与 `attachments` 同级)
+- API Create/Update Body 增加字段及 attachments 数组 alias;resolve 逻辑复用 `attachments_to_urls`
+
+MinIO 目录:
+
+- `protocol-consent`
+- `protocol-health-survey`
+
+## 4. 前端
+
+### 4.1 录入/编辑(`ProtocolFormDialog.vue`)
+
+基本信息 Tab,三行堆叠(各一行 `form-grid--attachment`):
+
+1. 协议附件  
+2. 知情同意书  
+3. 孕妇健康调查表  
+
+UI 与现有协议附件一致(卡片化上传 + 拖拽,OpenSpec 附件规范)。
+
+### 4.2 查看(`ProtocolViewDialog.vue`)
+
+基本信息 Tab 底部,三行堆叠展示;备注独立一行在其下。均使用 `ProtocolAttachmentReadonly`,无附件显示「暂无附件」。
+
+## 5. 范围外
+
+- 样本接收 checklist(知情同意书/健康调查表勾选)  
+- 协议转科研时同步知情同意书(本期不做)  
+- 一键导入标准文本解析  
+
+## 6. 改动文件
+
+| 层 | 文件 |
+|----|------|
+| DB | `docs/sql/migrate-protocol-consent-health-survey.sql`、`docs/sql/zkxw.sql` |
+| Backend | `models.py`、`crud/collection_protocol.py`、`api/collection_protocol.py` |
+| Frontend | `ProtocolFormDialog.vue`、`ProtocolViewDialog.vue` |

+ 219 - 0
docs/superpowers/specs/2026-07-13-sample-card-protocol-attachment-design.md

@@ -0,0 +1,219 @@
+# 样本卡片 · 协议级附件迷你缩略图展示
+
+> 日期:2026-07-13  
+> 状态:已确认(展示形态 B · 迷你缩略图)  
+> 关联:`2026-07-13-protocol-consent-health-survey-design.md`(孕妇健康调查表字段)、捐赠单传染病检测报告字段
+
+---
+
+## 1. 目标
+
+在以下三个卡片场景中,按来源展示协议单级附件,支持点击预览/下载;无附件显示「**无**」。
+
+| 页面 | 位置 |
+|------|------|
+| 预产及在途样本 | 紧凑小卡片 + 展开详情卡片 |
+| 样本接收 | 待接收 Tab 卡片 + 已接收 Tab 卡片 |
+
+### 1.1 来源与附件映射
+
+| `sourceType` | 展示标签 | 数据字段 |
+|--------------|----------|----------|
+| `cell_storage`(默认) | 孕妇健康调查表 | `healthSurveyAttachmentFiles` |
+| `research_donation` | 传染病检测报告 | `infectiousDiseaseReportAttachmentFiles` |
+
+**说明**:展示的是协议/捐赠单录入的附件,**不是**样本接收登记时的 `receiveAttachmentFiles`。
+
+### 1.2 已确认交互(B · 迷你缩略图)
+
+- 每个附件一张 **迷你卡片**:缩略图区 + 单行文件名 + 下载图标
+- 图片:缩略图 `object-fit: cover`;文档/视频:与 `ProtocolAttachmentReadonly` 相同的类型图标
+- 点击缩略图:图片新开页预览;非图片仅下载区可点
+- 点击下载图标:走现有 `downloadAttachmentFile`
+- 无附件:标签下方显示「无」(不用「暂无附件」)
+- 多附件:横向排列,超出宽度换行
+
+---
+
+## 2. 现状缺口
+
+### 2.1 后端
+
+| 接口 | 细胞存储 | 科研捐赠 |
+|------|----------|----------|
+| 预产及在途列表 | 仅有 `healthSurveyFormUrls` 字符串 | 已有 `infectiousDiseaseReportAttachmentFiles` |
+| 样本接收待/已接收列表 | 无协议附件字段 | 无协议附件字段 |
+
+### 2.2 前端
+
+- `PreTransitProtocolCardList.vue`、`SampleReceptionCardList.vue` 均未展示协议附件
+- `ProtocolAttachmentReadonly.vue` 为标准尺寸(140×96),不适合紧凑卡片
+
+---
+
+## 3. 方案(列表 API 补齐 + 共享迷你组件)
+
+采用列表接口直接返回 `*AttachmentFiles`(与协议 CRUD 字段命名一致),前端新建迷你缩略图组件统一渲染。不做懒加载详情、不做前端 URL 手拆。
+
+---
+
+## 4. 后端
+
+### 4.1 `pre_transit_sample.py`
+
+细胞存储行构建处(`_protocol_to_dict` 之后),补充:
+
+```python
+d["healthSurveyAttachmentFiles"] = urls_to_attachment_list(
+    r.health_survey_form_urls, db, tenant_no
+)
+```
+
+科研捐赠侧 `_donation_to_dict(r, db, tenant_no)` 已含 `infectiousDiseaseReportAttachmentFiles`,无需改动。
+
+### 4.2 `sample_reception.py`
+
+在 `_rows_to_sample_list_items`、`_donation_rows_to_sample_list_items` 构建 `item` 时,从已 join 的 `protocol` / `donation` 实体补充:
+
+- 细胞存储:`healthSurveyAttachmentFiles` ← `protocol.health_survey_form_urls`
+- 科研捐赠:`infectiousDiseaseReportAttachmentFiles` ← `donation.infectious_disease_report_urls`
+
+使用现有 `urls_to_attachment_list`;协议/捐赠对象已在查询行中,不额外 N+1。
+
+### 4.3 范围外
+
+- 不修改样本接收登记/编辑 API 写入逻辑
+- 不修改 checklist 检查项
+- 不修改生产工单、质检工单卡片
+
+---
+
+## 5. 前端
+
+### 5.1 新建 `ProtocolAttachmentMiniReadonly.vue`
+
+路径:`web-admin/src/components/business/protocol-attachment-mini-readonly/index.vue`
+
+**Props**
+
+| 属性 | 类型 | 说明 |
+|------|------|------|
+| `label` | `string` | 字段标签,如「孕妇健康调查表」 |
+| `files` | `Array<Record<string, unknown>>` | 附件列表 |
+| `emptyText` | `string` | 默认 `'无'` |
+
+**Events**:`preview`、`download`(与 `ProtocolAttachmentReadonly` 一致,由父级或 composable 处理)
+
+**尺寸与样式**(对齐 Fresh Mint,缩小版 `ProtocolAttachmentReadonly`)
+
+| 元素 | 尺寸 |
+|------|------|
+| 单卡宽度 | 80px |
+| 缩略图区高度 | 56px |
+| 圆角 | 6px |
+| 边框 | `#e2e8f0`,hover `#047857` |
+| 文件名字号 | 10px,单行 ellipsis |
+| 下载图标 | 18×18,与标准组件同色交互 |
+| 列表 gap | 6px,横向 flex-wrap |
+
+**空态**:仅显示 `label` + `emptyText`(「无」),不渲染缩略图占位。
+
+**逻辑复用**:`resolveFileUrl`、`isImageFile`、`isVideoFile` 与 `ProtocolAttachmentReadonly.vue` 保持一致(可后续抽 util,本期允许组件内复制以保持最小 diff)。
+
+### 5.2 共享工具
+
+**`web-admin/src/utils/protocol-source-attachment.ts`**
+
+```ts
+export function getSourceProtocolAttachment(row: Record<string, unknown>): {
+  label: string
+  files: Array<Record<string, unknown>>
+}
+```
+
+按 `sourceType` 返回标签与 files 数组。
+
+**`web-admin/src/composables/useAttachmentActions.ts`**(或复用 `qc-work-order/composables/useQcViewAttachmentHelpers.ts` 模式)
+
+- `fullFileUrl(url)`
+- `handleAttachmentPreview(file)` → `window.open`
+- `handleAttachmentDownload(file)` → `downloadAttachmentFile`
+
+供预产页、样本接收页、卡片组件父级共用。
+
+### 5.3 `PreTransitProtocolCardList.vue`
+
+**紧凑卡片**(`protocol-card-compact__body` 底部,展开按钮之上):
+
+```html
+<ProtocolAttachmentMiniReadonly
+  :label="attachmentMeta(row).label"
+  :files="attachmentMeta(row).files"
+  @preview="emit('attachment-preview', $event)"
+  @download="emit('attachment-download', $event)"
+/>
+```
+
+**展开卡片**(「基础信息」`protocol-card__kv-grid` 末尾,占满一行 `protocol-card__kv--stack`):
+
+- 同上组件;`files` 较多时可横向换行,不限高度截断
+
+**卡片高度**:紧凑卡 `--compact-card-height` 增加约 **52px**(标签 10px + 缩略图 56px + 间距);`has-special-sample` 变体同步上调。
+
+**事件**:组件 `emit` 上抛 `attachment-preview` / `attachment-download`,由 `pre-transit-sample/index.vue` 接入 composable。
+
+### 5.4 `SampleReceptionCardList.vue`
+
+在 `sample-reception-card__meta` 末尾增加 `meta-item`(`grid-column: 1 / -1` 或 `minmax(160px, 1fr)` 以保证缩略图行宽度):
+
+```html
+<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
+  <dt>{{ attachmentMeta(row).label }}</dt>
+  <dd>
+    <ProtocolAttachmentMiniReadonly
+      :label="''"
+      :files="attachmentMeta(row).files"
+      ...
+    />
+  </dd>
+</div>
+```
+
+`label` 放在 `dt`,组件内 `label` 传空避免重复。
+
+待接收 / 已接收 **同一展示逻辑**。
+
+### 5.5 类型(可选)
+
+`web-admin/src/api/pre-transit-sample.ts` 可为 `PreTransitProtocolRow` 补充附件字段注释;样本接收列表仍为 `Record<string, unknown>`,不强制改类型。
+
+---
+
+## 6. 测试要点
+
+1. 细胞存储协议有/无孕妇健康调查表:预产紧凑卡、展开卡、样本接收待/已接收均正确
+2. 科研捐赠有/无传染病检测报告:同上
+3. 多附件横向换行;图片预览、PDF 下载
+4. 预产「样本未录入」协议(仅有附件、无样本)仍展示附件
+5. 列表分页切换后附件仍对应正确协议
+6. 租户隔离:不串附件
+
+---
+
+## 7. 改动文件清单
+
+| 层 | 文件 |
+|----|------|
+| DB | 无(字段已存在) |
+| Backend | `pre_transit_sample.py`、`sample_reception.py` |
+| Frontend 新建 | `protocol-attachment-mini-readonly/index.vue`、`utils/protocol-source-attachment.ts`、`composables/useAttachmentActions.ts` |
+| Frontend 修改 | `PreTransitProtocolCardList.vue`、`SampleReceptionCardList.vue`、`pre-transit-sample/index.vue`、`sample-reception/index.vue`(或 `SampleReceptionCardList` 内直接 composable) |
+
+---
+
+## 8. 不在本期
+
+- 样本接收 checklist 勾选
+- 知情同意书在卡片展示
+- 生产/质检工单卡片
+- 将 `ProtocolAttachmentReadonly` 改为 size prop(本期独立迷你组件)

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 450 - 492
web-admin/graphify-out/.graphify_analysis.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 279 - 89
web-admin/graphify-out/.graphify_extract.json


+ 152 - 134
web-admin/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
-# Graph Report - src  (2026-07-07)
+# Graph Report - src  (2026-07-13)
 
 ## Corpus Check
-- Large corpus: 852 files · ~564,234 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 857 files · ~567,252 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 2866 nodes · 4016 edges · 58 communities detected
-- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 902 edges (avg confidence: 0.8)
+- 2879 nodes · 4028 edges · 62 communities detected
+- Extraction: 78% EXTRACTED · 22% INFERRED · 0% AMBIGUOUS · INFERRED: 903 edges (avg confidence: 0.8)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -46,30 +46,34 @@
 - [[_COMMUNITY_Community 34|Community 34]]
 - [[_COMMUNITY_Community 35|Community 35]]
 - [[_COMMUNITY_Community 36|Community 36]]
+- [[_COMMUNITY_Community 37|Community 37]]
 - [[_COMMUNITY_Community 38|Community 38]]
 - [[_COMMUNITY_Community 39|Community 39]]
-- [[_COMMUNITY_Community 40|Community 40]]
+- [[_COMMUNITY_Community 41|Community 41]]
+- [[_COMMUNITY_Community 42|Community 42]]
 - [[_COMMUNITY_Community 44|Community 44]]
-- [[_COMMUNITY_Community 45|Community 45]]
-- [[_COMMUNITY_Community 47|Community 47]]
+- [[_COMMUNITY_Community 46|Community 46]]
 - [[_COMMUNITY_Community 48|Community 48]]
 - [[_COMMUNITY_Community 49|Community 49]]
-- [[_COMMUNITY_Community 50|Community 50]]
 - [[_COMMUNITY_Community 51|Community 51]]
 - [[_COMMUNITY_Community 52|Community 52]]
+- [[_COMMUNITY_Community 53|Community 53]]
 - [[_COMMUNITY_Community 54|Community 54]]
+- [[_COMMUNITY_Community 55|Community 55]]
 - [[_COMMUNITY_Community 56|Community 56]]
-- [[_COMMUNITY_Community 58|Community 58]]
+- [[_COMMUNITY_Community 57|Community 57]]
 - [[_COMMUNITY_Community 59|Community 59]]
 - [[_COMMUNITY_Community 60|Community 60]]
-- [[_COMMUNITY_Community 63|Community 63]]
-- [[_COMMUNITY_Community 65|Community 65]]
-- [[_COMMUNITY_Community 73|Community 73]]
-- [[_COMMUNITY_Community 75|Community 75]]
-- [[_COMMUNITY_Community 76|Community 76]]
+- [[_COMMUNITY_Community 62|Community 62]]
+- [[_COMMUNITY_Community 67|Community 67]]
+- [[_COMMUNITY_Community 69|Community 69]]
+- [[_COMMUNITY_Community 70|Community 70]]
+- [[_COMMUNITY_Community 78|Community 78]]
+- [[_COMMUNITY_Community 80|Community 80]]
+- [[_COMMUNITY_Community 81|Community 81]]
 
 ## God Nodes (most connected - your core abstractions)
-1. `get()` - 281 edges
+1. `get()` - 282 edges
 2. `push()` - 78 edges
 3. `WebSocketClient` - 26 edges
 4. `emit()` - 25 edges
@@ -83,298 +87,312 @@
 ## Surprising Connections (you probably didn't know these)
 - `handleBatchDelete()` --calls--> `withFullscreenLoading()`  [INFERRED]
   views/basic-info/customer/index.vue → utils/fullscreen-loading.ts
-- `buildInboundMetaFields()` --calls--> `push()`  [INFERRED]
-  utils/production-work-order-card-meta.ts → views/operations/storage-facility/index.vue
+- `dash()` --calls--> `formatDisplayOrDash()`  [INFERRED]
+  components/business/infusion-schedule-outbound-chain-view/index.vue → utils/datetime-display.ts
 - `dash()` --calls--> `formatDisplayOrDash()`  [INFERRED]
   components/business/clinical-schedule-payment-detail/index.vue → utils/datetime-display.ts
+- `fmtMoney()` --calls--> `formatMoneyDisplay()`  [INFERRED]
+  components/business/clinical-schedule-payment-detail/index.vue → utils/money-format.ts
 - `defaultInstallmentRatios()` --calls--> `push()`  [INFERRED]
   utils/protocol-payment-installment.ts → views/operations/storage-facility/index.vue
-- `calcUnpaidAmountsByRatios()` --calls--> `push()`  [INFERRED]
-  utils/protocol-payment-installment.ts → views/operations/storage-facility/index.vue
 
 ## Communities
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (162): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchChannelAccount() (+154 more)
+Nodes (151): fetchAccessLogList(), fetchActiveRegions(), fetchCategoryList(), fetchCategoryParameters(), fetchCellProductPriceList(), fetchCellProductSpecList(), fetchCellProductTypeList(), fetchChannelAccount() (+143 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.02
-Nodes (84): fetchUploadAttachment(), handleReportUpload(), handleReportUpload(), walk(), buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections(), buildReceptionSection() (+76 more)
+Nodes (107): fetchUploadAttachment(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), FireworkSystem, handleReportUpload(), handleReportUpload(), walk() (+99 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.03
-Nodes (86): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupDetail(), fetchPilotCheckupList(), markPilotCheckupChecked() (+78 more)
+Nodes (86): batchSavePilotCheckup(), deletePilotCheckupActualItem(), fetchCheckupInstitutionList(), fetchCheckupInstitutionPackageDetail(), fetchCheckupInstitutionPackages(), fetchPilotCheckupActualItems(), fetchPilotCheckupDetail(), fetchPilotCheckupList() (+78 more)
 
 ### Community 3 - "Community 3"
 Cohesion: 0.03
-Nodes (60): fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCreateCheckupInstitution(), fetchCreateCheckupInstitutionInvoice(), fetchCreateCheckupInstitutionProject(), fetchUpdateCheckupInstitution(), fetchUpdateCheckupInstitutionInvoice(), fetchUpdateCheckupInstitutionProject() (+52 more)
+Nodes (61): fetchRoleOptions(), collectIds(), buildInboundBasicSection(), buildInboundCellStorageViewSections(), buildInboundResearchDonationViewSections(), buildReceptionSection(), buildTransportSection(), resolveInboundProtocolStatus() (+53 more)
 
 ### Community 4 - "Community 4"
 Cohesion: 0.03
-Nodes (33): fetchGetMenuList(), ComponentLoader, IframeRouteManager, MenuProcessor, RouteRegistry, RouteTransformer, useAppMode(), useAuth() (+25 more)
+Nodes (63): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList(), fetchCreateProtocolInvoiceRequest(), fetchInvoiceEligiblePayments(), fetchPaymentDetailList() (+55 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.04
-Nodes (69): formatNowDatetime(), formatTodayDate(), addPaymentDetail(), applyFqPaymentDefaults(), applyPaymentPresetForMode(), createEmptyTransport(), datetimeToYmd(), defaultInstallmentStartDateYmd() (+61 more)
+Cohesion: 0.03
+Nodes (57): fetchCheckupInstitutionInvoices(), fetchCheckupInstitutionProjects(), fetchCreateCheckupInstitution(), fetchCreateCheckupInstitutionInvoice(), fetchCreateCheckupInstitutionProject(), fetchUpdateCheckupInstitution(), fetchUpdateCheckupInstitutionInvoice(), fetchUpdateCheckupInstitutionProject() (+49 more)
 
 ### Community 6 - "Community 6"
 Cohesion: 0.03
-Nodes (57): fetchMyProductionTasks(), fetchMyQcTaskList(), fetchQcTaskDetail(), fetchQcTaskList(), fetchMineTasksForWorkOrders(), useInboundSiblingNavigation(), shouldApplyTriggerSourceFilter(), useQcWorkOrderTabPage() (+49 more)
+Nodes (33): fetchGetMenuList(), ComponentLoader, IframeRouteManager, MenuProcessor, RouteRegistry, RouteTransformer, useAppMode(), useAuth() (+25 more)
 
 ### Community 7 - "Community 7"
-Cohesion: 0.03
-Nodes (58): fetchFullmoonTodoList(), fetchUpdateFullmoonTodoInsurance(), fetchCollectionProtocolAuditList(), fetchCollectionProtocolDetail(), fetchCollectionProtocolList(), fetchCreateProtocolInvoiceRequest(), fetchInvoiceEligiblePayments(), fetchPaymentDetailList() (+50 more)
+Cohesion: 0.04
+Nodes (69): formatNowDatetime(), formatTodayDate(), addPaymentDetail(), applyFqPaymentDefaults(), applyPaymentPresetForMode(), createEmptyTransport(), datetimeToYmd(), defaultInstallmentStartDateYmd() (+61 more)
 
 ### Community 8 - "Community 8"
-Cohesion: 0.04
-Nodes (42): buildBaseFields(), buildFeeSection(), buildInboundBasicSection(), buildInboundCellStorageProductionViewSections(), buildInboundResearchDonationProductionViewSections(), buildOutboundBasicExperimentProductionViewSections(), buildOutboundClinicalProductionViewSections(), buildOutboundDetail() (+34 more)
+Cohesion: 0.02
+Nodes (28): fetchAllConsumablePacks(), fetchAllConsumables(), fetchAllConsumableStockLogs(), fetchAllEquipments(), fetchAllOutputTypes(), fetchAllProcessCategories(), fetchAllProcesses(), fetchAllReagents() (+20 more)
 
 ### Community 9 - "Community 9"
 Cohesion: 0.04
-Nodes (46): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), updateInfusionScheduleStatus(), handleDialogClose(), handleAdd(), handleBatchDelete() (+38 more)
+Nodes (42): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), RoutePermissionValidator, useCeremony(), useTheme(), if(), collectAllPostBindDeptIds() (+34 more)
 
 ### Community 10 - "Community 10"
 Cohesion: 0.04
-Nodes (39): useSettingsHandlers(), useSettingsPanel(), useSettingsState(), RoutePermissionValidator, useCeremony(), useTheme(), if(), walk() (+31 more)
+Nodes (46): fetchCreateCustomer(), fetchCustomerList(), fetchDeleteCustomer(), fetchUpdateCustomer(), updateInfusionScheduleStatus(), handleDialogClose(), handleAdd(), handleBatchDelete() (+38 more)
 
 ### Community 11 - "Community 11"
 Cohesion: 0.05
-Nodes (48): fetchEthnicList(), applyDonationBundleToForm(), createEmptyTransport(), ensureEthnicOptionInList(), handleOpenForAdd(), if(), loadEthnicOptions(), normalizeSampleRow() (+40 more)
+Nodes (34): fetchClinicalPaymentDetail(), fetchClinicalPayments(), fetchClinicalReceiptConfirmed(), fetchClinicalReceiptPending(), fetchClinicalRefundConfirmed(), fetchClinicalRefundPending(), fetchOrderAdjusts(), fetchOrderDetail() (+26 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.04
-Nodes (40): fetchCellSampleTypeList(), fetchCreateCellSampleType(), fetchCreateMenu(), fetchDeleteCellSampleType(), fetchDeleteMenu(), fetchMenuTree(), fetchRoleOptions(), fetchUpdateCellSampleType() (+32 more)
+Cohesion: 0.06
+Nodes (38): fetchUserList(), fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit() (+30 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.04
-Nodes (33): fetchMineProductionWorkOrderTabStepCounts(), fetchMineProductionWorkOrderTabStepCountsDeduped(), fetchProductionWorkOrderCellProductOptions(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchProductionWorkOrderQcDispatchOptions(), fetchProductionWorkOrderQcInfo(), fetchProductionWorkOrderTabStepCounts() (+25 more)
+Cohesion: 0.08
+Nodes (37): formatDatetimeToMinute(), handleChineseDatetimePaste(), parseChineseDatetimeText(), parseBirthOrder(), parseChineseNumeral(), addSampleHint(), earliestCollectTime(), isMaternalBloodEmpty() (+29 more)
 
 ### Community 14 - "Community 14"
-Cohesion: 0.05
-Nodes (34): fetchClinicalPaymentDetail(), fetchClinicalPayments(), fetchClinicalReceiptConfirmed(), fetchClinicalReceiptPending(), fetchClinicalRefundConfirmed(), fetchClinicalRefundPending(), fetchOrderAdjusts(), fetchOrderDetail() (+26 more)
+Cohesion: 0.06
+Nodes (22): fetchQcMaterialBatchDetail(), fetchQcMaterialBatchList(), fetchQcMaterialBatchOptions(), fetchQcMaterialList(), fetchQcMaterialOptions(), fetchUpdateQcMaterialBatch(), fetchMarkQcMaterialUsageFullyUsed(), financeAttachmentsToUrlString() (+14 more)
 
 ### Community 15 - "Community 15"
 Cohesion: 0.06
-Nodes (38): fetchUserList(), fetchAddTaskStep(), fetchCopyTaskStep(), fetchDeleteTaskStep(), fetchDispatchProductionTaskSteps(), fetchForwardProductionTask(), fetchProductionTaskDetail(), fetchProductionTaskEditorInit() (+30 more)
+Nodes (13): fetchProductionWorkOrderQcInfo(), dash(), fmtMoney(), auditorDisplay(), dash(), ensureUnitsInOptions(), expressName(), formatYm() (+5 more)
 
 ### Community 16 - "Community 16"
-Cohesion: 0.05
-Nodes (15): handleBlur(), handleFocus(), handleInput(), formatAmount(), formatAmount(), formatProtocolAmount(), dash(), fmtMoney() (+7 more)
+Cohesion: 0.1
+Nodes (2): WebSocketClient, TableCache
 
 ### Community 17 - "Community 17"
-Cohesion: 0.04
-Nodes (21): fetchAvailableMaterialBatches(), fetchMyProductionMaterialConsumptions(), fetchMyProductionMaterialUsageList(), fetchProductionMaterialBatchList(), fetchProductionMaterialBatchOptions(), fetchProductionMaterialConsumptions(), fetchProductionMaterialFormula(), fetchProductionMaterialLinkedStepUsages() (+13 more)
+Cohesion: 0.08
+Nodes (32): toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url(), hexToBytes() (+24 more)
 
 ### Community 18 - "Community 18"
-Cohesion: 0.06
-Nodes (22): fetchQcMaterialBatchDetail(), fetchQcMaterialBatchList(), fetchQcMaterialBatchOptions(), fetchQcMaterialList(), fetchQcMaterialOptions(), fetchUpdateQcMaterialBatch(), fetchMarkQcMaterialUsageFullyUsed(), financeAttachmentsToUrlString() (+14 more)
+Cohesion: 0.09
+Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
 
 ### Community 19 - "Community 19"
-Cohesion: 0.09
-Nodes (36): attachmentFullUrl(), dash(), handleAttachmentDownload(), handleAttachmentPreview(), buildPaymentFlowSteps(), dash(), formatAmount(), hasFinanceReceipt() (+28 more)
+Cohesion: 0.08
+Nodes (20): createEmptySnapshots(), normalizeConfiguredReagents(), normalizeConsumables(), normalizeDataCollections(), normalizeReagents(), normalizeStepOutputs(), serializeAttachments(), serializeEquipmentIds() (+12 more)
 
 ### Community 20 - "Community 20"
-Cohesion: 0.1
-Nodes (2): WebSocketClient, TableCache
+Cohesion: 0.08
+Nodes (18): fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), onUserSelectVisible(), remoteSearchUsers(), buildPayload(), ensureDefaultHandler(), initDialog() (+10 more)
 
 ### Community 21 - "Community 21"
-Cohesion: 0.08
-Nodes (33): fetchRefreshToken(), toLogin(), base64ToBytes(), bytesToBase64(), decryptJson(), deriveKey(), encryptJson(), fromBase64Url() (+25 more)
+Cohesion: 0.07
+Nodes (13): handleBlur(), handleFocus(), handleInput(), formatAmount(), formatAmount(), formatProtocolAmount(), formatInvoiceAmount(), fmtMoney() (+5 more)
 
 ### Community 22 - "Community 22"
-Cohesion: 0.09
-Nodes (8): getDefaultLanguage(), checkStorageCompatibility(), StorageConfig, getSystemStorage(), getSysVersion(), StorageKeyManager, StorageCompatibilityManager, validateStorageData()
+Cohesion: 0.07
+Nodes (19): fetchDeleteMaterialBatch(), fetchMaterialBatchList(), fetchMaterialBatchOptions(), fetchMaterialList(), fetchMaterialOptions(), fetchUpdateMaterialBatch(), fetchMaterialUsageList(), fetchMyMaterialUsageList() (+11 more)
 
 ### Community 23 - "Community 23"
-Cohesion: 0.08
-Nodes (20): createEmptySnapshots(), normalizeConfiguredReagents(), normalizeConsumables(), normalizeDataCollections(), normalizeReagents(), normalizeStepOutputs(), serializeAttachments(), serializeEquipmentIds() (+12 more)
+Cohesion: 0.09
+Nodes (22): fetchMineProductionWorkOrderTabStepCounts(), fetchMineProductionWorkOrderTabStepCountsDeduped(), fetchProductionWorkOrderCellProductOptions(), fetchProductionWorkOrderDetail(), fetchProductionWorkOrderList(), fetchProductionWorkOrderQcDispatchOptions(), fetchProductionWorkOrderTabStepCounts(), fetchProductionWorkOrderTabStepCountsDeduped() (+14 more)
 
 ### Community 24 - "Community 24"
-Cohesion: 0.08
-Nodes (18): fetchProductionReceptionBySampleCode(), fetchProductionReceptionSubmit(), fetchProductionReceptionUpdate(), onUserSelectVisible(), remoteSearchUsers(), buildPayload(), ensureDefaultHandler(), initDialog() (+10 more)
+Cohesion: 0.09
+Nodes (27): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), handleBatchComplete(), handleBatchStart(), handleBatchVoid() (+19 more)
 
 ### Community 25 - "Community 25"
 Cohesion: 0.09
-Nodes (28): completePilotApplication(), deletePilotApplication(), fetchPilotApplicationList(), startPilotApplication(), voidPilotApplication(), clearTableSelection(), handleBatchComplete(), handleBatchStart() (+20 more)
+Nodes (21): fetchMyProductionTasks(), buildMineTableRows(), buildPresetTasksMap(), buildWorkOrderFromTasks(), fetchAllMineProductionTasks(), fetchMineTasksByScope(), groupMineTasksByWorkOrder(), keywordMatchesWorkOrder() (+13 more)
 
 ### Community 26 - "Community 26"
-Cohesion: 0.07
-Nodes (19): fetchDeleteMaterialBatch(), fetchMaterialBatchList(), fetchMaterialBatchOptions(), fetchMaterialList(), fetchMaterialOptions(), fetchUpdateMaterialBatch(), fetchMaterialUsageList(), fetchMyMaterialUsageList() (+11 more)
+Cohesion: 0.11
+Nodes (21): fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchRefreshToken(), fetchUpdateProfile(), fetchUploadProfileAvatar(), fetchUserInfo(), buildWebEncryptedLoginBody() (+13 more)
 
 ### Community 27 - "Community 27"
-Cohesion: 0.12
-Nodes (20): fetchGetMenuCodes(), fetchGetUserInfo(), fetchLogin(), fetchUpdateProfile(), fetchUploadProfileAvatar(), fetchUserInfo(), buildWebEncryptedLoginBody(), defaultAvatarPathByRole() (+12 more)
+Cohesion: 0.07
+Nodes (14): fetchSampleSmsNotificationList(), fetchStorageEntrySmsList(), fetchApproveTask(), fetchBusinessAllTasks(), fetchCompletedTasks(), fetchDesignerData(), fetchInstanceDetail(), fetchMyInstances() (+6 more)
 
 ### Community 28 - "Community 28"
 Cohesion: 0.09
 Nodes (12): fetchMineQcWorkOrderTabUnfinishedCounts(), fetchOutboundQcDetail(), fetchQcWorkOrderDetail(), fetchQcWorkOrderList(), fetchQcWorkOrdersBySample(), fetchQcWorkOrderTabUnfinishedCounts(), reload(), reloadTabCounts() (+4 more)
 
 ### Community 29 - "Community 29"
-Cohesion: 0.12
-Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
+Cohesion: 0.18
+Nodes (22): buildPaymentFlowSteps(), dash(), formatAmount(), hasFinanceReceipt(), hasFinanceRefund(), hasReceiptSubmit(), hasRefundSubmit(), receiptSubmitTime() (+14 more)
 
 ### Community 30 - "Community 30"
-Cohesion: 0.19
-Nodes (15): fetchAttachmentDownloadUrl(), attachmentFullUrl(), ensureUniqueZipEntryName(), fetchAttachmentBlob(), getAttachmentExtension(), resolveAttachmentFileName(), sanitizeZipPathSegment(), applyInboundLabStorageCodePrefix() (+7 more)
+Cohesion: 0.13
+Nodes (19): fetchCreateMenu(), fetchDeleteMenu(), fetchMenuTree(), fetchUpdateMenu(), postorder(), preorder(), applyTreeExpand(), handleAdd() (+11 more)
 
 ### Community 31 - "Community 31"
 Cohesion: 0.12
-Nodes (12): fetchApproveTask(), fetchBusinessAllTasks(), fetchCompletedTasks(), fetchDesignerData(), fetchInstanceDetail(), fetchMyInstances(), fetchPendingTasks(), fetchProcessDefList() (+4 more)
+Nodes (15): addCopyButton(), highlightCode(), insertLineNumbers(), isBlockProcessed(), markBlockAsProcessed(), processBlock(), setupHighlightDirective(), setupRippleDirective() (+7 more)
 
 ### Community 32 - "Community 32"
+Cohesion: 0.11
+Nodes (11): fetchOutboundReleaseMaterial(), fetchOutboundReleaseReviewDetail(), fetchOutboundReleaseReviewList(), fetchReleaseReviewList(), handleReset(), handleSearch(), handleToggleSelect(), loadData() (+3 more)
+
+### Community 33 - "Community 33"
+Cohesion: 0.13
+Nodes (18): fetchCellSampleTypeList(), fetchCreateCellSampleType(), fetchDeleteCellSampleType(), fetchUpdateCellSampleType(), handleAdd(), handleDelete(), handleDropdownCommand(), handleEdit() (+10 more)
+
+### Community 34 - "Community 34"
+Cohesion: 0.19
+Nodes (15): fetchAttachmentDownloadUrl(), attachmentFullUrl(), ensureUniqueZipEntryName(), fetchAttachmentBlob(), getAttachmentExtension(), resolveAttachmentFileName(), sanitizeZipPathSegment(), applyInboundLabStorageCodePrefix() (+7 more)
+
+### Community 35 - "Community 35"
 Cohesion: 0.16
 Nodes (14): fetchCreateEnvDetectItem(), fetchDeleteEnvDetectItem(), fetchEnvDetectItemDetail(), fetchEnvDetectItemList(), fetchEnvDetectItemsActive(), fetchUpdateEnvDetectItem(), handleAdd(), handleBatchDelete() (+6 more)
 
-### Community 33 - "Community 33"
+### Community 36 - "Community 36"
+Cohesion: 0.13
+Nodes (11): fetchEthnicList(), applyDonationBundleToForm(), createEmptyTransport(), ensureEthnicOptionInList(), handleOpenForAdd(), if(), loadEthnicOptions(), normalizeSampleRow() (+3 more)
+
+### Community 37 - "Community 37"
 Cohesion: 0.16
 Nodes (10): useTable(), useTableImpl(), getColumnChecks(), useTableColumns(), createErrorHandler(), createSmartDebounce(), defaultResponseAdapter(), extractPagination() (+2 more)
 
-### Community 34 - "Community 34"
+### Community 38 - "Community 38"
 Cohesion: 0.27
 Nodes (14): useChart(), useChartComponent(), useChartOps(), colourBlend(), getCssVar(), getDarkColor(), getLightColor(), handleElementThemeColor() (+6 more)
 
-### Community 35 - "Community 35"
+### Community 39 - "Community 39"
 Cohesion: 0.19
 Nodes (6): fetchServiceOrderTxnList(), handleReset(), handleSearch(), handleTabChange(), loadData(), switchTab()
 
-### Community 36 - "Community 36"
+### Community 41 - "Community 41"
 Cohesion: 0.24
-Nodes (8): fullFileUrl(), handlePreviewAttachment(), isVideoFile(), normalizeDisplayValue(), resolveReviewCellType(), resolveReviewQuantity(), resolveReviewSpecText(), uniqueJoined()
-
-### Community 38 - "Community 38"
-Cohesion: 0.15
-Nodes (4): fetchNextDonationSampleCode(), fetchResearchDonationAuditList(), fetchResearchDonationDetail(), fetchResearchDonationList()
+Nodes (4): addMaterialRow(), nextMaterialRowKey(), onRequisitionSelect(), removeMaterialRow()
 
-### Community 39 - "Community 39"
+### Community 42 - "Community 42"
 Cohesion: 0.25
 Nodes (2): handleTouchEnd(), setTransition()
 
-### Community 40 - "Community 40"
+### Community 44 - "Community 44"
+Cohesion: 0.31
+Nodes (4): annotateForkEdges(), chainApiToElements(), walkAncestorBranch(), walkAncestorParents()
+
+### Community 46 - "Community 46"
 Cohesion: 0.28
-Nodes (1): FireworkSystem
+Nodes (4): fetchMyProductionMaterialConsumptions(), handleReset(), handleSearch(), loadData()
 
-### Community 44 - "Community 44"
+### Community 48 - "Community 48"
 Cohesion: 0.39
 Nodes (6): isIgnorableScriptError(), normalizeErrorMessage(), registerPromiseErrorHandler(), registerResourceErrorHandler(), scriptErrorHandler(), setupErrorHandle()
 
-### Community 45 - "Community 45"
+### Community 49 - "Community 49"
 Cohesion: 0.36
 Nodes (1): TableHeightCalculator
 
-### Community 47 - "Community 47"
+### Community 51 - "Community 51"
 Cohesion: 0.33
 Nodes (2): logApiCryptoBootInfo(), maskSecret()
 
-### Community 48 - "Community 48"
+### Community 52 - "Community 52"
 Cohesion: 0.38
 Nodes (4): handleFocus(), handleInput(), parseDecimalToNumber(), sanitizeDecimalInput()
 
-### Community 49 - "Community 49"
+### Community 53 - "Community 53"
+Cohesion: 0.29
+Nodes (2): fetchLeaveDetail(), fetchLeaveList()
+
+### Community 54 - "Community 54"
 Cohesion: 0.4
 Nodes (2): isUnfinishedQcWorkOrder(), resolveQcWorkOrderUnfinishedBadgeCount()
 
-### Community 50 - "Community 50"
+### Community 55 - "Community 55"
 Cohesion: 0.47
 Nodes (3): isPaymentDueWithinWarnWindow(), parsePaymentDueYmd(), startOfLocalDay()
 
-### Community 51 - "Community 51"
+### Community 56 - "Community 56"
 Cohesion: 0.5
 Nodes (2): formatProtocolDocDeleteConfirm(), formatProtocolDocSubject()
 
-### Community 52 - "Community 52"
+### Community 57 - "Community 57"
 Cohesion: 0.5
 Nodes (2): parseEquipmentIds(), syncEquipmentNamesFromOptions()
 
-### Community 54 - "Community 54"
+### Community 59 - "Community 59"
 Cohesion: 0.5
 Nodes (2): focusInput(), handleKeydown()
 
-### Community 56 - "Community 56"
+### Community 60 - "Community 60"
+Cohesion: 0.4
+Nodes (1): fetchQcMaterialAmountAuthUsers()
+
+### Community 62 - "Community 62"
 Cohesion: 0.67
 Nodes (2): if(), logSidebarMenu()
 
-### Community 58 - "Community 58"
-Cohesion: 0.5
-Nodes (1): fetchStorageEntrySmsList()
-
-### Community 59 - "Community 59"
-Cohesion: 0.5
-Nodes (1): fetchSampleSmsNotificationList()
-
-### Community 60 - "Community 60"
-Cohesion: 0.5
-Nodes (1): fetchQcSmsList()
-
-### Community 63 - "Community 63"
+### Community 67 - "Community 67"
 Cohesion: 1.0
 Nodes (2): getSettingDefaults(), resetToDefaults()
 
-### Community 65 - "Community 65"
+### Community 69 - "Community 69"
 Cohesion: 1.0
 Nodes (2): copyTextToClipboard(), copyTextWithFallback()
 
-### Community 73 - "Community 73"
+### Community 70 - "Community 70"
+Cohesion: 1.0
+Nodes (2): getSourceProtocolAttachment(), normalizeAttachmentFiles()
+
+### Community 78 - "Community 78"
 Cohesion: 1.0
 Nodes (2): emptyTabCounts(), useProductionWorkOrderTabStepCounts()
 
-### Community 75 - "Community 75"
+### Community 80 - "Community 80"
 Cohesion: 1.0
 Nodes (2): initialProgress(), useQcWorkOrderAttachmentExport()
 
-### Community 76 - "Community 76"
+### Community 81 - "Community 81"
 Cohesion: 1.0
 Nodes (2): emptyTabCounts(), useQcWorkOrderTabUnfinishedCounts()
 
 ## Knowledge Gaps
-- **Thin community `Community 20`** (42 nodes): `WebSocketClient`, `.calculateReconnectDelay()`, `.clearAllTimers()`, `.clearConnectionTimers()`, `.clearTimer()`, `.close()`, `.closeCurrentSocketForReconnect()`, `.connect()`, `.connectionStatusText()`, `.constructor()`, `.destroyInstance()`, `.flushMessageQueue()`, `.getInstance()`, `.getReadyStateText()`, `.handleClose()`, `.handleConnectionTimeout()`, `.handleError()`, `.handleMessage()`, `.handleOpen()`, `.init()`, `.isWebSocketConnected()`, `.reconnect()`, `.resetHeartbeat()`, `.send()`, `.startHeartbeat()`, `.startPing()`, `TableCache`, `.cleanupExpired()`, `.clear()`, `.clearByTags()`, `.clearCurrentSearch()`, `.clearPagination()`, `.constructor()`, `.evictLRU()`, `.generateKey()`, `.generateTags()`, `.get()`, `.getStats()`, `.log()`, `.set()`, `index.ts`, `tableCache.ts`
-  Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 39`** (9 nodes): `cleanupEventListeners()`, `handleTouchEnd()`, `handleTouchMove()`, `handleTouchStart()`, `handleWheelScroll()`, `if()`, `setTransition()`, `setupEventListeners()`, `index.vue`
+- **Thin community `Community 16`** (42 nodes): `WebSocketClient`, `.calculateReconnectDelay()`, `.clearAllTimers()`, `.clearConnectionTimers()`, `.clearTimer()`, `.close()`, `.closeCurrentSocketForReconnect()`, `.connect()`, `.connectionStatusText()`, `.constructor()`, `.destroyInstance()`, `.flushMessageQueue()`, `.getInstance()`, `.getReadyStateText()`, `.handleClose()`, `.handleConnectionTimeout()`, `.handleError()`, `.handleMessage()`, `.handleOpen()`, `.init()`, `.isWebSocketConnected()`, `.reconnect()`, `.resetHeartbeat()`, `.send()`, `.startHeartbeat()`, `.startPing()`, `TableCache`, `.cleanupExpired()`, `.clear()`, `.clearByTags()`, `.clearCurrentSearch()`, `.clearPagination()`, `.constructor()`, `.evictLRU()`, `.generateKey()`, `.generateTags()`, `.get()`, `.getStats()`, `.log()`, `.set()`, `index.ts`, `tableCache.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 40`** (9 nodes): `FireworkSystem`, `.constructor()`, `.createFirework()`, `.createParticle()`, `.getAvailableParticle()`, `.initializePool()`, `.preloadAllImages()`, `.preloadImage()`, `index.vue`
+- **Thin community `Community 42`** (9 nodes): `cleanupEventListeners()`, `handleTouchEnd()`, `handleTouchMove()`, `handleTouchStart()`, `handleWheelScroll()`, `if()`, `setTransition()`, `setupEventListeners()`, `index.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 45`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
+- **Thin community `Community 49`** (8 nodes): `TableHeightCalculator`, `.calculate()`, `.calculateOffset()`, `.calculatePaginationOffset()`, `.constructor()`, `.getHeaderHeight()`, `useTableHeight()`, `useTableHeight.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 47`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
+- **Thin community `Community 51`** (7 nodes): `App.vue`, `main.ts`, `refreshButtonAutoIcons()`, `refreshPanelTitleIcons()`, `logApiCryptoBootInfo()`, `maskSecret()`, `console.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 49`** (6 nodes): `formatQcWorkOrderUnfinishedBadgeCount()`, `isUnfinishedQcWorkOrder()`, `qcWorkOrderUnfinishedBadgeTitle()`, `resolveQcWorkOrderUnfinishedBadgeCount()`, `sumQcWorkOrderGroupUnfinishedBadgeCount()`, `qc-work-order-unfinished-badge.ts`
+- **Thin community `Community 53`** (7 nodes): `fetchCreateLeave()`, `fetchDeleteLeave()`, `fetchLeaveDetail()`, `fetchLeaveList()`, `fetchSubmitLeave()`, `fetchUpdateLeave()`, `leave.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 51`** (5 nodes): `formatProtocolDocBatchDeleteConfirm()`, `formatProtocolDocDeleteConfirm()`, `formatProtocolDocSubject()`, `getProtocolDocTypeLabel()`, `protocol-doc-label.ts`
+- **Thin community `Community 54`** (6 nodes): `formatQcWorkOrderUnfinishedBadgeCount()`, `isUnfinishedQcWorkOrder()`, `qcWorkOrderUnfinishedBadgeTitle()`, `resolveQcWorkOrderUnfinishedBadgeCount()`, `sumQcWorkOrderGroupUnfinishedBadgeCount()`, `qc-work-order-unfinished-badge.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 52`** (5 nodes): `equipmentOptionLabel()`, `joinEquipmentIds()`, `parseEquipmentIds()`, `syncEquipmentNamesFromOptions()`, `equipment-select.ts`
+- **Thin community `Community 56`** (5 nodes): `formatProtocolDocBatchDeleteConfirm()`, `formatProtocolDocDeleteConfirm()`, `formatProtocolDocSubject()`, `getProtocolDocTypeLabel()`, `protocol-doc-label.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 54`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
+- **Thin community `Community 57`** (5 nodes): `equipmentOptionLabel()`, `joinEquipmentIds()`, `parseEquipmentIds()`, `syncEquipmentNamesFromOptions()`, `equipment-select.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 56`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
+- **Thin community `Community 59`** (5 nodes): `focusInput()`, `handleKeydown()`, `if()`, `search()`, `index.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 58`** (4 nodes): `fetchGenerateStorageEntrySms()`, `fetchStorageEntrySmsList()`, `fetchToggleStorageEntrySmsNotified()`, `storage-entry-sms-notification.ts`
+- **Thin community `Community 60`** (5 nodes): `fetchBatchRevokeQcMaterialAmountAuth()`, `fetchGrantQcMaterialAmountAuth()`, `fetchQcMaterialAmountAuthUsers()`, `fetchRevokeQcMaterialAmountAuth()`, `qc-material-amount-auth.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 59`** (4 nodes): `fetchGenerateSms()`, `fetchSampleSmsNotificationList()`, `fetchToggleSmsNotified()`, `sample-sms-notification.ts`
+- **Thin community `Community 62`** (4 nodes): `if()`, `logSidebarMenu()`, `index.vue`, `SidebarSubmenu.vue`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 60`** (4 nodes): `fetchGenerateQcSms()`, `fetchQcSmsList()`, `fetchToggleQcSmsNotified()`, `qc-sms-notification.ts`
+- **Thin community `Community 67`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 63`** (3 nodes): `getSettingDefaults()`, `resetToDefaults()`, `setting.ts`
+- **Thin community `Community 69`** (3 nodes): `copyTextToClipboard()`, `copyTextWithFallback()`, `copy-text.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 65`** (3 nodes): `copyTextToClipboard()`, `copyTextWithFallback()`, `copy-text.ts`
+- **Thin community `Community 70`** (3 nodes): `getSourceProtocolAttachment()`, `normalizeAttachmentFiles()`, `protocol-source-attachment.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 73`** (3 nodes): `emptyTabCounts()`, `useProductionWorkOrderTabStepCounts()`, `useProductionWorkOrderTabStepCounts.ts`
+- **Thin community `Community 78`** (3 nodes): `emptyTabCounts()`, `useProductionWorkOrderTabStepCounts()`, `useProductionWorkOrderTabStepCounts.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 75`** (3 nodes): `initialProgress()`, `useQcWorkOrderAttachmentExport()`, `useQcWorkOrderAttachmentExport.ts`
+- **Thin community `Community 80`** (3 nodes): `initialProgress()`, `useQcWorkOrderAttachmentExport()`, `useQcWorkOrderAttachmentExport.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 76`** (3 nodes): `emptyTabCounts()`, `useQcWorkOrderTabUnfinishedCounts()`, `useQcWorkOrderTabUnfinishedCounts.ts`
+- **Thin community `Community 81`** (3 nodes): `emptyTabCounts()`, `useQcWorkOrderTabUnfinishedCounts()`, `useQcWorkOrderTabUnfinishedCounts.ts`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 13`, `Community 14`, `Community 15`, `Community 17`, `Community 18`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 31`, `Community 32`, `Community 35`, `Community 38`, `Community 58`, `Community 59`, `Community 60`?**
-  _High betweenness centrality (0.273) - this node is a cross-community bridge._
-- **Why does `push()` connect `Community 1` to `Community 0`, `Community 2`, `Community 3`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 10`, `Community 11`, `Community 13`, `Community 15`, `Community 19`, `Community 20`, `Community 21`, `Community 23`, `Community 25`, `Community 28`, `Community 30`, `Community 40`?**
-  _High betweenness centrality (0.255) - this node is a cross-community bridge._
+- **Why does `get()` connect `Community 0` to `Community 1`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 14`, `Community 15`, `Community 20`, `Community 22`, `Community 23`, `Community 24`, `Community 25`, `Community 26`, `Community 27`, `Community 28`, `Community 30`, `Community 32`, `Community 33`, `Community 35`, `Community 36`, `Community 39`, `Community 44`, `Community 46`, `Community 53`, `Community 60`?**
+  _High betweenness centrality (0.267) - this node is a cross-community bridge._
+- **Why does `push()` connect `Community 1` to `Community 0`, `Community 2`, `Community 3`, `Community 4`, `Community 5`, `Community 7`, `Community 9`, `Community 12`, `Community 13`, `Community 15`, `Community 16`, `Community 17`, `Community 19`, `Community 23`, `Community 24`, `Community 25`, `Community 28`, `Community 34`, `Community 41`?**
+  _High betweenness centrality (0.259) - this node is a cross-community bridge._
 - **Why does `fetchNotDeletedRegions()` connect `Community 0` to `Community 1`?**
-  _High betweenness centrality (0.034) - this node is a cross-community bridge._
-- **Are the 280 inferred relationships involving `get()` (e.g. with `buildNodeCodePathFromFlat()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
-  _`get()` has 280 INFERRED edges - model-reasoned connections that need verification._
+  _High betweenness centrality (0.038) - this node is a cross-community bridge._
+- **Are the 281 inferred relationships involving `get()` (e.g. with `buildNodeCodePathFromFlat()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
+  _`get()` has 281 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 77 inferred relationships involving `push()` (e.g. with `refreshQcMenuBadges()` and `collectLeafFacilityNodesInTreeOrder()`) actually correct?**
   _`push()` has 77 INFERRED edges - model-reasoned connections that need verification._
 - **Are the 24 inferred relationships involving `emit()` (e.g. with `handleSubmit()` and `handleDialogClose()`) actually correct?**

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
web-admin/graphify-out/graph.html


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 136 - 109
web-admin/graphify-out/graph.json


+ 20 - 15
web-admin/graphify-out/manifest.json

@@ -17,10 +17,11 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/index.ts": 1781272556.3597305,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/directive/directive.d.ts": 1781272556.3601494,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api/api.d.ts": 1781272556.3606477,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1783428533.0117855,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1783428029.977557,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1783923617.6177902,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1783922785.1197648,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1781272556.364766,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1782394569.8806834,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/composables/useAttachmentActions.ts": 1783921717.4015942,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/composables/useAttachmentDropZone.ts": 1782873161.9839864,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/locales/index.ts": 1781272556.3657222,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/config/pilot-biz-type.ts": 1781682539.4897008,
@@ -106,6 +107,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/production-task-editor-cache.ts": 1782602746.754815,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-pay-mode-switch.ts": 1783329869.6204023,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-payment-installment.ts": 1783329873.2829163,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/protocol-source-attachment.ts": 1783921716.666595,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/download-attachment.ts": 1781272556.4160507,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/ui/colors.ts": 1781272556.3798137,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/utils/ui/emojo.ts": 1781272556.3802173,
@@ -222,6 +224,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/media/art-video-player/index.vue": 1782893851.5118508,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/core/media/art-cutter-img/index.vue": 1782893851.4950457,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-transport-form-section/index.vue": 1782893852.3610222,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-attachment-mini-readonly/index.vue": 1783921757.1369452,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-finance-confirm-fields/index.vue": 1782893852.350682,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-finance-confirm-fields/finance-attachments.ts": 1781272556.6256514,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/qc-task-view-dialog/index.vue": 1782894734.8201208,
@@ -231,7 +234,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/dialog-form-body/index.vue": 1782893852.1647537,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/clinical-payment-submit-readonly/index.vue": 1782893852.1397598,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-qc-info-cards/index.vue": 1782893852.4513032,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/special-sample-display/index.vue": 1782893852.3469865,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/special-sample-display/index.vue": 1783925058.170451,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/order-operate-local-dialog/index.vue": 1782893852.4089837,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/product-view-dialog/index.vue": 1782894734.7548196,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-production-info-cards/index.vue": 1782893852.297569,
@@ -244,7 +247,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/outbound-material-form-sections/index.vue": 1782893851.934857,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/attachment-zip-progress-dialog/index.vue": 1782893851.6959429,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-payment-detail-readonly/index.vue": 1783330021.0864918,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-code-with-special-display/index.vue": 1782893851.6283524,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-code-with-special-display/index.vue": 1783925058.1716585,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/qc-item-param-select-editor/index.vue": 1782893851.5957565,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/system-view-dialog/index.vue": 1782893851.578115,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/production-task-view-dialog/index.vue": 1782894735.276092,
@@ -279,6 +282,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/workflow.ts": 1781272556.6579714,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/production-work-order.ts": 1783307216.2495985,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/sample-production-reception.ts": 1782719155.415783,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-material-amount-auth.ts": 1783916949.4248378,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/qc-task-result.ts": 1782379634.2324347,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/protocol-sample-collection.ts": 1781272556.659353,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/api/material-usage.ts": 1782894693.658203,
@@ -315,9 +319,9 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/my-material-usage/index.vue": 1782893854.311591,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/index.vue": 1783326448.8937888,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolStatusChangeDialog.vue": 1782894806.0188534,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue": 1783330003.107557,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue": 1783920105.0170739,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue": 1782893854.403395,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue": 1783393773.5269098,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue": 1783923109.0031872,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol/ProtocolAuditDialog.vue": 1782893854.3675368,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/sample-info/index.vue": 1782893854.321358,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/sample-reception-info/index.vue": 1782893854.265202,
@@ -333,7 +337,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue": 1783326448.9027853,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/protocol-audit/PaymentInfoTab.vue": 1783326448.9036555,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/index.vue": 1783326448.9051046,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/MaterialUsageTab.vue": 1783326448.9058185,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/MaterialUsageTab.vue": 1783429524.4350624,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/MaterialStockCheckTab.vue": 1783326448.9063764,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/fullmoon-todo/index.vue": 1782893854.0469422,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/storage-entry-info/index.vue": 1782893854.0034747,
@@ -351,8 +355,8 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/sample-info/DonationSampleCollectionInfoTab.vue": 1783326448.9150352,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/index.vue": 1783326448.9154768,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationAuditDialog.vue": 1782893853.885547,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationViewDialog.vue": 1782894737.847069,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationFormDialog.vue": 1782959415.9363396,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationViewDialog.vue": 1783920539.4073346,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationFormDialog.vue": 1783920544.2442346,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/research-donation/list/DonationStatusChangeDialog.vue": 1782894808.1240149,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/dashboard/console/index.vue": 1782893853.5152776,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/system/tenant/index.vue": 1783326448.9181187,
@@ -445,7 +449,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/index.vue": 1783326448.9552636,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/MaterialConsumptionTab.vue": 1783326448.9566333,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/ProductionMaterialConfiguredCatalogDialog.vue": 1782893852.288642,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/ProductionMaterialUsageTab.vue": 1783326448.9573576,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/ProductionMaterialUsageTab.vue": 1783429524.4349964,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/ProductionMaterialStockCheckTab.vue": 1783326448.957976,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/ProductionMaterialPrepTab.vue": 1783326448.9587293,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/sample-reception-completion/index.vue": 1782893851.986683,
@@ -504,14 +508,15 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/lab-test-item/index.vue": 1782893853.832618,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/lab-test-item/ThirdPartyCompanyTab.vue": 1783326448.9753742,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/my-qc-material-usage/index.vue": 1782893853.6503608,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/index.vue": 1783428546.2955363,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/index.vue": 1783916949.4252357,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/QcMaterialStockCheckTab.vue": 1783326448.981454,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/QcMaterialUsageTab.vue": 1783428549.951238,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/QcMaterialUsageTab.vue": 1783429524.2582674,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material/QcMaterialBatchAttachmentDialog.vue": 1782893853.076457,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material-amount-auth/index.vue": 1783919123.1912484,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/pre-transit-sample/index.vue": 1783326448.9852715,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/pre-transit-sample/PreTransitProtocolCardList.vue": 1783391495.9213836,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/pre-transit-sample/PreTransitProtocolCardList.vue": 1783925902.3931653,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/index.vue": 1783389101.2287407,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue": 1783389098.9469657,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue": 1783924089.846302,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/my-qc-task/index.vue": 1783325990.456282,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/env-detect-record/index.vue": 1782893853.44988,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/env-detect-item/index.vue": 1783326448.9953067,
@@ -597,7 +602,7 @@
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/RoutePermissionValidator.ts": 1781272559.4235668,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/index.ts": 1781272559.424284,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/core/IframeRouteManager.ts": 1781272559.425215,
-  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1782203479.9824018,
+  "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts": 1783917044.1127987,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/index.ts": 1781272559.5419338,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/exception.ts": 1781272559.549901,
   "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/routes/staticRoutes.ts": 1781272559.596198,

+ 43 - 0
web-admin/src/api/qc-material-amount-auth.ts

@@ -0,0 +1,43 @@
+import request from '@/utils/http'
+
+export type QcMaterialAmountAuthUser = {
+  id: string
+  userId: string
+  userNo: string
+  nickName: string
+  phoneNo: string
+  deptName: string
+  status: string
+  grantTime: string
+}
+
+export function fetchQcMaterialAmountAuthUsers(params?: {
+  page?: number
+  pageSize?: number
+  keyword?: string
+}) {
+  return request.get<Api.Common.PageList<QcMaterialAmountAuthUser>>({
+    url: '/web/admin/qc-material-amount-auth/users',
+    params
+  })
+}
+
+export function fetchGrantQcMaterialAmountAuth(userId: string) {
+  return request.post<{ created: boolean }>({
+    url: '/web/admin/qc-material-amount-auth/users',
+    data: { userId }
+  })
+}
+
+export function fetchRevokeQcMaterialAmountAuth(userId: string) {
+  return request.delete({
+    url: `/web/admin/qc-material-amount-auth/users/${userId}`
+  })
+}
+
+export function fetchBatchRevokeQcMaterialAmountAuth(userIds: string[]) {
+  return request.post<{ count: number }>({
+    url: '/web/admin/qc-material-amount-auth/users/batch-revoke',
+    data: { userIds }
+  })
+}

+ 222 - 0
web-admin/src/components/business/protocol-attachment-mini-readonly/index.vue

@@ -0,0 +1,222 @@
+<!-- 协议附件 — 迷你缩略图只读(卡片列表用) -->
+<template>
+  <div class="protocol-attachment-mini">
+    <div v-if="label" class="protocol-attachment-mini__label">{{ label }}</div>
+    <span v-if="!files.length" class="protocol-attachment-mini__empty">{{ emptyText }}</span>
+    <div v-else class="protocol-attachment-mini__list">
+      <div
+        v-for="(file, index) in files"
+        :key="String(file.id || file.fileUrl || index)"
+        class="protocol-attachment-mini__item"
+      >
+        <div
+          class="protocol-attachment-mini__preview"
+          :class="{ 'is-clickable': isImageFile(file) && file.fileUrl }"
+          @click="isImageFile(file) && file.fileUrl ? emit('preview', file) : undefined"
+        >
+          <template v-if="isImageFile(file) && file.fileUrl">
+            <img
+              :src="resolveFileUrl(String(file.fileUrl || ''))"
+              :alt="String(file.fileName || '')"
+            />
+          </template>
+          <template v-else-if="isVideoFile(file)">
+            <div class="protocol-attachment-mini__icon protocol-attachment-mini__icon--video">
+              <el-icon :size="20"><VideoPlay /></el-icon>
+            </div>
+          </template>
+          <template v-else>
+            <div class="protocol-attachment-mini__icon protocol-attachment-mini__icon--doc">
+              <el-icon :size="20"><Document /></el-icon>
+            </div>
+          </template>
+        </div>
+        <div class="protocol-attachment-mini__footer">
+          <span class="protocol-attachment-mini__name" :title="String(file.fileName || '')">{{
+            file.fileName || '未命名'
+          }}</span>
+          <el-icon
+            class="protocol-attachment-mini__download"
+            title="下载"
+            @click.stop="emit('download', file)"
+          >
+            <Download />
+          </el-icon>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { Document, Download, VideoPlay } from '@element-plus/icons-vue'
+  import { attachmentFullUrl } from '@/composables/useAttachmentActions'
+
+  defineOptions({ name: 'ProtocolAttachmentMiniReadonly' })
+
+  withDefaults(
+    defineProps<{
+      label?: string
+      files: Array<Record<string, unknown>>
+      emptyText?: string
+    }>(),
+    {
+      label: '',
+      emptyText: '无'
+    }
+  )
+
+  const emit = defineEmits<{
+    preview: [file: Record<string, unknown>]
+    download: [file: Record<string, unknown>]
+  }>()
+
+  function resolveFileUrl(suffix: string) {
+    return attachmentFullUrl(suffix)
+  }
+
+  function isImageFile(file: Record<string, unknown>) {
+    const url = String(file.fileUrl || '').toLowerCase()
+    const name = String(file.fileName || '').toLowerCase()
+    return (
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
+      String(file.fileType || '').startsWith('image/')
+    )
+  }
+
+  function isVideoFile(file: Record<string, unknown>) {
+    const url = String(file.fileUrl || '').toLowerCase()
+    const name = String(file.fileName || '').toLowerCase()
+    return (
+      /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
+      /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
+      String(file.fileType || '').startsWith('video/')
+    )
+  }
+</script>
+
+<style scoped lang="scss">
+  .protocol-attachment-mini {
+    min-width: 0;
+  }
+
+  .protocol-attachment-mini__label {
+    margin-bottom: 3px;
+    font-size: 10px;
+    line-height: 1.35;
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  .protocol-attachment-mini__empty {
+    font-size: 11px;
+    line-height: 1.35;
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  .protocol-attachment-mini__list {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+  }
+
+  .protocol-attachment-mini__item {
+    width: 80px;
+    display: flex;
+    flex-direction: column;
+    border: 1px solid #e2e8f0;
+    border-radius: 6px;
+    background: #fff;
+    overflow: hidden;
+    transition:
+      border-color 0.15s,
+      box-shadow 0.15s;
+
+    &:hover {
+      border-color: #047857;
+      box-shadow: 0 2px 6px rgb(4 120 87 / 10%);
+    }
+  }
+
+  .protocol-attachment-mini__preview {
+    width: 100%;
+    height: 56px;
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #f8fafc;
+    overflow: hidden;
+
+    &.is-clickable {
+      cursor: pointer;
+    }
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      display: block;
+    }
+  }
+
+  .protocol-attachment-mini__icon {
+    width: 32px;
+    height: 32px;
+    border-radius: 8px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    &--video {
+      background: #ede9fe;
+      color: #7c3aed;
+    }
+
+    &--doc {
+      background: #e0f2fe;
+      color: #0284c7;
+    }
+  }
+
+  .protocol-attachment-mini__footer {
+    display: flex;
+    align-items: center;
+    gap: 2px;
+    padding: 3px 4px;
+    min-width: 0;
+  }
+
+  .protocol-attachment-mini__name {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    font-size: 10px;
+    line-height: 1.3;
+    color: #334155;
+  }
+
+  .protocol-attachment-mini__download {
+    flex-shrink: 0;
+    width: 18px;
+    height: 18px;
+    border-radius: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 11px;
+    color: var(--mint-text-muted, #64748b);
+    cursor: pointer;
+    background: #f1f5f9;
+    transition:
+      background 0.15s,
+      color 0.15s;
+
+    &:hover {
+      background: #047857;
+      color: #fff;
+    }
+  }
+</style>

+ 12 - 9
web-admin/src/components/business/sample-code-with-special-display/index.vue

@@ -1,10 +1,9 @@
-<!-- 样本编码 + 特殊样本警示(列表场景:编码与说明均为红色加粗) -->
+<!-- 样本编码 + 特殊样本警示(特殊样本以 danger Tag 展示) -->
 <template>
   <span
     class="sample-code-with-special"
     :class="[
       `sample-code-with-special--${layout}`,
-      { 'sample-code-with-special--danger': isSpecial },
       { 'sample-code-with-special--mono': monospace }
     ]"
   >
@@ -14,6 +13,7 @@
       :is-special="isSpecial"
       :special-remark="specialRemark"
       :layout="layout"
+      :size="tagSize"
     />
   </span>
 </template>
@@ -32,6 +32,7 @@
       fallback?: string
       showCode?: boolean
       monospace?: boolean
+      tagSize?: 'large' | 'default' | 'small'
     }>(),
     {
       sampleCode: '',
@@ -40,7 +41,8 @@
       layout: 'inline',
       fallback: '—',
       showCode: true,
-      monospace: false
+      monospace: false,
+      tagSize: 'small'
     }
   )
 
@@ -53,20 +55,21 @@
 <style scoped lang="scss">
   .sample-code-with-special {
     &.sample-code-with-special--inline {
-      display: inline;
+      display: inline-flex;
+      align-items: center;
+      flex-wrap: wrap;
+      gap: 6px;
+      vertical-align: middle;
     }
 
     &.sample-code-with-special--block {
       display: block;
+      min-width: 0;
+      max-width: 100%;
     }
   }
 
   .sample-code-with-special--mono .sample-code-with-special__code {
     font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
   }
-
-  .sample-code-with-special--danger .sample-code-with-special__code {
-    color: var(--el-color-danger);
-    font-weight: 700;
-  }
 </style>

+ 25 - 15
web-admin/src/components/business/special-sample-display/index.vue

@@ -1,8 +1,7 @@
-<!-- 特殊样本警示展示(红色加粗) -->
+<!-- 特殊样本警示展示(danger Tag) -->
 <template>
   <span v-if="isSpecial" class="special-sample-display" :class="`special-sample-display--${layout}`">
-    <span class="special-sample-mark">【特殊样本】</span>
-    <span v-if="displayRemark" class="special-sample-remark">{{ displayRemark }}</span>
+    <el-tag type="danger" :size="size">{{ tagText }}</el-tag>
   </span>
 </template>
 
@@ -14,35 +13,46 @@
       isSpecial?: boolean
       specialRemark?: string
       layout?: 'inline' | 'block'
+      size?: 'large' | 'default' | 'small'
     }>(),
     {
       isSpecial: false,
       specialRemark: '',
-      layout: 'inline'
+      layout: 'inline',
+      size: 'small'
     }
   )
 
   const displayRemark = computed(() => String(props.specialRemark || '').trim())
+
+  const tagText = computed(() => {
+    if (displayRemark.value) return `特殊样本:${displayRemark.value}`
+    return '特殊样本'
+  })
 </script>
 
 <style scoped lang="scss">
   .special-sample-display {
     &.special-sample-display--inline {
-      display: inline;
+      display: inline-flex;
+      vertical-align: middle;
     }
 
     &.special-sample-display--block {
       display: block;
-    }
-  }
+      margin-top: 4px;
+      min-width: 0;
+      max-width: 100%;
 
-  .special-sample-mark,
-  .special-sample-remark {
-    color: var(--el-color-danger);
-    font-weight: 700;
-  }
-
-  .special-sample-remark {
-    margin-left: 4px;
+      :deep(.el-tag) {
+        display: inline-block;
+        max-width: 100%;
+        height: auto !important;
+        white-space: normal;
+        word-break: break-word;
+        line-height: 1.4;
+        text-align: left;
+      }
+    }
   }
 </style>

+ 28 - 0
web-admin/src/composables/useAttachmentActions.ts

@@ -0,0 +1,28 @@
+import { downloadAttachmentFile } from '@/utils/download-attachment'
+
+const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
+
+export function attachmentFullUrl(url: string) {
+  const u = String(url || '').trim()
+  if (!u) return ''
+  if (/^https?:\/\//i.test(u)) return u
+  return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u
+}
+
+export function useAttachmentActions() {
+  function handleAttachmentPreview(file: Record<string, unknown>) {
+    const url = attachmentFullUrl(String(file.fileUrl || ''))
+    if (url) window.open(url, '_blank')
+  }
+
+  function handleAttachmentDownload(file: Record<string, unknown>) {
+    downloadAttachmentFile(file, {
+      fullFileUrl: (u: string) => attachmentFullUrl(u)
+    })
+  }
+
+  return {
+    handleAttachmentPreview,
+    handleAttachmentDownload
+  }
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/04552f64934bfbb9a4aa11779cfc00792dbd444bb4b377c3a6f1f90aee6ea614.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/06ee05d39776487253211de6b0654506bf950ec692ba229fa435dd81a78ea339.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/14516ebd2a273c2aa98f94f75088e8e4c7fc6a95f7d487c72ca3b00341dd41cf.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_router_modules_sqxp_ts", "label": "sqxp.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_web_admin_src_router_modules_sqxp_ts", "target": "users_wuzilong_work_00code_zkxw_web_admin_src_types_router", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/router/modules/sqxp.ts", "source_location": "L1", "weight": 1.0}], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/1a58caa72d2c7797db4ccefeb407d16efdaad8c8c203898f2cbd3cd35aa7a806.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_sample_code_with_special_display_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-code-with-special-display/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/1bc1f23024a71aadc5f144c71e077f7c71497e2d219e5a8e22145bce1e81dd82.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/20b5c6f93b63479a33a1bf074fe65ed1ef9e732255adddf326a653483584ecd3.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/260a86725af577c4ee3bfa672b233307087310d8326e84653099c3beaea96999.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/29800c2dd6181b09ae104fa5958a3268c387cdee5c8c1f8be7e7f8c92966f132.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/31c120793a813993ffbc0655d8c1df464a392f5f1a1f898bbd77e629cbaf0784.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_qc_material_amount_auth_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material-amount-auth/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/39179fd2bc7fe4f8f423325bb7f056904fed40cee3b1d86f701baf6f2caffd63.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/3a8b693651d750a3e44e4d4327fe04d043bd5c990bc5b2fa52b46be26cd74c03.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_cell_storage_material_materialusagetab_vue", "label": "MaterialUsageTab.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/cell-storage/material/MaterialUsageTab.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/3d24c625eb0eb5339c622a6d57dc1bfe21395df861b5cdcddc9f44e0844ec5a9.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_sample_reception_samplereceptioncardlist_vue", "label": "SampleReceptionCardList.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/3e2877646fbef7a12b2857d98581a724a09b5e5814fdc96d068698207fbdcbd3.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/3eb439ae9676d9ce2c45f2b5be9c02da6132b6fa2da16803525230d92744c514.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/55df8487ec42be1a1c45007b9fb500858f4ee9ce23a4fe10f76b5bf146b307b9.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_sample_reception_samplereceptioncardlist_vue", "label": "SampleReceptionCardList.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/5cfee7cd694f792bbc2ad34f587de49176f4cb411e61377f424db9e6a7572180.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_protocol_attachment_mini_readonly_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-attachment-mini-readonly/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/684e1e42458eb8dc36fea98605ab068d46c21bd13029cbc88274e739b6211061.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_production_production_material_productionmaterialusagetab_vue", "label": "ProductionMaterialUsageTab.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/production/production-material/ProductionMaterialUsageTab.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/6ba54761673800740275cd23a829a4e665a0063b12c6d138264cd3b593fab556.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/72f5546a16450218fd4211f41bc79d92ffe2bc73558f5a25ebb0ae002068a6d7.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/7ad7f5c535844cca608724769662ef7c224bc06178ce8ff0c3cc86207705fcf9.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/7fc55960df9394d35c9ba3e75a35f64b41299d881cb62b8e524a7ce3325bc1c0.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/80a777aac98ff466fed629d733ff61332a1a0d242d7f8072f479ed182e85f76c.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_special_sample_display_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/special-sample-display/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/88a661bceb0c617756378da0418f4dea138398aa5633fa55bf12078f5e376acb.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/8ddbf33903b24278a000a8c8de8e58f8862ccd2b70044c7eb9f266abb3e73bfa.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/91637564c702f3ce7724bfb7f67dd44316a641767ce3212346ab5acdacc6bbd0.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/938ae0b61acf7d5fda5c3951ce27d53cfe38194a576cbf015d58daaa2f34e08d.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/94f792d188d7ae69bd0300daf0d6d4bee0a38da41199171c7a97a958a4917a5d.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_sample_code_with_special_display_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/sample-code-with-special-display/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/a164550a822f6c951bb23239b60e330d84670ded853bb2e82c06560d413d6e13.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_special_sample_display_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/special-sample-display/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/a7251ca9410680ac9ff9aebef287feb50c6239b367449d2f6f8be0c5fdee171d.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/ac34bbf3abcfb4d901f650f62976641be088126130d91d75abb67fe83c6f817e.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_sample_reception_samplereceptioncardlist_vue", "label": "SampleReceptionCardList.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/b3b7c8be7ed841e4aacfa019fb843144cb4e0f52dcd116c35b7af757af9d76f0.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_qc_material_amount_auth_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/qc-material-amount-auth/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/b99ab5d6f7618ee24cb9a42cdc46c2bd07f1b44ac4703302f6bdcb4c77b4442b.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/c021f5253ced109729f52a4001b0dd0a8c039d346b62a9769ecf0b6cfca8ff1b.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/c3e90aa505646052f67eeec8554ff46ad7483ecde65a4dedd0d20446684cbd10.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/c648411917880b21b2e6bdb2c3dc21c7782ac2d94ae2ab927e1b78c39b66e404.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/cda47123ee59f216c4f6361d17c0fc07d29490e4bdd8edd83ddeb20ee30731c7.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_types_import_components_d_ts", "label": "components.d.ts", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts", "source_location": "L1"}], "edges": [], "raw_calls": []}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/d11b497961394ab2cd52e7090931dfc3212c6a73bcb0c01f654b275081dbb375.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
web-admin/src/graphify-out/cache/ast/dd842bb915df5161ee4307592a436f5cfe5ed5b655061a1438ce4f54ed7f493e.json


+ 1 - 0
web-admin/src/graphify-out/cache/ast/ddffbeb0bfe884d2239a7a27bd35dd8c1340b76d2276b6a9b6867eda23175a55.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_components_business_protocol_attachment_mini_readonly_index_vue", "label": "index.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/components/business/protocol-attachment-mini-readonly/index.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
web-admin/src/graphify-out/cache/ast/f19b346efc330959cd49caddc040db6e746523d27b0f03ceb96f9b7af77c7f95.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_web_admin_src_views_quality_inspection_sample_reception_samplereceptioncardlist_vue", "label": "SampleReceptionCardList.vue", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 11 - 0
web-admin/src/router/modules/sqxp.ts

@@ -1097,6 +1097,17 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [
               menuCode: 'biz:qcMaterial:list'
             }
           },
+          {
+            id: '95100000000000000000000000000001',
+            name: 'QualityInspectionQcMaterialAmountAuth',
+            path: 'qc-material-amount-auth',
+            component: '/quality-inspection/qc-material-amount-auth/index',
+            meta: {
+              title: '质检物料金额查看授权',
+              keepAlive: true,
+              menuCode: 'biz:qcMaterialAmountAuth:list'
+            }
+          },
           {
             id: '70000000000000000000000000000010',
             name: 'QualityInspectionQcMaterialUsage',

+ 26 - 0
web-admin/src/utils/protocol-source-attachment.ts

@@ -0,0 +1,26 @@
+export type ProtocolSourceAttachmentMeta = {
+  label: string
+  files: Array<Record<string, unknown>>
+}
+
+function normalizeAttachmentFiles(raw: unknown): Array<Record<string, unknown>> {
+  if (!Array.isArray(raw)) return []
+  return raw.filter((item) => item && typeof item === 'object') as Array<Record<string, unknown>>
+}
+
+/** 按来源返回协议级附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
+export function getSourceProtocolAttachment(
+  row: Record<string, unknown>
+): ProtocolSourceAttachmentMeta {
+  const source = String(row.sourceType || 'cell_storage').trim()
+  if (source === 'research_donation') {
+    return {
+      label: '传染病检测报告',
+      files: normalizeAttachmentFiles(row.infectiousDiseaseReportAttachmentFiles)
+    }
+  }
+  return {
+    label: '孕妇健康调查表',
+    files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
+  }
+}

+ 334 - 31
web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue

@@ -44,11 +44,12 @@
             <div class="section-title">
               <div class="section-title-left"><h3>一键导入</h3></div>
             </div>
-            <el-form-item label="标准信息">
+            <el-form-item label="标准信息" class="import-textarea-item">
               <el-input
                 v-model="standardImportText"
                 type="textarea"
-                :rows="12"
+                :rows="28"
+                class="import-standard-textarea"
                 placeholder="请粘贴标准格式信息,点击「一键导入」填充各 Tab 字段"
               />
             </el-form-item>
@@ -198,6 +199,164 @@
                 </div>
               </el-form-item>
             </div>
+            <div class="form-grid form-grid--attachment">
+              <el-form-item label="知情同意书">
+                <div
+                  class="attachment-upload"
+                  :class="{ 'is-dragover': isConsentAttachDragover }"
+                  v-bind="consentAttachDropHandlers"
+                >
+                  <div class="attachment-list">
+                    <el-upload
+                      ref="consentAttachmentUploadRef"
+                      :auto-upload="false"
+                      :show-file-list="false"
+                      :multiple="true"
+                      accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
+                      @change="handleConsentAttachmentChange"
+                    >
+                      <div class="upload-card">
+                        <el-icon :size="28" color="#047857"><Plus /></el-icon>
+                        <span class="upload-card-text">上传附件</span>
+                      </div>
+                    </el-upload>
+                    <div
+                      v-for="(file, index) in formData.consentAttachments"
+                      :key="file.id || index"
+                      class="attachment-item"
+                      :class="{ 'is-uploading': file._uploading }"
+                    >
+                      <div
+                        class="attachment-preview"
+                        @click="
+                          isImageFile(file) && file.fileUrl
+                            ? handlePreviewAttachment(file)
+                            : undefined
+                        "
+                      >
+                        <template v-if="isImageFile(file) && file.fileUrl">
+                          <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
+                        </template>
+                        <template v-else-if="isImageFile(file) && !file.fileUrl">
+                          <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
+                        </template>
+                        <template v-else-if="isVideoFile(file)">
+                          <div class="attachment-preview__icon attachment-preview__icon--video">
+                            <el-icon :size="28"><VideoPlay /></el-icon>
+                          </div>
+                        </template>
+                        <template v-else>
+                          <div class="attachment-preview__icon attachment-preview__icon--doc">
+                            <el-icon :size="28"><Document /></el-icon>
+                          </div>
+                        </template>
+                      </div>
+                      <div class="attachment-footer">
+                        <span class="attachment-name" :title="file.fileName">{{
+                          file.fileName
+                        }}</span>
+                        <div class="attachment-actions">
+                          <el-icon
+                            class="action-btn"
+                            title="下载"
+                            @click="handleDownloadAttachment(file)"
+                            ><Download
+                          /></el-icon>
+                          <el-icon
+                            class="action-btn action-btn--danger"
+                            title="删除"
+                            @click="removeConsentAttachment(index)"
+                            ><Delete
+                          /></el-icon>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                  <span class="upload-hint"
+                    >支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
+                  >
+                </div>
+              </el-form-item>
+            </div>
+            <div class="form-grid form-grid--attachment">
+              <el-form-item label="孕妇健康调查表">
+                <div
+                  class="attachment-upload"
+                  :class="{ 'is-dragover': isHealthSurveyAttachDragover }"
+                  v-bind="healthSurveyAttachDropHandlers"
+                >
+                  <div class="attachment-list">
+                    <el-upload
+                      ref="healthSurveyAttachmentUploadRef"
+                      :auto-upload="false"
+                      :show-file-list="false"
+                      :multiple="true"
+                      accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
+                      @change="handleHealthSurveyAttachmentChange"
+                    >
+                      <div class="upload-card">
+                        <el-icon :size="28" color="#047857"><Plus /></el-icon>
+                        <span class="upload-card-text">上传附件</span>
+                      </div>
+                    </el-upload>
+                    <div
+                      v-for="(file, index) in formData.healthSurveyAttachments"
+                      :key="file.id || index"
+                      class="attachment-item"
+                      :class="{ 'is-uploading': file._uploading }"
+                    >
+                      <div
+                        class="attachment-preview"
+                        @click="
+                          isImageFile(file) && file.fileUrl
+                            ? handlePreviewAttachment(file)
+                            : undefined
+                        "
+                      >
+                        <template v-if="isImageFile(file) && file.fileUrl">
+                          <img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
+                        </template>
+                        <template v-else-if="isImageFile(file) && !file.fileUrl">
+                          <el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
+                        </template>
+                        <template v-else-if="isVideoFile(file)">
+                          <div class="attachment-preview__icon attachment-preview__icon--video">
+                            <el-icon :size="28"><VideoPlay /></el-icon>
+                          </div>
+                        </template>
+                        <template v-else>
+                          <div class="attachment-preview__icon attachment-preview__icon--doc">
+                            <el-icon :size="28"><Document /></el-icon>
+                          </div>
+                        </template>
+                      </div>
+                      <div class="attachment-footer">
+                        <span class="attachment-name" :title="file.fileName">{{
+                          file.fileName
+                        }}</span>
+                        <div class="attachment-actions">
+                          <el-icon
+                            class="action-btn"
+                            title="下载"
+                            @click="handleDownloadAttachment(file)"
+                            ><Download
+                          /></el-icon>
+                          <el-icon
+                            class="action-btn action-btn--danger"
+                            title="删除"
+                            @click="removeHealthSurveyAttachment(index)"
+                            ><Delete
+                          /></el-icon>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                  <span class="upload-hint"
+                    >支持拖拽或点击上传图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB</span
+                  >
+                </div>
+              </el-form-item>
+            </div>
           </div>
         </div>
 
@@ -1218,6 +1377,8 @@
     registerDate: '',
     collectorUserId: '',
     protocolAttachments: '',
+    consentFormUrls: '',
+    healthSurveyFormUrls: '',
     remark: '',
     attachments: [] as Array<{
       id?: string
@@ -1228,6 +1389,24 @@
       fileType?: string
       _uploading?: boolean
     }>,
+    consentAttachments: [] as Array<{
+      id?: string
+      fileName: string
+      fileUrl: string
+      fileSize: number
+      fileSizeDisplay?: string
+      fileType?: string
+      _uploading?: boolean
+    }>,
+    healthSurveyAttachments: [] as Array<{
+      id?: string
+      fileName: string
+      fileUrl: string
+      fileSize: number
+      fileSizeDisplay?: string
+      fileType?: string
+      _uploading?: boolean
+    }>,
     // Party Customer
     customerId: '' as string,
     motherName: '',
@@ -1284,7 +1463,11 @@
       registerDate: '',
       collectorUserId: '',
       protocolAttachments: '',
+      consentFormUrls: '',
+      healthSurveyFormUrls: '',
       attachments: [],
+      consentAttachments: [],
+      healthSurveyAttachments: [],
       remark: '',
       customerId: '',
       motherName: '',
@@ -1875,6 +2058,16 @@
     if (att && att.length) {
       formData.attachments = att.map((a) => ({ ...a })) as any
     }
+    const consentAtt = (bundle as any).consentAttachmentFiles as Array<Record<string, any>> | null
+    if (consentAtt && consentAtt.length) {
+      formData.consentAttachments = consentAtt.map((a) => ({ ...a })) as any
+    }
+    const healthSurveyAtt = (bundle as any).healthSurveyAttachmentFiles as
+      | Array<Record<string, any>>
+      | null
+    if (healthSurveyAtt && healthSurveyAtt.length) {
+      formData.healthSurveyAttachments = healthSurveyAtt.map((a) => ({ ...a })) as any
+    }
   }
 
   async function handleOpenForEdit() {
@@ -1891,6 +2084,8 @@
     formData.registerDate = String(row.registerDate || '')
     formData.collectorUserId = String(row.collectorUserId || '')
     formData.protocolAttachments = String((row as any).protocolAttachments || '')
+    formData.consentFormUrls = String((row as any).consentFormUrls || '')
+    formData.healthSurveyFormUrls = String((row as any).healthSurveyFormUrls || '')
     formData.remark = String(row.remark || '')
 
     dialogFormLoading.value = true
@@ -1979,11 +2174,22 @@
         .map((a) => a.fileUrl)
         .filter(Boolean)
         .join(',')
+      const consentAttachmentUrls = formData.consentAttachments
+        .map((a) => a.fileUrl)
+        .filter(Boolean)
+        .join(',')
+      const healthSurveyAttachmentUrls = formData.healthSurveyAttachments
+        .map((a) => a.fileUrl)
+        .filter(Boolean)
+        .join(',')
       const payload: Record<string, unknown> = {
         protocolStatus: formData.protocolStatus,
         registerDate: formData.registerDate || undefined,
         collectorUserId: formData.collectorUserId || undefined,
         protocolAttachments: protocolAttachmentUrls || formData.protocolAttachments || undefined,
+        consentFormUrls: consentAttachmentUrls || formData.consentFormUrls || undefined,
+        healthSurveyFormUrls:
+          healthSurveyAttachmentUrls || formData.healthSurveyFormUrls || undefined,
         attachments: formData.attachments.map((a) => ({
           id: a.id,
           fileName: a.fileName,
@@ -1992,6 +2198,22 @@
           fileSizeDisplay: a.fileSizeDisplay,
           fileType: a.fileType
         })),
+        consentAttachments: formData.consentAttachments.map((a) => ({
+          id: a.id,
+          fileName: a.fileName,
+          fileUrl: a.fileUrl,
+          fileSize: a.fileSize,
+          fileSizeDisplay: a.fileSizeDisplay,
+          fileType: a.fileType
+        })),
+        healthSurveyAttachments: formData.healthSurveyAttachments.map((a) => ({
+          id: a.id,
+          fileName: a.fileName,
+          fileUrl: a.fileUrl,
+          fileSize: a.fileSize,
+          fileSizeDisplay: a.fileSizeDisplay,
+          fileType: a.fileType
+        })),
         remark: formData.remark || undefined,
         partyCustomer: {
           customerId: formData.customerId || undefined,
@@ -3294,33 +3516,30 @@
 
   /* ====== 附件上传 ====== */
   const attachmentUploadRef = ref()
-
-  function isImageFile(file: any) {
-    const url = (file.fileUrl || '').toLowerCase()
-    const name = (file.fileName || '').toLowerCase()
-    return (
-      /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
-      /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
-      (file.fileType || '').startsWith('image/')
-    )
-  }
-
-  function isVideoFile(file: any) {
-    const url = (file.fileUrl || '').toLowerCase()
-    const name = (file.fileName || '').toLowerCase()
-    return (
-      /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
-      /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
-      (file.fileType || '').startsWith('video/')
-    )
-  }
-
-  async function handleAttachmentChange(uploadFile: any) {
+  const consentAttachmentUploadRef = ref()
+  const healthSurveyAttachmentUploadRef = ref()
+
+  type ProtocolAttachmentItem = {
+    id?: string
+    fileName: string
+    fileUrl: string
+    fileSize: number
+    fileSizeDisplay?: string
+    fileType?: string
+    _uploading?: boolean
+  }
+
+  async function uploadProtocolAttachmentFile(
+    uploadFile: any,
+    list: ProtocolAttachmentItem[],
+    folder: string,
+    uploadRef: { value?: { clearFiles?: () => void } | null }
+  ) {
     const file = uploadFile.raw || uploadFile
     if (!file) return
 
     const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
-    formData.attachments.push({
+    list.push({
       id: tempId,
       fileName: file.name,
       fileUrl: '',
@@ -3329,15 +3548,15 @@
       fileType: file.type || '',
       _uploading: true
     })
-    const idx = formData.attachments.length - 1
+    const idx = list.length - 1
 
     try {
       const fd = new FormData()
-      fd.append('folder', 'protocol')
+      fd.append('folder', folder)
       fd.append('file', file)
       const res = await fetchUploadAttachment(fd)
-      formData.attachments[idx] = {
-        ...formData.attachments[idx],
+      list[idx] = {
+        ...list[idx],
         id: res.id || tempId,
         fileName: file.name || res.fileName,
         fileUrl: res.fileUrl,
@@ -3348,16 +3567,71 @@
       }
     } catch {
       ElMessage.error('文件上传失败')
-      formData.attachments.splice(idx, 1)
+      list.splice(idx, 1)
     } finally {
-      nextTick(() => attachmentUploadRef.value?.clearFiles())
+      nextTick(() => uploadRef.value?.clearFiles())
     }
   }
 
+  function isImageFile(file: any) {
+    const url = (file.fileUrl || '').toLowerCase()
+    const name = (file.fileName || '').toLowerCase()
+    return (
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)(\?|$)/.test(url) ||
+      /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/.test(name) ||
+      (file.fileType || '').startsWith('image/')
+    )
+  }
+
+  function isVideoFile(file: any) {
+    const url = (file.fileUrl || '').toLowerCase()
+    const name = (file.fileName || '').toLowerCase()
+    return (
+      /\.(mp4|webm|ogg|mov|avi)(\?|$)/.test(url) ||
+      /\.(mp4|webm|ogg|mov|avi)$/.test(name) ||
+      (file.fileType || '').startsWith('video/')
+    )
+  }
+
+  async function handleAttachmentChange(uploadFile: any) {
+    await uploadProtocolAttachmentFile(
+      uploadFile,
+      formData.attachments,
+      'protocol',
+      attachmentUploadRef
+    )
+  }
+
   function removeAttachment(index: number) {
     formData.attachments.splice(index, 1)
   }
 
+  async function handleConsentAttachmentChange(uploadFile: any) {
+    await uploadProtocolAttachmentFile(
+      uploadFile,
+      formData.consentAttachments,
+      'protocol-consent',
+      consentAttachmentUploadRef
+    )
+  }
+
+  function removeConsentAttachment(index: number) {
+    formData.consentAttachments.splice(index, 1)
+  }
+
+  async function handleHealthSurveyAttachmentChange(uploadFile: any) {
+    await uploadProtocolAttachmentFile(
+      uploadFile,
+      formData.healthSurveyAttachments,
+      'protocol-health-survey',
+      healthSurveyAttachmentUploadRef
+    )
+  }
+
+  function removeHealthSurveyAttachment(index: number) {
+    formData.healthSurveyAttachments.splice(index, 1)
+  }
+
   const { isDragover: isProtocolAttachDragover, dropZoneHandlers: protocolAttachDropHandlers } =
     useAttachmentDropZone({
       onFiles: (files) => {
@@ -3367,6 +3641,26 @@
       }
     })
 
+  const { isDragover: isConsentAttachDragover, dropZoneHandlers: consentAttachDropHandlers } =
+    useAttachmentDropZone({
+      onFiles: (files) => {
+        for (const file of files) {
+          void handleConsentAttachmentChange({ raw: file })
+        }
+      }
+    })
+
+  const {
+    isDragover: isHealthSurveyAttachDragover,
+    dropZoneHandlers: healthSurveyAttachDropHandlers
+  } = useAttachmentDropZone({
+    onFiles: (files) => {
+      for (const file of files) {
+        void handleHealthSurveyAttachmentChange({ raw: file })
+      }
+    }
+  })
+
   const { isDragover: isPaymentDetailDragover, handlersFor: paymentDetailDropHandlers } =
     useIndexedAttachmentDropZone({
       onFiles: (detailIndex, files) => {
@@ -3507,11 +3801,20 @@
 
     &--import {
       margin-bottom: 0;
+
+      .import-textarea-item {
+        margin-bottom: 12px;
+
+        :deep(.el-textarea__inner) {
+          min-height: 560px;
+        }
+      }
     }
   }
 
   .import-actions {
     display: flex;
+    flex-shrink: 0;
     justify-content: flex-end;
     padding: 0 0 8px;
   }

+ 42 - 6
web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue

@@ -118,12 +118,15 @@
                 </template>
               </div>
             </div>
-            <!-- 协议附件、备注同一行 -->
+            <!-- 协议附件 / 知情同意书 / 孕妇健康调查表 / 备注 -->
             <div
               v-if="
-                protocolAttachments.length || (protocol?.remark && String(protocol.remark).trim())
+                protocolAttachments.length ||
+                consentAttachments.length ||
+                healthSurveyAttachments.length ||
+                (protocol?.remark && String(protocol.remark).trim())
               "
-              class="data-grid data-grid--2 protocol-attach-remark-row"
+              class="protocol-basic-attachments-block"
             >
               <div class="data-item protocol-attachments-item">
                 <div class="data-label">协议附件</div>
@@ -135,7 +138,27 @@
                   />
                 </div>
               </div>
-              <div class="data-item">
+              <div class="data-item protocol-attachments-item">
+                <div class="data-label">知情同意书</div>
+                <div class="data-value protocol-attachments-value">
+                  <ProtocolAttachmentReadonly
+                    :files="consentAttachments"
+                    @preview="openPreview"
+                    @download="downloadFile"
+                  />
+                </div>
+              </div>
+              <div class="data-item protocol-attachments-item">
+                <div class="data-label">孕妇健康调查表</div>
+                <div class="data-value protocol-attachments-value">
+                  <ProtocolAttachmentReadonly
+                    :files="healthSurveyAttachments"
+                    @preview="openPreview"
+                    @download="downloadFile"
+                  />
+                </div>
+              </div>
+              <div v-if="protocol?.remark && String(protocol.remark).trim()" class="data-item">
                 <div class="data-label">备注</div>
                 <div class="data-value data-value--muted">{{ dash(protocol?.remark) }}</div>
               </div>
@@ -1205,6 +1228,16 @@
     return Array.isArray(list) ? list : []
   })
 
+  const consentAttachments = computed(() => {
+    const list = bundle.value?.consentAttachmentFiles
+    return Array.isArray(list) ? list : []
+  })
+
+  const healthSurveyAttachments = computed(() => {
+    const list = bundle.value?.healthSurveyAttachmentFiles
+    return Array.isArray(list) ? list : []
+  })
+
   const storageCertInfo = computed(() => {
     const sc = bundle.value?.storageCert as Record<string, any> | undefined | null
     if (sc) return sc
@@ -2063,9 +2096,12 @@
     }
   }
 
-  /* 协议附件、备注同一行 */
-  .protocol-attach-remark-row {
+  /* 协议附件 / 知情同意书 / 孕妇健康调查表 / 备注(三行堆叠) */
+  .protocol-basic-attachments-block {
     margin-top: 14px;
+    display: flex;
+    flex-direction: column;
+    gap: 14px;
   }
 
   .protocol-attachments-item {

+ 224 - 102
web-admin/src/views/quality-inspection/pre-transit-sample/PreTransitProtocolCardList.vue

@@ -38,25 +38,26 @@
                     {{ statusLabel(row) }}
                   </el-tag>
                 </div>
-                <div
-                  v-if="getSpecialSampleItems(row).length"
-                  class="protocol-card-compact__special"
-                >
+                <div class="protocol-card-compact__scroll">
                   <div
-                    v-for="(item, si) in getSpecialSampleItems(row)"
-                    :key="si"
-                    class="protocol-card-compact__special-line"
+                    v-if="getSpecialSampleItems(row).length"
+                    class="protocol-card-compact__special"
                   >
-                    <SampleCodeWithSpecialDisplay
-                      :sample-code="item.sampleCode"
-                      :is-special="true"
-                      :special-remark="item.specialRemark"
-                      layout="block"
-                      monospace
-                    />
+                    <div
+                      v-for="(item, si) in getSpecialSampleItems(row)"
+                      :key="si"
+                      class="protocol-card-compact__special-line"
+                    >
+                      <SampleCodeWithSpecialDisplay
+                        :sample-code="item.sampleCode"
+                        :is-special="true"
+                        :special-remark="item.specialRemark"
+                        layout="block"
+                        monospace
+                      />
+                    </div>
                   </div>
-                </div>
-                <div class="protocol-card-compact__body">
+                  <div class="protocol-card-compact__body">
                   <template v-if="mode === 'in_transit'">
                     <div class="protocol-card-compact__field">
                       <label>预计到达</label>
@@ -83,32 +84,44 @@
                       </div>
                       <span v-else class="protocol-card__kv-value">—</span>
                     </div>
-                    <div class="protocol-card-compact__field">
-                      <label>预产期</label>
-                      <span
-                        class="protocol-card__kv-value protocol-card__kv-value--danger"
-                        :title="String(row.expectedDeliveryDate || '')"
-                      >
-                        {{ formatDisplayOrDash(row.expectedDeliveryDate) }}
-                      </span>
-                    </div>
-                    <div class="protocol-card-compact__field">
-                      <label>母血采集</label>
-                      <span
-                        class="protocol-card__kv-value"
-                        :title="String(row.maternalBloodCollectTime || '')"
-                      >
-                        {{ formatDisplayOrDash(row.maternalBloodCollectTime) }}
-                      </span>
+                    <div class="protocol-card-compact__field protocol-card-compact__field--split-row">
+                      <div class="protocol-card-compact__field-col">
+                        <label>预产期</label>
+                        <span
+                          class="protocol-card__kv-value protocol-card__kv-value--danger"
+                          :title="String(row.expectedDeliveryDate || '')"
+                        >
+                          {{ formatDisplayOrDash(row.expectedDeliveryDate) }}
+                        </span>
+                      </div>
+                      <div class="protocol-card-compact__field-col">
+                        <label>样本采集时间</label>
+                        <span
+                          class="protocol-card__kv-value"
+                          :title="String(row.sampleCollectTime || '')"
+                        >
+                          {{ formatDisplayOrDash(row.sampleCollectTime) }}
+                        </span>
+                      </div>
                     </div>
-                    <div class="protocol-card-compact__field">
-                      <label>样本采集</label>
-                      <span
-                        class="protocol-card__kv-value"
-                        :title="String(row.sampleCollectTime || '')"
-                      >
-                        {{ formatDisplayOrDash(row.sampleCollectTime) }}
-                      </span>
+                    <div class="protocol-card-compact__field protocol-card-compact__field--split-row">
+                      <div class="protocol-card-compact__field-col">
+                        <label>母血采集</label>
+                        <span
+                          class="protocol-card__kv-value"
+                          :title="String(row.maternalBloodCollectTime || '')"
+                        >
+                          {{ formatDisplayOrDash(row.maternalBloodCollectTime) }}
+                        </span>
+                      </div>
+                      <div class="protocol-card-compact__field-col protocol-card-compact__field-col--attachment">
+                        <ProtocolAttachmentMiniReadonly
+                          :label="attachmentMeta(row).label"
+                          :files="attachmentMeta(row).files"
+                          @preview="handleAttachmentPreview"
+                          @download="handleAttachmentDownload"
+                        />
+                      </div>
                     </div>
                   </template>
                   <template v-else>
@@ -121,44 +134,53 @@
                         {{ row.etaTimeSummary || '—' }}
                       </span>
                     </div>
-                    <div class="protocol-card-compact__field">
-                      <label>预产期</label>
-                      <span
-                        class="protocol-card__kv-value protocol-card__kv-value--danger"
-                        :title="String(row.expectedDeliveryDate || '')"
-                      >
-                        {{ formatDisplayOrDash(row.expectedDeliveryDate) }}
-                      </span>
+                    <div class="protocol-card-compact__field protocol-card-compact__field--split-row">
+                      <div class="protocol-card-compact__field-col">
+                        <label>预产期</label>
+                        <span
+                          class="protocol-card__kv-value protocol-card__kv-value--danger"
+                          :title="String(row.expectedDeliveryDate || '')"
+                        >
+                          {{ formatDisplayOrDash(row.expectedDeliveryDate) }}
+                        </span>
+                      </div>
+                      <div class="protocol-card-compact__field-col">
+                        <label>样本采集时间</label>
+                        <span
+                          class="protocol-card__kv-value"
+                          :title="String(row.sampleCollectTime || '')"
+                        >
+                          {{ formatDisplayOrDash(row.sampleCollectTime) }}
+                        </span>
+                      </div>
                     </div>
-                    <div class="protocol-card-compact__field">
-                      <label>样本采集</label>
-                      <span
-                        class="protocol-card-compact__sample"
-                        :title="String(row.sampleCollectSummary || '')"
-                      >
-                        {{ row.sampleCollectSummary || '—' }}
-                      </span>
+                    <div class="protocol-card-compact__field protocol-card-compact__field--attachment-only">
+                      <ProtocolAttachmentMiniReadonly
+                        :label="attachmentMeta(row).label"
+                        :files="attachmentMeta(row).files"
+                        @preview="handleAttachmentPreview"
+                        @download="handleAttachmentDownload"
+                      />
                     </div>
                   </template>
                 </div>
+                </div>
                 <div class="protocol-card-compact__foot">
-                  <el-button
-                    class="btn-ghost protocol-card-compact__toggle"
+                  <button
+                    type="button"
+                    class="protocol-card-compact__toggle"
                     :title="isExpanded(row) ? '收起详情' : '展开详情'"
                     @click.stop="toggleExpand(row)"
                   >
                     <el-icon><ArrowUp v-if="isExpanded(row)" /><ArrowDown v-else /></el-icon>
-                  </el-button>
+                  </button>
                 </div>
               </div>
             </article>
           </div>
 
           <div v-if="getGroupExpandedItem(group)" class="protocol-card-detail">
-            <article
-              v-if="getGroupExpandedItem(group)"
-              class="protocol-card protocol-card--detail"
-            >
+            <article v-if="getGroupExpandedItem(group)" class="protocol-card protocol-card--detail">
               <div class="protocol-card__aside">
                 <div class="protocol-card__name-row">
                   <span class="protocol-card__index">{{
@@ -229,9 +251,7 @@
                     </div>
                     <div class="protocol-card__kv">
                       <label>样本采集时间</label>
-                      <span>{{
-                        getGroupExpandedItem(group)!.row.sampleCollectTime || '—'
-                      }}</span>
+                      <span>{{ getGroupExpandedItem(group)!.row.sampleCollectTime || '—' }}</span>
                     </div>
                     <div class="protocol-card__kv">
                       <label>母血采集时间</label>
@@ -240,7 +260,10 @@
                       }}</span>
                     </div>
                     <div
-                      v-if="mode === 'in_transit' && getInTransitEtaLines(getGroupExpandedItem(group)!.row).length"
+                      v-if="
+                        mode === 'in_transit' &&
+                        getInTransitEtaLines(getGroupExpandedItem(group)!.row).length
+                      "
                       class="protocol-card__kv protocol-card__kv--stack"
                     >
                       <label>预计到达</label>
@@ -253,9 +276,7 @@
                           class="protocol-card__eta-line"
                         >
                           <span>{{ line.sampleTypeName || '样本' }}</span>
-                          <span
-                            class="protocol-card__kv-value protocol-card__kv-value--danger"
-                          >
+                          <span class="protocol-card__kv-value protocol-card__kv-value--danger">
                             {{ line.etaTime || '—' }}
                           </span>
                         </div>
@@ -282,6 +303,16 @@
                       <label>预产类型</label>
                       <span>{{ getGroupExpandedItem(group)!.row.preDeliveryKindLabel }}</span>
                     </div>
+                    <div
+                      class="protocol-card__kv protocol-card__kv--stack protocol-card__kv--attachment"
+                    >
+                      <label>{{ attachmentMeta(getGroupExpandedItem(group)!.row).label }}</label>
+                      <ProtocolAttachmentMiniReadonly
+                        :files="attachmentMeta(getGroupExpandedItem(group)!.row).files"
+                        @preview="handleAttachmentPreview"
+                        @download="handleAttachmentDownload"
+                      />
+                    </div>
                   </div>
                 </section>
 
@@ -308,9 +339,7 @@
                     </div>
                     <div class="protocol-card__kv">
                       <label>分娩时间</label>
-                      <span>{{
-                        getGroupExpandedItem(group)!.row.newbornDeliveryTime || '—'
-                      }}</span>
+                      <span>{{ getGroupExpandedItem(group)!.row.newbornDeliveryTime || '—' }}</span>
                     </div>
                   </div>
                 </section>
@@ -352,7 +381,9 @@
                         </div>
                         <div class="protocol-card__sample-line">
                           <span class="protocol-card__sample-label">编码</span>
-                          <span class="protocol-card__sample-value protocol-card__sample-value--code">
+                          <span
+                            class="protocol-card__sample-value protocol-card__sample-value--code"
+                          >
                             <SampleCodeWithSpecialDisplay
                               :sample-code="item.sampleCode"
                               :is-special="!!item.isSpecial"
@@ -440,6 +471,9 @@
   } from '@/utils/status-tag-registry'
   import type { TagType } from '@/utils/status-tag-registry'
   import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
+  import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
+  import { getSourceProtocolAttachment } from '@/utils/protocol-source-attachment'
+  import { useAttachmentActions } from '@/composables/useAttachmentActions'
 
   defineOptions({ name: 'PreTransitProtocolCardList' })
 
@@ -512,10 +546,7 @@
     if (!el) return
     const innerWidth = Math.max(0, el.clientWidth - LIST_PADDING)
     const { cardWidth, gap } = compactCardLayoutForViewport()
-    columnsPerRow.value = Math.max(
-      1,
-      Math.floor((innerWidth + gap) / (cardWidth + gap))
-    )
+    columnsPerRow.value = Math.max(1, Math.floor((innerWidth + gap) / (cardWidth + gap)))
   }
 
   const groupedRows = computed<CardRowGroup[]>(() => {
@@ -561,6 +592,12 @@
     window.removeEventListener('resize', updateColumnsPerRow)
   })
 
+  const { handleAttachmentPreview, handleAttachmentDownload } = useAttachmentActions()
+
+  function attachmentMeta(row: PreTransitProtocolRow) {
+    return getSourceProtocolAttachment(row as Record<string, unknown>)
+  }
+
   function rowKey(row: PreTransitProtocolRow) {
     const id = String(row.collectionProtocolId || row.researchDonationId || row.id || '')
     return `${String(row.sourceType || '')}:${id}`
@@ -755,6 +792,7 @@
       align-items: stretch;
       border-radius: 8px;
       cursor: pointer;
+      overflow: hidden;
     }
 
     &.is-compact.has-special-sample {
@@ -819,6 +857,33 @@
     }
   }
 
+  .protocol-card-compact__scroll {
+    flex: 1;
+    min-height: 0;
+    overflow-x: hidden;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    margin-right: -2px;
+    padding-right: 2px;
+    scrollbar-width: thin;
+    scrollbar-color: #cbd5e1 transparent;
+
+    &::-webkit-scrollbar {
+      width: 4px;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      background: #cbd5e1;
+      border-radius: 4px;
+    }
+
+    &::-webkit-scrollbar-thumb:hover {
+      background: #94a3b8;
+    }
+  }
+
   .protocol-card-compact__special {
     display: flex;
     flex-direction: column;
@@ -838,12 +903,11 @@
   }
 
   .protocol-card-compact__body {
-    flex: 1;
+    flex: 0 0 auto;
     display: flex;
     flex-direction: column;
     gap: 4px;
     min-height: 0;
-    overflow: hidden;
   }
 
   .protocol-card-compact__field {
@@ -876,14 +940,37 @@
     }
   }
 
+  .protocol-card-compact__field--split-row {
+    flex-direction: row;
+    align-items: flex-start;
+    gap: 8px;
+  }
+
+  .protocol-card-compact__field-col {
+    flex: 1;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 1px;
+  }
+
+  .protocol-card-compact__field-col--attachment {
+    flex: 1.1;
+    min-width: 84px;
+  }
+
+  .protocol-card-compact__field--attachment-only {
+    flex-shrink: 0;
+    min-width: 0;
+  }
+
   .protocol-card-compact__eta-list {
     display: flex;
     flex-direction: column;
     gap: 6px;
     width: 100%;
     min-width: 0;
-    max-height: 72px;
-    overflow: hidden;
   }
 
   .protocol-card-compact__eta-line {
@@ -914,17 +1001,13 @@
     white-space: nowrap;
   }
 
-  .protocol-card-compact__eta-line.protocol-card__kv-value--danger .protocol-card-compact__eta-time {
+  .protocol-card-compact__eta-line.protocol-card__kv-value--danger
+    .protocol-card-compact__eta-time {
     color: #dc2626;
     font-weight: 500;
   }
 
   .protocol-card-compact__sample {
-    display: -webkit-box;
-    -webkit-box-orient: vertical;
-    -webkit-line-clamp: 2;
-    line-clamp: 2;
-    overflow: hidden;
     white-space: normal !important;
     word-break: break-all;
   }
@@ -932,14 +1015,38 @@
   .protocol-card-compact__foot {
     display: flex;
     justify-content: center;
+    align-items: center;
+    flex-shrink: 0;
     margin-top: auto;
-    padding-top: 2px;
+    padding-top: 0;
+    min-height: 16px;
   }
 
   .protocol-card-compact__toggle {
-    min-width: 30px;
-    height: 26px;
-    padding: 0 7px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 16px;
+    height: 16px;
+    padding: 0;
+    border: none;
+    border-radius: 4px;
+    background: transparent;
+    color: var(--mint-text-muted, #94a3b8);
+    cursor: pointer;
+    line-height: 1;
+    transition:
+      color 0.15s,
+      background 0.15s;
+
+    &:hover {
+      color: var(--primary, #10b981);
+      background: var(--primary-bg, #ecfdf5);
+    }
+
+    :deep(.el-icon) {
+      font-size: 12px;
+    }
   }
 
   .protocol-card__kv-value--danger {
@@ -948,8 +1055,8 @@
   }
 
   .protocol-card__aside {
-    flex: 0 0 120px;
-    min-width: 108px;
+    flex: 0 0 152px;
+    min-width: 140px;
     display: flex;
     flex-direction: column;
     gap: 6px;
@@ -976,9 +1083,16 @@
   }
 
   .protocol-card__special-banner-line {
+    min-width: 0;
     font-size: 12px;
     line-height: 1.4;
-    word-break: break-all;
+    word-break: break-word;
+    white-space: normal;
+
+    :deep(.sample-code-with-special__code) {
+      word-break: break-all;
+      white-space: normal;
+    }
   }
 
   .protocol-card__create-time {
@@ -1041,8 +1155,8 @@
     border: 1px solid #eef2f6;
 
     &--basic {
-      flex: 0.9;
-      min-width: 150px;
+      flex: 0.72;
+      min-width: 128px;
     }
 
     &--newborn {
@@ -1141,6 +1255,14 @@
     }
   }
 
+  .protocol-card__kv--attachment {
+    align-items: flex-start;
+
+    label {
+      margin-bottom: 2px;
+    }
+  }
+
   .protocol-card__empty {
     font-size: 12px;
     color: var(--mint-text-muted, #334155);
@@ -1248,11 +1370,11 @@
   @media (max-width: 1200px) {
     .protocol-card-list {
       --compact-card-width: 200px;
-      --compact-card-height: 262px;
+      --compact-card-height: 266px;
     }
 
     .protocol-card.is-compact.has-special-sample {
-      --compact-card-height: 302px;
+      --compact-card-height: 306px;
     }
 
     .protocol-card--detail {
@@ -1280,11 +1402,11 @@
   @media (max-width: 768px) {
     .protocol-card-list {
       --compact-card-width: 184px;
-      --compact-card-height: 238px;
+      --compact-card-height: 244px;
     }
 
     .protocol-card.is-compact.has-special-sample {
-      --compact-card-height: 278px;
+      --compact-card-height: 284px;
     }
 
     .protocol-card-row {

+ 391 - 0
web-admin/src/views/quality-inspection/qc-material-amount-auth/index.vue

@@ -0,0 +1,391 @@
+<!-- 质检物料金额查看授权 -->
+<template>
+  <div class="page-container">
+    <section class="panel">
+      <div class="panel-top">
+        <h2 class="title-bar-heading">质检物料金额查看授权</h2>
+      </div>
+
+      <div class="filter-block">
+        <el-form :model="searchForm" class="filter-form" @submit.prevent>
+          <div class="filter-grid">
+            <el-form-item label="关键词">
+              <el-input
+                v-model="searchForm.keyword"
+                placeholder="工号/姓名/手机号"
+                clearable
+              />
+            </el-form-item>
+          </div>
+          <div class="filter-actions-row">
+            <div class="filter-actions-end">
+              <el-button class="btn-ghost" @click="handleReset">
+                <el-icon><RefreshLeft /></el-icon>
+                重置
+              </el-button>
+              <el-button class="btn-main" type="primary" @click="handleSearch">
+                <el-icon><Search /></el-icon>
+                搜索
+              </el-button>
+            </div>
+          </div>
+        </el-form>
+      </div>
+
+      <div class="list-toolbar">
+        <div class="list-toolbar-row">
+          <div class="list-toolbar-selected">
+            已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
+          </div>
+          <el-button class="btn-main" type="primary" @click="handleAdd">
+            <el-icon><Plus /></el-icon>
+            添加授权用户
+          </el-button>
+          <el-button
+            class="btn-ghost"
+            type="danger"
+            :disabled="selectedRows.length === 0"
+            @click="handleBatchRevoke"
+          >
+            <el-icon><Delete /></el-icon>
+            批量解除授权
+          </el-button>
+        </div>
+      </div>
+
+      <div class="list-body list-wrap">
+        <el-table
+          class="list-table"
+          :data="tableData"
+          v-loading="loading"
+          stripe
+          row-key="userId"
+          @selection-change="handleSelectionChange"
+        >
+          <el-table-column type="selection" min-width="55" align="center" header-align="center" />
+          <el-table-column
+            type="index"
+            label="序号"
+            min-width="68"
+            align="center"
+            header-align="center"
+            :index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
+          />
+          <el-table-column
+            prop="userNo"
+            label="工号"
+            min-width="120"
+            align="center"
+            header-align="center"
+          />
+          <el-table-column
+            prop="nickName"
+            label="姓名"
+            min-width="120"
+            align="center"
+            header-align="center"
+          />
+          <el-table-column
+            prop="deptName"
+            label="部门"
+            min-width="140"
+            align="center"
+            header-align="center"
+            show-overflow-tooltip
+          />
+          <el-table-column
+            prop="phoneNo"
+            label="手机号"
+            min-width="130"
+            align="center"
+            header-align="center"
+          />
+          <el-table-column
+            prop="grantTime"
+            label="授权时间"
+            min-width="180"
+            align="center"
+            header-align="center"
+          />
+          <el-table-column
+            label="操作"
+            min-width="140"
+            fixed="right"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <el-button class="btn-op-item" type="danger" @click="handleRevoke(row)">
+                <el-icon><Delete /></el-icon>
+                解除授权
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <div class="pagination list-pager-bar">
+          <el-pagination
+            v-model:current-page="pagination.page"
+            v-model:page-size="pagination.pageSize"
+            :total="pagination.total"
+            :page-sizes="[10, 20, 50]"
+            layout="total, sizes, prev, pager, next, jumper"
+            background
+            @size-change="loadData"
+            @current-change="loadData"
+          />
+        </div>
+      </div>
+    </section>
+
+    <el-dialog
+      v-model="addVisible"
+      title="添加授权用户"
+      width="520px"
+      append-to-body
+      align-center
+      modal-class="protocol-dialog-mask"
+      class="dialog-form-layout"
+      destroy-on-close
+      @closed="resetAddForm"
+    >
+      <el-form
+        ref="addFormRef"
+        :model="addForm"
+        :rules="addRules"
+        label-position="top"
+        @submit.prevent
+      >
+        <el-form-item label="用户" prop="userId">
+          <el-select
+            v-model="addForm.userId"
+            filterable
+            remote
+            reserve-keyword
+            placeholder="搜索质量安全部用户(工号/姓名/手机号)"
+            :remote-method="searchUsers"
+            :loading="userSearchLoading"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="item in userOptions"
+              :key="item.id"
+              :label="formatUserLabel(item)"
+              :value="item.id"
+            />
+          </el-select>
+        </el-form-item>
+        <p class="form-tip">
+          仅可选择「质量安全部」下的用户。授权后将拥有「质检物料金额查看」角色,可在质检物料库与我的物料领用记录中查看、编辑单价与金额。
+        </p>
+      </el-form>
+      <template #footer>
+        <el-button class="btn-ghost" @click="addVisible = false">
+          <el-icon><Close /></el-icon>
+          取消
+        </el-button>
+        <el-button class="btn-main" type="primary" :loading="addLoading" @click="handleAddSubmit">
+          <el-icon><Check /></el-icon>
+          确定
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { Check, Close, Delete, Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
+  import { fetchDeptTree, fetchUserList } from '@/api/admin'
+  import {
+    fetchBatchRevokeQcMaterialAmountAuth,
+    fetchGrantQcMaterialAmountAuth,
+    fetchQcMaterialAmountAuthUsers,
+    fetchRevokeQcMaterialAmountAuth,
+    type QcMaterialAmountAuthUser
+  } from '@/api/qc-material-amount-auth'
+
+  defineOptions({ name: 'QualityInspectionQcMaterialAmountAuth' })
+
+  const QUALITY_SAFETY_DEPT_NAME = '质量安全部'
+
+  const loading = ref(false)
+  const tableData = ref<QcMaterialAmountAuthUser[]>([])
+  const selectedRows = ref<QcMaterialAmountAuthUser[]>([])
+  const searchForm = reactive({ keyword: '' })
+  const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
+
+  const addVisible = ref(false)
+  const addLoading = ref(false)
+  const addFormRef = ref()
+  const addForm = reactive({ userId: '' })
+  const addRules = {
+    userId: [{ required: true, message: '请选择用户', trigger: 'change' }]
+  }
+  const userOptions = ref<Api.User.UserItem[]>([])
+  const userSearchLoading = ref(false)
+  const qualitySafetyDeptId = ref('')
+
+  function findDeptByName(nodes: Api.Dept.DeptItem[], name: string): Api.Dept.DeptItem | null {
+    for (const n of nodes || []) {
+      if ((n.deptName || '').trim() === name) return n
+      const child = findDeptByName(n.children || [], name)
+      if (child) return child
+    }
+    return null
+  }
+
+  async function ensureQualitySafetyDeptId() {
+    if (qualitySafetyDeptId.value) return qualitySafetyDeptId.value
+    const tree = await fetchDeptTree()
+    const dept = findDeptByName(tree || [], QUALITY_SAFETY_DEPT_NAME)
+    qualitySafetyDeptId.value = dept?.id || ''
+    return qualitySafetyDeptId.value
+  }
+
+  function formatUserLabel(u: Api.User.UserItem) {
+    const name = (u.nickName || '').trim() || '-'
+    const no = (u.userNo || '').trim()
+    const phone = (u.phoneNo || '').trim()
+    const parts = [name]
+    if (no) parts.push(no)
+    if (phone) parts.push(phone)
+    return parts.join(' · ')
+  }
+
+  async function searchUsers(query: string) {
+    userSearchLoading.value = true
+    try {
+      const deptId = await ensureQualitySafetyDeptId()
+      if (!deptId) {
+        userOptions.value = []
+        ElMessage.warning(`未找到部门「${QUALITY_SAFETY_DEPT_NAME}」`)
+        return
+      }
+      const res = await fetchUserList({
+        page: 1,
+        pageSize: 50,
+        keyword: query?.trim() || undefined,
+        status: '1',
+        deptId
+      })
+      userOptions.value = res.list || []
+    } catch {
+      userOptions.value = []
+    } finally {
+      userSearchLoading.value = false
+    }
+  }
+
+  function handleSelectionChange(rows: QcMaterialAmountAuthUser[]) {
+    selectedRows.value = rows
+  }
+
+  async function loadData() {
+    loading.value = true
+    try {
+      const res = await fetchQcMaterialAmountAuthUsers({
+        page: pagination.page,
+        pageSize: pagination.pageSize,
+        keyword: searchForm.keyword?.trim() || undefined
+      })
+      tableData.value = res.list || []
+      pagination.total = res.total || 0
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function handleSearch() {
+    pagination.page = 1
+    void loadData()
+  }
+
+  function handleReset() {
+    searchForm.keyword = ''
+    pagination.page = 1
+    void loadData()
+  }
+
+  function handleAdd() {
+    addForm.userId = ''
+    userOptions.value = []
+    addVisible.value = true
+    void searchUsers('')
+  }
+
+  function resetAddForm() {
+    addForm.userId = ''
+    userOptions.value = []
+    addFormRef.value?.clearValidate?.()
+  }
+
+  async function handleAddSubmit() {
+    await addFormRef.value?.validate?.()
+    addLoading.value = true
+    try {
+      await fetchGrantQcMaterialAmountAuth(addForm.userId)
+      ElMessage.success('授权成功')
+      addVisible.value = false
+      await loadData()
+    } catch {
+      /* 全局处理 */
+    } finally {
+      addLoading.value = false
+    }
+  }
+
+  async function handleRevoke(row: QcMaterialAmountAuthUser) {
+    try {
+      await ElMessageBox.confirm(
+        `确定解除用户「${row.nickName || row.userNo}」的金额查看授权吗?`,
+        '解除授权确认',
+        { type: 'warning' }
+      )
+    } catch {
+      return
+    }
+    try {
+      await fetchRevokeQcMaterialAmountAuth(row.userId)
+      ElMessage.success('已解除授权')
+      await loadData()
+    } catch {
+      /* 全局处理 */
+    }
+  }
+
+  async function handleBatchRevoke() {
+    if (!selectedRows.value.length) return
+    try {
+      await ElMessageBox.confirm(
+        `确定批量解除 ${selectedRows.value.length} 位用户的授权吗?`,
+        '批量解除确认',
+        { type: 'warning' }
+      )
+    } catch {
+      return
+    }
+    try {
+      const res = await fetchBatchRevokeQcMaterialAmountAuth(
+        selectedRows.value.map((r) => r.userId)
+      )
+      ElMessage.success(`已解除 ${res.count ?? selectedRows.value.length} 项授权`)
+      selectedRows.value = []
+      await loadData()
+    } catch {
+      /* 全局处理 */
+    }
+  }
+
+  onMounted(() => {
+    void loadData()
+  })
+</script>
+
+<style scoped lang="scss">
+  .form-tip {
+    margin: 0;
+    font-size: 12px;
+    line-height: 1.5;
+    color: var(--mint-text-muted, #64748b);
+  }
+</style>

+ 35 - 27
web-admin/src/views/quality-inspection/qc-material/index.vue

@@ -555,7 +555,7 @@
                 @change="onSingleStockInPriceQtyChange"
               />
             </el-form-item>
-            <el-form-item label="单价(元)" prop="unitPrice">
+            <el-form-item v-if="showAmountColumns" label="单价(元)" prop="unitPrice">
               <AmountInput
                 v-model="stockInForm.unitPrice"
                 :min="0"
@@ -563,7 +563,7 @@
                 @change="onSingleStockInPriceQtyChange"
               />
             </el-form-item>
-            <el-form-item label="金额(元)" prop="amount">
+            <el-form-item v-if="showAmountColumns" label="金额(元)" prop="amount">
               <AmountInput
                 v-model="stockInForm.amount"
                 :min="0"
@@ -736,14 +736,14 @@
                       @change="() => onBatchInLinePriceQtyChange(row)"
                     />
                   </el-form-item>
-                  <el-form-item label="单价">
+                  <el-form-item v-if="showAmountColumns" label="单价">
                     <AmountInput
                       v-model="row.unitPrice"
                       :min="0"
                       @change="() => onBatchInLinePriceQtyChange(row)"
                     />
                   </el-form-item>
-                  <el-form-item label="金额">
+                  <el-form-item v-if="showAmountColumns" label="金额">
                     <AmountInput
                       v-model="row.amount"
                       :min="0"
@@ -1473,28 +1473,33 @@
     return materialOptions.value.find((o) => o.id === key) ?? null
   })
 
-  const stockInRules = {
-    materialKey: [{ required: true, message: '请选择或输入物资', trigger: 'change' }],
-    newMaterialCode: [
-      {
-        validator: (_r: unknown, _v: unknown, cb: (e?: Error) => void) => {
-          if (!stockInIsManualMaterial.value) return cb()
-          if (!(stockInForm.newMaterialCode || '').trim()) return cb(new Error('请输入新物资编码'))
-          cb()
-        },
-        trigger: 'blur'
-      }
-    ],
-    stockInNo: [{ required: true, message: '请输入入库编号', trigger: 'blur' }],
-    stockInDate: [{ required: true, message: '请选择入库日期', trigger: 'change' }],
-    expireDate: [{ required: true, message: '请选择过期日期', trigger: 'change' }],
-    catalogNo: [{ required: true, message: '请输入货号', trigger: 'blur' }],
-    batchNo: [{ required: true, message: '请输入批号', trigger: 'blur' }],
-    manufacturer: [{ required: true, message: '请输入厂家', trigger: 'blur' }],
-    unitPrice: [{ required: true, message: '请输入单价', trigger: 'change' }],
-    amount: [{ required: true, message: '请输入金额', trigger: 'change' }],
-    qty: [{ required: true, message: '请输入入库数量', trigger: 'blur' }]
-  }
+  const stockInRules = computed(() => {
+    const rules: Record<string, unknown[]> = {
+      materialKey: [{ required: true, message: '请选择或输入物资', trigger: 'change' }],
+      newMaterialCode: [
+        {
+          validator: (_r: unknown, _v: unknown, cb: (e?: Error) => void) => {
+            if (!stockInIsManualMaterial.value) return cb()
+            if (!(stockInForm.newMaterialCode || '').trim()) return cb(new Error('请输入新物资编码'))
+            cb()
+          },
+          trigger: 'blur'
+        }
+      ],
+      stockInNo: [{ required: true, message: '请输入入库编号', trigger: 'blur' }],
+      stockInDate: [{ required: true, message: '请选择入库日期', trigger: 'change' }],
+      expireDate: [{ required: true, message: '请选择过期日期', trigger: 'change' }],
+      catalogNo: [{ required: true, message: '请输入货号', trigger: 'blur' }],
+      batchNo: [{ required: true, message: '请输入批号', trigger: 'blur' }],
+      manufacturer: [{ required: true, message: '请输入厂家', trigger: 'blur' }],
+      qty: [{ required: true, message: '请输入入库数量', trigger: 'blur' }]
+    }
+    if (showAmountColumns.value) {
+      rules.unitPrice = [{ required: true, message: '请输入单价', trigger: 'change' }]
+      rules.amount = [{ required: true, message: '请输入金额', trigger: 'change' }]
+    }
+    return rules
+  })
 
   function formatReceiverUserLabel(u: Api.User.UserItem) {
     const name = (u.nickName || '').trim() || '-'
@@ -2299,7 +2304,10 @@
         ElMessage.warning('请填写货号、批号、厂家')
         return
       }
-      if (line.unitPrice == null || line.amount == null) {
+      if (
+        showAmountColumns.value &&
+        (line.unitPrice == null || line.amount == null)
+      ) {
         ElMessage.warning('请填写单价和金额')
         return
       }

+ 29 - 5
web-admin/src/views/quality-inspection/sample-reception/SampleReceptionCardList.vue

@@ -63,10 +63,17 @@
                 <dt>样本类型</dt>
                 <dd>{{ row.sampleTypeName || '—' }}</dd>
               </div>
-              <div
-                v-if="activeTab === 'received'"
-                class="sample-reception-card__meta-item"
-              >
+              <div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
+                <dt>{{ attachmentMeta(row).label }}</dt>
+                <dd>
+                  <ProtocolAttachmentMiniReadonly
+                    :files="attachmentMeta(row).files"
+                    @preview="handleAttachmentPreview"
+                    @download="handleAttachmentDownload"
+                  />
+                </dd>
+              </div>
+              <div v-if="activeTab === 'received'" class="sample-reception-card__meta-item">
                 <dt>实验室入库编码</dt>
                 <dd>
                   {{ row.labStorageCode || '—' }}
@@ -211,6 +218,9 @@
   import type { QcProtocolPaymentSummary } from '@/api/qc-work-order'
   import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
   import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
+  import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
+  import { getSourceProtocolAttachment } from '@/utils/protocol-source-attachment'
+  import { useAttachmentActions } from '@/composables/useAttachmentActions'
 
   defineOptions({ name: 'SampleReceptionCardList' })
 
@@ -242,6 +252,12 @@
     'page-size-change': [size: number]
   }>()
 
+  const { handleAttachmentPreview, handleAttachmentDownload } = useAttachmentActions()
+
+  function attachmentMeta(row: Record<string, unknown>) {
+    return getSourceProtocolAttachment(row)
+  }
+
   function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
     return source === 'research_donation'
       ? donationStatusDisplayLabel(status, voidType)
@@ -249,7 +265,9 @@
   }
 
   function protocolStatusTagTypeBySource(status: string, source: string): TagType {
-    return source === 'research_donation' ? donationStatusTagType(status) : protocolStatusTagType(status)
+    return source === 'research_donation'
+      ? donationStatusTagType(status)
+      : protocolStatusTagType(status)
   }
 
   function parseEtaLocalDate(eta: unknown): Date | null {
@@ -429,6 +447,12 @@
         font-variant-numeric: tabular-nums;
       }
     }
+
+    &--attachment {
+      dd {
+        margin-top: 2px;
+      }
+    }
   }
 
   .sample-reception-card__ops {

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio