From 188edfa28756cecf5e14ea97073e5726f47526e8 Mon Sep 17 00:00:00 2001 From: cnbugs Date: Fri, 29 May 2026 19:24:43 +0800 Subject: [PATCH] initial commit --- README.md | 0 backend/Dockerfile | 12 + backend/data/inventory.db | Bin 0 -> 40960 bytes backend/database.py | 16 + backend/docker-compose.yml | 14 + backend/init_db.py | 6 + backend/main.py | 33 +++ backend/models.py | 30 ++ backend/requirements.txt | 6 + backend/routers.py | 435 ++++++++++++++++++++++++++++ backend/schemas.py | 56 ++++ backend/static/index.html | 579 +++++++++++++++++++++++++++++++++++++ 12 files changed, 1187 insertions(+) create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/data/inventory.db create mode 100644 backend/database.py create mode 100644 backend/docker-compose.yml create mode 100644 backend/init_db.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 backend/routers.py create mode 100644 backend/schemas.py create mode 100644 backend/static/index.html 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 0000000000000000000000000000000000000000..36d64435e3b896cc7c3fe32cc0a02dd7647c7b88 GIT binary patch literal 40960 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 literal 0 HcmV?d00001 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 }}
+
+
+ + + + + + + + + +
+
+ + + + + + +