zlwu 5 kuukautta sitten
vanhempi
commit
be4fab78e1
100 muutettua tiedostoa jossa 1960 lisäystä ja 16 poistoa
  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.**
 - **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
 
 - **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.production import router as production_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__ = [
     "public_router",
@@ -29,4 +31,6 @@ __all__ = [
     "research_donation_router",
     "production_router",
     "pilot_service_router",
+    "insurance_company_router",
+    "material_router",
 ]

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

@@ -1,11 +1,12 @@
 """细胞存储 — 采集协议单 / 审核 / 财务确认 / 分期预警 API"""
+from datetime import datetime, timedelta
 from typing import Any, Dict, List, Optional
 
 from fastapi import APIRouter, Depends, Query
 from pydantic import BaseModel, ConfigDict, Field
 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.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.crud import collection_protocol as cp_svc
 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=["细胞存储"])
 
@@ -657,3 +665,253 @@ async def post_refund_confirm(
     except ValueError as 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=["菜单管理"])
 
-# 与 web-admin/src/router/modules/sqxp.ts 一致:工作台、系统管理、基础信息管理 等
+# 与 web-admin/src/router/modules/sqxp.ts 一致:系统管理、基础信息管理 等
 STATIC_MENU_TREE = [
-    {
-        "id": "10000000000000000000000000000001_child",
-        "name": "Console",
-        "path": "/console",
-        "component": "/dashboard/console/index",
-        "meta": {
-            "title": "工作台",
-            "keepAlive": True,
-            "fixedTab": True,
-            "menuCode": "dashboard",
-        },
-    },
     {
         "id": "100",
         "name": "System",

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

@@ -22,6 +22,7 @@ from app.models.models import (
     BizCollectionProtocol,
     BizProtocolAudit,
     BizProtocolCollectionInfo,
+    BizProtocolFullmoonTodo,
     BizProtocolPartyCustomer,
     BizProtocolPayment,
     BizProtocolPaymentDetail,
@@ -2215,3 +2216,107 @@ def upsert_storage_cert(
     db.commit()
     db.refresh(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)

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/0178df3ca76f029df4c11407595898cd91ddbe9d68c0bb316ddcfcc3f788ac73.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/045052fe5369ece329b23a748c94e4de1fbb6c59ea7afd59230b1888d1c0a470.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/05307647aed22ccf9f225f623739f1116636628afa439f3ec357b3a0dafbe241.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/05364d36a2efb2a71169a4fa7f61fd439df1260b406869a3936b505b425f033d.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/07443951fa7de5dee70deb00908c0aa7ce6aea015595194ae50a1c76e39de492.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/07bc7a7ddf530664696a90fe692d077fc3944ff342fde2e182da4b1aa3f5dc75.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/092d1c08f225cb57a26b1d5e4cbec0707c3751b38b8db1aebb60415854e4f830.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/0a07933b219fe718d4f7e3acb642381eef869498a72eef7257183768c2e56eae.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/0afdc4fa3b8340739e06edddedc825a5cb64f44f978d8b5a6a13c7a5f950122c.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/12b63f4afbbf91165f3174cbd703a98fe92db546a2c81872237e8bff0ecd25c4.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/135567a524fdf022e399755fb2e64b36db6a7f02f9406c74d81a28a44b635b98.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/150b60fb97d8bbb3107f2dfd0a089dcd276132f15f000173f8af26ec3b5dbda1.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/1601fe88cbf53e11f66d3981def3f167552a4818bfdcbbe072403a044a695f8a.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/16c3e73e0fa77ace1af5ec44e95d40ea51ce3b2a3cafe857737f28ef8d181598.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/1881408bb1c98cbfcd34aa2a6452a690ea6b26cfc1fdd4de64ed005067700319.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/1b70e125bf6e8fb78f1c9a20f0789fa7898af19c9968fa7e9501834beadc5696.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/1cdcbb28e2071644e74c7b816869e233d22c0e806c5d3b2137daa788ccfec9a4.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/21f07c6b3c5ab8c3302649e2f79cfc6c9c31d57d309b82c9f54086bfadbe418a.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/255da3108d080bb80d6973d33b864acf740e7ec56b406ea6edf6dee41b054252.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/257f0e5abff51335304551f04b1a2c9b0cbfabcf2d8a56d94467b403d337cf19.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/26ffc9fabaac1bf76730bb7dddfa9d1ac81b597cb3c1cd098f526fd52fb11735.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/282dd7ca5d5f8645064c9c3ca4b501e9e5c8572478dee9abc53235d388475766.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/28ffa3438e4362811e3df070a3670805eca8a30d66f7aac8c446068f47e18d39.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/2da0936a5b1b96286f9cba7dd00a2257b6b9282fd01566f4be9c64321a309c32.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/2ff902e250ff46ec72f6b168eb0eb5f8d71c8d2a2151dfac9c241d519eef7b7e.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/300d8b31bb5f2e3db142c9cc9546fc974f7f4442a6efc6b6a06ecc6eba250e72.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/325036d20e45dda5d81094fa35e9085a7593c7830b4caa102742c2404b2870f6.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/334897822081300d1f8ac677ab825d2c81bd11cdbf3eaf7afaaffbb6729b76e8.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/34c91b5b7b1cb22024e3ca1f72c9cf698609152c3a6c1dc2a2243c9d45b356c4.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/357cb27e5e365f488479dace850cbc2c1357414409aa7c81d8448c6ebb28e955.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/35e1352500deef25d166d457f52d6708ed827e59f89d423f7c82d188652c42d8.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/37c9c6b195bb9b47f95ade2d9114686200b1a374b0afd17e62f5634cc7152f14.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 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": []}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/450fa5eaa0cc216976c426a28ece25b283dc96655523093e19b4427698483768.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/49d5f051daede00165d6e7e084f67db600fe91b2f4e3624fc67e8a9af0864c2f.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/4b12a9e6f80f19eeba0caa25dcea2d45217d5d85c5b470699e52d206e583dd52.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/4b3bd787d944c1631a3604e3276befffc290baae04e3685e0c1581f3986b739e.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/4fab2632bf987e4dbfebd3f649428f7c89f1cf458f991ad0956e6412625c36a4.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/4fb5baf19330fb9ed3f29ff3e336499bef030e4b77a138e05a1976e3a263af7e.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/5017c940e4999768e847f7db39ca383459b2e560fee5f9385d522feac08d57d3.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/5090815294183f68b1208711faf0aa7b61597f19383287b988f037257ab8034e.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/50b631a62d24f7108bf28781c5db8af317d9448c8c4594f1c95c91fa1bcb3849.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/51d7b154b1ee7f89ccee4ea04c9e4a5dda25a1646967d31cffa36bd9c066b124.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/51fd080dbf5796af672fff3627889f36c6a388882b942cb6a58b387fae83e6f0.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/537a96e63491637b5c800dcda89eb6aba7d09e9e08e81d2abe234cdf72683bc6.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/53b17c25bed69f45d534d07003cbf06503373f4801e31835dcf85fd15b3660aa.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/55267ce13b8d6768da12536c2a10b11bff031da7cf3311c7ada330fcc46b33ce.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/55cc6c7750537854b5e6ddd6249f3c7f4944ae4167db77ac423c39b9f3089ace.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/56f08be62f0aeecc65b9510e36324b8139b6ee0a896a3c28dd1560bc811154c5.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/5a0402b996a63f90f21df8bfd84052bb7bd6440d2b0d7b6647fa46df0b6631f3.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/5b536b1be9a31a0baf6a147156f2841247b901cfd386245580719a6e0fd2aaee.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/5d4e7f676d2e629cf3c92554dc5e40ea6f8efb963cfcb8866c2c88c3e415c6d8.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/5f47d4afc4794e6df350dffdec5fa7977064d47990787e5e586156eace8ff1d7.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/6150e65d9877f868cb6742b6530fa644091bc5a0376e0a192190faafe6daa933.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/61b68027aff5ce72e8b59430eea881f3355c868798202e4ad51c1935499fd0be.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/660492eaf04d94ff1a0235f2c5bc6ec0373cdb3593d07933f1b54cf03309ee6e.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 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": []}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/6c3724498b9b59e7f1f6a2bbbf89ca03d3a3cf58205f6922440781dbd1497296.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/6cb7f310b72a1bc02a03839456b4fe0c4abd81d331130f7e37b3b5105c6b1e1f.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/6f83a4a01b5c5010c1d7652ff64fde62e22c21abcdc93b7ee31a95b9af4332b3.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/6fb487984c414435fab136b7a68060fa11ff0d789d0c00f8f747bd609cb49ff1.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/70394a75d42c514cc5a85e98435790466d5cbfe8db2ffaa0907317ed0ef3d732.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/71ba712ac2f187506587936dbf8192b2cee617e6c209ba9dfde4833804fa0373.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 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": []}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/751c25b78a918ca3737d4fb9f8d80543ef0808e7340bd9d619f83d41b2966d18.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/7aaacbf972c21d9fd0b25c9a3466d7ac07cd9449dbc7ac96b59bfbd53ee67ed5.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/7e504aaa4ae8ec0b70adf3c455095f7bd795f5e4e1c6a53c255b5e78cdaba84b.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/7e7bd8a8e151a0da55a3465a8834b584753dbbb62b7c3c8b3cd80d5e63f7d654.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/8492a697a4d2fa79067b0c1231860c54e0c8378bce80be71a9ce42ec6d83515e.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/84ec888699750105783bc19a9a41c5b58e209bc711dde9101abb4907094a40b6.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/862aafed13413ed24a650dedbb5b6244c8cc42260f4acc86aac02890c21ee928.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/866afe02cfb7f6199669bcb73bf5bcb323499d8e03b8cc35f1a8e09cbde355ce.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 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": []}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/8c72f7839c6b43d400e6701a25490ffdcd41cc5fc900ab8685ed155d2135067b.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/8d2dff4703d5eb797f397c39fc922145ba7de2c69788ed773c1b883b81fd83f9.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/8edafab3d7e2222c3dbff0fd9b790ec4052ec16f57044ec8c6ad87a6ce1de909.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/92529b2c95133d962dc512483851681c73d90071e84c9b267f84a73d3582216a.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/98812922e59c20bb2dce85bef903c1c44c1db48617f4c72c7e4d32cd202ed881.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/9a4b4620451e9bb60f78a301dc57004e6af12bb3cf7e37b743fd711da44d113f.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/9c915143f69731d63f5dfc9cb169e56c8559d80ebc126bbbb32adbcb545a93da.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/9d16814e695401c424ba90b95ef61942399c0f21d398ddc8cfc7dd60ec0271e3.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/9d57e7ff48e271b88c2feaaa99978fae249a1e6c924c8b2cd2d57a7ddcf9bf03.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/9f36bdc7491fb4af3fdcc29d2eea0075f5b61bf4b060160984bc5c11cdb50311.json


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
backend/app/graphify-out/cache/ast/9f62c771f8d9859d1ae8fc3c5b5005022ee0d858d34a65a98dbc6c95d1e0c6cf.json


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä