routers.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query
  2. from fastapi.responses import StreamingResponse
  3. from sqlalchemy.orm import Session
  4. from sqlalchemy import or_
  5. from typing import Optional
  6. import io
  7. from datetime import datetime
  8. from urllib.parse import quote
  9. from database import get_db
  10. from models import Inventory, TransactionLog
  11. from schemas import InventoryCreate, InventoryUpdate, StockOperation
  12. import openpyxl
  13. router = APIRouter()
  14. def inventory_to_dict(item: Inventory) -> dict:
  15. """将Inventory模型转为字典"""
  16. return {
  17. "id": item.id,
  18. "cInvCode": item.cInvCode,
  19. "supplier": item.supplier,
  20. "casing_label_remark": item.casing_label_remark,
  21. "batch": item.batch,
  22. "current_remaining": item.current_remaining,
  23. "storage_location": item.storage_location,
  24. "created_at": item.created_at.isoformat() if item.created_at else None,
  25. "updated_at": item.updated_at.isoformat() if item.updated_at else None,
  26. }
  27. def log_to_dict(log: TransactionLog) -> dict:
  28. """将TransactionLog模型转为字典"""
  29. return {
  30. "id": log.id,
  31. "inventory_id": log.inventory_id,
  32. "cInvCode": log.cInvCode,
  33. "type": log.type,
  34. "quantity": log.quantity,
  35. "remark": log.remark,
  36. "created_at": log.created_at.isoformat() if log.created_at else None,
  37. }
  38. # ===== 库存 CRUD =====
  39. @router.get("/inventory")
  40. def list_inventory(
  41. page: int = Query(1, ge=1),
  42. page_size: int = Query(20, ge=1, le=100),
  43. search: Optional[str] = None,
  44. db: Session = Depends(get_db)
  45. ):
  46. """获取库存列表,支持分页和搜索"""
  47. query = db.query(Inventory)
  48. if search:
  49. keyword = f"%{search}%"
  50. query = query.filter(
  51. or_(
  52. Inventory.cInvCode.like(keyword),
  53. Inventory.supplier.like(keyword),
  54. Inventory.batch.like(keyword),
  55. Inventory.storage_location.like(keyword),
  56. Inventory.casing_label_remark.like(keyword),
  57. )
  58. )
  59. total = query.count()
  60. items = query.order_by(Inventory.id.asc()).offset((page - 1) * page_size).limit(page_size).all()
  61. return {"total": total, "items": [inventory_to_dict(i) for i in items]}
  62. @router.post("/inventory")
  63. def create_inventory(data: InventoryCreate, db: Session = Depends(get_db)):
  64. """新增库存"""
  65. item = Inventory(**data.model_dump())
  66. db.add(item)
  67. db.commit()
  68. db.refresh(item)
  69. return inventory_to_dict(item)
  70. @router.put("/inventory/{item_id}")
  71. def update_inventory(item_id: int, data: InventoryUpdate, db: Session = Depends(get_db)):
  72. """更新库存"""
  73. item = db.query(Inventory).filter(Inventory.id == item_id).first()
  74. if not item:
  75. raise HTTPException(status_code=404, detail="库存记录不存在")
  76. update_data = data.model_dump(exclude_unset=True)
  77. for key, value in update_data.items():
  78. setattr(item, key, value)
  79. db.commit()
  80. db.refresh(item)
  81. return inventory_to_dict(item)
  82. @router.delete("/inventory/{item_id}")
  83. def delete_inventory(item_id: int, db: Session = Depends(get_db)):
  84. """删除库存"""
  85. item = db.query(Inventory).filter(Inventory.id == item_id).first()
  86. if not item:
  87. raise HTTPException(status_code=404, detail="库存记录不存在")
  88. db.delete(item)
  89. db.commit()
  90. return {"message": "删除成功"}
  91. # ===== 出入库 =====
  92. @router.post("/stock/operation")
  93. def stock_operation(op: StockOperation, db: Session = Depends(get_db)):
  94. """出入库操作"""
  95. item = db.query(Inventory).filter(Inventory.id == op.inventory_id).first()
  96. if not item:
  97. raise HTTPException(status_code=404, detail="库存记录不存在")
  98. if op.type == "out":
  99. if item.current_remaining < op.quantity:
  100. raise HTTPException(status_code=400, detail=f"库存不足,当前剩余: {item.current_remaining}")
  101. item.current_remaining -= op.quantity
  102. elif op.type == "in":
  103. item.current_remaining += op.quantity
  104. else:
  105. raise HTTPException(status_code=400, detail="类型必须是 in 或 out")
  106. # 记录操作日志
  107. log = TransactionLog(
  108. inventory_id=item.id,
  109. cInvCode=item.cInvCode,
  110. type=op.type,
  111. quantity=op.quantity,
  112. remark=op.remark,
  113. )
  114. db.add(log)
  115. db.commit()
  116. db.refresh(item)
  117. return {"message": "操作成功", "current_remaining": item.current_remaining}
  118. @router.delete("/stock/logs")
  119. def clear_stock_logs(db: Session = Depends(get_db)):
  120. """清空所有出入库记录"""
  121. count = db.query(TransactionLog).delete()
  122. db.commit()
  123. return {"message": f"已清空 {count} 条出入库记录"}
  124. @router.get("/stock/logs")
  125. def get_stock_logs(
  126. page: int = Query(1, ge=1),
  127. page_size: int = Query(20, ge=1, le=100),
  128. search: Optional[str] = None,
  129. db: Session = Depends(get_db)
  130. ):
  131. """获取出入库记录"""
  132. query = db.query(TransactionLog)
  133. if search:
  134. keyword = f"%{search}%"
  135. query = query.filter(
  136. or_(
  137. TransactionLog.cInvCode.like(keyword),
  138. TransactionLog.remark.like(keyword),
  139. )
  140. )
  141. total = query.count()
  142. items = query.order_by(TransactionLog.id.asc()).offset((page - 1) * page_size).limit(page_size).all()
  143. return {"total": total, "items": [log_to_dict(l) for l in items]}
  144. # ===== Excel 导入导出 =====
  145. @router.get("/inventory/export")
  146. def export_inventory(db: Session = Depends(get_db)):
  147. """导出库存为Excel"""
  148. items = db.query(Inventory).order_by(Inventory.id.asc()).all()
  149. wb = openpyxl.Workbook()
  150. ws = wb.active
  151. ws.title = "库存数据"
  152. # 表头
  153. headers = ["序号", "产品编码", "供应商", "现外壳&标签&备注", "批次", "当前时间剩余", "存货地点"]
  154. ws.append(headers)
  155. # 表头样式
  156. from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
  157. header_font = Font(bold=True, size=12, color="FFFFFF")
  158. header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
  159. header_alignment = Alignment(horizontal="center", vertical="center")
  160. thin_border = Border(
  161. left=Side(style='thin'), right=Side(style='thin'),
  162. top=Side(style='thin'), bottom=Side(style='thin')
  163. )
  164. for col_idx, header in enumerate(headers, 1):
  165. cell = ws.cell(row=1, column=col_idx, value=header)
  166. cell.font = header_font
  167. cell.fill = header_fill
  168. cell.alignment = header_alignment
  169. cell.border = thin_border
  170. # 数据行
  171. for item in items:
  172. row_data = [
  173. item.id,
  174. item.cInvCode,
  175. item.supplier or "",
  176. item.casing_label_remark or "",
  177. item.batch or "",
  178. item.current_remaining,
  179. item.storage_location or "",
  180. ]
  181. ws.append(row_data)
  182. # 调整列宽
  183. col_widths = [8, 20, 20, 30, 15, 15, 20]
  184. for idx, width in enumerate(col_widths, 1):
  185. ws.column_dimensions[openpyxl.utils.get_column_letter(idx)].width = width
  186. # 保存到内存
  187. output = io.BytesIO()
  188. wb.save(output)
  189. output.seek(0)
  190. filename = f"库存数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
  191. filename_encoded = quote(filename)
  192. return StreamingResponse(
  193. output,
  194. media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  195. headers={"Content-Disposition": f"attachment; filename={filename_encoded}; filename*=UTF-8''{filename_encoded}"}
  196. )
  197. @router.post("/inventory/import")
  198. def import_inventory(file: UploadFile = File(...), db: Session = Depends(get_db)):
  199. """从Excel导入库存"""
  200. if not file.filename.endswith(('.xlsx', '.xls')):
  201. raise HTTPException(status_code=400, detail="只支持 .xlsx 或 .xls 文件")
  202. try:
  203. contents = file.file.read()
  204. wb = openpyxl.load_workbook(io.BytesIO(contents))
  205. ws = wb.active
  206. imported = 0
  207. updated = 0
  208. errors = []
  209. # 查找表头位置
  210. header_map = {}
  211. expected_headers = {
  212. "序号": "id",
  213. "产品编码": "cInvCode",
  214. "供应商": "supplier",
  215. "现外壳&标签&备注": "casing_label_remark",
  216. "外壳&标签&备注": "casing_label_remark",
  217. "批次": "batch",
  218. "当前时间剩余": "current_remaining",
  219. "存货地点": "storage_location",
  220. }
  221. for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=5), 1):
  222. for col_idx, cell in enumerate(row):
  223. if cell.value and str(cell.value).strip() in expected_headers:
  224. header_map[str(cell.value).strip()] = col_idx
  225. if not header_map.get("产品编码"):
  226. raise HTTPException(status_code=400, detail="未找到有效的表头行,请确保包含'产品编码'列")
  227. # 解析数据行(从第2行开始,假设第1行是表头)
  228. for row_idx, row in enumerate(ws.iter_rows(min_row=2), 2):
  229. try:
  230. # 先读取序号
  231. id_col = header_map.get("序号")
  232. record_id = None
  233. if id_col is not None and id_col < len(row):
  234. id_val = row[id_col].value
  235. if id_val is not None:
  236. try:
  237. record_id = int(float(id_val))
  238. except (ValueError, TypeError):
  239. record_id = None
  240. cInvCode_col = header_map.get("产品编码")
  241. if cInvCode_col is None:
  242. continue
  243. cInvCode = row[cInvCode_col].value if cInvCode_col < len(row) else None
  244. if not cInvCode:
  245. continue
  246. cInvCode = str(cInvCode).strip()
  247. def get_val(key, default=""):
  248. col = header_map.get(key)
  249. if col is not None and col < len(row):
  250. val = row[col].value
  251. return str(val).strip() if val is not None else default
  252. return default
  253. supplier = get_val("供应商")
  254. casing_label_remark = get_val("现外壳&标签&备注") or get_val("外壳&标签&备注")
  255. batch = get_val("批次")
  256. storage_location = get_val("存货地点")
  257. current_remaining_val = 0
  258. cr_col = header_map.get("当前时间剩余")
  259. if cr_col is not None and cr_col < len(row):
  260. val = row[cr_col].value
  261. try:
  262. current_remaining_val = float(val) if val is not None else 0
  263. except (ValueError, TypeError):
  264. current_remaining_val = 0
  265. # 按序号优先,否则按产品编码+批次
  266. if record_id:
  267. # 按序号查找
  268. existing = db.query(Inventory).filter(Inventory.id == record_id).first()
  269. if existing:
  270. existing.cInvCode = cInvCode
  271. existing.supplier = supplier
  272. existing.casing_label_remark = casing_label_remark
  273. existing.batch = batch
  274. existing.current_remaining = current_remaining_val
  275. existing.storage_location = storage_location
  276. updated += 1
  277. else:
  278. # 序号不存在,新增并指定ID
  279. new_item = Inventory(
  280. id=record_id,
  281. cInvCode=cInvCode,
  282. supplier=supplier,
  283. casing_label_remark=casing_label_remark,
  284. batch=batch,
  285. current_remaining=current_remaining_val,
  286. storage_location=storage_location,
  287. )
  288. db.add(new_item)
  289. imported += 1
  290. else:
  291. # 按产品编码+批次去重
  292. existing = db.query(Inventory).filter(
  293. Inventory.cInvCode == cInvCode,
  294. Inventory.batch == batch
  295. ).first()
  296. if existing:
  297. existing.supplier = supplier or existing.supplier
  298. existing.casing_label_remark = casing_label_remark or existing.casing_label_remark
  299. existing.current_remaining = current_remaining_val if current_remaining_val else existing.current_remaining
  300. existing.storage_location = storage_location or existing.storage_location
  301. updated += 1
  302. else:
  303. new_item = Inventory(
  304. cInvCode=cInvCode,
  305. supplier=supplier,
  306. casing_label_remark=casing_label_remark,
  307. batch=batch,
  308. current_remaining=current_remaining_val,
  309. storage_location=storage_location,
  310. )
  311. db.add(new_item)
  312. imported += 1
  313. except Exception as e:
  314. errors.append(f"第{row_idx}行: {str(e)}")
  315. continue
  316. db.commit()
  317. return {
  318. "message": f"导入完成:新增 {imported} 条,更新 {updated} 条",
  319. "imported": imported,
  320. "updated": updated,
  321. "errors": errors[:10],
  322. }
  323. except HTTPException:
  324. raise
  325. except Exception as e:
  326. raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
  327. # ===== 导出出入库记录 =====
  328. @router.get("/stock/export")
  329. def export_stock_logs(db: Session = Depends(get_db)):
  330. """导出出入库记录为Excel"""
  331. items = db.query(TransactionLog).order_by(TransactionLog.id.desc()).all()
  332. wb = openpyxl.Workbook()
  333. ws = wb.active
  334. ws.title = "出入库记录"
  335. headers = ["序号", "产品编码", "类型", "数量", "备注", "操作时间"]
  336. ws.append(headers)
  337. from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
  338. header_font = Font(bold=True, size=12, color="FFFFFF")
  339. header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
  340. header_alignment = Alignment(horizontal="center", vertical="center")
  341. thin_border = Border(
  342. left=Side(style='thin'), right=Side(style='thin'),
  343. top=Side(style='thin'), bottom=Side(style='thin')
  344. )
  345. for col_idx, header in enumerate(headers, 1):
  346. cell = ws.cell(row=1, column=col_idx, value=header)
  347. cell.font = header_font
  348. cell.fill = header_fill
  349. cell.alignment = header_alignment
  350. cell.border = thin_border
  351. for item in items:
  352. ws.append([
  353. item.id,
  354. item.cInvCode,
  355. "入库" if item.type == "in" else "出库",
  356. item.quantity,
  357. item.remark or "",
  358. item.created_at.strftime("%Y-%m-%d %H:%M:%S") if item.created_at else "",
  359. ])
  360. col_widths = [8, 20, 10, 10, 30, 20]
  361. for idx, width in enumerate(col_widths, 1):
  362. ws.column_dimensions[openpyxl.utils.get_column_letter(idx)].width = width
  363. output = io.BytesIO()
  364. wb.save(output)
  365. output.seek(0)
  366. filename = f"出入库记录_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
  367. filename_encoded = quote(filename)
  368. return StreamingResponse(
  369. output,
  370. media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
  371. headers={"Content-Disposition": f"attachment; filename={filename_encoded}; filename*=UTF-8''{filename_encoded}"}
  372. )