feat: 订单模块 v0.1 实现与样板二维平面图全量采集
- 后端分层架构: api(Controller)/services(业务)/repositories(数据)/models(ORM)
- 订单模块: 创建(契约对齐目标站{contract_id,drawing_data,pic_url})、搜索、
详情、更新、删除、状态机(意向单→正式单→生产中→完工/取消,非法迁移拒绝)、
7节点生产进度、审计日志
- 认证模块: JWT签发(HS256/24h)、PBKDF2密码、注册登录
- 样板库API: 分类树/模板搜索/模板详情, drawing_data 与采集样本同构
- 254张样板二维平面图全部采集并静态挂载 /static/windoor-images/
- 种子脚本: 管理员+分类树+254套样板入库
- 统一响应envelope与付费墙(code=10086)/权限(code=210)语义
- 端到端冒烟测试7/7通过, 更新API.md §12自研接口规范
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""华门窗包。"""
|
||||
@@ -0,0 +1,49 @@
|
||||
"""认证路由:登录/注册/当前用户。"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.schemas.auth import LoginRequest
|
||||
from huamenchuang.services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginRequest, db: DbSession):
|
||||
"""账号密码登录,签发 JWT。"""
|
||||
try:
|
||||
result = await AuthService(db).login(body.phone_number, body.password)
|
||||
except Exception:
|
||||
return fail("登录失败,请稍后再试")
|
||||
if result is None:
|
||||
return fail("手机号或密码错误")
|
||||
token, hours, user = result
|
||||
return ok(data={
|
||||
"token": token,
|
||||
"expires_hours": hours,
|
||||
"user": {"id": user.id, "nickname": user.nickname, "phone_number": user.phone_number,
|
||||
"company_id": user.company_id},
|
||||
}, message="登录成功")
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(body: LoginRequest, db: DbSession):
|
||||
"""注册并直接返回 token。"""
|
||||
service = AuthService(db)
|
||||
try:
|
||||
user = await service.register(body.phone_number, body.password)
|
||||
except ValueError as e:
|
||||
return fail(str(e))
|
||||
from huamenchuang.core.security import create_token
|
||||
return ok(data={"token": create_token(user.id), "user_id": user.id}, message="注册成功")
|
||||
|
||||
|
||||
@router.get("/current-user")
|
||||
async def current_user(user: CurrentUser):
|
||||
"""当前登录用户信息。"""
|
||||
return ok(data={
|
||||
"id": user.id, "nickname": user.nickname, "phone_number": user.phone_number,
|
||||
"company_id": user.company_id, "language": user.language,
|
||||
"try_expiration_time": str(user.try_expiration_time) if user.try_expiration_time else None,
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
"""订单路由(Controller 层,只做参数编排,业务在 Service)。"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession, require_module
|
||||
from huamenchuang.core.response import fail, ok, paginated
|
||||
from huamenchuang.schemas.order import OrderCreate, OrderProcessUpdate, OrderSearch, OrderUpdate
|
||||
from huamenchuang.services.order_service import OrderService
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["订单"],
|
||||
dependencies=[Depends(require_module("order"))])
|
||||
|
||||
|
||||
def _to_out(order) -> dict:
|
||||
return {
|
||||
"id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"contract_id": order.contract_id,
|
||||
"pic_url": order.pic_url,
|
||||
"customer_id": order.customer_id,
|
||||
"customer_name": order.customer_name,
|
||||
"phone": order.phone,
|
||||
"address": order.address,
|
||||
"area": float(order.area),
|
||||
"count": order.count,
|
||||
"money": float(order.money),
|
||||
"status": order.status,
|
||||
"status_label": order.status_label,
|
||||
"cut_status": order.cut_status,
|
||||
"sort": order.sort,
|
||||
"note": order.note,
|
||||
"created_at": str(order.created_at),
|
||||
"updated_at": str(order.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_order(body: OrderCreate, user: CurrentUser, db: DbSession):
|
||||
"""创建订单。契约对齐目标站:{contract_id, drawing_data, pic_url}。"""
|
||||
try:
|
||||
order = await OrderService(db).create_order(user, body)
|
||||
except Exception:
|
||||
return fail("创建订单失败")
|
||||
return ok(data=_to_out(order), message="创建成功")
|
||||
|
||||
|
||||
@router.post("/search")
|
||||
async def search_orders(body: OrderSearch, user: CurrentUser, db: DbSession):
|
||||
"""订单搜索(POST 搜索语义对齐目标站)。"""
|
||||
items, total = await OrderService(db).search(user, body)
|
||||
return ok(data=paginated([_to_out(o) for o in items], body.page, body.page_size, total))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_orders(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15):
|
||||
"""订单分页列表(GET 简化入口)。"""
|
||||
items, total = await OrderService(db).search(
|
||||
user, OrderSearch(page=page, page_size=page_size))
|
||||
return ok(data=paginated([_to_out(o) for o in items], page, page_size, total))
|
||||
|
||||
|
||||
@router.get("/{order_id}")
|
||||
async def order_detail(order_id: int, user: CurrentUser, db: DbSession):
|
||||
"""订单详情:含 drawing_data 与生产进度。"""
|
||||
try:
|
||||
order = await OrderService(db).get_detail(user, order_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
data = _to_out(order)
|
||||
data["drawing_data"] = order.drawing_data
|
||||
data["processes"] = [
|
||||
{"id": p.id, "name": p.name, "status": p.status,
|
||||
"finish_time": str(p.finish_time) if p.finish_time else None}
|
||||
for p in order.processes
|
||||
]
|
||||
return ok(data=data)
|
||||
|
||||
|
||||
@router.put("/{order_id}")
|
||||
async def update_order(order_id: int, body: OrderUpdate, user: CurrentUser, db: DbSession):
|
||||
"""更新订单基础字段;action=change_status 时执行状态迁移。"""
|
||||
try:
|
||||
order = await OrderService(db).update_order(user, order_id, body)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
except ValueError as e:
|
||||
return fail(str(e))
|
||||
return ok(data=_to_out(order), message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{order_id}")
|
||||
async def delete_order(order_id: int, user: CurrentUser, db: DbSession):
|
||||
"""删除订单(软动作:记日志后物理删除)。"""
|
||||
try:
|
||||
await OrderService(db).delete_order(user, order_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
return ok(message="删除成功")
|
||||
|
||||
|
||||
@router.post("/{order_id}/process")
|
||||
async def update_process(order_id: int, body: OrderProcessUpdate, user: CurrentUser, db: DbSession):
|
||||
"""更新生产进度节点。"""
|
||||
try:
|
||||
proc = await OrderService(db).update_process(user, order_id, body)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
return ok(data={"id": proc.id, "name": proc.name, "status": proc.status,
|
||||
"finish_time": str(proc.finish_time) if proc.finish_time else None},
|
||||
message="操作成功")
|
||||
|
||||
|
||||
@router.get("/{order_id}/logs")
|
||||
async def order_logs(order_id: int, user: CurrentUser, db: DbSession):
|
||||
"""订单操作日志(对齐 order_up_logs 语义)。"""
|
||||
try:
|
||||
logs = await OrderService(db).list_logs(user, order_id)
|
||||
except KeyError as e:
|
||||
return fail(str(e))
|
||||
return ok(data=[{"id": g.id, "action": g.action, "detail": g.detail,
|
||||
"created_at": str(g.created_at)} for g in logs])
|
||||
@@ -0,0 +1,9 @@
|
||||
"""路由汇总。"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from huamenchuang.api import auth, orders, windoor
|
||||
|
||||
api_router = APIRouter(prefix="/api")
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(orders.router)
|
||||
api_router.include_router(windoor.router)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""样板库路由:分类树、模板搜索、模板详情(含 drawing_data)。"""
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import select
|
||||
|
||||
from huamenchuang.core.deps import CurrentUser, DbSession
|
||||
from huamenchuang.core.response import fail, ok
|
||||
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
|
||||
|
||||
router = APIRouter(prefix="/windoor", tags=["样板库"])
|
||||
|
||||
|
||||
@router.get("/catelogs")
|
||||
async def catelogs(db: DbSession, user: CurrentUser):
|
||||
"""分类树(与目标站 default_windoor_catelog 同构)。"""
|
||||
stmt = select(WindoorCatelog).where(WindoorCatelog.company_id == user.company_id)
|
||||
nodes = list((await db.execute(stmt)).scalars())
|
||||
by_fid: dict[int, list] = {}
|
||||
for n in nodes:
|
||||
by_fid.setdefault(n.fid, []).append(n)
|
||||
|
||||
def build(fid: int) -> list[dict]:
|
||||
return [
|
||||
{"id": n.id, "name": n.name, "fid": n.fid, "note": n.note, "children": build(n.id)}
|
||||
for n in by_fid.get(fid, [])
|
||||
]
|
||||
|
||||
return ok(data=build(0))
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def search_templates(
|
||||
db: DbSession, user: CurrentUser,
|
||||
name: str = Query(default="", max_length=64),
|
||||
catelog_id: int | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
):
|
||||
"""模板搜索(列表不含大字段,含平面图地址)。"""
|
||||
conds = [WindoorTemplate.company_id == user.company_id]
|
||||
if name:
|
||||
conds.append(WindoorTemplate.name.like(f"%{name}%"))
|
||||
if catelog_id is not None:
|
||||
conds.append(WindoorTemplate.catelog_id == catelog_id)
|
||||
stmt = (select(WindoorTemplate).where(*conds)
|
||||
.order_by(WindoorTemplate.id).offset((page - 1) * page_size).limit(page_size))
|
||||
items = list((await db.execute(stmt)).scalars())
|
||||
return ok(data=[{
|
||||
"id": t.id, "name": t.name, "pic_url": t.pic_url, "catelog_id": t.catelog_id,
|
||||
"note": t.note, "times": t.times,
|
||||
"low_width": t.low_width, "high_width": t.high_width,
|
||||
"low_height": t.low_height, "high_height": t.high_height,
|
||||
} for t in items])
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
async def template_detail(template_id: int, db: DbSession, user: CurrentUser):
|
||||
"""模板详情:返回 drawing_data(画布可直接 openFile 渲染)。"""
|
||||
t = await db.get(WindoorTemplate, template_id)
|
||||
if t is None or t.company_id != user.company_id:
|
||||
return fail("模板不存在")
|
||||
return ok(data={
|
||||
"id": t.id, "name": t.name, "drawing_data": t.drawing_data, "pic_url": t.pic_url,
|
||||
"catelog_id": t.catelog_id, "note": t.note,
|
||||
"low_width": t.low_width, "high_width": t.high_width,
|
||||
"low_height": t.low_height, "high_height": t.high_height,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
"""华门窗包。"""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""全局配置。开发默认 SQLite,生产可通过环境变量切 MySQL。"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "huamenchuang"
|
||||
# 开发默认 SQLite;生产示例: mysql+aiomysql://user:pwd@localhost/hmc
|
||||
database_url: str = "sqlite+aiosqlite:///./huamenchuang.db"
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_hours: int = 24
|
||||
# 套餐/模块开关(code=10086 付费墙语义),key 为模块名
|
||||
enabled_modules: list[str] = [
|
||||
"template", "customer", "contract", "warehouse", "order",
|
||||
"quote", "optimize", "render",
|
||||
]
|
||||
|
||||
model_config = {"env_prefix": "HMC_", "env_file": ".env"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""异步 SQLAlchemy 引擎与 会话。"""
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from .config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False, pool_pre_ping=True)
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""请求级 会话。"""
|
||||
async with SessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""建表(开发用;生产请使用迁移工具)。"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""FastAPI 依赖:当前用户解析、模块付费墙校验。"""
|
||||
from typing import Annotated
|
||||
|
||||
import jwt as jwt_exc
|
||||
from fastapi import Depends, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .config import settings
|
||||
from .database import get_db
|
||||
from .response import fail, paywall
|
||||
from .security import decode_token
|
||||
|
||||
from huamenchuang.models.user import User
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""解析 Bearer token 得到当前用户;无效则 401 语义响应。"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise NotAuthedError("未登录或凭证缺失")
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
try:
|
||||
user_id = decode_token(token)
|
||||
except jwt_exc.PyJWTError:
|
||||
raise NotAuthedError("登录已过期,请重新登录")
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotAuthedError("用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
class NotAuthedError(Exception):
|
||||
"""401 语义异常,由全局异常处理器转换为标准 envelope。"""
|
||||
|
||||
|
||||
def require_module(module: str):
|
||||
"""模块付费墙依赖(code=10086 语义)。用法: dependencies=[Depends(require_module("order"))]"""
|
||||
|
||||
async def _check() -> None:
|
||||
if module not in settings.enabled_modules:
|
||||
raise ModuleLockedError(module)
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
class ModuleLockedError(Exception):
|
||||
def __init__(self, module: str) -> None:
|
||||
self.module = module
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CurrentUser",
|
||||
"DbSession",
|
||||
"ModuleLockedError",
|
||||
"NotAuthedError",
|
||||
"get_current_user",
|
||||
"paywall",
|
||||
"require_module",
|
||||
"fail",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""统一响应封装,对齐目标站 envelope:{code, message, data, other_data, other_data2}。"""
|
||||
from typing import Any
|
||||
|
||||
from fastapi import responses
|
||||
|
||||
|
||||
def ok(data: Any = None, message: str = "获取成功", other_data: Any = "", other_data2: Any = "") -> dict:
|
||||
"""业务成功响应体(HTTP 200 + code=1)。"""
|
||||
return {"code": 1, "message": message, "data": data, "other_data": other_data, "other_data2": other_data2}
|
||||
|
||||
|
||||
def fail(message: str, code: int | str = 0, data: Any = "") -> dict:
|
||||
"""业务失败响应体(HTTP 200 + code≠1)。"""
|
||||
return {"code": code, "message": message, "data": data, "other_data": "", "other_data2": ""}
|
||||
|
||||
|
||||
def paywall(module: str) -> dict:
|
||||
"""付费墙响应(对齐目标站 code=10086 语义)。"""
|
||||
return fail(f"购买后解锁此功能({module}),点击此处去购买", code="10086")
|
||||
|
||||
|
||||
def no_permission(code: int = 210, message: str = "该账号没有该权限") -> dict:
|
||||
"""角色权限不足(对齐目标站 code=210 语义)。"""
|
||||
return fail(message, code=code)
|
||||
|
||||
|
||||
def paginated(items: list, page: int, page_size: int, total: int) -> dict:
|
||||
"""Laravel 风格分页结构。"""
|
||||
return {
|
||||
"current_page": page,
|
||||
"data": items,
|
||||
"per_page": page_size,
|
||||
"total": total,
|
||||
"last_page": (total + page_size - 1) // page_size if page_size else 1,
|
||||
}
|
||||
|
||||
|
||||
def file_response(content: bytes, media_type: str) -> responses.Response:
|
||||
return responses.Response(content=content, media_type=media_type)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""JWT 签发与校验(HS256,默认 24h,对齐目标站鉴权方式)。"""
|
||||
import datetime
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def create_token(user_id: int) -> str:
|
||||
"""为用户签发 JWT,sub=用户id。"""
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
payload: dict[str, Any] = {
|
||||
"iss": "huamenchuang/api/login",
|
||||
"iat": now,
|
||||
"nbf": now,
|
||||
"exp": now + datetime.timedelta(hours=settings.jwt_expire_hours),
|
||||
"jti": uuid.uuid4().hex[:16],
|
||||
"sub": str(user_id), # RFC 7519: sub 必须为字符串
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str) -> int:
|
||||
"""校验 JWT 并返回用户 id;无效/过期抛 jwt 异常由调用方转换 401。"""
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return int(payload["sub"])
|
||||
@@ -0,0 +1,73 @@
|
||||
"""应用入口:FastAPI 装配、异常处理、静态资源挂载、开发服务器。"""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from huamenchuang.api.router import api_router
|
||||
from huamenchuang.core.database import init_db
|
||||
from huamenchuang.core.deps import ModuleLockedError, NotAuthedError
|
||||
from huamenchuang.core.response import fail, ok, paywall
|
||||
|
||||
app = FastAPI(title="华门窗云平台", version="0.1.0")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
await init_db()
|
||||
|
||||
|
||||
@app.exception_handler(NotAuthedError)
|
||||
async def auth_handler(request: Request, exc: NotAuthedError):
|
||||
return _json({"code": 401, "message": str(exc), "data": "", "other_data": "", "other_data2": ""})
|
||||
|
||||
|
||||
@app.exception_handler(ModuleLockedError)
|
||||
async def module_locked_handler(request: Request, exc: ModuleLockedError):
|
||||
return _json(paywall(exc.module))
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_handler(request: Request, exc: RequestValidationError):
|
||||
"""参数校验失败 → 422 语义 envelope(列出字段,不泄漏内部细节)。"""
|
||||
fields = [f"{'.'.join(str(x) for x in e['loc'][1:])}" for e in exc.errors()]
|
||||
return _json(fail(f"参数错误: {', '.join(fields)}"))
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_handler(request: Request, exc: Exception):
|
||||
"""兜底异常:统一 500 envelope(不回栈信息)。"""
|
||||
return _json(fail("服务器内部错误,请稍后再试"))
|
||||
|
||||
|
||||
def _json(payload: dict):
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse(payload)
|
||||
|
||||
|
||||
@api_router.get("/health", tags=["系统"])
|
||||
async def health():
|
||||
return ok(data={"status": "up"}, message="ok")
|
||||
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
# 静态资源:样板平面图(data/windoor-images)
|
||||
_imgdir = Path(__file__).resolve().parents[2] / "data" / "windoor-images"
|
||||
if _imgdir.exists():
|
||||
app.mount("/static/windoor-images", StaticFiles(directory=str(_imgdir)), name="windoor-images")
|
||||
|
||||
# 前端构建产物挂载(前后端规范:构建到后端目录)
|
||||
_dist = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
||||
if _dist.exists():
|
||||
app.mount("/", StaticFiles(directory=str(_dist), html=True), name="frontend")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
uvicorn.run("huamenchuang.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""ORM 模型统一导出。"""
|
||||
from huamenchuang.models.contract import Contract, Customer
|
||||
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess
|
||||
from huamenchuang.models.user import User
|
||||
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
|
||||
|
||||
__all__ = [
|
||||
"Contract",
|
||||
"Customer",
|
||||
"DEFAULT_PROCESSES",
|
||||
"Order",
|
||||
"OrderLog",
|
||||
"OrderProcess",
|
||||
"User",
|
||||
"WindoorCatelog",
|
||||
"WindoorTemplate",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""客户、合同模型(订单的上游实体)。"""
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from huamenchuang.core.database import Base
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
phone: Mapped[str] = mapped_column(String(32), default="")
|
||||
address: Mapped[str] = mapped_column(String(255), default="")
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
salesmen: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
discount: Mapped[float | None] = mapped_column(Numeric(6, 3), nullable=True)
|
||||
balance: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
|
||||
)
|
||||
|
||||
|
||||
class Contract(Base):
|
||||
"""合同/意向单:目标站契约 status="意向单" 起步,oids[] 记录关联订单。"""
|
||||
|
||||
__tablename__ = "contracts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
contract_no: Mapped[str] = mapped_column(String(32), unique=True) # 如 FG20260920001
|
||||
customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id"), nullable=True, index=True)
|
||||
customer_name: Mapped[str] = mapped_column(String(128), default="")
|
||||
phone: Mapped[str] = mapped_column(String(32), default="")
|
||||
address: Mapped[str] = mapped_column(String(255), default="")
|
||||
area: Mapped[float] = mapped_column(default=0) # ㎡
|
||||
count: Mapped[int] = mapped_column(default=0)
|
||||
money: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
# 0=意向单 1=正式单(已签) 2=已完成 3=已作废
|
||||
status: Mapped[int] = mapped_column(default=0, index=True)
|
||||
sign_status: Mapped[int] = mapped_column(default=0)
|
||||
sign_picture_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
discount: Mapped[float] = mapped_column(Numeric(6, 3), default=1)
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
user_id: Mapped[int] = mapped_column(default=0, index=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
|
||||
)
|
||||
|
||||
customer: Mapped[Customer | None] = relationship(lazy="joined")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""订单模块模型:订单本体、生产进度、操作日志。"""
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from huamenchuang.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from huamenchuang.models.contract import Contract
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""订单:由合同 + 画布 drawing_data 创建(对齐目标站创建契约)。"""
|
||||
|
||||
__tablename__ = "orders"
|
||||
|
||||
# 状态机(自研设计,语义对齐目标站"意向单/正式单"流转)
|
||||
STATUS_INTENTION = 0 # 意向单
|
||||
STATUS_FORMAL = 1 # 正式单
|
||||
STATUS_PRODUCING = 2 # 生产中
|
||||
STATUS_FINISHED = 3 # 已完工
|
||||
STATUS_CANCELED = 4 # 已取消
|
||||
STATUS_LABELS = {0: "意向单", 1: "正式单", 2: "生产中", 3: "已完工", 4: "已取消"}
|
||||
# 允许的状态迁移
|
||||
TRANSITIONS: dict[int, set[int]] = {
|
||||
0: {1, 4},
|
||||
1: {2, 4},
|
||||
2: {3},
|
||||
3: set(),
|
||||
4: set(),
|
||||
}
|
||||
|
||||
__tablename__ = "orders"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
order_no: Mapped[str] = mapped_column(String(32), unique=True) # DO+YYYYMMDD+4位流水
|
||||
contract_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("contracts.id"), nullable=True, index=True
|
||||
)
|
||||
drawing_data: Mapped[str] = mapped_column(Text, default="") # shapeManager 序列化 JSON
|
||||
pic_url: Mapped[str] = mapped_column(String(255), default="") # 平面图/缩略图
|
||||
# 冗余客户信息(下单快照,客户资料变更不影响订单)
|
||||
customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id"), nullable=True)
|
||||
customer_name: Mapped[str] = mapped_column(String(128), default="")
|
||||
phone: Mapped[str] = mapped_column(String(32), default="")
|
||||
address: Mapped[str] = mapped_column(String(255), default="")
|
||||
area: Mapped[float] = mapped_column(Numeric(10, 3), default=0)
|
||||
count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
money: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
|
||||
status: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
# 拆单/切割状态(对齐目标站 cut_status)
|
||||
cut_status: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sort: Mapped[int] = mapped_column(Integer, default=0)
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
user_id: Mapped[int] = mapped_column(default=0, index=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
|
||||
)
|
||||
|
||||
contract: Mapped["Contract | None"] = relationship("Contract", lazy="joined")
|
||||
processes: Mapped[list["OrderProcess"]] = relationship(
|
||||
back_populates="order", cascade="all, delete-orphan", order_by="OrderProcess.sort"
|
||||
)
|
||||
|
||||
@property
|
||||
def status_label(self) -> str:
|
||||
return self.STATUS_LABELS.get(self.status, "未知")
|
||||
|
||||
|
||||
class OrderProcess(Base):
|
||||
"""订单生产进度节点(下料→组装→玻璃→打胶→五金→质检→完工)。"""
|
||||
|
||||
__tablename__ = "order_processes"
|
||||
__table_args__ = (UniqueConstraint("order_id", "name", name="uq_order_process"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(32))
|
||||
status: Mapped[int] = mapped_column(Integer, default=0) # 0未开始 1进行中 2完成
|
||||
finish_time: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
sort: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
order: Mapped[Order] = relationship(back_populates="processes")
|
||||
|
||||
|
||||
DEFAULT_PROCESSES = ["下料", "组装", "玻璃", "打胶", "五金", "质检", "完工"]
|
||||
|
||||
|
||||
class OrderLog(Base):
|
||||
"""订单操作日志(对齐目标站 order_up_logs)。"""
|
||||
|
||||
__tablename__ = "order_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
order_id: Mapped[int] = mapped_column(ForeignKey("orders.id"), index=True)
|
||||
user_id: Mapped[int] = mapped_column(default=0)
|
||||
action: Mapped[str] = mapped_column(String(32)) # create/update_status/delete_process...
|
||||
detail: Mapped[str] = mapped_column(String(255), default="")
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""用户模型。"""
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from huamenchuang.core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
nickname: Mapped[str] = mapped_column(String(64), default="")
|
||||
phone_number: Mapped[str] = mapped_column(String(20), unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(128), default="")
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
language: Mapped[str] = mapped_column(String(16), default="zh-CN")
|
||||
# 套餐到期时间,空=试用
|
||||
try_expiration_time: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
note: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""样板库模型(drawing_data 与目标站 254 套样本同构)。"""
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from huamenchuang.core.database import Base
|
||||
|
||||
|
||||
class WindoorTemplate(Base):
|
||||
"""官方/企业窗型样板。drawing_data 为 shapeManager 序列化 JSON。"""
|
||||
|
||||
__tablename__ = "windoor_templates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), default="")
|
||||
drawing_data: Mapped[str] = mapped_column(Text) # 原样保留编辑器 JSON 字符串
|
||||
pic_url: Mapped[str] = mapped_column(String(255), default="")
|
||||
catelog_id: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
note: Mapped[str] = mapped_column(String(255), default="")
|
||||
times: Mapped[int] = mapped_column(Integer, default=0) # 引用次数
|
||||
# 尺寸限制(毫米),用于编辑器校验
|
||||
low_width: Mapped[int] = mapped_column(Integer, default=0)
|
||||
high_width: Mapped[int] = mapped_column(Integer, default=0)
|
||||
low_height: Mapped[int] = mapped_column(Integer, default=0)
|
||||
high_height: Mapped[int] = mapped_column(Integer, default=0)
|
||||
is_default: Mapped[int] = mapped_column(Integer, default=1) # 1=官方样板
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow
|
||||
)
|
||||
|
||||
|
||||
class WindoorCatelog(Base):
|
||||
"""样板分类树(推拉门{平开,推拉,复古门,肯德基门,中式门窗,转角窗})。"""
|
||||
|
||||
__tablename__ = "windoor_catelogs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
fid: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
note: Mapped[str] = mapped_column(String(255), default="")
|
||||
company_id: Mapped[int] = mapped_column(default=1, index=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""华门窗包。"""
|
||||
@@ -0,0 +1,98 @@
|
||||
"""订单数据访问层。"""
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from huamenchuang.models.order import Order, OrderLog, OrderProcess
|
||||
|
||||
|
||||
class OrderRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def next_order_no(self, company_id: int) -> str:
|
||||
"""生成订单号:DO+YYYYMMDD+4位流水(同日同企业递增)。"""
|
||||
today = datetime.date.today().strftime("%Y%m%d")
|
||||
prefix = f"DO{today}"
|
||||
stmt = select(Order.order_no).where(
|
||||
Order.company_id == company_id, Order.order_no.like(f"{prefix}%")
|
||||
).order_by(Order.order_no.desc()).limit(1)
|
||||
last = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
seq = int(last[-4:]) + 1 if last else 1
|
||||
return f"{prefix}{seq:04d}"
|
||||
|
||||
async def create(self, order: Order) -> Order:
|
||||
self.db.add(order)
|
||||
await self.db.flush()
|
||||
return order
|
||||
|
||||
async def get(self, order_id: int, company_id: int) -> Order | None:
|
||||
stmt = (
|
||||
select(Order)
|
||||
.options(selectinload(Order.processes))
|
||||
.where(Order.id == order_id, Order.company_id == company_id)
|
||||
)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def search(self, company_id: int, **f) -> tuple[list[Order], int]:
|
||||
"""按条件分页搜索。f: page,page_size,keyword,status,contract_id,start_date,end_date,order_by"""
|
||||
conds = [Order.company_id == company_id]
|
||||
if f.get("status") is not None:
|
||||
conds.append(Order.status == f["status"])
|
||||
if f.get("contract_id") is not None:
|
||||
conds.append(Order.contract_id == f["contract_id"])
|
||||
if kw := (f.get("keyword") or "").strip():
|
||||
like = f"%{kw}%"
|
||||
conds.append(or_(
|
||||
Order.order_no.like(like),
|
||||
Order.customer_name.like(like),
|
||||
Order.phone.like(like),
|
||||
))
|
||||
try:
|
||||
if f.get("start_date"):
|
||||
conds.append(Order.created_at >= datetime.datetime.fromisoformat(f["start_date"]))
|
||||
if f.get("end_date"):
|
||||
conds.append(Order.created_at <= datetime.datetime.fromisoformat(f["end_date"]))
|
||||
except ValueError:
|
||||
pass # 日期格式错误时忽略该条件,避免 500
|
||||
where = *conds,
|
||||
total = (await self.db.execute(select(func.count()).select_from(Order).where(*where))).scalar_one()
|
||||
order_map = {
|
||||
"created_desc": Order.created_at.desc(),
|
||||
"sort_asc": Order.sort.asc(),
|
||||
"money_desc": Order.money.desc(),
|
||||
}
|
||||
stmt = (
|
||||
select(Order)
|
||||
.where(*where)
|
||||
.order_by(order_map.get(f.get("order_by") or "", Order.created_at.desc()))
|
||||
.offset((f["page"] - 1) * f["page_size"])
|
||||
.limit(f["page_size"])
|
||||
)
|
||||
items = list((await self.db.execute(stmt)).scalars())
|
||||
return items, total
|
||||
|
||||
async def add_log(self, order_id: int, user_id: int, action: str, detail: str = "") -> None:
|
||||
self.db.add(OrderLog(order_id=order_id, user_id=user_id, action=action, detail=detail))
|
||||
|
||||
async def list_logs(self, order_id: int, limit: int = 50) -> list[OrderLog]:
|
||||
stmt = select(OrderLog).where(OrderLog.order_id == order_id).order_by(OrderLog.id.desc()).limit(limit)
|
||||
return list((await self.db.execute(stmt)).scalars())
|
||||
|
||||
async def upsert_process(self, order_id: int, name: str, status: int) -> OrderProcess | None:
|
||||
stmt = select(OrderProcess).where(OrderProcess.order_id == order_id, OrderProcess.name == name)
|
||||
proc = (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
if proc is None:
|
||||
proc = OrderProcess(order_id=order_id, name=name, status=status)
|
||||
self.db.add(proc)
|
||||
else:
|
||||
proc.status = status
|
||||
if status == 2:
|
||||
proc.finish_time = datetime.datetime.utcnow()
|
||||
await self.db.flush()
|
||||
return proc
|
||||
|
||||
async def delete(self, order: Order) -> None:
|
||||
await self.db.delete(order)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""用户数据访问层。"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from huamenchuang.models.user import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_by_phone(self, phone_number: str) -> User | None:
|
||||
stmt = select(User).where(User.phone_number == phone_number)
|
||||
return (await self.db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
async def create(self, user: User) -> User:
|
||||
self.db.add(user)
|
||||
await self.db.flush()
|
||||
return user
|
||||
@@ -0,0 +1 @@
|
||||
"""华门窗包。"""
|
||||
@@ -0,0 +1,13 @@
|
||||
"""认证模块 schemas。"""
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
phone_number: str = Field(min_length=5, max_length=20)
|
||||
password: str = Field(min_length=4, max_length=64)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
expires_hours: int
|
||||
user: dict
|
||||
@@ -0,0 +1,86 @@
|
||||
"""订单模块 Pydantic 请求/响应模型。"""
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from huamenchuang.models.order import DEFAULT_PROCESSES
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
"""创建订单(对齐目标站契约:contract_id + drawing_data + pic_url)。"""
|
||||
|
||||
contract_id: int | None = None
|
||||
drawing_data: str = Field(default="", description="shapeManager 序列化 JSON 字符串")
|
||||
pic_url: str = ""
|
||||
customer_id: int | None = None
|
||||
note: str = ""
|
||||
money: float = 0
|
||||
count: int = 0
|
||||
area: float = 0
|
||||
status: int = Field(default=0, ge=0, le=4, description="0意向单 1正式单 2生产中 3完工 4取消")
|
||||
|
||||
|
||||
class OrderUpdate(BaseModel):
|
||||
"""订单更新:基础字段 + 状态迁移(action=change_status)。"""
|
||||
|
||||
sort: int | None = None
|
||||
note: str | None = None
|
||||
money: float | None = None
|
||||
count: int | None = None
|
||||
area: float | None = None
|
||||
pic_url: str | None = None
|
||||
drawing_data: str | None = None
|
||||
cut_status: int | None = Field(default=None, ge=0, le=2)
|
||||
action: str | None = Field(default=None, description="change_status=执行状态迁移")
|
||||
target_status: int | None = Field(default=None, ge=0, le=4)
|
||||
|
||||
|
||||
class OrderProcessUpdate(BaseModel):
|
||||
name: str = Field(description=f"进度节点名,默认流程: {'/'.join(DEFAULT_PROCESSES)}")
|
||||
status: int = Field(ge=0, le=2, description="0未开始 1进行中 2完成")
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
"""订单列表项(不含大字段 drawing_data)。"""
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: int
|
||||
order_no: str
|
||||
contract_id: int | None
|
||||
pic_url: str
|
||||
customer_id: int | None
|
||||
customer_name: str
|
||||
phone: str
|
||||
address: str
|
||||
area: float
|
||||
count: int
|
||||
money: float
|
||||
status: int
|
||||
status_label: str
|
||||
cut_status: int
|
||||
sort: int
|
||||
note: str
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
|
||||
|
||||
class OrderDetail(OrderOut):
|
||||
"""订单详情:附带 drawing_data 与生产进度。"""
|
||||
|
||||
drawing_data: str = ""
|
||||
processes: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
class OrderSearch(BaseModel):
|
||||
"""订单搜索(对齐目标站 POST 搜索语义)。"""
|
||||
|
||||
page: int = Field(default=1, ge=1)
|
||||
page_size: int = Field(default=15, ge=1, le=100)
|
||||
keyword: str = Field(default="", description="订单号/客户名/手机号 模糊")
|
||||
status: int | None = Field(default=None, ge=0, le=4)
|
||||
contract_id: int | None = None
|
||||
start_date: str | None = ""
|
||||
end_date: str | None = ""
|
||||
order_by: str = Field(default="created_desc", description="created_desc/sort_asc/money_desc")
|
||||
@@ -0,0 +1,76 @@
|
||||
"""种子数据脚本:
|
||||
1. 创建默认企业/管理员(admin/123456)
|
||||
2. 导入采集到的 254 套官方样板(drawing_data + 分类树 + 平面图)
|
||||
用法: uv run python -m huamenchuang.scripts.seed
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DATA = Path(__file__).resolve().parents[3] / "data"
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
from huamenchuang.core.database import SessionLocal, init_db
|
||||
from huamenchuang.models.user import User
|
||||
from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate
|
||||
from huamenchuang.services.auth_service import hash_password
|
||||
|
||||
await init_db()
|
||||
async with SessionLocal() as db:
|
||||
# 1) 管理员
|
||||
if await db.get(User, 1) is None:
|
||||
db.add(User(id=1, nickname="管理员", phone_number="13800000000",
|
||||
password_hash=hash_password("123456"), company_id=1))
|
||||
print("已创建管理员: 13800000000 / 123456")
|
||||
|
||||
# 2) 分类树(来自 default_windoor_catelog.json,取目标站原始 id 便于对照)
|
||||
cat_file = DATA / "api-samples" / "default_windoor_catelog.json"
|
||||
if cat_file.exists():
|
||||
cats = json.load(open(cat_file, encoding="utf-8"))["data"]
|
||||
existing = {c.id for c in (await db.execute(
|
||||
__import__("sqlalchemy").select(WindoorCatelog.id))).scalars()}
|
||||
for top in cats:
|
||||
if top["id"] not in existing:
|
||||
db.add(WindoorCatelog(id=top["id"], name=top["name"], fid=0, company_id=1))
|
||||
for ch in top.get("children", []):
|
||||
if ch["id"] not in existing:
|
||||
db.add(WindoorCatelog(id=ch["id"], name=ch["name"], fid=top["id"], company_id=1))
|
||||
print("分类树导入完成")
|
||||
|
||||
# 3) 官方样板 254 套
|
||||
templates = json.load(open(DATA / "api-samples" / "windoor-templates-clean.json", encoding="utf-8"))
|
||||
drawings_dir = DATA / "windoor-drawings"
|
||||
img_dir = DATA / "windoor-images"
|
||||
from sqlalchemy import func, select
|
||||
count = (await db.execute(select(func.count()).select_from(WindoorTemplate))).scalar_one()
|
||||
if count == 0:
|
||||
for t in templates:
|
||||
dd_file = drawings_dir / f"{t['id']}.json"
|
||||
if not dd_file.exists():
|
||||
continue
|
||||
detail = json.load(open(dd_file, encoding="utf-8"))
|
||||
img = img_dir / f"{t['id']}.png"
|
||||
img2 = img_dir / f"{t['id']}.jpg"
|
||||
pic = "/static/windoor-images/" + (img.name if img.exists() else img2.name)
|
||||
db.add(WindoorTemplate(
|
||||
name=detail.get("name") or t.get("name") or f"样板{t['id']}",
|
||||
drawing_data=detail["drawing_data"],
|
||||
pic_url=pic,
|
||||
catelog_id=t["catelog_id"],
|
||||
note=detail.get("note") or "",
|
||||
times=detail.get("times") or 0,
|
||||
low_width=detail.get("low_width") or 0,
|
||||
high_width=detail.get("high_width") or 0,
|
||||
low_height=detail.get("low_height") or 0,
|
||||
high_height=detail.get("high_height") or 0,
|
||||
))
|
||||
print(f"已导入样板 {len(templates)} 套")
|
||||
else:
|
||||
print(f"样板表已有 {count} 条, 跳过导入")
|
||||
await db.commit()
|
||||
print("种子数据完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
@@ -0,0 +1 @@
|
||||
"""华门窗包。"""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""认证业务逻辑。"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from huamenchuang.core.security import create_token
|
||||
from huamenchuang.models.user import User
|
||||
from huamenchuang.repositories.user_repo import UserRepository
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""PBKDF2-SHA256(标准库实现,无额外依赖)。"""
|
||||
salt = os.urandom(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100_000)
|
||||
return salt.hex() + "$" + dk.hex()
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
salt_hex, dk_hex = stored.split("$", 1)
|
||||
except ValueError:
|
||||
return False
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), 100_000)
|
||||
return hmac.compare_digest(dk.hex(), dk_hex)
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.repo = UserRepository(db)
|
||||
|
||||
async def login(self, phone_number: str, password: str) -> tuple[str, int, User] | None:
|
||||
user = await self.repo.get_by_phone(phone_number)
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return create_token(user.id), 24, user
|
||||
|
||||
async def register(self, phone_number: str, password: str, nickname: str = "") -> User:
|
||||
if await self.repo.get_by_phone(phone_number):
|
||||
raise ValueError("手机号已注册")
|
||||
user = await self.repo.create(User(
|
||||
phone_number=phone_number,
|
||||
password_hash=hash_password(password),
|
||||
nickname=nickname or f"用户{phone_number[-4:]}",
|
||||
))
|
||||
return user
|
||||
@@ -0,0 +1,90 @@
|
||||
"""订单业务逻辑层:状态机、进度初始化、审计日志。"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from huamenchuang.core.deps import ModuleLockedError, NotAuthedError
|
||||
from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderProcess
|
||||
from huamenchuang.models.user import User
|
||||
from huamenchuang.repositories.order_repo import OrderRepository
|
||||
from huamenchuang.schemas.order import OrderCreate, OrderProcessUpdate, OrderSearch, OrderUpdate
|
||||
|
||||
|
||||
class OrderService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
self.repo = OrderRepository(db)
|
||||
|
||||
async def create_order(self, user: User, payload: OrderCreate) -> Order:
|
||||
"""创建订单:对齐目标站 {contract_id, drawing_data, pic_url} 契约。"""
|
||||
order = Order(
|
||||
order_no=await self.repo.next_order_no(user.company_id),
|
||||
contract_id=payload.contract_id,
|
||||
drawing_data=payload.drawing_data,
|
||||
pic_url=payload.pic_url,
|
||||
customer_id=payload.customer_id,
|
||||
note=payload.note,
|
||||
money=payload.money,
|
||||
count=payload.count,
|
||||
area=payload.area,
|
||||
status=payload.status,
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
)
|
||||
order = await self.repo.create(order)
|
||||
# 初始化默认生产进度节点(直接 add,避免触发关系惰性加载)
|
||||
for i, name in enumerate(DEFAULT_PROCESSES):
|
||||
self.db.add(OrderProcess(order_id=order.id, name=name, status=0, sort=i))
|
||||
await self.repo.add_log(order.id, user.id, "create", f"创建订单 {order.order_no}")
|
||||
return order
|
||||
|
||||
async def search(self, user: User, q: OrderSearch) -> tuple[list[Order], int]:
|
||||
return await self.repo.search(
|
||||
user.company_id, page=q.page, page_size=q.page_size, keyword=q.keyword,
|
||||
status=q.status, contract_id=q.contract_id,
|
||||
start_date=q.start_date, end_date=q.end_date, order_by=q.order_by,
|
||||
)
|
||||
|
||||
async def get_detail(self, user: User, order_id: int) -> Order:
|
||||
order = await self.repo.get(order_id, user.company_id)
|
||||
if order is None:
|
||||
raise KeyError("订单不存在")
|
||||
return order
|
||||
|
||||
async def update_order(self, user: User, order_id: int, payload: OrderUpdate) -> Order:
|
||||
order = await self.get_detail(user, order_id)
|
||||
data = payload.model_dump(exclude_none=True, exclude={"action", "target_status"})
|
||||
for k, v in data.items():
|
||||
setattr(order, k, v)
|
||||
if payload.action == "change_status":
|
||||
self._transition(order, payload.target_status)
|
||||
await self.repo.add_log(order.id, user.id, "change_status",
|
||||
f"{order.STATUS_LABELS.get(order.status)}→{order.STATUS_LABELS.get(payload.target_status)}")
|
||||
else:
|
||||
await self.repo.add_log(order.id, user.id, "update", f"更新字段: {', '.join(data) or '-'}")
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def _transition(order: Order, target: int | None) -> None:
|
||||
"""状态机校验:非法迁移直接拒绝。"""
|
||||
if target is None:
|
||||
raise ValueError("缺少 target_status")
|
||||
if target not in Order.TRANSITIONS.get(order.status, set()):
|
||||
raise ValueError(f"不允许的状态迁移: {order.status_label}→{order.STATUS_LABELS.get(target)}")
|
||||
order.status = target
|
||||
|
||||
async def update_process(self, user: User, order_id: int, payload: OrderProcessUpdate) -> OrderProcess:
|
||||
order = await self.get_detail(user, order_id)
|
||||
proc = await self.repo.upsert_process(order.id, payload.name, payload.status)
|
||||
await self.repo.add_log(order.id, user.id, "up-order-process", f"{payload.name}={payload.status}")
|
||||
return proc # type: ignore[return-value]
|
||||
|
||||
async def list_logs(self, user: User, order_id: int) -> list:
|
||||
await self.get_detail(user, order_id)
|
||||
return await self.repo.list_logs(order_id)
|
||||
|
||||
async def delete_order(self, user: User, order_id: int) -> None:
|
||||
order = await self.get_detail(user, order_id)
|
||||
await self.repo.add_log(order.id, user.id, "delete", f"删除订单 {order.order_no}")
|
||||
await self.repo.delete(order)
|
||||
|
||||
|
||||
__all__ = ["OrderService", "ModuleLockedError", "NotAuthedError"]
|
||||
Reference in New Issue
Block a user