commit 188edfa28756cecf5e14ea97073e5726f47526e8 Author: cnbugs Date: Fri May 29 19:24:43 2026 +0800 initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7342a5f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/data/inventory.db b/backend/data/inventory.db new file mode 100644 index 0000000..36d6443 Binary files /dev/null and b/backend/data/inventory.db differ diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..ed018c6 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,16 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +DATABASE_URL = "sqlite:///./data/inventory.db" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..931de35 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,14 @@ +version: "3.8" + +services: + inventory: + build: . + container_name: inventory-management + restart: unless-stopped + ports: + - "8011:8000" + volumes: + - ./data:/app/data + - ./static:/app/static + environment: + - TZ=Asia/Shanghai diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..0f9433a --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,6 @@ +from database import engine, Base +from models import Inventory, TransactionLog + +# 初始化数据库表 +Base.metadata.create_all(bind=engine) +print("数据库表创建成功!") diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..efb89f5 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from database import engine, Base +from routers import router +import os + +# 创建数据表 +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="库存管理系统", version="1.0.0") + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# API路由 +app.include_router(router, prefix="/api") + +# 健康检查 +@app.get("/api/health") +def health(): + return {"status": "ok"} + +# 静态文件(前端) - 必须放在最后 +static_dir = os.path.join(os.path.dirname(__file__), "static") +if os.path.exists(static_dir): + app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..41ab888 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, Text, func +from database import Base + + +class Inventory(Base): + """库存表""" + __tablename__ = "inventory" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True, comment="序号") + cInvCode = Column(String(100), nullable=False, index=True, comment="产品编码") + supplier = Column(String(200), nullable=True, comment="供应商") + casing_label_remark = Column(Text, nullable=True, comment="现外壳&标签&备注") + batch = Column(String(100), nullable=True, comment="批次") + current_remaining = Column(Float, default=0, comment="当前时间剩余") + storage_location = Column(String(200), nullable=True, comment="存货地点") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + +class TransactionLog(Base): + """出入库记录表""" + __tablename__ = "transaction_log" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + inventory_id = Column(Integer, nullable=False, index=True, comment="库存ID") + cInvCode = Column(String(100), nullable=False, index=True, comment="产品编码") + type = Column(String(10), nullable=False, comment="类型: in/out") + quantity = Column(Float, nullable=False, comment="数量") + remark = Column(Text, nullable=True, comment="备注") + created_at = Column(DateTime, server_default=func.now(), comment="操作时间") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..da43518 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +sqlalchemy==2.0.35 +openpyxl==3.1.5 +python-multipart==0.0.12 +pydantic==2.9.2 diff --git a/backend/routers.py b/backend/routers.py new file mode 100644 index 0000000..22b9275 --- /dev/null +++ b/backend/routers.py @@ -0,0 +1,435 @@ +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from sqlalchemy import or_ +from typing import Optional +import io +from datetime import datetime +from urllib.parse import quote + +from database import get_db +from models import Inventory, TransactionLog +from schemas import InventoryCreate, InventoryUpdate, StockOperation +import openpyxl + +router = APIRouter() + + +def inventory_to_dict(item: Inventory) -> dict: + """将Inventory模型转为字典""" + return { + "id": item.id, + "cInvCode": item.cInvCode, + "supplier": item.supplier, + "casing_label_remark": item.casing_label_remark, + "batch": item.batch, + "current_remaining": item.current_remaining, + "storage_location": item.storage_location, + "created_at": item.created_at.isoformat() if item.created_at else None, + "updated_at": item.updated_at.isoformat() if item.updated_at else None, + } + + +def log_to_dict(log: TransactionLog) -> dict: + """将TransactionLog模型转为字典""" + return { + "id": log.id, + "inventory_id": log.inventory_id, + "cInvCode": log.cInvCode, + "type": log.type, + "quantity": log.quantity, + "remark": log.remark, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + + +# ===== 库存 CRUD ===== + +@router.get("/inventory") +def list_inventory( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = None, + db: Session = Depends(get_db) +): + """获取库存列表,支持分页和搜索""" + query = db.query(Inventory) + if search: + keyword = f"%{search}%" + query = query.filter( + or_( + Inventory.cInvCode.like(keyword), + Inventory.supplier.like(keyword), + Inventory.batch.like(keyword), + Inventory.storage_location.like(keyword), + Inventory.casing_label_remark.like(keyword), + ) + ) + total = query.count() + items = query.order_by(Inventory.id.asc()).offset((page - 1) * page_size).limit(page_size).all() + return {"total": total, "items": [inventory_to_dict(i) for i in items]} + + +@router.post("/inventory") +def create_inventory(data: InventoryCreate, db: Session = Depends(get_db)): + """新增库存""" + item = Inventory(**data.model_dump()) + db.add(item) + db.commit() + db.refresh(item) + return inventory_to_dict(item) + + +@router.put("/inventory/{item_id}") +def update_inventory(item_id: int, data: InventoryUpdate, db: Session = Depends(get_db)): + """更新库存""" + item = db.query(Inventory).filter(Inventory.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="库存记录不存在") + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(item, key, value) + db.commit() + db.refresh(item) + return inventory_to_dict(item) + + +@router.delete("/inventory/{item_id}") +def delete_inventory(item_id: int, db: Session = Depends(get_db)): + """删除库存""" + item = db.query(Inventory).filter(Inventory.id == item_id).first() + if not item: + raise HTTPException(status_code=404, detail="库存记录不存在") + db.delete(item) + db.commit() + return {"message": "删除成功"} + + +# ===== 出入库 ===== + +@router.post("/stock/operation") +def stock_operation(op: StockOperation, db: Session = Depends(get_db)): + """出入库操作""" + item = db.query(Inventory).filter(Inventory.id == op.inventory_id).first() + if not item: + raise HTTPException(status_code=404, detail="库存记录不存在") + + if op.type == "out": + if item.current_remaining < op.quantity: + raise HTTPException(status_code=400, detail=f"库存不足,当前剩余: {item.current_remaining}") + item.current_remaining -= op.quantity + elif op.type == "in": + item.current_remaining += op.quantity + else: + raise HTTPException(status_code=400, detail="类型必须是 in 或 out") + + # 记录操作日志 + log = TransactionLog( + inventory_id=item.id, + cInvCode=item.cInvCode, + type=op.type, + quantity=op.quantity, + remark=op.remark, + ) + db.add(log) + db.commit() + db.refresh(item) + return {"message": "操作成功", "current_remaining": item.current_remaining} + + +@router.delete("/stock/logs") +def clear_stock_logs(db: Session = Depends(get_db)): + """清空所有出入库记录""" + count = db.query(TransactionLog).delete() + db.commit() + return {"message": f"已清空 {count} 条出入库记录"} + + +@router.get("/stock/logs") +def get_stock_logs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + search: Optional[str] = None, + db: Session = Depends(get_db) +): + """获取出入库记录""" + query = db.query(TransactionLog) + if search: + keyword = f"%{search}%" + query = query.filter( + or_( + TransactionLog.cInvCode.like(keyword), + TransactionLog.remark.like(keyword), + ) + ) + total = query.count() + items = query.order_by(TransactionLog.id.asc()).offset((page - 1) * page_size).limit(page_size).all() + return {"total": total, "items": [log_to_dict(l) for l in items]} + + +# ===== Excel 导入导出 ===== + +@router.get("/inventory/export") +def export_inventory(db: Session = Depends(get_db)): + """导出库存为Excel""" + items = db.query(Inventory).order_by(Inventory.id.asc()).all() + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "库存数据" + + # 表头 + headers = ["序号", "产品编码", "供应商", "现外壳&标签&备注", "批次", "当前时间剩余", "存货地点"] + ws.append(headers) + + # 表头样式 + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + header_font = Font(bold=True, size=12, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + thin_border = Border( + left=Side(style='thin'), right=Side(style='thin'), + top=Side(style='thin'), bottom=Side(style='thin') + ) + + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + + # 数据行 + for item in items: + row_data = [ + item.id, + item.cInvCode, + item.supplier or "", + item.casing_label_remark or "", + item.batch or "", + item.current_remaining, + item.storage_location or "", + ] + ws.append(row_data) + + # 调整列宽 + col_widths = [8, 20, 20, 30, 15, 15, 20] + for idx, width in enumerate(col_widths, 1): + ws.column_dimensions[openpyxl.utils.get_column_letter(idx)].width = width + + # 保存到内存 + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = f"库存数据_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + filename_encoded = quote(filename) + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename_encoded}; filename*=UTF-8''{filename_encoded}"} + ) + + +@router.post("/inventory/import") +def import_inventory(file: UploadFile = File(...), db: Session = Depends(get_db)): + """从Excel导入库存""" + if not file.filename.endswith(('.xlsx', '.xls')): + raise HTTPException(status_code=400, detail="只支持 .xlsx 或 .xls 文件") + + try: + contents = file.file.read() + wb = openpyxl.load_workbook(io.BytesIO(contents)) + ws = wb.active + + imported = 0 + updated = 0 + errors = [] + + # 查找表头位置 + header_map = {} + expected_headers = { + "序号": "id", + "产品编码": "cInvCode", + "供应商": "supplier", + "现外壳&标签&备注": "casing_label_remark", + "外壳&标签&备注": "casing_label_remark", + "批次": "batch", + "当前时间剩余": "current_remaining", + "存货地点": "storage_location", + } + + for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=5), 1): + for col_idx, cell in enumerate(row): + if cell.value and str(cell.value).strip() in expected_headers: + header_map[str(cell.value).strip()] = col_idx + + if not header_map.get("产品编码"): + raise HTTPException(status_code=400, detail="未找到有效的表头行,请确保包含'产品编码'列") + + # 解析数据行(从第2行开始,假设第1行是表头) + for row_idx, row in enumerate(ws.iter_rows(min_row=2), 2): + try: + # 先读取序号 + id_col = header_map.get("序号") + record_id = None + if id_col is not None and id_col < len(row): + id_val = row[id_col].value + if id_val is not None: + try: + record_id = int(float(id_val)) + except (ValueError, TypeError): + record_id = None + + cInvCode_col = header_map.get("产品编码") + if cInvCode_col is None: + continue + cInvCode = row[cInvCode_col].value if cInvCode_col < len(row) else None + if not cInvCode: + continue + cInvCode = str(cInvCode).strip() + + def get_val(key, default=""): + col = header_map.get(key) + if col is not None and col < len(row): + val = row[col].value + return str(val).strip() if val is not None else default + return default + + supplier = get_val("供应商") + casing_label_remark = get_val("现外壳&标签&备注") or get_val("外壳&标签&备注") + batch = get_val("批次") + storage_location = get_val("存货地点") + + current_remaining_val = 0 + cr_col = header_map.get("当前时间剩余") + if cr_col is not None and cr_col < len(row): + val = row[cr_col].value + try: + current_remaining_val = float(val) if val is not None else 0 + except (ValueError, TypeError): + current_remaining_val = 0 + + # 按序号优先,否则按产品编码+批次 + if record_id: + # 按序号查找 + existing = db.query(Inventory).filter(Inventory.id == record_id).first() + if existing: + existing.cInvCode = cInvCode + existing.supplier = supplier + existing.casing_label_remark = casing_label_remark + existing.batch = batch + existing.current_remaining = current_remaining_val + existing.storage_location = storage_location + updated += 1 + else: + # 序号不存在,新增并指定ID + new_item = Inventory( + id=record_id, + cInvCode=cInvCode, + supplier=supplier, + casing_label_remark=casing_label_remark, + batch=batch, + current_remaining=current_remaining_val, + storage_location=storage_location, + ) + db.add(new_item) + imported += 1 + else: + # 按产品编码+批次去重 + existing = db.query(Inventory).filter( + Inventory.cInvCode == cInvCode, + Inventory.batch == batch + ).first() + + if existing: + existing.supplier = supplier or existing.supplier + existing.casing_label_remark = casing_label_remark or existing.casing_label_remark + existing.current_remaining = current_remaining_val if current_remaining_val else existing.current_remaining + existing.storage_location = storage_location or existing.storage_location + updated += 1 + else: + new_item = Inventory( + cInvCode=cInvCode, + supplier=supplier, + casing_label_remark=casing_label_remark, + batch=batch, + current_remaining=current_remaining_val, + storage_location=storage_location, + ) + db.add(new_item) + imported += 1 + + except Exception as e: + errors.append(f"第{row_idx}行: {str(e)}") + continue + + db.commit() + return { + "message": f"导入完成:新增 {imported} 条,更新 {updated} 条", + "imported": imported, + "updated": updated, + "errors": errors[:10], + } + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}") + + +# ===== 导出出入库记录 ===== + +@router.get("/stock/export") +def export_stock_logs(db: Session = Depends(get_db)): + """导出出入库记录为Excel""" + items = db.query(TransactionLog).order_by(TransactionLog.id.desc()).all() + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "出入库记录" + + headers = ["序号", "产品编码", "类型", "数量", "备注", "操作时间"] + ws.append(headers) + + from openpyxl.styles import Font, Alignment, PatternFill, Border, Side + header_font = Font(bold=True, size=12, color="FFFFFF") + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_alignment = Alignment(horizontal="center", vertical="center") + thin_border = Border( + left=Side(style='thin'), right=Side(style='thin'), + top=Side(style='thin'), bottom=Side(style='thin') + ) + + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + + for item in items: + ws.append([ + item.id, + item.cInvCode, + "入库" if item.type == "in" else "出库", + item.quantity, + item.remark or "", + item.created_at.strftime("%Y-%m-%d %H:%M:%S") if item.created_at else "", + ]) + + col_widths = [8, 20, 10, 10, 30, 20] + for idx, width in enumerate(col_widths, 1): + ws.column_dimensions[openpyxl.utils.get_column_letter(idx)].width = width + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = f"出入库记录_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + filename_encoded = quote(filename) + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename_encoded}; filename*=UTF-8''{filename_encoded}"} + ) diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..52d3158 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +# ===== 库存 ===== +class InventoryBase(BaseModel): + cInvCode: str + supplier: Optional[str] = None + casing_label_remark: Optional[str] = None + batch: Optional[str] = None + current_remaining: float = 0 + storage_location: Optional[str] = None + + +class InventoryCreate(InventoryBase): + pass + + +class InventoryUpdate(BaseModel): + cInvCode: Optional[str] = None + supplier: Optional[str] = None + casing_label_remark: Optional[str] = None + batch: Optional[str] = None + current_remaining: Optional[float] = None + storage_location: Optional[str] = None + + +class InventoryResponse(InventoryBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# ===== 出入库 ===== +class StockOperation(BaseModel): + inventory_id: int + type: str # "in" or "out" + quantity: float + remark: Optional[str] = None + + +class TransactionLogResponse(BaseModel): + id: int + inventory_id: int + cInvCode: str + type: str + quantity: float + remark: Optional[str] = None + created_at: Optional[datetime] = None + + class Config: + from_attributes = True diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..eb9070d --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,579 @@ + + + + + + 库存管理系统 + + + + +
+
+ +
+ {{ currentTime }} +
+
+ +
+
+
+
📋
+

{{ stats.total }}

产品总数

+
+
+
📊
+

{{ stats.totalRemaining }}

库存总量

+
+
+
🏭
+

{{ stats.supplierCount }}

供应商数

+
+
+
⚠️
+

{{ stats.lowStock }}

库存预警 (≤0)

+
+
+ + + + +
+
+ + 查询 +
+
+ 新增 + 导入 + 导出 + +
+
+ + + + + + + + + + + + + + + + +
+ +
+
+ + + +
+
+ + 查询 +
+
+ 清空记录 + 导出记录 +
+
+ + + + + + + + + + + + + + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
产品编码:{{ stockItem ? stockItem.cInvCode : '' }}
+
当前库存:{{ stockItem ? stockItem.current_remaining : 0 }}
+
+
+ + + + + + + + + +
+
+ + + + + + +