77"""
88import csv
99import io
10+ from pypinyin import lazy_pinyin
1011import logging
1112import os
1213import tempfile
1516from typing import Optional , Dict , Any
1617from fastapi import APIRouter , Depends , HTTPException , status , UploadFile , File
1718from fastapi .responses import StreamingResponse
18- from sqlmodel import Session , select , func
19+ from sqlmodel import Session , select , func , case
1920
2021from app .database import get_db
2122from app .models .inventory import (
3536
3637logger = 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+
3851router = 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" )
529542def 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 ("/" )
576589def 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" ],
0 commit comments