zlwu 5 ماه پیش
والد
کامیت
be4fab78e1
100فایلهای تغییر یافته به همراه1960 افزوده شده و 16 حذف شده
  1. 20 0
      .cursor/mcp.json
  2. 21 0
      AGENTS.md
  3. 1 1
      backend/app.pid
  4. 4 0
      backend/app/api/__init__.py
  5. 260 2
      backend/app/api/collection_protocol.py
  6. 832 0
      backend/app/api/insurance_company.py
  7. 226 0
      backend/app/api/material.py
  8. 1 13
      backend/app/api/menu.py
  9. 105 0
      backend/app/crud/collection_protocol.py
  10. 397 0
      backend/app/crud/material.py
  11. 88 0
      backend/app/crud/material_stock_log.py
  12. 0 0
      backend/app/graphify-out/cache/ast/0178df3ca76f029df4c11407595898cd91ddbe9d68c0bb316ddcfcc3f788ac73.json
  13. 0 0
      backend/app/graphify-out/cache/ast/045052fe5369ece329b23a748c94e4de1fbb6c59ea7afd59230b1888d1c0a470.json
  14. 0 0
      backend/app/graphify-out/cache/ast/05307647aed22ccf9f225f623739f1116636628afa439f3ec357b3a0dafbe241.json
  15. 0 0
      backend/app/graphify-out/cache/ast/05364d36a2efb2a71169a4fa7f61fd439df1260b406869a3936b505b425f033d.json
  16. 0 0
      backend/app/graphify-out/cache/ast/07443951fa7de5dee70deb00908c0aa7ce6aea015595194ae50a1c76e39de492.json
  17. 0 0
      backend/app/graphify-out/cache/ast/07bc7a7ddf530664696a90fe692d077fc3944ff342fde2e182da4b1aa3f5dc75.json
  18. 0 0
      backend/app/graphify-out/cache/ast/092d1c08f225cb57a26b1d5e4cbec0707c3751b38b8db1aebb60415854e4f830.json
  19. 0 0
      backend/app/graphify-out/cache/ast/0a07933b219fe718d4f7e3acb642381eef869498a72eef7257183768c2e56eae.json
  20. 0 0
      backend/app/graphify-out/cache/ast/0afdc4fa3b8340739e06edddedc825a5cb64f44f978d8b5a6a13c7a5f950122c.json
  21. 0 0
      backend/app/graphify-out/cache/ast/12b63f4afbbf91165f3174cbd703a98fe92db546a2c81872237e8bff0ecd25c4.json
  22. 0 0
      backend/app/graphify-out/cache/ast/135567a524fdf022e399755fb2e64b36db6a7f02f9406c74d81a28a44b635b98.json
  23. 0 0
      backend/app/graphify-out/cache/ast/150b60fb97d8bbb3107f2dfd0a089dcd276132f15f000173f8af26ec3b5dbda1.json
  24. 0 0
      backend/app/graphify-out/cache/ast/1601fe88cbf53e11f66d3981def3f167552a4818bfdcbbe072403a044a695f8a.json
  25. 0 0
      backend/app/graphify-out/cache/ast/16c3e73e0fa77ace1af5ec44e95d40ea51ce3b2a3cafe857737f28ef8d181598.json
  26. 0 0
      backend/app/graphify-out/cache/ast/1881408bb1c98cbfcd34aa2a6452a690ea6b26cfc1fdd4de64ed005067700319.json
  27. 0 0
      backend/app/graphify-out/cache/ast/1b70e125bf6e8fb78f1c9a20f0789fa7898af19c9968fa7e9501834beadc5696.json
  28. 0 0
      backend/app/graphify-out/cache/ast/1cdcbb28e2071644e74c7b816869e233d22c0e806c5d3b2137daa788ccfec9a4.json
  29. 0 0
      backend/app/graphify-out/cache/ast/21f07c6b3c5ab8c3302649e2f79cfc6c9c31d57d309b82c9f54086bfadbe418a.json
  30. 0 0
      backend/app/graphify-out/cache/ast/255da3108d080bb80d6973d33b864acf740e7ec56b406ea6edf6dee41b054252.json
  31. 0 0
      backend/app/graphify-out/cache/ast/257f0e5abff51335304551f04b1a2c9b0cbfabcf2d8a56d94467b403d337cf19.json
  32. 0 0
      backend/app/graphify-out/cache/ast/26ffc9fabaac1bf76730bb7dddfa9d1ac81b597cb3c1cd098f526fd52fb11735.json
  33. 0 0
      backend/app/graphify-out/cache/ast/282dd7ca5d5f8645064c9c3ca4b501e9e5c8572478dee9abc53235d388475766.json
  34. 0 0
      backend/app/graphify-out/cache/ast/28ffa3438e4362811e3df070a3670805eca8a30d66f7aac8c446068f47e18d39.json
  35. 0 0
      backend/app/graphify-out/cache/ast/2da0936a5b1b96286f9cba7dd00a2257b6b9282fd01566f4be9c64321a309c32.json
  36. 0 0
      backend/app/graphify-out/cache/ast/2ff902e250ff46ec72f6b168eb0eb5f8d71c8d2a2151dfac9c241d519eef7b7e.json
  37. 0 0
      backend/app/graphify-out/cache/ast/300d8b31bb5f2e3db142c9cc9546fc974f7f4442a6efc6b6a06ecc6eba250e72.json
  38. 0 0
      backend/app/graphify-out/cache/ast/325036d20e45dda5d81094fa35e9085a7593c7830b4caa102742c2404b2870f6.json
  39. 0 0
      backend/app/graphify-out/cache/ast/334897822081300d1f8ac677ab825d2c81bd11cdbf3eaf7afaaffbb6729b76e8.json
  40. 0 0
      backend/app/graphify-out/cache/ast/34c91b5b7b1cb22024e3ca1f72c9cf698609152c3a6c1dc2a2243c9d45b356c4.json
  41. 0 0
      backend/app/graphify-out/cache/ast/357cb27e5e365f488479dace850cbc2c1357414409aa7c81d8448c6ebb28e955.json
  42. 0 0
      backend/app/graphify-out/cache/ast/35e1352500deef25d166d457f52d6708ed827e59f89d423f7c82d188652c42d8.json
  43. 0 0
      backend/app/graphify-out/cache/ast/37c9c6b195bb9b47f95ade2d9114686200b1a374b0afd17e62f5634cc7152f14.json
  44. 0 0
      backend/app/graphify-out/cache/ast/3c1905011a6fad7489031e1a4a7120dc3e3b4e669c78aa0c0acf59f7cfb7b76c.json
  45. 1 0
      backend/app/graphify-out/cache/ast/3d85793ab397a20a6e0b5960b670396021fac1c17998579b215792cb0eaa1552.json
  46. 0 0
      backend/app/graphify-out/cache/ast/450fa5eaa0cc216976c426a28ece25b283dc96655523093e19b4427698483768.json
  47. 0 0
      backend/app/graphify-out/cache/ast/49d5f051daede00165d6e7e084f67db600fe91b2f4e3624fc67e8a9af0864c2f.json
  48. 0 0
      backend/app/graphify-out/cache/ast/4b12a9e6f80f19eeba0caa25dcea2d45217d5d85c5b470699e52d206e583dd52.json
  49. 0 0
      backend/app/graphify-out/cache/ast/4b3bd787d944c1631a3604e3276befffc290baae04e3685e0c1581f3986b739e.json
  50. 0 0
      backend/app/graphify-out/cache/ast/4fab2632bf987e4dbfebd3f649428f7c89f1cf458f991ad0956e6412625c36a4.json
  51. 0 0
      backend/app/graphify-out/cache/ast/4fb5baf19330fb9ed3f29ff3e336499bef030e4b77a138e05a1976e3a263af7e.json
  52. 0 0
      backend/app/graphify-out/cache/ast/5017c940e4999768e847f7db39ca383459b2e560fee5f9385d522feac08d57d3.json
  53. 0 0
      backend/app/graphify-out/cache/ast/5090815294183f68b1208711faf0aa7b61597f19383287b988f037257ab8034e.json
  54. 0 0
      backend/app/graphify-out/cache/ast/50b631a62d24f7108bf28781c5db8af317d9448c8c4594f1c95c91fa1bcb3849.json
  55. 0 0
      backend/app/graphify-out/cache/ast/51d7b154b1ee7f89ccee4ea04c9e4a5dda25a1646967d31cffa36bd9c066b124.json
  56. 0 0
      backend/app/graphify-out/cache/ast/51fd080dbf5796af672fff3627889f36c6a388882b942cb6a58b387fae83e6f0.json
  57. 0 0
      backend/app/graphify-out/cache/ast/537a96e63491637b5c800dcda89eb6aba7d09e9e08e81d2abe234cdf72683bc6.json
  58. 0 0
      backend/app/graphify-out/cache/ast/53b17c25bed69f45d534d07003cbf06503373f4801e31835dcf85fd15b3660aa.json
  59. 0 0
      backend/app/graphify-out/cache/ast/55267ce13b8d6768da12536c2a10b11bff031da7cf3311c7ada330fcc46b33ce.json
  60. 0 0
      backend/app/graphify-out/cache/ast/55cc6c7750537854b5e6ddd6249f3c7f4944ae4167db77ac423c39b9f3089ace.json
  61. 0 0
      backend/app/graphify-out/cache/ast/56f08be62f0aeecc65b9510e36324b8139b6ee0a896a3c28dd1560bc811154c5.json
  62. 0 0
      backend/app/graphify-out/cache/ast/5a0402b996a63f90f21df8bfd84052bb7bd6440d2b0d7b6647fa46df0b6631f3.json
  63. 0 0
      backend/app/graphify-out/cache/ast/5b536b1be9a31a0baf6a147156f2841247b901cfd386245580719a6e0fd2aaee.json
  64. 0 0
      backend/app/graphify-out/cache/ast/5d4e7f676d2e629cf3c92554dc5e40ea6f8efb963cfcb8866c2c88c3e415c6d8.json
  65. 0 0
      backend/app/graphify-out/cache/ast/5f47d4afc4794e6df350dffdec5fa7977064d47990787e5e586156eace8ff1d7.json
  66. 0 0
      backend/app/graphify-out/cache/ast/6150e65d9877f868cb6742b6530fa644091bc5a0376e0a192190faafe6daa933.json
  67. 0 0
      backend/app/graphify-out/cache/ast/61b68027aff5ce72e8b59430eea881f3355c868798202e4ad51c1935499fd0be.json
  68. 0 0
      backend/app/graphify-out/cache/ast/660492eaf04d94ff1a0235f2c5bc6ec0373cdb3593d07933f1b54cf03309ee6e.json
  69. 0 0
      backend/app/graphify-out/cache/ast/6850589b89ae472c2a2aabbee350b1d6ae6e538f71abcceb5f93c286e47c6f4b.json
  70. 1 0
      backend/app/graphify-out/cache/ast/6ae616cf983837db1d17a61791e2d6e734a057cd7d4068eb7e651623ba234059.json
  71. 0 0
      backend/app/graphify-out/cache/ast/6c3724498b9b59e7f1f6a2bbbf89ca03d3a3cf58205f6922440781dbd1497296.json
  72. 0 0
      backend/app/graphify-out/cache/ast/6cb7f310b72a1bc02a03839456b4fe0c4abd81d331130f7e37b3b5105c6b1e1f.json
  73. 0 0
      backend/app/graphify-out/cache/ast/6f83a4a01b5c5010c1d7652ff64fde62e22c21abcdc93b7ee31a95b9af4332b3.json
  74. 0 0
      backend/app/graphify-out/cache/ast/6fb487984c414435fab136b7a68060fa11ff0d789d0c00f8f747bd609cb49ff1.json
  75. 0 0
      backend/app/graphify-out/cache/ast/70394a75d42c514cc5a85e98435790466d5cbfe8db2ffaa0907317ed0ef3d732.json
  76. 0 0
      backend/app/graphify-out/cache/ast/71ba712ac2f187506587936dbf8192b2cee617e6c209ba9dfde4833804fa0373.json
  77. 0 0
      backend/app/graphify-out/cache/ast/749a2df036ddbffeb70d0801d6482006b2d9f19a1b9404168b0e3b02637a5f98.json
  78. 1 0
      backend/app/graphify-out/cache/ast/74c312d82842ea3fa3b9b1a0b8c50911e36ba9082c14135bf028ca9f310fc493.json
  79. 0 0
      backend/app/graphify-out/cache/ast/751c25b78a918ca3737d4fb9f8d80543ef0808e7340bd9d619f83d41b2966d18.json
  80. 0 0
      backend/app/graphify-out/cache/ast/7aaacbf972c21d9fd0b25c9a3466d7ac07cd9449dbc7ac96b59bfbd53ee67ed5.json
  81. 0 0
      backend/app/graphify-out/cache/ast/7e504aaa4ae8ec0b70adf3c455095f7bd795f5e4e1c6a53c255b5e78cdaba84b.json
  82. 0 0
      backend/app/graphify-out/cache/ast/7e7bd8a8e151a0da55a3465a8834b584753dbbb62b7c3c8b3cd80d5e63f7d654.json
  83. 0 0
      backend/app/graphify-out/cache/ast/8492a697a4d2fa79067b0c1231860c54e0c8378bce80be71a9ce42ec6d83515e.json
  84. 0 0
      backend/app/graphify-out/cache/ast/84ec888699750105783bc19a9a41c5b58e209bc711dde9101abb4907094a40b6.json
  85. 0 0
      backend/app/graphify-out/cache/ast/862aafed13413ed24a650dedbb5b6244c8cc42260f4acc86aac02890c21ee928.json
  86. 0 0
      backend/app/graphify-out/cache/ast/866afe02cfb7f6199669bcb73bf5bcb323499d8e03b8cc35f1a8e09cbde355ce.json
  87. 0 0
      backend/app/graphify-out/cache/ast/88cf4251ce14e07ce042bb7aa2b2baa222bceb93f9c26c37906c2204cec00c50.json
  88. 1 0
      backend/app/graphify-out/cache/ast/8a0a7e4881b81c61129594c1c76d15aaaf192c0d1b79025bebbcf6eab9d64ffd.json
  89. 1 0
      backend/app/graphify-out/cache/ast/8a4e74a8348e05edd6aeb8d6c4caa6832dc7406cc8506c7dd3a5afd61a4f7875.json
  90. 0 0
      backend/app/graphify-out/cache/ast/8c72f7839c6b43d400e6701a25490ffdcd41cc5fc900ab8685ed155d2135067b.json
  91. 0 0
      backend/app/graphify-out/cache/ast/8d2dff4703d5eb797f397c39fc922145ba7de2c69788ed773c1b883b81fd83f9.json
  92. 0 0
      backend/app/graphify-out/cache/ast/8edafab3d7e2222c3dbff0fd9b790ec4052ec16f57044ec8c6ad87a6ce1de909.json
  93. 0 0
      backend/app/graphify-out/cache/ast/92529b2c95133d962dc512483851681c73d90071e84c9b267f84a73d3582216a.json
  94. 0 0
      backend/app/graphify-out/cache/ast/98812922e59c20bb2dce85bef903c1c44c1db48617f4c72c7e4d32cd202ed881.json
  95. 0 0
      backend/app/graphify-out/cache/ast/9a4b4620451e9bb60f78a301dc57004e6af12bb3cf7e37b743fd711da44d113f.json
  96. 0 0
      backend/app/graphify-out/cache/ast/9c915143f69731d63f5dfc9cb169e56c8559d80ebc126bbbb32adbcb545a93da.json
  97. 0 0
      backend/app/graphify-out/cache/ast/9d16814e695401c424ba90b95ef61942399c0f21d398ddc8cfc7dd60ec0271e3.json
  98. 0 0
      backend/app/graphify-out/cache/ast/9d57e7ff48e271b88c2feaaa99978fae249a1e6c924c8b2cd2d57a7ddcf9bf03.json
  99. 0 0
      backend/app/graphify-out/cache/ast/9f36bdc7491fb4af3fdcc29d2eea0075f5b61bf4b060160984bc5c11cdb50311.json
  100. 0 0
      backend/app/graphify-out/cache/ast/9f62c771f8d9859d1ae8fc3c5b5005022ee0d858d34a65a98dbc6c95d1e0c6cf.json

+ 20 - 0
.cursor/mcp.json

@@ -0,0 +1,20 @@
+{
+  "mcpServers": {
+    "graphify-backend": {
+      "command": "/opt/anaconda3/bin/python",
+      "args": [
+        "-m",
+        "graphify.serve",
+        "/Users/wuzilong/work/00code/zkxw/backend/graphify-out/graph.json"
+      ]
+    },
+    "graphify-frontend": {
+      "command": "/opt/anaconda3/bin/python",
+      "args": [
+        "-m",
+        "graphify.serve",
+        "/Users/wuzilong/work/00code/zkxw/web-admin/graphify-out/graph.json"
+      ]
+    }
+  }
+}

+ 21 - 0
AGENTS.md

@@ -181,6 +181,27 @@ pnpm clean:dev                # strip demo data for fresh start
 - **Color tokens** (web-admin): Primary `#10b981`, dark `#059669`, bg `#ecfdf5`. Fresh Mint palette. **Do NOT use miniprogram colors in web-admin.**
 - **Color tokens** (web-admin): Primary `#10b981`, dark `#059669`, bg `#ecfdf5`. Fresh Mint palette. **Do NOT use miniprogram colors in web-admin.**
 - **Tree display**: dropdown shows node name; selected/input/table shows full path (spaced slash separator).
 - **Tree display**: dropdown shows node name; selected/input/table shows full path (spaced slash separator).
 
 
+## Knowledge Graph (代码知识图谱)
+
+项目已生成代码知识图谱,用于理解模块间依赖关系、追踪修改影响范围。
+
+### 图谱位置
+
+| 项目 | 报告 | 可视化 | 数据 |
+|------|------|--------|------|
+| 后端 | `backend/graphify-out/GRAPH_REPORT.md` | `backend/graphify-out/graph.html` | `backend/graphify-out/graph.json` |
+| 前端 | `web-admin/graphify-out/GRAPH_REPORT.md` | `web-admin/graphify-out/graph.html` | `web-admin/graphify-out/graph.json` |
+
+### 使用方式
+
+- **浏览器**:直接打开 `*/graphify-out/graph.html` 查看交互式图谱
+- **AI Agent 查询**:已配置 MCP 服务(graphify),可通过工具调用查询图谱
+- **手动更新**:`./scripts/update-graph.sh [backend|frontend|all]`
+
+### Agent 更新规则
+
+完成代码修改后(新建文件、修改函数签名、新增/删除 import、重构模块),Agent SHALL 执行 `./scripts/update-graph.sh` 增量更新图谱,确保图谱与代码保持同步。如果只修改了单个项目(backend 或 web-admin),只需更新对应项目即可。
+
 ## Important Constraints
 ## Important Constraints
 
 
 - **Language**: User communication in Chinese (简体中文) when user writes in Chinese.
 - **Language**: User communication in Chinese (简体中文) when user writes in Chinese.

+ 1 - 1
backend/app.pid

@@ -1 +1 @@
-7085
+94150

+ 4 - 0
backend/app/api/__init__.py

@@ -13,6 +13,8 @@ from app.api.qc import router as qc_router
 from app.api.research_donation import router as research_donation_router
 from app.api.research_donation import router as research_donation_router
 from app.api.production import router as production_router
 from app.api.production import router as production_router
 from app.api.pilot_service import router as pilot_service_router
 from app.api.pilot_service import router as pilot_service_router
+from app.api.insurance_company import router as insurance_company_router
+from app.api.material import router as material_router
 
 
 __all__ = [
 __all__ = [
     "public_router",
     "public_router",
@@ -29,4 +31,6 @@ __all__ = [
     "research_donation_router",
     "research_donation_router",
     "production_router",
     "production_router",
     "pilot_service_router",
     "pilot_service_router",
+    "insurance_company_router",
+    "material_router",
 ]
 ]

+ 260 - 2
backend/app/api/collection_protocol.py

@@ -1,11 +1,12 @@
 """细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API"""
 """细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API"""
+from datetime import datetime, timedelta
 from typing import Any, Dict, List, Optional
 from typing import Any, Dict, List, Optional
 
 
 from fastapi import APIRouter, Depends, Query
 from fastapi import APIRouter, Depends, Query
 from pydantic import BaseModel, ConfigDict, Field
 from pydantic import BaseModel, ConfigDict, Field
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
-from sqlalchemy import and_ as sa_and
+from sqlalchemy import and_ as sa_and, func as sa_func, or_ as sa_or
 
 
 from app.core.database import get_db
 from app.core.database import get_db
 from app.core.menu_guard import require_admin_or_super_admin
 from app.core.menu_guard import require_admin_or_super_admin
@@ -13,7 +14,14 @@ from app.core.response import error_response, success_response
 from app.core.utils import parse_datetime
 from app.core.utils import parse_datetime
 from app.crud import collection_protocol as cp_svc
 from app.crud import collection_protocol as cp_svc
 from app.crud import customer as customer_crud
 from app.crud import customer as customer_crud
-from app.models.models import BizCollectionProtocol, BizCustomer, SysUser
+from app.models.models import (
+    BizCollectionProtocol,
+    BizCustomer,
+    BizProtocolPartyCustomer,
+    BizProtocolSample,
+    BizProtocolStorageCert,
+    SysUser,
+)
 
 
 router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
 router = APIRouter(prefix="/web/admin", tags=["细胞存储"])
 
 
@@ -657,3 +665,253 @@ async def post_refund_confirm(
     except ValueError as e:
     except ValueError as e:
         return error_response(400, str(e))
         return error_response(400, str(e))
 
 
+
+# ---------------------------------------------------------------------------
+# 储存证明管理 / 满月待办
+# ---------------------------------------------------------------------------
+
+
+@router.get("/storage-certs")
+async def list_storage_certs(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    keyword: Optional[str] = None,
+    makeStatus: Optional[str] = Query(None, alias="makeStatus"),
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    q = (
+        db.query(BizCollectionProtocol)
+        .filter(
+            BizCollectionProtocol.del_flag == 0,
+            BizCollectionProtocol.tenant_no == tenant_no,
+            BizCollectionProtocol.protocol_status == "sxz",
+        )
+    )
+    kw = (keyword or "").strip()
+    if kw:
+        like = f"%{kw}%"
+        q = q.filter(BizCollectionProtocol.protocol_no.like(like))
+
+    protocol_ids_sub = q.with_entities(BizCollectionProtocol.id).subquery()
+
+    sample_exists = (
+        db.query(BizProtocolSample)
+        .filter(
+            BizProtocolSample.collection_protocol_id == protocol_ids_sub.c.id,
+            BizProtocolSample.del_flag == 0,
+            BizProtocolSample.sample_status == "yrk",
+        )
+        .limit(1)
+        .exists()
+    )
+
+    q = q.filter(sample_exists)
+    rows_all = q.order_by(BizCollectionProtocol.create_time.desc()).all()
+
+    protocol_ids = [r.id for r in rows_all]
+    pc_map: Dict[str, Any] = {}
+    sc_map: Dict[str, Any] = {}
+    sample_map: Dict[str, List[Dict[str, Any]]] = {}
+    if protocol_ids:
+        pc_rows = (
+            db.query(BizProtocolPartyCustomer)
+            .filter(
+                BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids),
+                BizProtocolPartyCustomer.del_flag == 0,
+            )
+            .all()
+        )
+        for pc in pc_rows:
+            pc_map[pc.collection_protocol_id] = pc
+
+        sc_rows = (
+            db.query(BizProtocolStorageCert)
+            .filter(
+                BizProtocolStorageCert.collection_protocol_id.in_(protocol_ids),
+                BizProtocolStorageCert.del_flag == 0,
+            )
+            .all()
+        )
+        for sc in sc_rows:
+            sc_map[sc.collection_protocol_id] = sc
+
+        s_rows = (
+            db.query(BizProtocolSample)
+            .filter(
+                BizProtocolSample.collection_protocol_id.in_(protocol_ids),
+                BizProtocolSample.del_flag == 0,
+                BizProtocolSample.sample_status == "yrk",
+            )
+            .all()
+        )
+        for s in s_rows:
+            sample_map.setdefault(s.collection_protocol_id, []).append(
+                cp_svc._sample_to_dict(s)
+            )
+
+    items: List[Dict[str, Any]] = []
+    for r in rows_all:
+        sc = sc_map.get(r.id)
+        ms = (sc.make_status or "0") if sc else "0"
+        if makeStatus and makeStatus.strip() and ms != makeStatus.strip():
+            continue
+        pc = pc_map.get(r.id)
+        d: Dict[str, Any] = {
+            "id": r.id,
+            "protocolNo": r.protocol_no or "",
+            "protocolStatus": r.protocol_status or "",
+            "motherName": (pc.mother_name or "") if pc else "",
+            "newbornDeliveryTime": cp_svc._fmt_dt(pc.newborn_delivery_time) if pc else "",
+            "samples": sample_map.get(r.id, []),
+            "makeStatus": ms,
+            "certImageUrls": (sc.cert_image_urls or "") if sc else "",
+            "makeDate": ((sc.make_date or "").strip() if sc.make_date else "") if sc else "",
+        }
+        items.append(d)
+
+    total = len(items)
+    page_safe = max(1, page)
+    page_size_safe = min(max(1, pageSize), 100)
+    start = (page_safe - 1) * page_size_safe
+    page_items = items[start : start + page_size_safe]
+    tp = cp_svc.total_pages(total, page_size_safe)
+    return success_response(
+        {"list": page_items, "total": total, "page": page_safe, "pageSize": page_size_safe, "totalPages": tp}
+    )
+
+
+@router.get("/fullmoon-todos")
+async def list_fullmoon_todos(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    keyword: Optional[str] = None,
+    todoStatus: Optional[str] = Query(None, alias="todoStatus"),
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    q = (
+        db.query(BizCollectionProtocol, BizProtocolPartyCustomer)
+        .join(
+            BizProtocolPartyCustomer,
+            sa_and(
+                BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id,
+                BizProtocolPartyCustomer.del_flag == 0,
+            ),
+        )
+        .filter(
+            BizCollectionProtocol.del_flag == 0,
+            BizCollectionProtocol.tenant_no == tenant_no,
+            BizCollectionProtocol.protocol_status == "sxz",
+            BizProtocolPartyCustomer.tenant_no == tenant_no,
+        )
+    )
+    kw = (keyword or "").strip()
+    if kw:
+        like = f"%{kw}%"
+        q = q.filter(
+            sa_or(
+                BizCollectionProtocol.protocol_no.like(like),
+                BizProtocolPartyCustomer.mother_name.like(like),
+            )
+        )
+
+    rows = q.order_by(BizCollectionProtocol.create_time.desc()).all()
+
+    protocol_ids = [r.id for r, _ in rows]
+    ft_map: Dict[str, Any] = {}
+    if protocol_ids:
+        from app.models.models import BizProtocolFullmoonTodo
+        ft_rows = (
+            db.query(BizProtocolFullmoonTodo)
+            .filter(
+                BizProtocolFullmoonTodo.collection_protocol_id.in_(protocol_ids),
+                BizProtocolFullmoonTodo.tenant_no == tenant_no,
+                BizProtocolFullmoonTodo.del_flag == 0,
+            )
+            .all()
+        )
+        for ft in ft_rows:
+            ft_map[ft.collection_protocol_id] = ft
+
+    now = datetime.now()
+    items: List[Dict[str, Any]] = []
+    for proto, pc in rows:
+        dt = pc.newborn_delivery_time
+        if not dt:
+            continue
+        fullmoon_date = dt + timedelta(days=30)
+        if fullmoon_date > now:
+            continue
+        ft = ft_map.get(proto.id)
+        ts = (ft.todo_status or "0") if ft else "0"
+        if todoStatus and todoStatus.strip() and ts != todoStatus.strip():
+            continue
+        d: Dict[str, Any] = {
+            "id": proto.id,
+            "protocolNo": proto.protocol_no or "",
+            "protocolStatus": proto.protocol_status or "",
+            "motherName": pc.mother_name or "",
+            "motherPhone": pc.mother_phone or "",
+            "newbornName": pc.newborn_name or "",
+            "newbornDeliveryTime": cp_svc._fmt_dt(dt),
+            "fullmoonDate": fullmoon_date.strftime("%Y-%m-%d"),
+        }
+        if ft:
+            d["fullmoonTodo"] = cp_svc._fullmoon_todo_to_dict(ft)
+        else:
+            d["fullmoonTodo"] = None
+        items.append(d)
+
+    total = len(items)
+    page_safe = max(1, page)
+    page_size_safe = min(max(1, pageSize), 100)
+    start = (page_safe - 1) * page_size_safe
+    page_items = items[start : start + page_size_safe]
+    tp = cp_svc.total_pages(total, page_size_safe)
+    return success_response(
+        {"list": page_items, "total": total, "page": page_safe, "pageSize": page_size_safe, "totalPages": tp}
+    )
+
+
+class FullmoonTodoInsuranceBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    insurance_company_id: Optional[str] = Field(None, alias="insuranceCompanyId")
+    insurance_company_name: Optional[str] = Field(None, alias="insuranceCompanyName")
+    insurance_type: Optional[str] = Field(None, alias="insuranceType")
+    insurance_policy_no: Optional[str] = Field(None, alias="insurancePolicyNo")
+    insurance_effective_date: Optional[str] = Field(None, alias="insuranceEffectiveDate")
+    insurance_expiry_date: Optional[str] = Field(None, alias="insuranceExpiryDate")
+    insurance_subscribed: Optional[str] = Field(None, alias="insuranceSubscribed")
+    insurance_fee: Optional[float] = Field(None, alias="insuranceFee")
+    insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
+    todo_status: Optional[str] = Field(None, alias="todoStatus")
+    handle_date: Optional[str] = Field(None, alias="handleDate")
+    handle_remark: Optional[str] = Field(None, alias="handleRemark")
+
+
+@router.put("/fullmoon-todos/{protocol_id}/insurance")
+async def update_fullmoon_todo_insurance(
+    protocol_id: str,
+    body: FullmoonTodoInsuranceBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    user_id = _get_current_user_id(user)
+    try:
+        payload = body.model_dump(exclude_unset=True, by_alias=False)
+        ft = cp_svc.upsert_fullmoon_todo(
+            db,
+            protocol_id,
+            tenant_no,
+            user_id,
+            **payload,
+        )
+        return success_response(cp_svc._fullmoon_todo_to_dict(ft), message="更新成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+

+ 832 - 0
backend/app/api/insurance_company.py

@@ -0,0 +1,832 @@
+"""保险公司管理 API(保险公司 + 开票信息 + 项目价格)"""
+import math
+import uuid
+from datetime import datetime
+from typing import Any, List, Optional
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.menu_guard import require_admin_or_super_admin
+from app.core.response import error_response, success_response
+from app.models.models import (
+    BizInsuranceCompany,
+    BizInsuranceCompanyInvoice,
+    BizInsuranceProjectPrice,
+    SysUser,
+)
+
+router = APIRouter(prefix="/web/admin", tags=["保险公司管理"])
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _get_tenant_no(db: Session, user: dict) -> str:
+    user_id = user.get("user_id")
+    if not user_id:
+        raise PermissionError("用户身份信息缺失,无法确定租户")
+    sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
+    if not sys_user or not sys_user.tenant_no:
+        raise PermissionError("用户租户信息缺失")
+    return sys_user.tenant_no
+
+
+def _generate_uuid() -> str:
+    return str(uuid.uuid4()).replace("-", "")
+
+
+def _fmt_dt(v) -> Optional[str]:
+    if v is None:
+        return None
+    if isinstance(v, datetime):
+        return v.strftime("%Y-%m-%d %H:%M:%S")
+    return str(v)
+
+
+def _total_pages(total: int, page_size: int) -> int:
+    return math.ceil(total / page_size) if page_size > 0 else 0
+
+
+# ---------------------------------------------------------------------------
+# Row-to-dict helpers (camelCase JSON)
+# ---------------------------------------------------------------------------
+
+
+def _row_to_dict_company(row: BizInsuranceCompany) -> dict:
+    return {
+        "id": row.id,
+        "companyName": row.company_name,
+        "companyAddress": row.company_address,
+        "contactName": row.contact_name,
+        "contactPhone": row.contact_phone,
+        "status": row.status,
+        "tenantNo": row.tenant_no,
+        "orderNum": row.order_num,
+        "remark": row.remark,
+        "createBy": row.create_by,
+        "createDeptId": row.create_dept_id,
+        "createTime": _fmt_dt(row.create_time),
+        "updateBy": row.update_by,
+        "updateTime": _fmt_dt(row.update_time),
+    }
+
+
+def _row_to_dict_invoice(row: BizInsuranceCompanyInvoice) -> dict:
+    return {
+        "id": row.id,
+        "insuranceCompanyId": row.insurance_company_id,
+        "companyName": row.company_name,
+        "taxNo": row.tax_no,
+        "companyAddress": row.company_address,
+        "companyPhone": row.company_phone,
+        "bankName": row.bank_name,
+        "bankAccount": row.bank_account,
+        "tenantNo": row.tenant_no,
+        "orderNum": row.order_num,
+        "remark": row.remark,
+        "createBy": row.create_by,
+        "createDeptId": row.create_dept_id,
+        "createTime": _fmt_dt(row.create_time),
+        "updateBy": row.update_by,
+        "updateTime": _fmt_dt(row.update_time),
+    }
+
+
+def _row_to_dict_project_price(row: BizInsuranceProjectPrice) -> dict:
+    return {
+        "id": row.id,
+        "insuranceCompanyId": row.insurance_company_id,
+        "projectName": row.project_name,
+        "projectFee": float(row.project_fee) if row.project_fee is not None else None,
+        "status": row.status,
+        "tenantNo": row.tenant_no,
+        "orderNum": row.order_num,
+        "remark": row.remark,
+        "createBy": row.create_by,
+        "createDeptId": row.create_dept_id,
+        "createTime": _fmt_dt(row.create_time),
+        "updateBy": row.update_by,
+        "updateTime": _fmt_dt(row.update_time),
+    }
+
+
+# ---------------------------------------------------------------------------
+# Inline CRUD — 保险公司主表
+# ---------------------------------------------------------------------------
+
+
+def _list_insurance_companies(
+    db: Session, *, page: int, page_size: int, keyword: Optional[str], tenant_no: str
+):
+    q = db.query(BizInsuranceCompany).filter(
+        BizInsuranceCompany.tenant_no == tenant_no,
+        BizInsuranceCompany.del_flag == 0,
+    )
+    if keyword:
+        q = q.filter(BizInsuranceCompany.company_name.like(f"%{keyword}%"))
+    total = q.count()
+    rows = (
+        q.order_by(BizInsuranceCompany.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [_row_to_dict_company(r) for r in rows], total
+
+
+def _create_insurance_company(
+    db: Session,
+    *,
+    company_name: str,
+    company_address: Optional[str],
+    contact_name: Optional[str],
+    contact_phone: Optional[str],
+    status: Optional[str],
+    tenant_no: str,
+    order_num: Optional[int],
+    remark: Optional[str],
+    user_id: str,
+) -> BizInsuranceCompany:
+    obj = BizInsuranceCompany(
+        id=_generate_uuid(),
+        company_name=company_name,
+        company_address=company_address,
+        contact_name=contact_name,
+        contact_phone=contact_phone,
+        status=status or "1",
+        tenant_no=tenant_no,
+        order_num=order_num or 0,
+        remark=remark,
+        create_by=user_id,
+        create_dept_id="",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def _update_insurance_company(
+    db: Session,
+    row_id: str,
+    *,
+    company_name: Optional[str],
+    company_address: Optional[str],
+    contact_name: Optional[str],
+    contact_phone: Optional[str],
+    status: Optional[str],
+    order_num: Optional[int],
+    remark: Optional[str],
+    user_id: str,
+    tenant_no: str,
+) -> BizInsuranceCompany:
+    obj = (
+        db.query(BizInsuranceCompany)
+        .filter(
+            BizInsuranceCompany.id == row_id,
+            BizInsuranceCompany.tenant_no == tenant_no,
+            BizInsuranceCompany.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("记录不存在")
+    if company_name is not None:
+        obj.company_name = company_name
+    if company_address is not None:
+        obj.company_address = company_address
+    if contact_name is not None:
+        obj.contact_name = contact_name
+    if contact_phone is not None:
+        obj.contact_phone = contact_phone
+    if status is not None:
+        obj.status = status
+    if order_num is not None:
+        obj.order_num = order_num
+    if remark is not None:
+        obj.remark = remark
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def _soft_delete_insurance_company(db: Session, row_id: str, *, tenant_no: str):
+    obj = (
+        db.query(BizInsuranceCompany)
+        .filter(
+            BizInsuranceCompany.id == row_id,
+            BizInsuranceCompany.tenant_no == tenant_no,
+            BizInsuranceCompany.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("记录不存在")
+    obj.del_flag = 1
+    db.commit()
+
+
+# ---------------------------------------------------------------------------
+# Inline CRUD — 保险公司开票信息
+# ---------------------------------------------------------------------------
+
+
+def _list_insurance_company_invoices(
+    db: Session, insurance_company_id: str, *, tenant_no: str
+):
+    # Verify parent exists
+    parent = (
+        db.query(BizInsuranceCompany)
+        .filter(
+            BizInsuranceCompany.id == insurance_company_id,
+            BizInsuranceCompany.tenant_no == tenant_no,
+            BizInsuranceCompany.del_flag == 0,
+        )
+        .first()
+    )
+    if not parent:
+        raise ValueError("保险公司不存在")
+    rows = (
+        db.query(BizInsuranceCompanyInvoice)
+        .filter(
+            BizInsuranceCompanyInvoice.insurance_company_id == insurance_company_id,
+            BizInsuranceCompanyInvoice.tenant_no == tenant_no,
+            BizInsuranceCompanyInvoice.del_flag == 0,
+        )
+        .order_by(BizInsuranceCompanyInvoice.create_time.desc())
+        .all()
+    )
+    return [_row_to_dict_invoice(r) for r in rows]
+
+
+def _create_insurance_company_invoice(
+    db: Session,
+    *,
+    insurance_company_id: str,
+    company_name: str,
+    tax_no: Optional[str],
+    company_address: Optional[str],
+    company_phone: Optional[str],
+    bank_name: Optional[str],
+    bank_account: Optional[str],
+    tenant_no: str,
+    order_num: Optional[int],
+    remark: Optional[str],
+    user_id: str,
+) -> BizInsuranceCompanyInvoice:
+    obj = BizInsuranceCompanyInvoice(
+        id=_generate_uuid(),
+        insurance_company_id=insurance_company_id,
+        company_name=company_name,
+        tax_no=tax_no,
+        company_address=company_address,
+        company_phone=company_phone,
+        bank_name=bank_name,
+        bank_account=bank_account,
+        tenant_no=tenant_no,
+        order_num=order_num or 0,
+        remark=remark,
+        create_by=user_id,
+        create_dept_id="",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def _update_insurance_company_invoice(
+    db: Session,
+    row_id: str,
+    *,
+    company_name: Optional[str],
+    tax_no: Optional[str],
+    company_address: Optional[str],
+    company_phone: Optional[str],
+    bank_name: Optional[str],
+    bank_account: Optional[str],
+    order_num: Optional[int],
+    remark: Optional[str],
+    user_id: str,
+    tenant_no: str,
+) -> BizInsuranceCompanyInvoice:
+    obj = (
+        db.query(BizInsuranceCompanyInvoice)
+        .filter(
+            BizInsuranceCompanyInvoice.id == row_id,
+            BizInsuranceCompanyInvoice.tenant_no == tenant_no,
+            BizInsuranceCompanyInvoice.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("记录不存在")
+    if company_name is not None:
+        obj.company_name = company_name
+    if tax_no is not None:
+        obj.tax_no = tax_no
+    if company_address is not None:
+        obj.company_address = company_address
+    if company_phone is not None:
+        obj.company_phone = company_phone
+    if bank_name is not None:
+        obj.bank_name = bank_name
+    if bank_account is not None:
+        obj.bank_account = bank_account
+    if order_num is not None:
+        obj.order_num = order_num
+    if remark is not None:
+        obj.remark = remark
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def _soft_delete_insurance_company_invoice(db: Session, row_id: str, *, tenant_no: str):
+    obj = (
+        db.query(BizInsuranceCompanyInvoice)
+        .filter(
+            BizInsuranceCompanyInvoice.id == row_id,
+            BizInsuranceCompanyInvoice.tenant_no == tenant_no,
+            BizInsuranceCompanyInvoice.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("记录不存在")
+    obj.del_flag = 1
+    db.commit()
+
+
+# ---------------------------------------------------------------------------
+# Inline CRUD — 保险项目价格
+# ---------------------------------------------------------------------------
+
+
+def _list_insurance_project_prices(
+    db: Session, insurance_company_id: str, *, tenant_no: str
+):
+    # Verify parent exists
+    parent = (
+        db.query(BizInsuranceCompany)
+        .filter(
+            BizInsuranceCompany.id == insurance_company_id,
+            BizInsuranceCompany.tenant_no == tenant_no,
+            BizInsuranceCompany.del_flag == 0,
+        )
+        .first()
+    )
+    if not parent:
+        raise ValueError("保险公司不存在")
+    rows = (
+        db.query(BizInsuranceProjectPrice)
+        .filter(
+            BizInsuranceProjectPrice.insurance_company_id == insurance_company_id,
+            BizInsuranceProjectPrice.tenant_no == tenant_no,
+            BizInsuranceProjectPrice.del_flag == 0,
+        )
+        .order_by(BizInsuranceProjectPrice.create_time.desc())
+        .all()
+    )
+    return [_row_to_dict_project_price(r) for r in rows]
+
+
+def _create_insurance_project_price(
+    db: Session,
+    *,
+    insurance_company_id: str,
+    project_name: str,
+    project_fee: Optional[float],
+    status: Optional[str],
+    tenant_no: str,
+    order_num: Optional[int],
+    remark: Optional[str],
+    user_id: str,
+) -> BizInsuranceProjectPrice:
+    obj = BizInsuranceProjectPrice(
+        id=_generate_uuid(),
+        insurance_company_id=insurance_company_id,
+        project_name=project_name,
+        project_fee=project_fee,
+        status=status or "1",
+        tenant_no=tenant_no,
+        order_num=order_num or 0,
+        remark=remark,
+        create_by=user_id,
+        create_dept_id="",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def _update_insurance_project_price(
+    db: Session,
+    row_id: str,
+    *,
+    project_name: Optional[str],
+    project_fee: Optional[float],
+    status: Optional[str],
+    order_num: Optional[int],
+    remark: Optional[str],
+    user_id: str,
+    tenant_no: str,
+) -> BizInsuranceProjectPrice:
+    obj = (
+        db.query(BizInsuranceProjectPrice)
+        .filter(
+            BizInsuranceProjectPrice.id == row_id,
+            BizInsuranceProjectPrice.tenant_no == tenant_no,
+            BizInsuranceProjectPrice.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("记录不存在")
+    if project_name is not None:
+        obj.project_name = project_name
+    if project_fee is not None:
+        obj.project_fee = project_fee
+    if status is not None:
+        obj.status = status
+    if order_num is not None:
+        obj.order_num = order_num
+    if remark is not None:
+        obj.remark = remark
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.commit()
+    db.refresh(obj)
+    return obj
+
+
+def _soft_delete_insurance_project_price(db: Session, row_id: str, *, tenant_no: str):
+    obj = (
+        db.query(BizInsuranceProjectPrice)
+        .filter(
+            BizInsuranceProjectPrice.id == row_id,
+            BizInsuranceProjectPrice.tenant_no == tenant_no,
+            BizInsuranceProjectPrice.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("记录不存在")
+    obj.del_flag = 1
+    db.commit()
+
+
+# ---------------------------------------------------------------------------
+# 1. 保险公司(主表)
+# ---------------------------------------------------------------------------
+
+
+class InsuranceCompanyCreateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    company_name: str = Field(..., alias="companyName")
+    company_address: Optional[str] = Field(None, alias="companyAddress")
+    contact_name: Optional[str] = Field(None, alias="contactName")
+    contact_phone: Optional[str] = Field(None, alias="contactPhone")
+    status: Optional[str] = Field("1", alias="status")
+    order_num: Optional[int] = Field(0, alias="orderNum")
+    remark: Optional[str] = None
+
+
+class InsuranceCompanyUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    company_name: Optional[str] = Field(None, alias="companyName")
+    company_address: Optional[str] = Field(None, alias="companyAddress")
+    contact_name: Optional[str] = Field(None, alias="contactName")
+    contact_phone: Optional[str] = Field(None, alias="contactPhone")
+    status: Optional[str] = Field(None, alias="status")
+    order_num: Optional[int] = Field(None, alias="orderNum")
+    remark: Optional[str] = None
+
+
+@router.get("/insurance-companies")
+async def list_insurance_companies(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
+    keyword: Optional[str] = None,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    items, total = _list_insurance_companies(
+        db, page=page, page_size=pageSize, keyword=keyword, tenant_no=tenant_no
+    )
+    tp = _total_pages(total, pageSize)
+    return success_response(
+        {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
+    )
+
+
+@router.post("/insurance-companies")
+async def create_insurance_company(
+    body: InsuranceCompanyCreateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        obj = _create_insurance_company(
+            db,
+            company_name=body.company_name,
+            company_address=body.company_address,
+            contact_name=body.contact_name,
+            contact_phone=body.contact_phone,
+            status=body.status,
+            tenant_no=tenant_no,
+            order_num=body.order_num,
+            remark=body.remark,
+            user_id=user.get("user_id"),
+        )
+        return success_response(_row_to_dict_company(obj), message="创建成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.put("/insurance-companies/{row_id}")
+async def update_insurance_company(
+    row_id: str,
+    body: InsuranceCompanyUpdateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
+        obj = _update_insurance_company(
+            db,
+            row_id,
+            company_name=payload.get("company_name"),
+            company_address=payload.get("company_address"),
+            contact_name=payload.get("contact_name"),
+            contact_phone=payload.get("contact_phone"),
+            status=payload.get("status"),
+            order_num=payload.get("order_num"),
+            remark=payload.get("remark"),
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        return success_response(_row_to_dict_company(obj), message="更新成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.delete("/insurance-companies/{row_id}")
+async def delete_insurance_company(
+    row_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        _soft_delete_insurance_company(db, row_id, tenant_no=tenant_no)
+        return success_response(None, message="删除成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+# ---------------------------------------------------------------------------
+# 2. 保险公司开票信息(子表)
+# ---------------------------------------------------------------------------
+
+
+class InsuranceCompanyInvoiceCreateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    company_name: str = Field(..., alias="companyName")
+    tax_no: Optional[str] = Field(None, alias="taxNo")
+    company_address: Optional[str] = Field(None, alias="companyAddress")
+    company_phone: Optional[str] = Field(None, alias="companyPhone")
+    bank_name: Optional[str] = Field(None, alias="bankName")
+    bank_account: Optional[str] = Field(None, alias="bankAccount")
+    order_num: Optional[int] = Field(0, alias="orderNum")
+    remark: Optional[str] = None
+
+
+class InsuranceCompanyInvoiceUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    company_name: Optional[str] = Field(None, alias="companyName")
+    tax_no: Optional[str] = Field(None, alias="taxNo")
+    company_address: Optional[str] = Field(None, alias="companyAddress")
+    company_phone: Optional[str] = Field(None, alias="companyPhone")
+    bank_name: Optional[str] = Field(None, alias="bankName")
+    bank_account: Optional[str] = Field(None, alias="bankAccount")
+    order_num: Optional[int] = Field(None, alias="orderNum")
+    remark: Optional[str] = None
+
+
+@router.get("/insurance-companies/{company_id}/invoices")
+async def list_insurance_company_invoices(
+    company_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items = _list_insurance_company_invoices(db, company_id, tenant_no=tenant_no)
+        return success_response(items)
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/insurance-companies/{company_id}/invoices")
+async def create_insurance_company_invoice(
+    company_id: str,
+    body: InsuranceCompanyInvoiceCreateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        obj = _create_insurance_company_invoice(
+            db,
+            insurance_company_id=company_id,
+            company_name=body.company_name,
+            tax_no=body.tax_no,
+            company_address=body.company_address,
+            company_phone=body.company_phone,
+            bank_name=body.bank_name,
+            bank_account=body.bank_account,
+            tenant_no=tenant_no,
+            order_num=body.order_num,
+            remark=body.remark,
+            user_id=user.get("user_id"),
+        )
+        return success_response(_row_to_dict_invoice(obj), message="创建成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.put("/insurance-company-invoices/{invoice_id}")
+async def update_insurance_company_invoice(
+    invoice_id: str,
+    body: InsuranceCompanyInvoiceUpdateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
+        obj = _update_insurance_company_invoice(
+            db,
+            invoice_id,
+            company_name=payload.get("company_name"),
+            tax_no=payload.get("tax_no"),
+            company_address=payload.get("company_address"),
+            company_phone=payload.get("company_phone"),
+            bank_name=payload.get("bank_name"),
+            bank_account=payload.get("bank_account"),
+            order_num=payload.get("order_num"),
+            remark=payload.get("remark"),
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        return success_response(_row_to_dict_invoice(obj), message="更新成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.delete("/insurance-company-invoices/{invoice_id}")
+async def delete_insurance_company_invoice(
+    invoice_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        _soft_delete_insurance_company_invoice(db, invoice_id, tenant_no=tenant_no)
+        return success_response(None, message="删除成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+# ---------------------------------------------------------------------------
+# 3. 保险项目价格(子表)
+# ---------------------------------------------------------------------------
+
+
+class InsuranceProjectPriceCreateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    project_name: str = Field(..., alias="projectName")
+    project_fee: Optional[float] = Field(None, alias="projectFee")
+    status: Optional[str] = Field("1", alias="status")
+    order_num: Optional[int] = Field(0, alias="orderNum")
+    remark: Optional[str] = None
+
+
+class InsuranceProjectPriceUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    project_name: Optional[str] = Field(None, alias="projectName")
+    project_fee: Optional[float] = Field(None, alias="projectFee")
+    status: Optional[str] = Field(None, alias="status")
+    order_num: Optional[int] = Field(None, alias="orderNum")
+    remark: Optional[str] = None
+
+
+@router.get("/insurance-companies/{company_id}/project-prices")
+async def list_insurance_project_prices(
+    company_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items = _list_insurance_project_prices(db, company_id, tenant_no=tenant_no)
+        return success_response(items)
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.post("/insurance-companies/{company_id}/project-prices")
+async def create_insurance_project_price(
+    company_id: str,
+    body: InsuranceProjectPriceCreateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        obj = _create_insurance_project_price(
+            db,
+            insurance_company_id=company_id,
+            project_name=body.project_name,
+            project_fee=body.project_fee,
+            status=body.status,
+            tenant_no=tenant_no,
+            order_num=body.order_num,
+            remark=body.remark,
+            user_id=user.get("user_id"),
+        )
+        return success_response(_row_to_dict_project_price(obj), message="创建成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.put("/insurance-project-prices/{price_id}")
+async def update_insurance_project_price(
+    price_id: str,
+    body: InsuranceProjectPriceUpdateBody,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
+        obj = _update_insurance_project_price(
+            db,
+            price_id,
+            project_name=payload.get("project_name"),
+            project_fee=payload.get("project_fee"),
+            status=payload.get("status"),
+            order_num=payload.get("order_num"),
+            remark=payload.get("remark"),
+            user_id=user.get("user_id"),
+            tenant_no=tenant_no,
+        )
+        return success_response(_row_to_dict_project_price(obj), message="更新成功")
+    except ValueError as e:
+        return error_response(400, str(e))
+
+
+@router.delete("/insurance-project-prices/{price_id}")
+async def delete_insurance_project_price(
+    price_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        _soft_delete_insurance_project_price(db, price_id, tenant_no=tenant_no)
+        return success_response(None, message="删除成功")
+    except ValueError as e:
+        return error_response(400, str(e))

+ 226 - 0
backend/app/api/material.py

@@ -0,0 +1,226 @@
+"""市场物料库 API"""
+from typing import Optional
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, ConfigDict, Field
+from sqlalchemy.orm import Session
+
+from app.api.sample_reception import _get_current_user_id, _get_tenant_no, _page_payload
+from app.core.database import get_db
+from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
+from app.core.response import error_response, success_response
+from app.crud import material as mat_svc
+from app.crud import material_stock_log as msl_svc
+
+router = APIRouter(prefix="/web/admin", tags=["市场物料"])
+
+
+# ---------- Body models ----------
+
+
+class MaterialBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    material_code: str = Field(..., alias="materialCode")
+    material_name: str = Field(..., alias="materialName")
+    category: Optional[str] = None
+    spec_model: Optional[str] = Field(None, alias="specModel")
+    unit: Optional[str] = None
+    status: str = "1"
+    order_num: Optional[int] = Field(None, alias="orderNum")
+    remark: Optional[str] = None
+    image_urls: Optional[str] = Field(None, alias="imageUrls")
+
+
+class MaterialUpdateBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    material_code: Optional[str] = Field(None, alias="materialCode")
+    material_name: Optional[str] = Field(None, alias="materialName")
+    category: Optional[str] = None
+    spec_model: Optional[str] = Field(None, alias="specModel")
+    unit: Optional[str] = None
+    status: Optional[str] = None
+    order_num: Optional[int] = Field(None, alias="orderNum")
+    remark: Optional[str] = None
+    image_urls: Optional[str] = Field(None, alias="imageUrls")
+
+
+class StockInBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    qty: float = Field(..., gt=0)
+    remark: Optional[str] = None
+
+
+class StockOutBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+    qty: float = Field(..., gt=0)
+    receiver: Optional[str] = None
+    purpose: Optional[str] = None
+    remark: Optional[str] = None
+
+
+# ---------- 物料 CRUD ----------
+
+
+@router.get("/materials")
+async def list_materials(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100),
+    keyword: Optional[str] = None,
+    category: Optional[str] = None,
+    status: Optional[str] = None,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:material:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items, total = mat_svc.list_materials(
+            db, tenant_no, page=page, page_size=pageSize,
+            keyword=keyword, category=category, status=status,
+        )
+        return success_response(_page_payload(items, total, page, pageSize))
+    except PermissionError as e:
+        return error_response(str(e), code=403)
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.get("/materials/options")
+async def material_options(
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:material:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        return success_response(mat_svc.options_list(db, tenant_no=tenant_no))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.post("/materials")
+async def create_material(
+    body: MaterialBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:material:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        obj = mat_svc.create_material(
+            db, body.model_dump(by_alias=True, exclude_none=True),
+            tenant_no=tenant_no, user_id=user_id,
+        )
+        return success_response(obj, message="创建成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.put("/materials/{material_id}")
+async def update_material(
+    material_id: str,
+    body: MaterialUpdateBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:material:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        obj = mat_svc.update_material(
+            db, material_id, body.model_dump(by_alias=True, exclude_none=True),
+            tenant_no=tenant_no, user_id=user_id,
+        )
+        return success_response(obj, message="更新成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.delete("/materials/{material_id}")
+async def delete_material(
+    material_id: str,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:material:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        mat_svc.soft_delete_material(db, material_id, tenant_no=tenant_no, user_id=user_id)
+        return success_response(None, message="删除成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.post("/materials/{material_id}/stock-in")
+async def stock_in_material(
+    material_id: str,
+    body: StockInBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:material:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        obj = mat_svc.stock_in(
+            db, material_id, qty=body.qty, remark=body.remark,
+            tenant_no=tenant_no, user_id=user_id,
+        )
+        return success_response(obj, message="入库成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+@router.post("/materials/{material_id}/stock-out")
+async def stock_out_material(
+    material_id: str,
+    body: StockOutBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:material:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        obj = mat_svc.stock_out(
+            db, material_id, qty=body.qty, receiver=body.receiver,
+            purpose=body.purpose, remark=body.remark,
+            tenant_no=tenant_no, user_id=user_id,
+        )
+        return success_response(obj, message="出库成功")
+    except ValueError as e:
+        return error_response(str(e))
+    except Exception as e:
+        return error_response(str(e))
+
+
+# ---------- 库存变动记录 ----------
+
+
+@router.get("/material-stock-logs")
+async def list_stock_logs(
+    page: int = Query(1, ge=1),
+    pageSize: int = Query(10, ge=1, le=100),
+    materialId: Optional[str] = None,
+    changeType: Optional[str] = None,
+    startTime: Optional[str] = None,
+    endTime: Optional[str] = None,
+    keyword: Optional[str] = None,
+    db: Session = Depends(get_db),
+    user: dict = Depends(require_menu_code("biz:materialStockLog:list")),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        items, total = msl_svc.list_stock_logs(
+            db, tenant_no, page=page, page_size=pageSize,
+            material_id=materialId, change_type=changeType,
+            start_time=startTime, end_time=endTime, keyword=keyword,
+        )
+        return success_response(_page_payload(items, total, page, pageSize))
+    except PermissionError as e:
+        return error_response(str(e), code=403)
+    except Exception as e:
+        return error_response(str(e))

+ 1 - 13
backend/app/api/menu.py

@@ -6,20 +6,8 @@ from app.core.response import success_response
 
 
 router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
 router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
 
 
-# 与 web-admin/src/router/modules/sqxp.ts 一致:工作台、系统管理、基础信息管理 等
+# 与 web-admin/src/router/modules/sqxp.ts 一致:系统管理、基础信息管理 等
 STATIC_MENU_TREE = [
 STATIC_MENU_TREE = [
-    {
-        "id": "10000000000000000000000000000001_child",
-        "name": "Console",
-        "path": "/console",
-        "component": "/dashboard/console/index",
-        "meta": {
-            "title": "工作台",
-            "keepAlive": True,
-            "fixedTab": True,
-            "menuCode": "dashboard",
-        },
-    },
     {
     {
         "id": "100",
         "id": "100",
         "name": "System",
         "name": "System",

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

@@ -22,6 +22,7 @@ from app.models.models import (
     BizCollectionProtocol,
     BizCollectionProtocol,
     BizProtocolAudit,
     BizProtocolAudit,
     BizProtocolCollectionInfo,
     BizProtocolCollectionInfo,
+    BizProtocolFullmoonTodo,
     BizProtocolPartyCustomer,
     BizProtocolPartyCustomer,
     BizProtocolPayment,
     BizProtocolPayment,
     BizProtocolPaymentDetail,
     BizProtocolPaymentDetail,
@@ -2215,3 +2216,107 @@ def upsert_storage_cert(
     db.commit()
     db.commit()
     db.refresh(obj)
     db.refresh(obj)
     return obj
     return obj
+
+
+# ---------------------------------------------------------------------------
+# 子表:满月待办
+# ---------------------------------------------------------------------------
+
+
+def _fullmoon_todo_to_dict(ft: BizProtocolFullmoonTodo) -> Dict[str, Any]:
+    return {
+        "id": ft.id,
+        "collectionProtocolId": ft.collection_protocol_id,
+        "todoStatus": ft.todo_status or "0",
+        "handleDate": (ft.handle_date or "").strip() if ft.handle_date else "",
+        "handleRemark": ft.handle_remark or "",
+        "insuranceAttachments": ft.insurance_attachments or "",
+        "insuranceCompanyId": ft.insurance_company_id or "",
+        "insuranceCompanyName": ft.insurance_company_name or "",
+        "insuranceType": ft.insurance_type or "",
+        "insurancePolicyNo": ft.insurance_policy_no or "",
+        "insuranceEffectiveDate": (ft.insurance_effective_date or "").strip() if ft.insurance_effective_date else "",
+        "insuranceExpiryDate": (ft.insurance_expiry_date or "").strip() if ft.insurance_expiry_date else "",
+        "insuranceSubscribed": ft.insurance_subscribed or "0",
+        "insuranceFee": _dec(ft.insurance_fee),
+        "tenantNo": ft.tenant_no,
+        "orderNum": ft.order_num if ft.order_num is not None else 0,
+        "remark": ft.remark or "",
+        "createTime": _fmt_dt(ft.create_time),
+        "updateTime": _fmt_dt(ft.update_time),
+    }
+
+
+def get_fullmoon_todo(
+    db: Session,
+    collection_protocol_id: str,
+    tenant_no: str,
+) -> Optional[BizProtocolFullmoonTodo]:
+    return (
+        db.query(BizProtocolFullmoonTodo)
+        .filter(
+            and_(
+                BizProtocolFullmoonTodo.collection_protocol_id == collection_protocol_id,
+                BizProtocolFullmoonTodo.tenant_no == tenant_no,
+                BizProtocolFullmoonTodo.del_flag == 0,
+            )
+        )
+        .first()
+    )
+
+
+def upsert_fullmoon_todo(
+    db: Session,
+    collection_protocol_id: str,
+    tenant_no: str,
+    user_id: Optional[str],
+    **fields,
+) -> BizProtocolFullmoonTodo:
+    fields = _normalize_fields(fields)
+    obj = get_fullmoon_todo(db, collection_protocol_id, tenant_no)
+    _STR_FIELDS = [
+        "todo_status", "handle_date", "handle_remark",
+        "insurance_attachments", "insurance_company_id",
+        "insurance_company_name", "insurance_type",
+        "insurance_policy_no", "insurance_effective_date",
+        "insurance_expiry_date", "insurance_subscribed",
+    ]
+    _DEC_FIELDS = ["insurance_fee"]
+    if obj:
+        for k in _STR_FIELDS:
+            if k in fields:
+                setattr(obj, k, (fields[k] or "").strip() or None)
+        for k in _DEC_FIELDS:
+            if k in fields and fields[k] is not None:
+                setattr(obj, k, Decimal(str(fields[k])))
+        if "remark" in fields:
+            obj.remark = (fields["remark"] or "").strip() or None
+        obj.update_by = user_id
+        obj.update_time = datetime.now()
+        db.add(obj)
+        db.commit()
+        db.refresh(obj)
+        return obj
+    obj = BizProtocolFullmoonTodo(
+        id=generate_uuid(),
+        collection_protocol_id=collection_protocol_id,
+        tenant_no=tenant_no,
+        order_num=0,
+        remark=(fields.get("remark") or "").strip() or None,
+        create_by=user_id,
+        create_dept_id="",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    for k in _STR_FIELDS:
+        if k in fields:
+            setattr(obj, k, (fields[k] or "").strip() or None)
+    for k in _DEC_FIELDS:
+        if k in fields and fields[k] is not None:
+            setattr(obj, k, Decimal(str(fields[k])))
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return obj

+ 397 - 0
backend/app/crud/material.py

@@ -0,0 +1,397 @@
+"""市场物料库 CRUD"""
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from typing import Any, Dict, List, Optional, Tuple
+
+from sqlalchemy import func, or_
+from sqlalchemy.orm import Session
+
+from app.core.utils import generate_uuid
+from app.models.material import BizMaterial, BizMaterialStockLog
+
+
+def _dec(v: Any) -> float:
+    if v is None:
+        return 0.0
+    return float(v)
+
+
+def _fmt_dt(t: Optional[datetime]) -> str:
+    return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
+
+
+def _row_to_dict(r: BizMaterial) -> Dict[str, Any]:
+    return {
+        "id": r.id,
+        "materialCode": r.material_code,
+        "materialName": r.material_name,
+        "category": r.category or "",
+        "specModel": r.spec_model or "",
+        "unit": r.unit or "",
+        "stockQty": _dec(r.stock_qty),
+        "status": r.status or "1",
+        "imageUrls": r.image_urls or "",
+        "orderNum": r.order_num if r.order_num is not None else 0,
+        "remark": r.remark or "",
+        "createTime": _fmt_dt(r.create_time),
+        "updateTime": _fmt_dt(r.update_time),
+    }
+
+
+def _code_exists(
+    db: Session, code: str, tenant_no: str, exclude_id: Optional[str] = None
+) -> bool:
+    c = (code or "").strip()
+    if not c:
+        return False
+    q = db.query(BizMaterial).filter(
+        BizMaterial.material_code == c,
+        BizMaterial.tenant_no == tenant_no,
+        BizMaterial.del_flag == 0,
+    )
+    if exclude_id:
+        q = q.filter(BizMaterial.id != exclude_id)
+    return q.first() is not None
+
+
+def list_materials(
+    db: Session,
+    tenant_no: str,
+    *,
+    page: int = 1,
+    page_size: int = 10,
+    keyword: Optional[str] = None,
+    category: Optional[str] = None,
+    status: Optional[str] = None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    def _filtered():
+        q0 = db.query(BizMaterial).filter(
+            BizMaterial.tenant_no == tenant_no,
+            BizMaterial.del_flag == 0,
+        )
+        kw = (keyword or "").strip()
+        if kw:
+            like = f"%{kw}%"
+            q0 = q0.filter(
+                or_(
+                    BizMaterial.material_code.ilike(like),
+                    BizMaterial.material_name.ilike(like),
+                )
+            )
+        cat = (category or "").strip()
+        if cat:
+            q0 = q0.filter(BizMaterial.category == cat)
+        st = (status or "").strip()
+        if st in ("0", "1"):
+            q0 = q0.filter(BizMaterial.status == st)
+        return q0
+
+    total = _filtered().order_by(None).with_entities(func.count(BizMaterial.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        _filtered()
+        .order_by(BizMaterial.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [_row_to_dict(x) for x in rows], int(total)
+
+
+def get_material(
+    db: Session, material_id: str, tenant_no: str
+) -> Optional[Dict[str, Any]]:
+    obj = (
+        db.query(BizMaterial)
+        .filter(
+            BizMaterial.id == material_id,
+            BizMaterial.tenant_no == tenant_no,
+            BizMaterial.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        return None
+    return _row_to_dict(obj)
+
+
+def create_material(
+    db: Session,
+    data: Dict[str, Any],
+    *,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    dept_id: str = "",
+) -> Dict[str, Any]:
+    code = (data.get("materialCode") or "").strip()
+    if not code:
+        raise ValueError("物料编码不能为空")
+    if _code_exists(db, code, tenant_no):
+        raise ValueError("物料编码已存在")
+    name = (data.get("materialName") or "").strip()
+    if not name:
+        raise ValueError("物料名称不能为空")
+    status = (data.get("status") or "1").strip()
+    if status not in ("0", "1"):
+        raise ValueError("状态无效")
+    obj = BizMaterial(
+        id=generate_uuid(),
+        material_code=code,
+        material_name=name,
+        category=(data.get("category") or "").strip() or None,
+        spec_model=(data.get("specModel") or "").strip() or None,
+        unit=(data.get("unit") or "").strip() or None,
+        stock_qty=0,
+        status=status,
+        image_urls=(data.get("imageUrls") or "").strip() or None,
+        tenant_no=tenant_no,
+        order_num=data.get("orderNum") if data.get("orderNum") is not None else 0,
+        remark=(data.get("remark") or "").strip() or None,
+        create_by=user_id,
+        create_dept_id=dept_id or "",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return _row_to_dict(obj)
+
+
+def update_material(
+    db: Session,
+    material_id: str,
+    data: Dict[str, Any],
+    *,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+) -> Dict[str, Any]:
+    obj = (
+        db.query(BizMaterial)
+        .filter(
+            BizMaterial.id == material_id,
+            BizMaterial.tenant_no == tenant_no,
+            BizMaterial.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("物料不存在")
+
+    if "materialCode" in data and data["materialCode"] is not None:
+        code = data["materialCode"].strip()
+        if not code:
+            raise ValueError("物料编码不能为空")
+        if code != obj.material_code and _code_exists(db, code, tenant_no, exclude_id=material_id):
+            raise ValueError("物料编码已存在")
+        obj.material_code = code
+    if "materialName" in data and data["materialName"] is not None:
+        name = data["materialName"].strip()
+        if not name:
+            raise ValueError("物料名称不能为空")
+        obj.material_name = name
+    if "category" in data:
+        obj.category = (data["category"] or "").strip() or None
+    if "specModel" in data:
+        obj.spec_model = (data["specModel"] or "").strip() or None
+    if "unit" in data:
+        obj.unit = (data["unit"] or "").strip() or None
+    if "status" in data and data["status"] is not None:
+        st = data["status"].strip()
+        if st not in ("0", "1"):
+            raise ValueError("状态无效")
+        obj.status = st
+    if "imageUrls" in data:
+        obj.image_urls = (data["imageUrls"] or "").strip() or None
+    if "orderNum" in data:
+        obj.order_num = data["orderNum"]
+    if "remark" in data:
+        obj.remark = (data["remark"] or "").strip() or None
+
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+    db.commit()
+    db.refresh(obj)
+    return _row_to_dict(obj)
+
+
+def soft_delete_material(
+    db: Session,
+    material_id: str,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+) -> None:
+    obj = (
+        db.query(BizMaterial)
+        .filter(
+            BizMaterial.id == material_id,
+            BizMaterial.tenant_no == tenant_no,
+            BizMaterial.del_flag == 0,
+        )
+        .first()
+    )
+    if not obj:
+        raise ValueError("物料不存在")
+    if obj.stock_qty is not None and _dec(obj.stock_qty) > 0:
+        raise ValueError("该物料仍有库存,无法删除")
+    obj.del_flag = 1
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+    db.commit()
+
+
+def stock_in(
+    db: Session,
+    material_id: str,
+    *,
+    qty: float,
+    remark: Optional[str] = None,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    dept_id: str = "",
+) -> Dict[str, Any]:
+    if qty is None or qty <= 0:
+        raise ValueError("入库数量必须大于0")
+    qty_dec = Decimal(str(qty))
+
+    obj = (
+        db.query(BizMaterial)
+        .filter(
+            BizMaterial.id == material_id,
+            BizMaterial.tenant_no == tenant_no,
+            BizMaterial.del_flag == 0,
+        )
+        .with_for_update()
+        .first()
+    )
+    if not obj:
+        raise ValueError("物料不存在")
+
+    stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
+    stock_after = stock_before + qty_dec
+    obj.stock_qty = stock_after
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+
+    log = BizMaterialStockLog(
+        id=generate_uuid(),
+        material_id=obj.id,
+        material_code=obj.material_code,
+        material_name=obj.material_name,
+        change_type="rk",
+        change_qty=qty_dec,
+        stock_before=stock_before,
+        stock_after=stock_after,
+        tenant_no=tenant_no,
+        remark=(remark or "").strip() or None,
+        create_by=user_id,
+        create_dept_id=dept_id or "",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(log)
+    db.commit()
+    db.refresh(obj)
+    return _row_to_dict(obj)
+
+
+def stock_out(
+    db: Session,
+    material_id: str,
+    *,
+    qty: float,
+    receiver: Optional[str] = None,
+    purpose: Optional[str] = None,
+    remark: Optional[str] = None,
+    tenant_no: str,
+    user_id: Optional[str] = None,
+    dept_id: str = "",
+) -> Dict[str, Any]:
+    if qty is None or qty <= 0:
+        raise ValueError("出库数量必须大于0")
+    qty_dec = Decimal(str(qty))
+
+    obj = (
+        db.query(BizMaterial)
+        .filter(
+            BizMaterial.id == material_id,
+            BizMaterial.tenant_no == tenant_no,
+            BizMaterial.del_flag == 0,
+        )
+        .with_for_update()
+        .first()
+    )
+    if not obj:
+        raise ValueError("物料不存在")
+
+    stock_before = obj.stock_qty if obj.stock_qty is not None else Decimal("0")
+    if stock_before < qty_dec:
+        raise ValueError(
+            f"物料「{obj.material_name}」库存不足,当前库存 {stock_before},需出库 {qty_dec}"
+        )
+
+    stock_after = stock_before - qty_dec
+    obj.stock_qty = stock_after
+    obj.update_by = user_id
+    obj.update_time = datetime.now()
+    db.add(obj)
+
+    log = BizMaterialStockLog(
+        id=generate_uuid(),
+        material_id=obj.id,
+        material_code=obj.material_code,
+        material_name=obj.material_name,
+        change_type="ck",
+        change_qty=qty_dec,
+        stock_before=stock_before,
+        stock_after=stock_after,
+        receiver=(receiver or "").strip() or None,
+        purpose=(purpose or "").strip() or None,
+        tenant_no=tenant_no,
+        remark=(remark or "").strip() or None,
+        create_by=user_id,
+        create_dept_id=dept_id or "",
+        create_time=datetime.now(),
+        update_by=user_id,
+        update_time=datetime.now(),
+        del_flag=0,
+    )
+    db.add(log)
+    db.commit()
+    db.refresh(obj)
+    return _row_to_dict(obj)
+
+
+def options_list(db: Session, tenant_no: str) -> List[Dict[str, Any]]:
+    rows = (
+        db.query(BizMaterial)
+        .filter(
+            BizMaterial.tenant_no == tenant_no,
+            BizMaterial.del_flag == 0,
+            BizMaterial.status == "1",
+        )
+        .order_by(BizMaterial.create_time.desc())
+        .all()
+    )
+    return [
+        {
+            "id": r.id,
+            "label": r.material_name,
+            "materialCode": r.material_code,
+            "materialName": r.material_name,
+            "specModel": r.spec_model or "",
+            "unit": r.unit or "",
+            "stockQty": _dec(r.stock_qty),
+        }
+        for r in rows
+    ]

+ 88 - 0
backend/app/crud/material_stock_log.py

@@ -0,0 +1,88 @@
+"""市场物料库存变动记录 CRUD"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Dict, List, Optional, Tuple
+
+from sqlalchemy import func, or_
+from sqlalchemy.orm import Session
+
+from app.models.material import BizMaterialStockLog
+
+
+def _dec(v: Any) -> float:
+    if v is None:
+        return 0.0
+    return float(v)
+
+
+def _fmt_dt(t: Optional[datetime]) -> str:
+    return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
+
+
+def _row_to_dict(r: BizMaterialStockLog) -> Dict[str, Any]:
+    return {
+        "id": r.id,
+        "materialId": r.material_id,
+        "materialCode": r.material_code or "",
+        "materialName": r.material_name or "",
+        "changeType": r.change_type,
+        "changeQty": _dec(r.change_qty),
+        "stockBefore": _dec(r.stock_before),
+        "stockAfter": _dec(r.stock_after),
+        "receiver": r.receiver or "",
+        "purpose": r.purpose or "",
+        "remark": r.remark or "",
+        "createBy": r.create_by or "",
+        "createTime": _fmt_dt(r.create_time),
+        "updateTime": _fmt_dt(r.update_time),
+    }
+
+
+def list_stock_logs(
+    db: Session,
+    tenant_no: str,
+    *,
+    page: int = 1,
+    page_size: int = 10,
+    material_id: Optional[str] = None,
+    change_type: Optional[str] = None,
+    start_time: Optional[str] = None,
+    end_time: Optional[str] = None,
+    keyword: Optional[str] = None,
+) -> Tuple[List[Dict[str, Any]], int]:
+    def _filtered():
+        q0 = db.query(BizMaterialStockLog).filter(
+            BizMaterialStockLog.tenant_no == tenant_no,
+            BizMaterialStockLog.del_flag == 0,
+        )
+        if material_id:
+            q0 = q0.filter(BizMaterialStockLog.material_id == material_id)
+        if change_type:
+            q0 = q0.filter(BizMaterialStockLog.change_type == change_type)
+        if start_time:
+            q0 = q0.filter(BizMaterialStockLog.create_time >= start_time)
+        if end_time:
+            q0 = q0.filter(BizMaterialStockLog.create_time <= end_time + " 23:59:59")
+        kw = (keyword or "").strip()
+        if kw:
+            like = f"%{kw}%"
+            q0 = q0.filter(
+                or_(
+                    BizMaterialStockLog.material_code.ilike(like),
+                    BizMaterialStockLog.material_name.ilike(like),
+                )
+            )
+        return q0
+
+    total = _filtered().order_by(None).with_entities(func.count(BizMaterialStockLog.id)).scalar() or 0
+    page = max(1, page)
+    page_size = min(max(1, page_size), 100)
+    rows = (
+        _filtered()
+        .order_by(BizMaterialStockLog.create_time.desc())
+        .offset((page - 1) * page_size)
+        .limit(page_size)
+        .all()
+    )
+    return [_row_to_dict(x) for x in rows], int(total)

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/0178df3ca76f029df4c11407595898cd91ddbe9d68c0bb316ddcfcc3f788ac73.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/045052fe5369ece329b23a748c94e4de1fbb6c59ea7afd59230b1888d1c0a470.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/05307647aed22ccf9f225f623739f1116636628afa439f3ec357b3a0dafbe241.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/05364d36a2efb2a71169a4fa7f61fd439df1260b406869a3936b505b425f033d.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/07443951fa7de5dee70deb00908c0aa7ce6aea015595194ae50a1c76e39de492.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/07bc7a7ddf530664696a90fe692d077fc3944ff342fde2e182da4b1aa3f5dc75.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/092d1c08f225cb57a26b1d5e4cbec0707c3751b38b8db1aebb60415854e4f830.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/0a07933b219fe718d4f7e3acb642381eef869498a72eef7257183768c2e56eae.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/0afdc4fa3b8340739e06edddedc825a5cb64f44f978d8b5a6a13c7a5f950122c.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/12b63f4afbbf91165f3174cbd703a98fe92db546a2c81872237e8bff0ecd25c4.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/135567a524fdf022e399755fb2e64b36db6a7f02f9406c74d81a28a44b635b98.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/150b60fb97d8bbb3107f2dfd0a089dcd276132f15f000173f8af26ec3b5dbda1.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/1601fe88cbf53e11f66d3981def3f167552a4818bfdcbbe072403a044a695f8a.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/16c3e73e0fa77ace1af5ec44e95d40ea51ce3b2a3cafe857737f28ef8d181598.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/1881408bb1c98cbfcd34aa2a6452a690ea6b26cfc1fdd4de64ed005067700319.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/1b70e125bf6e8fb78f1c9a20f0789fa7898af19c9968fa7e9501834beadc5696.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/1cdcbb28e2071644e74c7b816869e233d22c0e806c5d3b2137daa788ccfec9a4.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/21f07c6b3c5ab8c3302649e2f79cfc6c9c31d57d309b82c9f54086bfadbe418a.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/255da3108d080bb80d6973d33b864acf740e7ec56b406ea6edf6dee41b054252.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/257f0e5abff51335304551f04b1a2c9b0cbfabcf2d8a56d94467b403d337cf19.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/26ffc9fabaac1bf76730bb7dddfa9d1ac81b597cb3c1cd098f526fd52fb11735.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/282dd7ca5d5f8645064c9c3ca4b501e9e5c8572478dee9abc53235d388475766.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/28ffa3438e4362811e3df070a3670805eca8a30d66f7aac8c446068f47e18d39.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/2da0936a5b1b96286f9cba7dd00a2257b6b9282fd01566f4be9c64321a309c32.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/2ff902e250ff46ec72f6b168eb0eb5f8d71c8d2a2151dfac9c241d519eef7b7e.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/300d8b31bb5f2e3db142c9cc9546fc974f7f4442a6efc6b6a06ecc6eba250e72.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/325036d20e45dda5d81094fa35e9085a7593c7830b4caa102742c2404b2870f6.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/334897822081300d1f8ac677ab825d2c81bd11cdbf3eaf7afaaffbb6729b76e8.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/34c91b5b7b1cb22024e3ca1f72c9cf698609152c3a6c1dc2a2243c9d45b356c4.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/357cb27e5e365f488479dace850cbc2c1357414409aa7c81d8448c6ebb28e955.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/35e1352500deef25d166d457f52d6708ed827e59f89d423f7c82d188652c42d8.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/37c9c6b195bb9b47f95ade2d9114686200b1a374b0afd17e62f5634cc7152f14.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/3c1905011a6fad7489031e1a4a7120dc3e3b4e669c78aa0c0acf59f7cfb7b76c.json


+ 1 - 0
backend/app/graphify-out/cache/ast/3d85793ab397a20a6e0b5960b670396021fac1c17998579b215792cb0eaa1552.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_backend_app_ws_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/ws/__init__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/450fa5eaa0cc216976c426a28ece25b283dc96655523093e19b4427698483768.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/49d5f051daede00165d6e7e084f67db600fe91b2f4e3624fc67e8a9af0864c2f.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/4b12a9e6f80f19eeba0caa25dcea2d45217d5d85c5b470699e52d206e583dd52.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/4b3bd787d944c1631a3604e3276befffc290baae04e3685e0c1581f3986b739e.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/4fab2632bf987e4dbfebd3f649428f7c89f1cf458f991ad0956e6412625c36a4.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/4fb5baf19330fb9ed3f29ff3e336499bef030e4b77a138e05a1976e3a263af7e.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/5017c940e4999768e847f7db39ca383459b2e560fee5f9385d522feac08d57d3.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/5090815294183f68b1208711faf0aa7b61597f19383287b988f037257ab8034e.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/50b631a62d24f7108bf28781c5db8af317d9448c8c4594f1c95c91fa1bcb3849.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/51d7b154b1ee7f89ccee4ea04c9e4a5dda25a1646967d31cffa36bd9c066b124.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/51fd080dbf5796af672fff3627889f36c6a388882b942cb6a58b387fae83e6f0.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/537a96e63491637b5c800dcda89eb6aba7d09e9e08e81d2abe234cdf72683bc6.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/53b17c25bed69f45d534d07003cbf06503373f4801e31835dcf85fd15b3660aa.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/55267ce13b8d6768da12536c2a10b11bff031da7cf3311c7ada330fcc46b33ce.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/55cc6c7750537854b5e6ddd6249f3c7f4944ae4167db77ac423c39b9f3089ace.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/56f08be62f0aeecc65b9510e36324b8139b6ee0a896a3c28dd1560bc811154c5.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/5a0402b996a63f90f21df8bfd84052bb7bd6440d2b0d7b6647fa46df0b6631f3.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/5b536b1be9a31a0baf6a147156f2841247b901cfd386245580719a6e0fd2aaee.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/5d4e7f676d2e629cf3c92554dc5e40ea6f8efb963cfcb8866c2c88c3e415c6d8.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/5f47d4afc4794e6df350dffdec5fa7977064d47990787e5e586156eace8ff1d7.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/6150e65d9877f868cb6742b6530fa644091bc5a0376e0a192190faafe6daa933.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/61b68027aff5ce72e8b59430eea881f3355c868798202e4ad51c1935499fd0be.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/660492eaf04d94ff1a0235f2c5bc6ec0373cdb3593d07933f1b54cf03309ee6e.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/6850589b89ae472c2a2aabbee350b1d6ae6e538f71abcceb5f93c286e47c6f4b.json


+ 1 - 0
backend/app/graphify-out/cache/ast/6ae616cf983837db1d17a61791e2d6e734a057cd7d4068eb7e651623ba234059.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_backend_app_core_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_backend_app_core_init_py", "target": "app_core_config", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/core/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/6c3724498b9b59e7f1f6a2bbbf89ca03d3a3cf58205f6922440781dbd1497296.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/6cb7f310b72a1bc02a03839456b4fe0c4abd81d331130f7e37b3b5105c6b1e1f.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/6f83a4a01b5c5010c1d7652ff64fde62e22c21abcdc93b7ee31a95b9af4332b3.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/6fb487984c414435fab136b7a68060fa11ff0d789d0c00f8f747bd609cb49ff1.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/70394a75d42c514cc5a85e98435790466d5cbfe8db2ffaa0907317ed0ef3d732.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/71ba712ac2f187506587936dbf8192b2cee617e6c209ba9dfde4833804fa0373.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/749a2df036ddbffeb70d0801d6482006b2d9f19a1b9404168b0e3b02637a5f98.json


+ 1 - 0
backend/app/graphify-out/cache/ast/74c312d82842ea3fa3b9b1a0b8c50911e36ba9082c14135bf028ca9f310fc493.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_backend_app_crud_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/crud/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_backend_app_crud_init_py", "target": "app_crud_base", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/crud/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_backend_app_crud_init_py", "target": "app_crud_user", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/crud/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/751c25b78a918ca3737d4fb9f8d80543ef0808e7340bd9d619f83d41b2966d18.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/7aaacbf972c21d9fd0b25c9a3466d7ac07cd9449dbc7ac96b59bfbd53ee67ed5.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/7e504aaa4ae8ec0b70adf3c455095f7bd795f5e4e1c6a53c255b5e78cdaba84b.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/7e7bd8a8e151a0da55a3465a8834b584753dbbb62b7c3c8b3cd80d5e63f7d654.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/8492a697a4d2fa79067b0c1231860c54e0c8378bce80be71a9ce42ec6d83515e.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/84ec888699750105783bc19a9a41c5b58e209bc711dde9101abb4907094a40b6.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/862aafed13413ed24a650dedbb5b6244c8cc42260f4acc86aac02890c21ee928.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/866afe02cfb7f6199669bcb73bf5bcb323499d8e03b8cc35f1a8e09cbde355ce.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/88cf4251ce14e07ce042bb7aa2b2baa222bceb93f9c26c37906c2204cec00c50.json


+ 1 - 0
backend/app/graphify-out/cache/ast/8a0a7e4881b81c61129594c1c76d15aaaf192c0d1b79025bebbcf6eab9d64ffd.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_backend_app_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/__init__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}

+ 1 - 0
backend/app/graphify-out/cache/ast/8a4e74a8348e05edd6aeb8d6c4caa6832dc7406cc8506c7dd3a5afd61a4f7875.json

@@ -0,0 +1 @@
+{"nodes": [{"id": "users_wuzilong_work_00code_zkxw_backend_app_schemas_attachment_py", "label": "attachment.py", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/attachment.py", "source_location": "L1"}, {"id": "schemas_attachment_attachmentuploadresponse", "label": "AttachmentUploadResponse", "file_type": "code", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/attachment.py", "source_location": "L5"}, {"id": "basemodel", "label": "BaseModel", "file_type": "code", "source_file": "", "source_location": ""}], "edges": [{"source": "users_wuzilong_work_00code_zkxw_backend_app_schemas_attachment_py", "target": "pydantic", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/attachment.py", "source_location": "L1", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_backend_app_schemas_attachment_py", "target": "typing", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/attachment.py", "source_location": "L2", "weight": 1.0}, {"source": "users_wuzilong_work_00code_zkxw_backend_app_schemas_attachment_py", "target": "schemas_attachment_attachmentuploadresponse", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/attachment.py", "source_location": "L5", "weight": 1.0}, {"source": "schemas_attachment_attachmentuploadresponse", "target": "basemodel", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "/Users/wuzilong/work/00code/zkxw/backend/app/schemas/attachment.py", "source_location": "L5", "weight": 1.0}], "raw_calls": []}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/8c72f7839c6b43d400e6701a25490ffdcd41cc5fc900ab8685ed155d2135067b.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/8d2dff4703d5eb797f397c39fc922145ba7de2c69788ed773c1b883b81fd83f9.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/8edafab3d7e2222c3dbff0fd9b790ec4052ec16f57044ec8c6ad87a6ce1de909.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/92529b2c95133d962dc512483851681c73d90071e84c9b267f84a73d3582216a.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/98812922e59c20bb2dce85bef903c1c44c1db48617f4c72c7e4d32cd202ed881.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/9a4b4620451e9bb60f78a301dc57004e6af12bb3cf7e37b743fd711da44d113f.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/9c915143f69731d63f5dfc9cb169e56c8559d80ebc126bbbb32adbcb545a93da.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/9d16814e695401c424ba90b95ef61942399c0f21d398ddc8cfc7dd60ec0271e3.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/9d57e7ff48e271b88c2feaaa99978fae249a1e6c924c8b2cd2d57a7ddcf9bf03.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/9f36bdc7491fb4af3fdcc29d2eea0075f5b61bf4b060160984bc5c11cdb50311.json


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
backend/app/graphify-out/cache/ast/9f62c771f8d9859d1ae8fc3c5b5005022ee0d858d34a65a98dbc6c95d1e0c6cf.json


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است