Skip to content

Commit 95d9e00

Browse files
committed
refactor: 重构库存模块 - 字段重命名、拼音排序、DataTable组件
- location -> storage_location 字段标准化 - 添加 pypinyin 库支持中文拼音排序 - 新增 DataTable 组件(虚拟滚动表格) - 优化侧边栏折叠动画 - 修复代码重复问题(lint)
1 parent df30f6d commit 95d9e00

34 files changed

+2087
-627
lines changed

.claude/GIT_STRATEGY.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Git 分支管理规范
2+
3+
不要更改此文档!
4+
本项目采用 Git Flow 工作流,包含以下分支类型:
5+
## 注意事项
6+
7+
1. **禁止在 main 分支直接开发**
8+
2. **保持分支原子性**,一个分支只做一件事
9+
3. **定期同步 develop**,避免冲突
10+
4. **使用有意义的提交信息**
11+
12+
## 分支类型
13+
14+
| 分支 | 用途 | 生命周期 | 合并目标 |
15+
|-----|------|---------|---------|
16+
| `main` | 生产环境,包含稳定发布版本 | 长期 | - |
17+
| `develop` | 开发主分支,包含最新开发成果 | 长期 | `main` |
18+
| `feature/*` | 新功能开发 | 临时 | `develop` |
19+
| `release/*` | 发布前准备(bug修复、文档更新) | 临时 | `main` + `develop` |
20+
| `hotfix/*` | 生产环境紧急修复 | 临时 | `main` + `develop` |
21+
22+
## 提交规范
23+
24+
| 类型 | 说明 |
25+
|-----|------|
26+
| `feat` | 新功能 |
27+
| `fix` | Bug修复 |
28+
| `docs` | 文档更新 |
29+
| `refactor` | 代码重构 |
30+
| `perf` | 性能优化 |
31+
| `chore` | 构建/工具变动 |
32+
33+
格式:`类型: 简短描述`
34+
35+
36+
## 命名规范
37+
38+
```
39+
<类型>/<描述>
40+
# 示例
41+
feature/user-login
42+
release/v1.0.0
43+
hotfix/security-fix
44+
```
45+
46+
## 工作流程
47+
48+
### 功能开发 (Feature)
49+
```bash
50+
# 1. 创建功能分支
51+
git checkout develop
52+
git pull origin develop
53+
git checkout -b feature/xxx
54+
55+
# 2. 开发并提交
56+
git commit -m "feat: 新功能描述"
57+
58+
# 3. 合并到 develop
59+
git checkout develop
60+
git merge --no-ff feature/xxx
61+
git push origin develop
62+
```
63+
64+
### 发布准备 (Release)
65+
```bash
66+
# 1. 创建 release 分支
67+
git checkout develop
68+
git checkout -b release/v1.0.0
69+
70+
# 2. 发布前准备
71+
git commit -m "chore: 发布准备"
72+
73+
# 3. 合并到 main 和 develop
74+
git checkout main
75+
git merge --no-ff release/v1.0.0
76+
git tag -a v1.0.0 -m "Release v1.0.0"
77+
git push origin main
78+
79+
git checkout develop
80+
git merge --no-ff release/v1.0.0
81+
git push origin develop
82+
```
83+
84+
### 紧急修复 (Hotfix)
85+
```bash
86+
# 1. 创建 hotfix 分支
87+
git checkout main
88+
git checkout -b hotfix/xxx
89+
90+
# 2. 修复并提交
91+
git commit -m "hotfix: 紧急修复"
92+
93+
# 3. 同时合并到 main 和 develop
94+
git checkout main
95+
git merge --no-ff hotfix/xxx
96+
git push origin main
97+
98+
git checkout develop
99+
git merge --no-ff hotfix/xxx
100+
git push origin develop
101+
```
102+
103+

.claude/Rules.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Rules.md
2+
不要更改此文档!
3+
4+
## System Personality
5+
你是一个专业的 LIMS 系统架构师。你注重数据的准确性(CAS号)、系统的响应速度(WAL模式)和操作的便捷性(Dashboard优先)。你的思考和回答都要用中文。
6+
7+
## Critical Rules (Must Follow)
8+
1. **Concurrency**: 初始化 SQLite 时必须启用 **WAL Mode**
9+
2. **CAS Normalization**: 所有涉及 CAS 号的输入,必须在后端进行标准化清洗(去除空格、大写)。这是系统的防重基石。
10+
3. **Image Optimization**: 禁止将图片存入数据库 Blob。必须在后端使用 Pillow 压缩至 <100KB 并存入文件系统。
11+
4. **No Mobile Dependency**: 系统设计不依赖扫码枪或手机摄像头。所有流程闭环在 PC/平板 Web 端完成。
12+
5. **Git Commit**: 完成重大修改后,切换到Review模式在当前对话进行Code Review,给出报告与我进行讨论,讨论完成后更新`Readme.md`,更新`milestone.md`,之后执行git提交,注意分支和提交信息。
13+
6. **Chinese**: 前端使用中文展示(除英文名称等),后端保存用英文方便管理(除中文名称等),因此需要添加映射表
14+
7. **Debug**:我会将我自己发现的问题记录在 `BUGS.md`,在debug时请做好相关记录,你进行代码审查时发现的问题也要写入此文档
15+
8. **Dark Mode**: 前端开发必须使用语义化颜色(bg-background, bg-card, bg-muted, text-foreground, text-muted-foreground 等),禁止使用硬编码颜色(bg-gray-*, bg-white, text-gray-* 等),确保暗黑模式支持。
16+
17+
## Critical Logic
18+
1. **一键入库**: 在实现 Order 到 Inventory 的转换时,必须确保是 Copy 数据而不是 Move,保留 Order 记录用于审计。
19+
2. **图片处理**: 图片上传后重命名(UUID),存入 `/static`,数据库只存 URL。
20+
3. **权限**: 凡是修改数据的接口,必须检查 `current_user`
21+
22+
## Tech Context
23+
* FastAPI, SQLModel, SQLite
24+
* React, Shadcn/UI, TanStack Table
25+
* Pillow, Pandas
26+
27+
**重要**:每次开始编写代码前,先阅读 `Progress.md` 确认当前步骤和计划。在写任何代码之前,在 Architect 模式下无尽地审问我的想法。不要假设任何问题,问问题直到没有疑问剩下,并根据每次讨论更新对应的Prompt文件夹的相关md文档。每完成一个步骤,更新`Progress.md`,并在文档后添加更新时间线和Plans,更新`IMPLEMENTATION_PLAN.md`。每次犯错修复后,把教训写进 `Lessons.md`,之后遇到类似问题先学习教训。

.kilocode/mcp.json

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1 @@
1-
{
2-
"mcpServers": {
3-
"filesystem": {
4-
"command": "npx",
5-
"args": [
6-
"-y",
7-
"@modelcontextprotocol/server-filesystem",
8-
"D:\\Code"
9-
],
10-
"disabled": false,
11-
"alwaysAllow": []
12-
}
13-
}
14-
}
1+
{"mcpServers":{"filesystem":{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","D:\\Code"],"disabled":false,"alwaysAllow":["read_text_file","read_multiple_files","read_file"]}}}

app/api/inventory.py

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88
import csv
99
import io
10+
from pypinyin import lazy_pinyin
1011
import logging
1112
import os
1213
import tempfile
@@ -15,7 +16,7 @@
1516
from typing import Optional, Dict, Any
1617
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
1718
from fastapi.responses import StreamingResponse
18-
from sqlmodel import Session, select, func
19+
from sqlmodel import Session, select, func, case
1920

2021
from app.database import get_db
2122
from app.models.inventory import (
@@ -35,6 +36,18 @@
3536

3637
logger = logging.getLogger(__name__)
3738

39+
40+
def _to_pinyin_sort_key(text: str) -> str:
41+
"""
42+
将文本转换为拼音排序键
43+
用于中文按拼音排序,将中文转换为对应的拼音字母序列
44+
"""
45+
if not text:
46+
return ''
47+
# 使用 lazpinyin 获取拼音首字母,风格为普通风格(不带声调)
48+
pinyin_list = lazy_pinyin(text, style=0) # Style 0 = NORMAL
49+
return ''.join(pinyin_list)
50+
3851
router = APIRouter(prefix="/inventory", tags=["Inventory"])
3952

4053
# ==================== File Upload Security ====================
@@ -284,7 +297,7 @@ def check_cas_inventory(
284297
{
285298
"id": item.id,
286299
"name": item.name,
287-
"location": item.location,
300+
"storage_location": item.storage_location,
288301
"remaining_quantity": item.remaining_quantity,
289302
"unit": item.unit,
290303
"status": item.status,
@@ -368,7 +381,7 @@ def export_inventory(
368381
item.alias or "",
369382
item.category or "",
370383
item.brand or "",
371-
item.location or "",
384+
item.storage_location or "",
372385
item.initial_quantity,
373386
item.remaining_quantity,
374387
item.unit,
@@ -425,7 +438,7 @@ def manual_add_inventory(
425438
alias=item_data.alias,
426439
category=item_data.category,
427440
brand=item_data.brand,
428-
location=item_data.location,
441+
storage_location=item_data.storage_location,
429442
initial_quantity=per_bottle_value,
430443
remaining_quantity=per_bottle_value,
431444
unit=unit,
@@ -492,9 +505,9 @@ def get_pending_stockin(
492505
current_user: User = Depends(get_current_user),
493506
db: Session = Depends(get_db),
494507
):
495-
"""Get items pending location assignment (temporary keeper = current user)."""
508+
"""Get items pending storage_location assignment (temporary keeper = current user)."""
496509
statement = select(Inventory).where(
497-
Inventory.location is None,
510+
Inventory.storage_location is None,
498511
Inventory.temporary_keeper_id == current_user.id,
499512
).order_by(Inventory.created_at.desc())
500513

@@ -528,7 +541,7 @@ def get_import_template():
528541
@router.post("/import")
529542
def import_inventory(
530543
file: UploadFile = File(...),
531-
default_location: Optional[str] = None,
544+
default_storage_location: Optional[str] = None,
532545
default_is_hazardous: bool = False,
533546
admin_user: User = Depends(require_admin),
534547
db: Session = Depends(get_db),
@@ -547,7 +560,7 @@ def import_inventory(
547560
result = import_inventory_from_excel(
548561
db=db,
549562
file_path=tmp_file_path,
550-
default_location=default_location,
563+
default_storage_location=default_storage_location,
551564
default_is_hazardous=default_is_hazardous,
552565
user_id=admin_user.id,
553566
)
@@ -575,21 +588,23 @@ def import_inventory(
575588
@router.get("/")
576589
def list_inventory(
577590
skip: int = 0,
578-
limit: int = 50,
591+
limit: int = 0, # 0 表示不分页,返回全部数据
579592
status_filter: Optional[InventoryStatus] = None,
580593
cas_filter: Optional[str] = None,
581594
hazardous_only: bool = False,
582595
search: Optional[str] = None,
583596
search_field: Optional[str] = None, # 精确搜索指定字段
584597
fuzzy: bool = False, # 模糊搜索(忽略空格和连字符)
598+
sort_by: Optional[str] = None, # 排序字段
599+
sort_order: Optional[str] = 'desc', # 排序方向:asc 或 desc
585600
db: Session = Depends(get_db),
586601
):
587602
"""List inventory with optional filters and pagination"""
588-
# 生成缓存key(包含所有搜索参数,包括分页
589-
cache_key = f"list:{skip}:{limit}:{search or ''}:{status_filter or ''}:{cas_filter or ''}:{hazardous_only}:{search_field or ''}:{fuzzy}"
603+
# 生成缓存key(包含所有搜索参数,包括分页和排序
604+
cache_key = f"list:{skip}:{limit}:{search or ''}:{status_filter or ''}:{cas_filter or ''}:{hazardous_only}:{search_field or ''}:{fuzzy}:{sort_by or ''}:{sort_order or ''}"
590605

591-
# 尝试从缓存获取(仅当是第一页时
592-
if skip == 0:
606+
# 尝试从缓存获取(仅当是不分页查询或第一页时
607+
if limit == 0 or skip == 0:
593608
cached = _get_cached_result(cache_key)
594609
if cached is not None:
595610
# 返回缓存结果,但更新分页参数
@@ -633,7 +648,7 @@ def norm_field(field):
633648
base = base.where(
634649
(norm_field(Inventory.cas_number).ilike(f"%{search_normalized}%")) |
635650
(norm_field(Inventory.name).ilike(f"%{search_normalized}%")) |
636-
(norm_field(Inventory.location).ilike(f"%{search_normalized}%")) |
651+
(norm_field(Inventory.storage_location).ilike(f"%{search_normalized}%")) |
637652
(norm_field(Inventory.brand).ilike(f"%{search_normalized}%")) |
638653
(norm_field(Inventory.category).ilike(f"%{search_normalized}%"))
639654
)
@@ -645,7 +660,7 @@ def norm_field(field):
645660
field_map = {
646661
'name': Inventory.name,
647662
'cas_number': Inventory.cas_number,
648-
'location': Inventory.location,
663+
'storage_location': Inventory.storage_location,
649664
'brand': Inventory.brand,
650665
'category': Inventory.category,
651666
}
@@ -656,7 +671,7 @@ def norm_field(field):
656671
base = base.where(
657672
(Inventory.name.ilike(search_pattern)) |
658673
(Inventory.cas_number.ilike(search_pattern)) |
659-
(Inventory.location.ilike(search_pattern)) |
674+
(Inventory.storage_location.ilike(search_pattern)) |
660675
(Inventory.brand.ilike(search_pattern)) |
661676
(Inventory.category.ilike(search_pattern))
662677
)
@@ -665,13 +680,77 @@ def norm_field(field):
665680
base = base.where(
666681
(Inventory.name.ilike(search_pattern)) |
667682
(Inventory.cas_number.ilike(search_pattern)) |
668-
(Inventory.location.ilike(search_pattern)) |
683+
(Inventory.storage_location.ilike(search_pattern)) |
669684
(Inventory.brand.ilike(search_pattern)) |
670685
(Inventory.category.ilike(search_pattern))
671686
)
672687

673688
total = db.exec(select(func.count()).select_from(base.subquery())).one()
674-
items = db.exec(base.order_by(Inventory.created_at.desc()).offset(skip).limit(limit)).all()
689+
690+
# 构建排序表达式
691+
# 支持的排序字段映射
692+
# 使用 CASE 表达式处理 initial_quantity 为 0 的情况,避免除零错误
693+
from sqlmodel import case as sql_case
694+
695+
# 计算剩余百分比(处理除零情况)
696+
remaining_percent_expr = sql_case(
697+
(Inventory.initial_quantity > 0, Inventory.remaining_quantity * 1.0 / Inventory.initial_quantity),
698+
else_=0
699+
)
700+
701+
sort_field_map = {
702+
'cas_number': Inventory.cas_number,
703+
'name': Inventory.name,
704+
'category': Inventory.category,
705+
'storage_location': Inventory.storage_location,
706+
'brand': Inventory.brand,
707+
'remaining_quantity': Inventory.remaining_quantity,
708+
'remaining_percent': remaining_percent_expr,
709+
'initial_quantity': Inventory.initial_quantity,
710+
'status': Inventory.status,
711+
'created_at': Inventory.created_at,
712+
'updated_at': Inventory.updated_at,
713+
}
714+
715+
# 确定排序字段和方向
716+
order_column = sort_field_map.get(sort_by, Inventory.created_at)
717+
order_direction = sort_order.lower() if sort_order else 'desc'
718+
719+
# 中文拼音排序字段列表
720+
pinyin_sort_fields = {'name', 'category', 'brand', 'alias'}
721+
722+
# 判断是否需要使用拼音排序
723+
use_pinyin_sort = sort_by in pinyin_sort_fields
724+
725+
logger.info(f"[SORT DEBUG] sort_by={sort_by}, sort_order={sort_order}, order_column={order_column}, order_direction={order_direction}, use_pinyin_sort={use_pinyin_sort}")
726+
727+
if use_pinyin_sort:
728+
# 中文拼音排序:先按拼音键排序
729+
pinyin_key_field = f"{sort_by}_pinyin"
730+
logger.info(f"[PINYIN SORT] Using pinyin sorting for field: {sort_by}")
731+
732+
if order_direction == 'asc':
733+
order_expr = order_column.asc()
734+
else:
735+
order_expr = order_column.desc()
736+
737+
# 如果 limit 为 0,不使用分页,返回全部数据
738+
# 如果需要拼音排序,先获取全部数据再在 Python 中排序
739+
if limit == 0 or use_pinyin_sort:
740+
items = db.exec(base.order_by(order_expr)).all()
741+
if use_pinyin_sort:
742+
# Python 端拼音排序
743+
logger.info(f"[PINYIN SORT] Performing Python-side pinyin sorting for field: {sort_by}")
744+
reverse = order_direction == 'desc'
745+
# 使用 pypinyin 转换键进行排序
746+
items = sorted(items, key=lambda x: _to_pinyin_sort_key(getattr(x, sort_by) or ''), reverse=reverse)
747+
# 如果有 limit,应用分页
748+
if limit > 0:
749+
items = items[skip:skip + limit]
750+
elif limit > 0:
751+
items = db.exec(base.order_by(order_expr).offset(skip).limit(limit)).all()
752+
else:
753+
items = db.exec(base.order_by(order_expr)).all()
675754

676755
result = {
677756
"data": [
@@ -683,8 +762,8 @@ def norm_field(field):
683762
"limit": limit,
684763
}
685764

686-
# 缓存第一页结果
687-
if skip == 0:
765+
# 缓存查询结果(不分页或第一页时缓存)
766+
if limit == 0 or skip == 0:
688767
# 缓存时不包含分页参数
689768
cache_data = {
690769
"data": result["data"],

app/api/reagent_orders.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ def stock_in_reagent_order(
509509
alias=order.alias,
510510
category=order.category,
511511
brand=order.brand,
512-
location=None, # No location in new design
512+
storage_location=None, # No storage_location in new design
513513
initial_quantity=per_bottle_value,
514514
remaining_quantity=per_bottle_value,
515515
unit=unit,

0 commit comments

Comments
 (0)