From 065d387399ef2730fee038a2be5d59c5f3e2fdbf Mon Sep 17 00:00:00 2001 From: HHY Date: Sun, 20 Sep 2026 08:58:14 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v0.3=20=E6=8A=A5=E4=BB=B7=E7=AE=97?= =?UTF-8?q?=E6=96=99=E5=BC=95=E6=93=8E=E4=B8=8E=E4=BB=93=E5=BA=93=E5=BA=93?= =?UTF-8?q?=E5=AD=98=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 报价算料: 从drawing_data几何提取型材清单(外框/中梃/扇框投影长度) 与玻璃面积(鞋带公式), 套价格本计价, 支持样板/订单/自定义图纸三入口 - 仓库库存: 材料CRUD/出入库/流水/负库存拦截/预警线 - SQLite默认路径锚定项目根目录(修复cwd依赖) 前端: - 仓库库存页(筛选/出入库对话框/预警高亮) - 算料报价弹窗(样板库与订单详情入口, 支持打印) - 路由与导航更新(8页面) 回归: 9个API端点全部200, 报价/优化/SPA路由验证通过 --- CHANGELOG | 1 + doc/API.md | 26 +++- frontend/src/components/QuoteDialog.vue | 64 +++++++++ frontend/src/router/index.ts | 1 + frontend/src/views/LayoutView.vue | 1 + frontend/src/views/OrdersView.vue | 7 + frontend/src/views/TemplatesView.vue | 4 + frontend/src/views/WarehouseView.vue | 120 +++++++++++++++++ src/huamenchuang/api/quote.py | 45 +++++++ src/huamenchuang/api/router.py | 13 +- src/huamenchuang/api/warehouse.py | 109 +++++++++++++++ src/huamenchuang/core/config.py | 9 +- src/huamenchuang/core/drawing.py | 23 ++++ src/huamenchuang/models/__init__.py | 11 +- src/huamenchuang/models/contract.py | 57 ++++++++ src/huamenchuang/services/quote_service.py | 147 +++++++++++++++++++++ 16 files changed, 630 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/QuoteDialog.vue create mode 100644 frontend/src/views/WarehouseView.vue create mode 100644 src/huamenchuang/api/quote.py create mode 100644 src/huamenchuang/api/warehouse.py create mode 100644 src/huamenchuang/core/drawing.py create mode 100644 src/huamenchuang/services/quote_service.py diff --git a/CHANGELOG b/CHANGELOG index 8d0596b..0aef52a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-09-20 09:35] FEAT: v0.3 报价算料+仓库库存+前端扩展——报价引擎(从drawing_data几何提取外框/中梃/扇框型材清单+玻璃鞋带面积,套价格本计价);仓库模块(材料CRUD/出入库/流水/负库存拦截/预警);前端新增仓库页与报价弹窗(样板库/订单详情可唤起);SQLite路径锚定项目根修复cwd问题;全接口回归9/9通过 (Files: src/huamenchuang/**, frontend/**, doc/API.md) - [2026-09-20 09:05] FEAT: 前后端全量开发 v0.2——后端新增客户/合同(意向单→正式)/优化下料(FFD)模块与订单合同快照;前端 Vue3+Element Plus 七页面(登录/工作台/订单/合同/客户/样板库/优化下料)与自研二维窗型画布渲染器(缩放/平移/开向标记/尺寸标注);构建产物挂载后端+SPA history 回退;全接口回归 6/6 通过 (Files: src/huamenchuang/**, frontend/**, README.md, doc/API.md) - [2026-09-20 08:14] FEAT: 订单模块 v0.1 实现与二维平面图全量采集——FastAPI 分层架构(api/services/repositories/models)、订单状态机(意向单→正式单→生产中→完工/取消)、7节点生产进度、审计日志、JWT认证、样板库API;创建契约对齐目标站{contract_id,drawing_data,pic_url};254张样板平面图全部下载成功并静态挂载;端到端冒烟测试7/7通过 (Files: src/huamenchuang/**, pyproject.toml, README.md, doc/API.md, data/windoor-images/*, data/fetch-images.py) - [2026-09-20 07:18] FEAT: 整站全量采集——GET 扫描 279 个接口全部留档(业务成功76)、POST 安全探测 24 个(付费墙/参数契约确认)、订单写入接口契约经源码静态分析补全(orders CRUD + 创建订单请求体);doc/API.md 新增 §11 采集结果与付费墙语义 (Files: data/full-site/*, doc/API.md, doc/克隆实施方案.md) diff --git a/doc/API.md b/doc/API.md index da69f5b..d0bde85 100644 --- a/doc/API.md +++ b/doc/API.md @@ -258,21 +258,39 @@ |---|---|---|---|---| | 一维优化 | POST | `/api/optimize1d` | `{demands:[{length,qty,label}], stocks:[{length,qty,label}], kerf:5}` | FFD 首递减装箱;返回 `plans[]`(每根原料的切割组合+余料)、`unplaced[]`、`stats.utilization`;需求>库存时 code=0 | -### 12.8 前端(v0.2,Vue3 + Element Plus,构建产物挂载于后端) +### 12.8 报价算料 `/api/offer-price`(v0.3 新增) + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| 算料报价 | POST | `/api/offer-price` | `{drawing_data \| template_id \| order_id(三选一), prices?}` | 从 drawing_data 几何提取:外框/中梃/扇框型材清单(工程近似)+ 玻璃面积(鞋带公式)+ 五金,套价格本计价。默认价: 型材40-50元/米、玻璃120元/㎡、五金180元/扇;`prices` 可覆盖 | + +### 12.9 仓库库存 `/api/warehouse`(v0.3 新增) + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| 库存列表 | GET | `/api/warehouse/list?page=1&keyword=&material=` | query | material: profile/glass/hardware/addon | +| 新增材料 | POST | `/api/warehouse` | `{name(必填), material, spec, color, unit, price, count, warning, length}` | | +| 编辑材料 | PUT | `/api/warehouse/{id}` | 同新增 | | +| **出入库** | POST | `/api/warehouse/move` | `{item_id, direction: 1\|-1, count, note}` | 出库拒绝负库存,写流水 | +| 出入库流水 | GET | `/api/warehouse/moves?page=1` | query | 全部流水倒序 | + +### 12.10 前端(v0.3,Vue3 + Element Plus,构建产物挂载于后端) | 路由 | 页面 | 功能 | |---|---|---| | `/login` | 登录 | JWT 登录 | | `/dashboard` | 工作台 | 订单/意向单/客户/合同统计 + 快捷入口 | -| `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新 | +| `/orders` | 订单中心 | 搜索/新建(选合同+样板自动带图纸)/详情抽屉(**画布渲染 drawing_data**)+状态机操作+生产进度更新+算料报价+打印 | | `/contracts` | 合同管理 | 列表/新建(选客户带出信息)/意向单转正式/一键下订单 | | `/customers` | 客户管理 | CRUD + 搜索 | -| `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 点击预览(**画布渲染**) + 用此样板下单 | +| `/templates` | 样板库 | 254 套样板网格(平面图) + 分类筛选 + 画布预览 + 算料报价 + 用此样板下单 | +| `/warehouse` | 仓库库存 | 材料 CRUD + 出入库(流水) + 库存预警高亮 | | `/optimize` | 优化下料 | 需求/原料编辑 + FFD 计算结果(利用率/切割组合表) | **画布渲染器**(`components/WindoorCanvas.vue`,自研 Canvas2D):解析 drawing_data → Frame 玻璃区域多边形填充/描边 → 外框加粗 → 扇开向三角标记(od 角度) → 总宽高尺寸标注;支持滚轮缩放、拖拽平移、双击复位。 +**报价弹窗**(`components/QuoteDialog.vue`):明细表 + 总价,可从样板库/订单详情唤起,支持浏览器打印。 -### 12.9 种子数据 +### 12.11 种子数据 ```bash uv sync # 安装依赖 diff --git a/frontend/src/components/QuoteDialog.vue b/frontend/src/components/QuoteDialog.vue new file mode 100644 index 0000000..dab86b4 --- /dev/null +++ b/frontend/src/components/QuoteDialog.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bbd29c1..35e8921 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -16,6 +16,7 @@ const router = createRouter({ { path: 'contracts', component: () => import('../views/ContractsView.vue'), meta: { title: '合同管理' } }, { path: 'customers', component: () => import('../views/CustomersView.vue'), meta: { title: '客户管理' } }, { path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } }, + { path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, ], }, diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/views/LayoutView.vue index acb0cc9..6cead86 100644 --- a/frontend/src/views/LayoutView.vue +++ b/frontend/src/views/LayoutView.vue @@ -9,6 +9,7 @@ 合同管理 客户管理 样板库 + 仓库库存 优化下料 diff --git a/frontend/src/views/OrdersView.vue b/frontend/src/views/OrdersView.vue index 67bbcef..f61f1f2 100644 --- a/frontend/src/views/OrdersView.vue +++ b/frontend/src/views/OrdersView.vue @@ -72,6 +72,8 @@ 客户:{{ detail.customer_name || '-' }} 金额:{{ detail.money }} 面积:{{ detail.area }}㎡ + 算料报价 + 打印订单
@@ -92,6 +94,7 @@
+ @@ -101,8 +104,12 @@ import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { api } from '../api' import WindoorCanvas from '../components/WindoorCanvas.vue' +import QuoteDialog from '../components/QuoteDialog.vue' const route = useRoute() +const quoteRef = ref() + +function printOrder() { window.print() } const statusLabels: Record = { 0: '意向单', 1: '正式单', 2: '生产中', 3: '已完工', 4: '已取消' } diff --git a/frontend/src/views/TemplatesView.vue b/frontend/src/views/TemplatesView.vue index 59be384..ec54615 100644 --- a/frontend/src/views/TemplatesView.vue +++ b/frontend/src/views/TemplatesView.vue @@ -28,10 +28,12 @@
尺寸限制: {{ current.low_width }}-{{ current.high_width }} × {{ current.low_height }}-{{ current.high_height }} mm 引用次数: {{ current.times }} + 算料报价 用此样板下单
+ @@ -40,8 +42,10 @@ import { onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { api } from '../api' import WindoorCanvas from '../components/WindoorCanvas.vue' +import QuoteDialog from '../components/QuoteDialog.vue' const router = useRouter() +const quoteRef = ref() const rows = ref([]) const total = ref(0) const page = ref(1) diff --git a/frontend/src/views/WarehouseView.vue b/frontend/src/views/WarehouseView.vue new file mode 100644 index 0000000..e5fae5b --- /dev/null +++ b/frontend/src/views/WarehouseView.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/huamenchuang/api/quote.py b/src/huamenchuang/api/quote.py new file mode 100644 index 0000000..675e98f --- /dev/null +++ b/src/huamenchuang/api/quote.py @@ -0,0 +1,45 @@ +"""报价路由:从 drawing_data 算料计价(对齐目标站 offer-price 语义,自研实现)。""" +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from huamenchuang.core.deps import CurrentUser, DbSession, require_module +from huamenchuang.core.response import fail, ok +from huamenchuang.services.quote_service import quote as calc_quote + +router = APIRouter(prefix="/offer-price", tags=["报价"], + dependencies=[Depends(require_module("quote"))]) + + +class QuoteRequest(BaseModel): + drawing_data: str | dict | None = Field(default=None, description="画布 drawing_data(与 template_id/order_id 三选一)") + template_id: int | None = Field(default=None, description="或直接指定样板") + order_id: int | None = Field(default=None, description="或对已有订单报价") + prices: dict | None = Field(default=None, description="价格本覆盖 {frame,mullion,sash,glass,hardware}") + + +async def _resolve_drawing(db, body: QuoteRequest) -> str | dict | None: + if body.drawing_data: + return body.drawing_data + if body.template_id: + from sqlalchemy import select + from huamenchuang.models.windoor_template import WindoorTemplate + t = (await db.execute(select(WindoorTemplate).where(WindoorTemplate.id == body.template_id))).scalar_one_or_none() + return t.drawing_data if t else None + if body.order_id: + from huamenchuang.models.order import Order + o = await db.get(Order, body.order_id) + return o.drawing_data if o else None + return None + + +@router.post("") +async def offer_price(body: QuoteRequest, user: CurrentUser, db: DbSession): + """算料报价:型材清单 + 玻璃面积 + 五金 + 总价。""" + drawing = await _resolve_drawing(db, body) + if drawing is None: + return fail("未找到图纸数据") + try: + result = calc_quote(drawing, body.prices) + except Exception: + return fail("图纸数据解析失败") + return ok(data=result, message="报价成功") diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py index 9ccf529..3c61386 100644 --- a/src/huamenchuang/api/router.py +++ b/src/huamenchuang/api/router.py @@ -1,7 +1,16 @@ """路由汇总。""" from fastapi import APIRouter -from huamenchuang.api import auth, contracts, customers, optimize, orders, windoor +from huamenchuang.api import ( + auth, + contracts, + customers, + optimize, + orders, + quote, + warehouse, + windoor, +) api_router = APIRouter(prefix="/api") api_router.include_router(auth.router) @@ -10,3 +19,5 @@ api_router.include_router(customers.router) api_router.include_router(contracts.router) api_router.include_router(windoor.router) api_router.include_router(optimize.router) +api_router.include_router(quote.router) +api_router.include_router(warehouse.router) diff --git a/src/huamenchuang/api/warehouse.py b/src/huamenchuang/api/warehouse.py new file mode 100644 index 0000000..7d9ef4d --- /dev/null +++ b/src/huamenchuang/api/warehouse.py @@ -0,0 +1,109 @@ +"""仓库库存路由:材料 CRUD + 出入库。""" +from fastapi import APIRouter +from pydantic import BaseModel, Field +from sqlalchemy import desc, select + +from huamenchuang.core.deps import CurrentUser, DbSession +from huamenchuang.core.response import fail, ok, paginated +from huamenchuang.models.contract import StockMove, WarehouseItem + +router = APIRouter(prefix="/warehouse", tags=["仓库"]) + + +class ItemBody(BaseModel): + name: str = Field(min_length=1, max_length=128) + bom_code: str = "" + material: str = Field(default="profile", pattern="^(profile|glass|hardware|addon)$") + color: str = "" + spec: str = "" + unit: str = "根" + cost: float = 0 + price: float = 0 + count: float = 0 + warning: float = 0 + length: int = 0 + note: str = "" + + +class MoveBody(BaseModel): + item_id: int + direction: int = Field(default=1, ge=-1, le=1) # 1入库 -1出库 + count: float = Field(gt=0) + note: str = "" + + +def _out(i: WarehouseItem) -> dict: + return { + "id": i.id, "bom_code": i.bom_code, "name": i.name, "material": i.material, + "color": i.color, "spec": i.spec, "unit": i.unit, "cost": float(i.cost), + "price": float(i.price), "count": float(i.count), "warning": float(i.warning), + "length": i.length, "note": i.note, "updated_at": str(i.updated_at), + } + + +@router.get("/list") +async def list_items(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 15, + keyword: str = "", material: str | None = None): + """材料库存列表(分页 + 筛选,对齐目标站 warehouse_list 语义)。""" + from sqlalchemy import func, or_ + conds = [WarehouseItem.company_id == user.company_id] + if material: + conds.append(WarehouseItem.material == material) + if kw := keyword.strip(): + like = f"%{kw}%" + conds.append(or_(WarehouseItem.name.like(like), WarehouseItem.bom_code.like(like))) + where = *conds, + total = (await db.execute(select(func.count()).select_from(WarehouseItem).where(*where))).scalar_one() + stmt = (select(WarehouseItem).where(*where).order_by(WarehouseItem.id.desc()) + .offset((page - 1) * page_size).limit(page_size)) + items = list((await db.execute(stmt)).scalars()) + return ok(data=paginated([_out(i) for i in items], page, page_size, total)) + + +@router.post("") +async def create_item(body: ItemBody, user: CurrentUser, db: DbSession): + """新增材料。""" + item = WarehouseItem(company_id=user.company_id, **body.model_dump()) + db.add(item) + await db.flush() + return ok(data={"id": item.id}, message="创建成功") + + +@router.put("/{item_id}") +async def update_item(item_id: int, body: ItemBody, user: CurrentUser, db: DbSession): + """编辑材料。""" + item = await db.get(WarehouseItem, item_id) + if item is None or item.company_id != user.company_id: + return fail("材料不存在") + for k, v in body.model_dump().items(): + setattr(item, k, v) + return ok(message="更新成功") + + +@router.post("/move") +async def stock_move(body: MoveBody, user: CurrentUser, db: DbSession): + """出入库:direction=1 入库 / -1 出库(出库不允许负库存)。""" + item = await db.get(WarehouseItem, body.item_id) + if item is None or item.company_id != user.company_id: + return fail("材料不存在") + delta = body.count if body.direction == 1 else -body.count + if float(item.count) + delta < 0: + return fail(f"库存不足: 当前 {float(item.count)}{item.unit}") + item.count = float(item.count) + delta + db.add(StockMove(item_id=item.id, direction=body.direction, count=body.count, + note=body.note, user_id=user.id)) + return ok(data={"count": float(item.count)}, message="操作成功") + + +@router.get("/moves") +async def move_logs(user: CurrentUser, db: DbSession, page: int = 1, page_size: int = 20): + """出入库流水。""" + total_q = select(func.count()).select_from(StockMove) + total = (await db.execute(total_q)).scalar_one() + stmt = (select(StockMove).order_by(desc(StockMove.id)) + .offset((page - 1) * page_size).limit(page_size)) + moves = list((await db.execute(stmt)).scalars()) + return ok(data=paginated([{ + "id": m.id, "item_id": m.item_id, "direction": m.direction, "count": float(m.count), + "note": m.note, "created_at": str(m.created_at), + } for m in moves], page, page_size, total)) diff --git a/src/huamenchuang/core/config.py b/src/huamenchuang/core/config.py index d58b4c7..9ccb099 100644 --- a/src/huamenchuang/core/config.py +++ b/src/huamenchuang/core/config.py @@ -1,11 +1,16 @@ -"""全局配置。开发默认 SQLite,生产可通过环境变量切 MySQL。""" +"""全局配置。开发默认 SQLite(锚定项目根,避免 cwd 影响),生产可通过环境变量切 MySQL。""" +from pathlib import Path + from pydantic_settings import BaseSettings +_PROJECT_ROOT = Path(__file__).resolve().parents[3] +_DEFAULT_DB = f"sqlite+aiosqlite:///{(_PROJECT_ROOT / 'huamenchuang.db').as_posix()}" + class Settings(BaseSettings): app_name: str = "huamenchuang" # 开发默认 SQLite;生产示例: mysql+aiomysql://user:pwd@localhost/hmc - database_url: str = "sqlite+aiosqlite:///./huamenchuang.db" + database_url: str = _DEFAULT_DB jwt_secret: str = "change-me-in-production" jwt_algorithm: str = "HS256" jwt_expire_hours: int = 24 diff --git a/src/huamenchuang/core/drawing.py b/src/huamenchuang/core/drawing.py new file mode 100644 index 0000000..2f42491 --- /dev/null +++ b/src/huamenchuang/core/drawing.py @@ -0,0 +1,23 @@ +"""drawing_data 的 Python 侧解析(与前端 types/drawing.ts 同构的最小模型)。""" +import json +from typing import Any, TypedDict + + +class FrameShape(TypedDict, total=False): + type: str + polygon: dict + fm: dict + mm: dict + cm: dict + sm: list + + +class DrawingData(TypedDict, total=False): + s: dict + sm: list + + +def parse_drawing(raw: str | dict) -> DrawingData: + """解析 drawing_data(兼容 JSON 字符串或对象)。""" + dd = json.loads(raw) if isinstance(raw, str) else raw + return dd # type: ignore[return-value] diff --git a/src/huamenchuang/models/__init__.py b/src/huamenchuang/models/__init__.py index 7875223..7c08e01 100644 --- a/src/huamenchuang/models/__init__.py +++ b/src/huamenchuang/models/__init__.py @@ -1,5 +1,11 @@ """ORM 模型统一导出。""" -from huamenchuang.models.contract import Contract, Customer +from huamenchuang.models.contract import ( + Contract, + Customer, + ReceiptAccount, + StockMove, + WarehouseItem, +) from huamenchuang.models.order import DEFAULT_PROCESSES, Order, OrderLog, OrderProcess from huamenchuang.models.user import User from huamenchuang.models.windoor_template import WindoorCatelog, WindoorTemplate @@ -11,7 +17,10 @@ __all__ = [ "Order", "OrderLog", "OrderProcess", + "ReceiptAccount", + "StockMove", "User", + "WarehouseItem", "WindoorCatelog", "WindoorTemplate", ] diff --git a/src/huamenchuang/models/contract.py b/src/huamenchuang/models/contract.py index 9b95cc7..b46f9fe 100644 --- a/src/huamenchuang/models/contract.py +++ b/src/huamenchuang/models/contract.py @@ -25,6 +25,63 @@ class Customer(Base): ) +class ReceiptAccount(Base): + """收款账户。""" + + __tablename__ = "receipt_accounts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + type: Mapped[int] = mapped_column(default=1) # 1银行 2现金 3其他 + name: Mapped[str] = mapped_column(String(64)) + number: Mapped[str] = mapped_column(String(64), default="") + bank: Mapped[str] = mapped_column(String(64), default="") + note: Mapped[str] = mapped_column(Text, default="") + is_default: Mapped[int] = mapped_column(default=0) + balance: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + + +class WarehouseItem(Base): + """材料库存(型材/玻璃/五金/配件)。""" + + __tablename__ = "warehouse_items" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + bom_code: Mapped[str] = mapped_column(String(32), default="") + name: Mapped[str] = mapped_column(String(128)) + # profile型材/glass玻璃/hardware五金/addon配件 + material: Mapped[str] = mapped_column(String(16), default="profile", index=True) + color: Mapped[str] = mapped_column(String(32), default="") + spec: Mapped[str] = mapped_column(String(64), default="") # 规格型号 + unit: Mapped[str] = mapped_column(String(8), default="根") + cost: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + price: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + count: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + warning: Mapped[float] = mapped_column(Numeric(12, 2), default=0) # 库存预警线 + length: Mapped[int] = mapped_column(default=0) # 单根长度mm(型材) + 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 + ) + + +class StockMove(Base): + """出入库流水。direction: 1入库 -1出库""" + + __tablename__ = "stock_moves" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + item_id: Mapped[int] = mapped_column(ForeignKey("warehouse_items.id"), index=True) + direction: Mapped[int] = mapped_column(default=1) + count: Mapped[float] = mapped_column(Numeric(12, 2), default=0) + note: Mapped[str] = mapped_column(String(255), default="") + user_id: Mapped[int] = mapped_column(default=0) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + + class Contract(Base): """合同/意向单:目标站契约 status="意向单" 起步,oids[] 记录关联订单。""" diff --git a/src/huamenchuang/services/quote_service.py b/src/huamenchuang/services/quote_service.py new file mode 100644 index 0000000..7be7b56 --- /dev/null +++ b/src/huamenchuang/services/quote_service.py @@ -0,0 +1,147 @@ +"""报价算料服务:从 drawing_data 几何信息提取型材下料清单与玻璃面积,套价格本计价。 + +提取规则(自研 v1,工程近似): +- 外框:每 Frame 形状 2 根宽 + 2 根高(忽略 45° 拼角损耗) +- 中梃:每条分割线按其在框内投影长度计(竖梃=框高,横梃=框宽) +- 扇框:每个 Sash 4 根(2×扇宽 + 2×扇高) +- 玻璃:全部玻璃区域面积之和(鞋带公式) +""" +from dataclasses import dataclass, field + +from huamenchuang.core.drawing import DrawingData, parse_drawing + + +@dataclass +class BarItem: + """型材清单行。""" + kind: str # frame/mullion/sash + length: int # mm + qty: int = 1 + note: str = "" + + +@dataclass +class TakeoffResult: + bars: list[BarItem] = field(default_factory=list) + glass_area_m2: float = 0.0 + width_mm: int = 0 + height_mm: int = 0 + + +def _shoelace_area(pts: list[tuple[float, float]]) -> float: + """多边形面积(鞋带公式)。""" + n = len(pts) + s = 0.0 + for i in range(n): + x1, y1 = pts[i] + x2, y2 = pts[(i + 1) % n] + s += x1 * y2 - x2 * y1 + return abs(s) / 2 + + +def _region_bbox(region_pts): + xs = [p[0] for p in region_pts] + ys = [p[1] for p in region_pts] + return min(xs), min(ys), max(xs), max(ys) + + +def takeoff(dd: DrawingData) -> TakeoffResult: + """从解析后的 drawing_data 提取算料清单。""" + result = TakeoffResult() + xs, ys = [], [] + + for shape in dd.get("sm", []): + regions = [[(seg["ps"]["x"], seg["ps"]["y"]) for seg in r] + for r in ((shape.get("polygon") or {}).get("polygon") or [])] + if not regions: + continue + # 形状外框 bbox + all_pts = [p for r in regions for p in r] + x0, y0, x1, y1 = _region_bbox(all_pts) + w, h = round(x1 - x0), round(y1 - y0) + xs.extend([x0, x1]) + ys.extend([y0, y1]) + result.bars.append(BarItem("frame", w, 2, "外框-宽")) + result.bars.append(BarItem("frame", h, 2, "外框-高")) + + # 中梃:投影长度(竖梃=框高,横梃=框宽) + for sp in ((shape.get("mm") or {}).get("sp") or []): + norm = sp["line"]["norm"] + if abs(norm["x"]) >= abs(norm["y"]): + result.bars.append(BarItem("mullion", h, 1, "竖梃")) + else: + result.bars.append(BarItem("mullion", w, 1, "横梃")) + + # 玻璃面积 + result.glass_area_m2 += sum(_shoelace_area(r) for r in regions) / 1_000_000 + + # 扇框 + for sub in (shape.get("sm") or []): + bd = sub.get("bd") if isinstance(sub, dict) else None + if not bd or bd.get("hidden"): + continue + bw = round(abs(bd["to"]["x"] - bd["from"]["x"])) + bh = round(abs(bd["to"]["y"] - bd["from"]["y"])) + if bw < 10 or bh < 10: + continue + result.bars.append(BarItem("sash", bw, 2, "扇框-宽")) + result.bars.append(BarItem("sash", bh, 2, "扇框-高")) + + if xs: + result.width_mm = round(max(xs) - min(xs)) + result.height_mm = round(max(ys) - min(ys)) + return result + + +def merge_bars(bars: list[BarItem]) -> list[dict]: + """同长度同类合并。""" + merged: dict[tuple, dict] = {} + for b in bars: + key = (b.kind, b.length, b.note) + if key in merged: + merged[key]["qty"] += b.qty + else: + merged[key] = {"kind": b.kind, "length": b.length, "note": b.note, "qty": b.qty} + return sorted(merged.values(), key=lambda x: -x["length"]) + + +def quote(drawing: str | dict, prices: dict | None = None) -> dict: + """完整报价:算料 + 套价。 + + prices 结构: {frame: 元/米, mullion: 元/米, sash: 元/米, glass: 元/㎡, hardware: 元/扇} + """ + p = {"frame": 45, "mullion": 40, "sash": 50, "glass": 120, "hardware": 180} + if prices: + p.update(prices) + + dd = parse_drawing(drawing) + t = takeoff(dd) + rows, total = [], 0.0 + unit_kind = {"frame": "frame", "mullion": "mullion", "sash": "sash"} + for b in merge_bars(t.bars): + amount = b["length"] * b["qty"] / 1000 * p[unit_kind[b["kind"]]] + total += amount + rows.append({**b, "unit_price": p[unit_kind[b["kind"]]], "unit": "元/米", "amount": round(amount, 2)}) + + glass_amount = t.glass_area_m2 * p["glass"] + total += glass_amount + rows.append({"kind": "glass", "length": 0, "note": "玻璃", "qty": round(t.glass_area_m2, 3), + "unit_price": p["glass"], "unit": "元/㎡", "amount": round(glass_amount, 2)}) + + sash_count = sum( + 1 for s in dd.get("sm", []) for sub in (s.get("sm") or []) + if isinstance(sub, dict) and sub.get("bd") and not sub.get("bd").get("hidden") + ) + hw_amount = sash_count * p["hardware"] + if sash_count: + rows.append({"kind": "hardware", "length": 0, "note": "五金", "qty": sash_count, + "unit_price": p["hardware"], "unit": "元/扇", "amount": round(hw_amount, 2)}) + total += hw_amount + + return { + "rows": rows, + "glass_area_m2": round(t.glass_area_m2, 3), + "width_mm": t.width_mm, + "height_mm": t.height_mm, + "total": round(total, 2), + }