v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
This commit is contained in:
@@ -14,7 +14,8 @@ from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import BizException, NotFoundException
|
||||
from app.models.erp import InventoryFlow, ProductCategory, ProductSku
|
||||
from app.models.erp import ErpSkuInventory, InventoryFlow, ProductCategory, ProductSku
|
||||
from app.models.sys import SysUser
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.erp import (
|
||||
CategoryCreate,
|
||||
@@ -31,7 +32,10 @@ from app.schemas.erp import (
|
||||
|
||||
# ── ORM → Response ───────────────────────────────────────
|
||||
|
||||
def _sku_to_response(s: ProductSku) -> SkuResponse:
|
||||
def _sku_to_response(
|
||||
s: ProductSku,
|
||||
inv: ErpSkuInventory | None = None,
|
||||
) -> SkuResponse:
|
||||
return SkuResponse(
|
||||
id=s.id,
|
||||
sku_code=s.sku_code,
|
||||
@@ -40,8 +44,8 @@ def _sku_to_response(s: ProductSku) -> SkuResponse:
|
||||
category_name=s.category.name if s.category else None,
|
||||
spec=s.spec,
|
||||
standard_price=float(s.standard_price or 0),
|
||||
stock_qty=float(s.stock_qty or 0),
|
||||
warning_threshold=float(s.warning_threshold or 0),
|
||||
stock_qty=float(inv.stock_qty) if inv else 0.0,
|
||||
warning_threshold=float(inv.warning_threshold) if inv else 0.0,
|
||||
unit=s.unit,
|
||||
status=s.status,
|
||||
created_at=s.created_at,
|
||||
@@ -200,11 +204,13 @@ async def delete_category(db: AsyncSession, cat_id: uuid.UUID) -> None:
|
||||
|
||||
async def list_skus(
|
||||
db: AsyncSession,
|
||||
company_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
category_id: uuid.UUID | None = None,
|
||||
keyword: str | None = None,
|
||||
) -> SkuListResponse:
|
||||
"""LEFT JOIN erp_sku_inventory 获取当前公司库存,COALESCE 兜底为 0"""
|
||||
where: list[Any] = [ProductSku.is_deleted.is_(False)]
|
||||
if category_id:
|
||||
where.append(ProductSku.category_id == category_id)
|
||||
@@ -218,24 +224,31 @@ async def list_skus(
|
||||
await db.execute(select(func.count()).select_from(ProductSku).where(*where))
|
||||
).scalar() or 0
|
||||
|
||||
# LEFT JOIN erp_sku_inventory 带出当前公司库存
|
||||
stmt = (
|
||||
select(ProductSku)
|
||||
select(ProductSku, ErpSkuInventory)
|
||||
.outerjoin(
|
||||
ErpSkuInventory,
|
||||
(ErpSkuInventory.sku_id == ProductSku.id)
|
||||
& (ErpSkuInventory.company_id == company_id),
|
||||
)
|
||||
.where(*where)
|
||||
.order_by(ProductSku.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
return SkuListResponse(
|
||||
total=total,
|
||||
items=[_sku_to_response(s) for s in rows],
|
||||
items=[_sku_to_response(sku, inv) for sku, inv in rows],
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
async def create_sku(db: AsyncSession, body: SkuCreate) -> SkuResponse:
|
||||
"""创建 SKU(不创建库存行,LEFT JOIN 查询自动兜底为 0)"""
|
||||
exists = (
|
||||
await db.execute(
|
||||
select(ProductSku.id).where(
|
||||
@@ -253,8 +266,6 @@ async def create_sku(db: AsyncSession, body: SkuCreate) -> SkuResponse:
|
||||
category_id=body.category_id,
|
||||
spec=body.spec,
|
||||
standard_price=body.standard_price,
|
||||
stock_qty=body.stock_qty,
|
||||
warning_threshold=body.warning_threshold,
|
||||
unit=body.unit,
|
||||
status=body.status,
|
||||
)
|
||||
@@ -299,7 +310,9 @@ async def create_inventory_flow(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
body: InventoryFlowCreate,
|
||||
company_id: uuid.UUID,
|
||||
) -> InventoryFlowResponse:
|
||||
"""库存变更(upsert erp_sku_inventory + 写流水)"""
|
||||
sku = (
|
||||
await db.execute(
|
||||
select(ProductSku).where(
|
||||
@@ -310,35 +323,74 @@ async def create_inventory_flow(
|
||||
if sku is None:
|
||||
raise NotFoundException("产品 SKU 不存在")
|
||||
|
||||
if body.change_qty < 0:
|
||||
current_stock = float(sku.stock_qty or 0)
|
||||
if current_stock + body.change_qty < 0:
|
||||
raise BizException(
|
||||
message=f"库存不足:当前库存 {current_stock},请求出库 {abs(body.change_qty)}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
# ── upsert: 查找或创建当前公司的库存行 ──
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(ErpSkuInventory)
|
||||
.where(
|
||||
ErpSkuInventory.sku_id == body.sku_id,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if inv is None:
|
||||
# 首次操作该 SKU:自动创建 0 库存行
|
||||
inv = ErpSkuInventory(
|
||||
sku_id=body.sku_id,
|
||||
company_id=company_id,
|
||||
stock_qty=0,
|
||||
warning_threshold=0,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
# 重新锁行
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(ErpSkuInventory)
|
||||
.where(ErpSkuInventory.id == inv.id)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
# ── 校验库存 ──
|
||||
current_stock = float(inv.stock_qty or 0)
|
||||
if body.change_qty < 0 and current_stock + body.change_qty < 0:
|
||||
raise BizException(
|
||||
message=f"库存不足:当前库存 {current_stock},请求出库 {abs(body.change_qty)}"
|
||||
)
|
||||
|
||||
# ── 更新库存 ──
|
||||
await db.execute(
|
||||
update(ErpSkuInventory)
|
||||
.where(ErpSkuInventory.id == inv.id)
|
||||
.values(
|
||||
stock_qty=ErpSkuInventory.stock_qty + Decimal(str(body.change_qty)),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
# ── 写流水 ──
|
||||
flow = InventoryFlow(
|
||||
sku_id=body.sku_id,
|
||||
company_id=company_id,
|
||||
change_qty=body.change_qty,
|
||||
reason=body.reason,
|
||||
remark=body.remark,
|
||||
purchase_unit_price=body.purchase_unit_price if body.change_qty > 0 else 0,
|
||||
is_special_zero_cost=body.is_special_zero_cost if body.change_qty > 0 else False,
|
||||
operator_id=user.user_id,
|
||||
)
|
||||
db.add(flow)
|
||||
await db.flush()
|
||||
|
||||
await db.execute(
|
||||
update(ProductSku)
|
||||
.where(ProductSku.id == body.sku_id)
|
||||
.values(
|
||||
stock_qty=ProductSku.stock_qty + Decimal(str(body.change_qty)),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
except BizException:
|
||||
await db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
raise BizException(code=500, message=f"库存变更事务失败: {e!s}") from e
|
||||
@@ -352,9 +404,11 @@ async def create_inventory_flow(
|
||||
async def get_inventory_flows(
|
||||
db: AsyncSession,
|
||||
sku_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""获取单个 SKU 在当前公司的库存流水"""
|
||||
sku = (
|
||||
await db.execute(
|
||||
select(ProductSku).where(
|
||||
@@ -365,8 +419,19 @@ async def get_inventory_flows(
|
||||
if sku is None:
|
||||
raise NotFoundException("产品 SKU 不存在")
|
||||
|
||||
# 查当前公司库存
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(ErpSkuInventory).where(
|
||||
ErpSkuInventory.sku_id == sku_id,
|
||||
ErpSkuInventory.company_id == company_id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
where: list[Any] = [
|
||||
InventoryFlow.sku_id == sku_id,
|
||||
InventoryFlow.company_id == company_id,
|
||||
InventoryFlow.is_deleted.is_(False),
|
||||
]
|
||||
|
||||
@@ -389,7 +454,7 @@ async def get_inventory_flows(
|
||||
"total": total,
|
||||
"sku_code": sku.sku_code,
|
||||
"sku_name": sku.name,
|
||||
"current_stock": float(sku.stock_qty or 0),
|
||||
"current_stock": float(inv.stock_qty) if inv else 0.0,
|
||||
"items": [_flow_to_response(f).model_dump(mode="json") for f in flows],
|
||||
"page": page,
|
||||
"size": size,
|
||||
|
||||
Reference in New Issue
Block a user