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:
HHY
2026-09-20 08:16:03 -07:00
parent 7554f2e3c7
commit d3e6de585c
288 changed files with 1835 additions and 9 deletions
+1
View File
@@ -0,0 +1 @@
"""华门窗包。"""
+49
View File
@@ -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,
})
+120
View File
@@ -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])
+9
View File
@@ -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)
+66
View File
@@ -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,
})
+1
View File
@@ -0,0 +1 @@
"""华门窗包。"""
+21
View File
@@ -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()
+31
View File
@@ -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)
+66
View File
@@ -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",
]
+39
View File
@@ -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)
+28
View File
@@ -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"])
+73
View File
@@ -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()
+17
View File
@@ -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",
]
+55
View File
@@ -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")
+103
View File
@@ -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)
+26
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""华门窗包。"""
+13
View File
@@ -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
+86
View File
@@ -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")
+76
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""华门窗包。"""
+47
View File
@@ -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"]