184 lines
8.4 KiB
Python
184 lines
8.4 KiB
Python
"""仓库库存路由:材料 CRUD + 出入库 + Excel 导入导出。"""
|
|
from fastapi import APIRouter, UploadFile
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import desc, select
|
|
|
|
from huamenchuang.core.deps import CurrentUser, DbSession
|
|
from huamenchuang.core.response import fail, ok, paginated
|
|
from huamenchuang.models.contract import StockMove, WarehouseItem
|
|
|
|
router = APIRouter(prefix="/warehouse", tags=["仓库"])
|
|
|
|
|
|
class ItemBody(BaseModel):
|
|
name: str = Field(min_length=1, max_length=128)
|
|
bom_code: str = ""
|
|
material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$")
|
|
color: str = ""
|
|
spec: str = ""
|
|
unit: str = "根"
|
|
cost: float = 0
|
|
price: float = 0
|
|
count: float = 0
|
|
warning: float = 0
|
|
length: int = 0
|
|
note: str = ""
|
|
|
|
|
|
class MoveBody(BaseModel):
|
|
item_id: int
|
|
direction: int = Field(default=1, ge=-1, le=1) # 1入库 -1出库
|
|
count: float = Field(gt=0)
|
|
note: str = ""
|
|
|
|
|
|
def _out(i: WarehouseItem) -> dict:
|
|
return {
|
|
"id": i.id, "bom_code": i.bom_code, "name": i.name, "material": i.material,
|
|
"color": i.color, "spec": i.spec, "unit": i.unit, "cost": float(i.cost),
|
|
"price": float(i.price), "count": float(i.count), "warning": float(i.warning),
|
|
"length": i.length, "note": i.note, "updated_at": str(i.updated_at),
|
|
}
|
|
|
|
|
|
@router.get("/list")
|
|
async def list_items(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15,
|
|
keyword: str = "", material: str | None = None):
|
|
"""材料库存列表(分页 + 筛选,对齐目标站 warehouse_list 语义)。"""
|
|
from sqlalchemy import func, or_
|
|
conds = [WarehouseItem.company_id == user.company_id]
|
|
if material:
|
|
conds.append(WarehouseItem.material == material)
|
|
if kw := keyword.strip():
|
|
like = f"%{kw}%"
|
|
conds.append(or_(WarehouseItem.name.like(like), WarehouseItem.bom_code.like(like)))
|
|
where = *conds,
|
|
total = (await db.execute(select(func.count()).select_from(WarehouseItem).where(*where))).scalar_one()
|
|
stmt = (select(WarehouseItem).where(*where).order_by(WarehouseItem.id.desc())
|
|
.offset((page - 1) * page_size).limit(page_size))
|
|
items = list((await db.execute(stmt)).scalars())
|
|
return ok(data=paginated([_out(i) for i in items], page, page_size, total))
|
|
|
|
|
|
@router.post("")
|
|
async def create_item(body: ItemBody, user: CurrentUser, db: DbSession):
|
|
"""新增材料。"""
|
|
item = WarehouseItem(company_id=user.company_id, **body.model_dump())
|
|
db.add(item)
|
|
await db.flush()
|
|
return ok(data={"id": item.id}, message="创建成功")
|
|
|
|
|
|
# ---------------- Excel 导入导出 ----------------
|
|
|
|
@router.get("/excel-out")
|
|
async def excel_out(user: CurrentUser, db: DbSession):
|
|
"""库存全量导出(对齐目标站 warehouse_excel_out)。"""
|
|
from fastapi.responses import Response
|
|
from huamenchuang.services.excel_service import simple_table_workbook
|
|
items = list((await db.execute(
|
|
select(WarehouseItem).where(WarehouseItem.company_id == user.company_id)
|
|
.order_by(WarehouseItem.id))).scalars())
|
|
mat_label = {"profile": "型材", "glass": "玻璃", "hardware": "五金", "addon": "配件"}
|
|
data = [[i.bom_code, i.name, mat_label.get(i.material, i.material), i.spec, i.color,
|
|
float(i.price), float(i.cost), float(i.count), i.unit, i.length, float(i.warning)]
|
|
for i in items]
|
|
content = simple_table_workbook("库存列表",
|
|
["编码", "名称", "类别", "规格", "颜色", "售价", "成本",
|
|
"库存数量", "单位", "单根长度mm", "预警线"],
|
|
data, widths=[12, 20, 8, 14, 10, 10, 10, 10, 8, 12, 10])
|
|
return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": "attachment; filename=warehouse.xlsx"})
|
|
|
|
|
|
@router.post("/excel-in")
|
|
async def excel_in(file: UploadFile, user: CurrentUser, db: DbSession):
|
|
"""材料批量导入(对齐目标站 materials_config_excel:同名材料按名称匹配更新数量,否则新增)。"""
|
|
if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")):
|
|
return fail("请上传 xlsx 文件")
|
|
from huamenchuang.services.excel_service import pick, read_rows
|
|
try:
|
|
rows = read_rows(await file.read())
|
|
except Exception:
|
|
return fail("文件解析失败")
|
|
if not rows:
|
|
return fail("文件中没有数据行")
|
|
mat_map = {"型材": "profile", "玻璃": "glass", "五金": "hardware", "配件": "addon",
|
|
"profile": "profile", "glass": "glass", "hardware": "hardware", "addon": "addon"}
|
|
existing = {i.name: i for i in (await db.execute(
|
|
select(WarehouseItem).where(WarehouseItem.company_id == user.company_id))).scalars()}
|
|
created, updated, skipped = 0, 0, 0
|
|
for i, row in enumerate(rows, 2):
|
|
name = pick(row, "名称", "材料名称", "name")
|
|
if not name:
|
|
skipped += 1
|
|
continue
|
|
name = str(name).strip()
|
|
mat = mat_map.get(str(pick(row, "类别", "材料", "material", default="型材") or "型材"), "profile")
|
|
values = dict(
|
|
bom_code=str(pick(row, "编码", "bom_code", default="") or ""),
|
|
material=mat, spec=str(pick(row, "规格", "spec", default="") or ""),
|
|
color=str(pick(row, "颜色", "color", default="") or ""),
|
|
price=float(pick(row, "售价", "price", default=0) or 0),
|
|
cost=float(pick(row, "成本", "cost", default=0) or 0),
|
|
count=float(pick(row, "库存数量", "数量", "count", default=0) or 0),
|
|
unit=str(pick(row, "单位", "unit", default="根") or "根"),
|
|
length=int(float(pick(row, "单根长度mm", "长度", "length", default=0) or 0)),
|
|
warning=float(pick(row, "预警线", "warning", default=0) or 0))
|
|
if name in existing: # 同名更新(数量累加)
|
|
item = existing[name]
|
|
for k, v in values.items():
|
|
if k == "count":
|
|
continue
|
|
setattr(item, k, v)
|
|
item.count = float(item.count) + values["count"]
|
|
updated += 1
|
|
else:
|
|
item = WarehouseItem(company_id=user.company_id, name=name, **values)
|
|
db.add(item)
|
|
existing[name] = item
|
|
created += 1
|
|
await db.flush()
|
|
return ok(data={"created": created, "updated": updated, "skipped": skipped},
|
|
message=f"导入完成:新增 {created} 条,更新 {updated} 条,跳过 {skipped} 条")
|
|
|
|
|
|
@router.put("/{item_id}")
|
|
async def update_item(item_id: int, body: ItemBody, user: CurrentUser, db: DbSession):
|
|
"""编辑材料。"""
|
|
item = await db.get(WarehouseItem, item_id)
|
|
if item is None or item.company_id != user.company_id:
|
|
return fail("材料不存在")
|
|
for k, v in body.model_dump().items():
|
|
setattr(item, k, v)
|
|
return ok(message="更新成功")
|
|
|
|
|
|
@router.post("/move")
|
|
async def stock_move(body: MoveBody, user: CurrentUser, db: DbSession):
|
|
"""出入库:direction=1 入库 / -1 出库(出库不允许负库存)。"""
|
|
item = await db.get(WarehouseItem, body.item_id)
|
|
if item is None or item.company_id != user.company_id:
|
|
return fail("材料不存在")
|
|
delta = body.count if body.direction == 1 else -body.count
|
|
if float(item.count) + delta < 0:
|
|
return fail(f"库存不足: 当前 {float(item.count)}{item.unit}")
|
|
item.count = float(item.count) + delta
|
|
db.add(StockMove(item_id=item.id, direction=body.direction, count=body.count,
|
|
note=body.note, user_id=user.id))
|
|
return ok(data={"count": float(item.count)}, message="操作成功")
|
|
|
|
|
|
@router.get("/moves")
|
|
async def move_logs(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 20):
|
|
"""出入库流水。"""
|
|
total_q = select(func.count()).select_from(StockMove)
|
|
total = (await db.execute(total_q)).scalar_one()
|
|
stmt = (select(StockMove).order_by(desc(StockMove.id))
|
|
.offset((page - 1) * page_size).limit(page_size))
|
|
moves = list((await db.execute(stmt)).scalars())
|
|
return ok(data=paginated([{
|
|
"id": m.id, "item_id": m.item_id, "direction": m.direction, "count": float(m.count),
|
|
"note": m.note, "created_at": str(m.created_at),
|
|
} for m in moves], page, page_size, total))
|