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:
hankin
2026-05-11 07:24:19 +00:00
parent 0f4c6b7924
commit 815cbf9d8c
2526 changed files with 11875 additions and 804148 deletions
+91 -26
View File
@@ -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,