mini_client_extra.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986
  1. """小程序客户端扩展接口:购物车、商品分类、仪表盘、发票、下单、支付"""
  2. from typing import Optional, List, Tuple, Dict
  3. from decimal import Decimal
  4. from fastapi import APIRouter, Depends, Query
  5. from sqlalchemy.orm import Session
  6. from sqlalchemy import and_
  7. from app.core.database import get_db
  8. from app.core.auth import get_current_user
  9. from app.core.response import success_response, error_response
  10. from app.core.config import settings
  11. from app.core.utils import generate_id
  12. from datetime import datetime
  13. import random
  14. from app.models.models import BizInvoice
  15. from app.crud.user import user_crud
  16. from app.crud.address import address_crud
  17. from app.crud.client_cart import client_cart_crud
  18. from app.crud.price_list import price_list_crud, price_list_item_crud
  19. from app.crud.product import product_crud
  20. from app.models.models import BasProduct, BasProductCategory
  21. from app.crud.order import order_crud
  22. from app.crud.category import category_crud
  23. from app.crud.unit import unit_crud
  24. from app.crud.product_banner import product_banner
  25. from app.crud.product_detail_media import product_detail_media
  26. from app.crud.product_parameter_value import product_parameter_value
  27. from app.api.mini_client import require_client
  28. router = APIRouter(prefix="/api/mini/client", tags=["小程序端-客户端扩展"])
  29. def _cart_item_to_dict(
  30. row,
  31. product: Optional[BasProduct] = None,
  32. root_category: Optional[BasProductCategory] = None,
  33. ) -> dict:
  34. return {
  35. "id": row.id,
  36. "productId": row.product_id,
  37. "productName": row.product_name or "",
  38. "categoryId": product.category_id if product and product.category_id else None,
  39. "firstCategoryId": root_category.id if root_category else None,
  40. "firstCategoryName": (root_category.name if root_category else None) or "",
  41. "categoryName": row.category_name or "",
  42. "unitName": row.unit_name or "",
  43. "measureValue": float(product.measure_value) if (product and product.measure_value is not None) else None,
  44. "price": float(row.unit_price) if row.unit_price is not None else 0,
  45. "quantity": float(row.quantity) if row.quantity is not None else 0,
  46. "amount": float(row.amount) if row.amount is not None else 0,
  47. "imageUrl": row.image_url,
  48. }
  49. def _root_and_leaf_category(
  50. db: Session, category_id: Optional[str], cache: Dict[str, Tuple[Optional[BasProductCategory], Optional[BasProductCategory]]]
  51. ) -> Tuple[Optional[BasProductCategory], Optional[BasProductCategory]]:
  52. """从叶子分类 id 向上解析(含缓存):返回 (根分类, 叶子分类)。"""
  53. if not category_id:
  54. return None, None
  55. if category_id in cache:
  56. return cache[category_id]
  57. leaf = category_crud.get(db, category_id)
  58. if not leaf or getattr(leaf, "is_del", 0):
  59. cache[category_id] = (None, None)
  60. return None, None
  61. cur = leaf
  62. guard = 0
  63. while cur.parent_id and guard < 10:
  64. parent = category_crud.get(db, cur.parent_id)
  65. if not parent or getattr(parent, "is_del", 0):
  66. break
  67. cur = parent
  68. guard += 1
  69. root = cur
  70. cache[category_id] = (root, leaf)
  71. return root, leaf
  72. def _cart_resolve_root_category(db: Session, product: Optional[BasProduct]) -> Optional[BasProductCategory]:
  73. """商品对应的一级(根)分类,供购物车行展示与序列化。"""
  74. if not product or not product.category_id:
  75. return None
  76. cache: Dict[str, Tuple[Optional[BasProductCategory], Optional[BasProductCategory]]] = {}
  77. root, _ = _root_and_leaf_category(db, product.category_id, cache)
  78. return root
  79. def _cart_row_sort_key(
  80. db: Session,
  81. row,
  82. pmap: dict,
  83. cat_cache: Dict[str, Tuple[Optional[BasProductCategory], Optional[BasProductCategory]]],
  84. ) -> tuple:
  85. """购物车:先一级分类 sort_order,再商品名、商品 id(稳定)。"""
  86. product = pmap.get(row.product_id)
  87. pname = (row.product_name or (product.name if product else "") or "").lower()
  88. pid = row.product_id or ""
  89. if not product or not product.category_id:
  90. return (999999, pname, pid)
  91. root, _leaf = _root_and_leaf_category(db, product.category_id, cat_cache)
  92. fo = int(root.sort_order) if root else 999999
  93. return (fo, pname, pid)
  94. def _cart_rows_after_sync(db: Session, uid: str, region_id: Optional[str], op: dict) -> List:
  95. """当前用户有效购物车行(含区域价目表同步、过滤已删)。"""
  96. rows = client_cart_crud.list_by_client(db, uid)
  97. if region_id:
  98. client_cart_crud.sync_to_active_price_list(db, uid, region_id, op, rows=rows)
  99. rows = [r for r in rows if getattr(r, "is_del", 0) == 0]
  100. return rows
  101. def _cart_list_dicts(db: Session, rows: List) -> List[dict]:
  102. """将购物车 ORM 行序列化为与 GET /cart 一致的 list 项(仅未删除行)。"""
  103. rows = [r for r in rows if getattr(r, "is_del", 0) == 0]
  104. product_ids = list({r.product_id for r in rows if r and getattr(r, "product_id", None)})
  105. products = (
  106. db.query(BasProduct)
  107. .filter(and_(BasProduct.id.in_(product_ids), BasProduct.is_del == 0))
  108. .all()
  109. if product_ids
  110. else []
  111. )
  112. pmap = {p.id: p for p in products}
  113. cat_cache: Dict[str, Tuple[Optional[BasProductCategory], Optional[BasProductCategory]]] = {}
  114. rows_sorted = sorted(rows, key=lambda r: _cart_row_sort_key(db, r, pmap, cat_cache))
  115. out: List[dict] = []
  116. for r in rows_sorted:
  117. p = pmap.get(r.product_id)
  118. root: Optional[BasProductCategory] = None
  119. if p and p.category_id:
  120. root, _ = _root_and_leaf_category(db, p.category_id, cat_cache)
  121. out.append(_cart_item_to_dict(r, p, root))
  122. return out
  123. @router.get("/cart/summary", response_model=dict)
  124. def cart_summary(
  125. db: Session = Depends(get_db),
  126. current_user: dict = Depends(require_client),
  127. ):
  128. """购物车汇总(件数、行数、金额),供 Tab 角标等轻量刷新。"""
  129. uid = current_user.get("user_id")
  130. region_id = current_user.get("region_id")
  131. op = {"user_id": uid, "real_name": current_user.get("real_name")}
  132. rows = _cart_rows_after_sync(db, uid, region_id, op)
  133. tq = sum(float(r.quantity or 0) for r in rows)
  134. ta = sum(float(r.amount or 0) for r in rows)
  135. return success_response({
  136. "lineCount": len(rows),
  137. "totalQuantity": tq,
  138. "totalAmount": ta,
  139. })
  140. @router.get("/cart", response_model=dict)
  141. def list_cart(
  142. db: Session = Depends(get_db),
  143. current_user: dict = Depends(require_client),
  144. page: Optional[int] = Query(None, ge=1, description="传则分页;不传则返回全量 list(兼容确认订单等)"),
  145. pageSize: Optional[int] = Query(None, ge=1, le=100, description="分页时每页条数,默认 20"),
  146. ):
  147. uid = current_user.get("user_id")
  148. region_id = current_user.get("region_id")
  149. op = {"user_id": uid, "real_name": current_user.get("real_name")}
  150. rows = _cart_rows_after_sync(db, uid, region_id, op)
  151. full_list = _cart_list_dicts(db, rows)
  152. if page is None:
  153. return success_response({"list": full_list})
  154. ps = pageSize if pageSize is not None else 20
  155. total = len(full_list)
  156. start = (page - 1) * ps
  157. chunk = full_list[start : start + ps]
  158. total_pages = (total + ps - 1) // ps if total else 0
  159. return success_response({
  160. "list": chunk,
  161. "total": total,
  162. "page": page,
  163. "pageSize": ps,
  164. "totalPages": total_pages,
  165. })
  166. @router.post("/cart", response_model=dict)
  167. def add_cart(
  168. request: dict,
  169. db: Session = Depends(get_db),
  170. current_user: dict = Depends(require_client),
  171. ):
  172. product_id = request.get("productId")
  173. quantity = Decimal(str(request.get("quantity", 1)))
  174. if not product_id or quantity <= 0:
  175. return error_response(400, "商品与数量无效")
  176. region_id = current_user.get("region_id")
  177. if not region_id:
  178. return error_response(400, "用户未绑定区域")
  179. pl = price_list_crud.get_active(db, region_id)
  180. if not pl:
  181. return error_response(404, "当前区域无生效价格清单")
  182. # 只查询该商品对应的价目表行,避免拉取整张价目表明细导致加购变慢
  183. pi = price_list_item_crud.get_by_product_and_price_list(db, product_id, pl.id)
  184. if not pi:
  185. return error_response(400, "商品不在当前价格清单中")
  186. # 用一次 JOIN 拉齐商品 + 单位 + 分类,避免 3 次单查
  187. from app.models.models import BasProductUnit, BasProductCategory
  188. q = (
  189. db.query(BasProduct, BasProductUnit.name, BasProductCategory.name)
  190. .select_from(BasProduct)
  191. # 明确从 BasProduct 出发,避免 join 路径歧义
  192. .outerjoin(BasProductUnit, BasProductUnit.id == pi.unit_id)
  193. .outerjoin(BasProductCategory, BasProductCategory.id == BasProduct.category_id)
  194. .filter(and_(BasProduct.id == product_id, BasProduct.is_del == 0))
  195. )
  196. r = q.first()
  197. product = r[0] if r else None
  198. unit_name = r[1] if r else ""
  199. cat_name = r[2] if r else ""
  200. op = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  201. row = client_cart_crud.add_or_update(
  202. db,
  203. client_id=current_user.get("user_id"),
  204. price_list_id=pl.id,
  205. product_id=product_id,
  206. unit_id=pi.unit_id,
  207. quantity=quantity,
  208. unit_price=Decimal(str(pi.price)),
  209. product_name=product.name if product else "",
  210. unit_name=unit_name or "",
  211. category_name=cat_name,
  212. image_url=product.image_url if product else "",
  213. operator=op,
  214. )
  215. root_cat = _cart_resolve_root_category(db, product)
  216. return success_response(_cart_item_to_dict(row, product, root_cat))
  217. @router.put("/cart/{cart_id}", response_model=dict)
  218. def put_cart(
  219. cart_id: str,
  220. request: dict,
  221. db: Session = Depends(get_db),
  222. current_user: dict = Depends(require_client),
  223. ):
  224. qty = Decimal(str(request.get("quantity", 0)))
  225. if qty <= 0:
  226. return error_response(400, "数量无效")
  227. uid = current_user.get("user_id")
  228. region_id = current_user.get("region_id")
  229. op = {"user_id": uid, "real_name": current_user.get("real_name")}
  230. # 先拿到该行,只有当其 price_list_id 不是当前生效清单时才触发“全车同步”
  231. cur = client_cart_crud.get(db, cart_id)
  232. if not cur or cur.client_id != uid:
  233. return error_response(404, "购物车项不存在")
  234. need_full_cart = False
  235. if region_id:
  236. pl = price_list_crud.get_active(db, region_id)
  237. if pl and getattr(cur, "price_list_id", None) != pl.id:
  238. client_cart_crud.sync_to_active_price_list(db, uid, region_id, op)
  239. # 同步可能合并/软删多行,前端需拉全量列表
  240. need_full_cart = True
  241. row = client_cart_crud.update_quantity(db, cart_id, uid, qty, op, current_row=cur)
  242. if not row:
  243. return error_response(404, "购物车项不存在")
  244. # 行更新接口优先返回轻量字段,避免每次点加减都额外查询商品/分类。
  245. line = _cart_item_to_dict(row, None, None)
  246. if need_full_cart:
  247. # 与 GET /cart 同结构,避免前端再发一次全量请求
  248. rows_after = _cart_rows_after_sync(db, uid, region_id, op)
  249. return success_response({"list": _cart_list_dicts(db, rows_after), "item": line})
  250. return success_response(line)
  251. @router.delete("/cart", response_model=dict)
  252. def delete_cart_clear(
  253. db: Session = Depends(get_db),
  254. current_user: dict = Depends(require_client),
  255. ):
  256. op = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  257. n = client_cart_crud.clear(db, current_user.get("user_id"), op)
  258. return success_response({"cleared": n})
  259. @router.delete("/cart/batch", response_model=dict)
  260. def delete_cart_batch(
  261. request: dict,
  262. db: Session = Depends(get_db),
  263. current_user: dict = Depends(require_client),
  264. ):
  265. ids = request.get("cartIds") or []
  266. if not isinstance(ids, list):
  267. return error_response(400, "cartIds 格式错误")
  268. op = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  269. n = client_cart_crud.batch_delete(db, current_user.get("user_id"), ids, op)
  270. return success_response({"removed": n})
  271. @router.delete("/cart/{cart_id}", response_model=dict)
  272. def delete_cart_one(
  273. cart_id: str,
  274. db: Session = Depends(get_db),
  275. current_user: dict = Depends(require_client),
  276. ):
  277. op = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  278. ok = client_cart_crud.soft_delete(db, cart_id, current_user.get("user_id"), op)
  279. if not ok:
  280. return error_response(404, "购物车项不存在")
  281. return success_response({"ok": True})
  282. @router.get("/price-list", response_model=dict)
  283. def get_price_list_alias(
  284. db: Session = Depends(get_db),
  285. current_user: dict = Depends(require_client),
  286. ):
  287. """与 /price-list/current 相同,便于小程序路径统一"""
  288. user = user_crud.get(db, current_user.get("user_id"))
  289. if not user or not user.region_id:
  290. return error_response(400, "用户未绑定区域")
  291. price_list = price_list_crud.get_active(db, user.region_id)
  292. if not price_list:
  293. return error_response(404, "暂无生效价格清单")
  294. items = price_list_item_crud.get_by_price_list(db, price_list.id)
  295. item_details = []
  296. for item in items:
  297. product = product_crud.get(db, item.product_id)
  298. unit = unit_crud.get(db, item.unit_id) if item.unit_id else None
  299. item_details.append({
  300. "productId": item.product_id,
  301. "productName": product.name if product else None,
  302. "unitId": item.unit_id,
  303. "unitName": unit.name if unit else None,
  304. "price": float(item.price) if item.price else 0,
  305. "imageUrl": product.image_url if product else None,
  306. })
  307. return success_response({
  308. "id": price_list.id,
  309. "regionId": price_list.region_id,
  310. "version": price_list.version,
  311. "items": item_details,
  312. })
  313. def _sort_category_dicts_locally(cats: List[dict]) -> List[dict]:
  314. def _key(c: dict):
  315. return (
  316. int(c.get("sortOrder") or 0),
  317. (c.get("name") or "").lower(),
  318. c.get("id") or "",
  319. )
  320. return sorted(cats, key=_key)
  321. def _client_aggregated_categories(db: Session, current_user: dict) -> Tuple[Optional[List[dict]], Optional[str]]:
  322. """返回 (分类 dict 列表, 错误信息)。无清单时返回 ([], None)。"""
  323. user = user_crud.get(db, current_user.get("user_id"))
  324. if not user or not user.region_id:
  325. return None, "用户未绑定区域"
  326. pl = price_list_crud.get_active(db, user.region_id)
  327. if not pl:
  328. return [], None
  329. items = price_list_item_crud.get_by_price_list(db, pl.id)
  330. pmap: Dict[str, dict] = {}
  331. for it in items:
  332. product = product_crud.get(db, it.product_id)
  333. if not product or not product.category_id:
  334. continue
  335. cid = product.category_id
  336. if cid not in pmap:
  337. cat = category_crud.get(db, cid)
  338. pmap[cid] = {
  339. "id": cid,
  340. "name": cat.name if cat else "",
  341. "parentId": cat.parent_id if cat else None,
  342. "sortOrder": cat.sort_order if cat else 0,
  343. "isActive": True,
  344. "imageUrl": cat.image_url if cat else None,
  345. }
  346. return list(pmap.values()), None
  347. @router.get("/product-categories", response_model=dict)
  348. def list_product_categories(
  349. db: Session = Depends(get_db),
  350. current_user: dict = Depends(require_client),
  351. ):
  352. """基于当前生效价格清单中的商品,聚合分类(全量,兼容旧调用)"""
  353. cats, err = _client_aggregated_categories(db, current_user)
  354. if err:
  355. return error_response(400, err)
  356. return success_response(cats)
  357. @router.get("/product-categories/first-level", response_model=dict)
  358. def list_product_categories_first_level(
  359. db: Session = Depends(get_db),
  360. current_user: dict = Depends(require_client),
  361. page: int = Query(1, ge=1),
  362. pageSize: int = Query(8, ge=1, le=50),
  363. ):
  364. """首页一级分类(swiper),服务端分页"""
  365. cats, err = _client_aggregated_categories(db, current_user)
  366. if err:
  367. return error_response(400, err)
  368. sorted_all = _sort_category_dicts_locally(cats or [])
  369. roots = [c for c in sorted_all if not c.get("parentId")]
  370. total = len(roots)
  371. start = (page - 1) * pageSize
  372. slice_roots = roots[start : start + pageSize]
  373. out = [
  374. {
  375. "id": c["id"],
  376. "name": c["name"],
  377. "imageUrl": c.get("imageUrl") or "",
  378. }
  379. for c in slice_roots
  380. ]
  381. total_pages = (total + pageSize - 1) // pageSize if total else 0
  382. return success_response({
  383. "list": out,
  384. "total": total,
  385. "page": page,
  386. "pageSize": pageSize,
  387. "totalPages": total_pages,
  388. })
  389. @router.get("/product-categories/sidebar", response_model=dict)
  390. def list_product_categories_sidebar(
  391. db: Session = Depends(get_db),
  392. current_user: dict = Depends(require_client),
  393. firstCategoryId: str = Query("all", description="一级分类 id,all 表示顶部选「全部」"),
  394. page: int = Query(1, ge=1),
  395. pageSize: int = Query(30, ge=1, le=100),
  396. ):
  397. """首页左侧二级分类栏选项,服务端分页(含「全部」行)"""
  398. cats, err = _client_aggregated_categories(db, current_user)
  399. if err:
  400. return error_response(400, err)
  401. sorted_all = _sort_category_dicts_locally(cats or [])
  402. roots = [c for c in sorted_all if not c.get("parentId")]
  403. rows: List[dict] = [{"id": "all", "name": "全部"}]
  404. fid = (firstCategoryId or "all").strip()
  405. if fid == "all":
  406. rows.extend({"id": c["id"], "name": c["name"]} for c in roots)
  407. else:
  408. children = [c for c in sorted_all if c.get("parentId") == fid]
  409. rows.extend({"id": c["id"], "name": c["name"]} for c in children)
  410. total = len(rows)
  411. start = (page - 1) * pageSize
  412. chunk = rows[start : start + pageSize]
  413. total_pages = (total + pageSize - 1) // pageSize if total else 0
  414. return success_response({
  415. "list": chunk,
  416. "total": total,
  417. "page": page,
  418. "pageSize": pageSize,
  419. "totalPages": total_pages,
  420. })
  421. def _root_category_id_for_leaf(db: Session, category_id: Optional[str], memo: Dict[str, Optional[str]]) -> Optional[str]:
  422. """商品分类叶子节点向上解析一级(根)分类 id,与小程序端 getFirstCategoryId 一致。"""
  423. if not category_id:
  424. return None
  425. if category_id in memo:
  426. return memo[category_id]
  427. cur: Optional[str] = category_id
  428. chain: List[str] = []
  429. while cur and len(chain) < 32:
  430. chain.append(cur)
  431. cat = category_crud.get(db, cur)
  432. if not cat:
  433. root = chain[-1]
  434. for cid in chain:
  435. memo[cid] = root
  436. return root
  437. if not cat.parent_id:
  438. root = cat.id
  439. for cid in chain:
  440. memo[cid] = root
  441. return root
  442. cur = cat.parent_id
  443. root = chain[-1] if chain else None
  444. for cid in chain:
  445. memo[cid] = root
  446. return root
  447. @router.get("/products", response_model=dict)
  448. def list_products(
  449. db: Session = Depends(get_db),
  450. current_user: dict = Depends(require_client),
  451. page: int = Query(1, ge=1),
  452. pageSize: int = Query(20, ge=1, le=100),
  453. categoryId: Optional[str] = None,
  454. rootCategoryId: Optional[str] = None,
  455. keyword: Optional[str] = None,
  456. ):
  457. """分页查询当前价目表中的商品(与 price-list/products 一致逻辑)"""
  458. user = user_crud.get(db, current_user.get("user_id"))
  459. if not user or not user.region_id:
  460. return error_response(400, "用户未绑定区域")
  461. pl = price_list_crud.get_active(db, user.region_id)
  462. if not pl:
  463. return success_response({"list": [], "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
  464. items = price_list_item_crud.get_by_price_list(db, pl.id)
  465. root_memo: Dict[str, Optional[str]] = {}
  466. rows = []
  467. for item in items:
  468. product = product_crud.get(db, item.product_id)
  469. if not product:
  470. continue
  471. if categoryId and product.category_id != categoryId:
  472. continue
  473. if rootCategoryId:
  474. rid = _root_category_id_for_leaf(db, product.category_id, root_memo)
  475. if rid != rootCategoryId:
  476. continue
  477. if keyword and keyword.lower() not in (product.name or "").lower():
  478. continue
  479. unit = unit_crud.get(db, item.unit_id) if item.unit_id else None
  480. cat_name = ""
  481. if product.category_id:
  482. cat = category_crud.get(db, product.category_id)
  483. cat_name = cat.name if cat else ""
  484. rows.append({
  485. "id": product.id,
  486. "name": product.name,
  487. "categoryId": product.category_id,
  488. "categoryName": cat_name,
  489. "unitId": item.unit_id,
  490. "unitName": unit.name if unit else "",
  491. "measureValue": float(product.measure_value) if getattr(product, "measure_value", None) is not None else None,
  492. "description": product.description,
  493. "imageUrl": product.image_url,
  494. "price": float(item.price) if item.price else 0,
  495. "isActive": True,
  496. "sortOrder": int(getattr(product, "sort_order", None) or 0),
  497. })
  498. cat_cache: Dict[str, Optional[BasProductCategory]] = {}
  499. def _ensure_category_chain_cached(cid: Optional[str]) -> None:
  500. cur = cid
  501. for _ in range(32):
  502. if not cur:
  503. break
  504. if cur in cat_cache:
  505. c = cat_cache[cur]
  506. cur = c.parent_id if c else None
  507. continue
  508. c = category_crud.get(db, cur)
  509. cat_cache[cur] = c
  510. cur = c.parent_id if c else None
  511. for row in rows:
  512. _ensure_category_chain_cached(row.get("categoryId"))
  513. def _sort_tuple(r: dict) -> Tuple:
  514. cid = r.get("categoryId")
  515. first_ord = 0
  516. second_ord = 0
  517. if cid:
  518. chain: List[BasProductCategory] = []
  519. cur: Optional[str] = cid
  520. for _ in range(32):
  521. c = cat_cache.get(cur) if cur else None
  522. if not c:
  523. break
  524. chain.append(c)
  525. if not c.parent_id:
  526. break
  527. cur = c.parent_id
  528. if chain:
  529. second_ord = int(chain[0].sort_order or 0)
  530. first_ord = int(chain[-1].sort_order or 0)
  531. name = (r.get("name") or "").lower()
  532. sid = r.get("id") or ""
  533. return (first_ord, second_ord, int(r.get("sortOrder") or 0), name, sid)
  534. rows.sort(key=_sort_tuple)
  535. total = len(rows)
  536. start = (page - 1) * pageSize
  537. paginated = rows[start : start + pageSize]
  538. total_pages = (total + pageSize - 1) // pageSize if total else 0
  539. return success_response({
  540. "list": paginated,
  541. "total": total,
  542. "page": page,
  543. "pageSize": pageSize,
  544. "totalPages": total_pages,
  545. })
  546. @router.get("/products/{product_id}", response_model=dict)
  547. def get_client_product_detail(
  548. product_id: str,
  549. db: Session = Depends(get_db),
  550. current_user: dict = Depends(require_client),
  551. ):
  552. """客户端商品详情:价目表内商品 + 轮播图 + 参数值 + 富文本 + 详情媒体(图/视频)"""
  553. user = user_crud.get(db, current_user.get("user_id"))
  554. if not user or not user.region_id:
  555. return error_response(400, "用户未绑定区域")
  556. pl = price_list_crud.get_active(db, user.region_id)
  557. if not pl:
  558. return error_response(404, "暂无生效价格清单")
  559. items = price_list_item_crud.get_by_price_list(db, pl.id)
  560. line = next((it for it in items if it.product_id == product_id), None)
  561. if not line:
  562. return error_response(404, "商品不存在或不在当前价目表")
  563. product = product_crud.get(db, product_id)
  564. if not product or getattr(product, "is_del", 0) == 1:
  565. return error_response(404, "商品不存在")
  566. unit = unit_crud.get(db, line.unit_id) if line.unit_id else None
  567. cat_name = ""
  568. if product.category_id:
  569. cat = category_crud.get(db, product.category_id)
  570. cat_name = cat.name if cat else ""
  571. banners_raw = product_banner.get_by_product(db, product_id)
  572. banners = [{
  573. "mediaType": b.media_type,
  574. "fileUrl": b.file_url,
  575. "thumbnailUrl": b.thumbnail_url,
  576. "sortOrder": b.sort_order,
  577. } for b in banners_raw]
  578. pv_rows = product_parameter_value.get_by_product_with_detail(db, product_id)
  579. parameter_values = []
  580. for row in pv_rows:
  581. val = row.get("param_value")
  582. if val is None or str(val).strip() == "":
  583. continue
  584. parameter_values.append({
  585. "parameterId": row.get("parameter_id"),
  586. "parameterName": row.get("parameter_name") or "",
  587. "paramValue": str(val),
  588. })
  589. media_raw = product_detail_media.get_by_product(db, product_id)
  590. detail_media = []
  591. for m in media_raw:
  592. if m.content_type not in ("image", "video"):
  593. continue
  594. if not m.file_url:
  595. continue
  596. detail_media.append({
  597. "id": m.id,
  598. "contentType": m.content_type,
  599. "fileUrl": m.file_url,
  600. "thumbnailUrl": m.thumbnail_url,
  601. "description": m.description,
  602. "sortOrder": m.sort_order,
  603. })
  604. product_payload = {
  605. "id": product.id,
  606. "name": product.name,
  607. "skuCode": product.sku_code,
  608. "categoryId": product.category_id,
  609. "categoryName": cat_name,
  610. "unitId": line.unit_id,
  611. "unitName": unit.name if unit else None,
  612. "measureValue": float(product.measure_value) if getattr(product, "measure_value", None) is not None else None,
  613. "price": float(line.price) if line.price else 0,
  614. "imageUrl": product.image_url,
  615. "description": product.description or "",
  616. "detailContent": product.detail_content or "",
  617. "isActive": bool(getattr(product, "is_active", 1)),
  618. }
  619. return success_response({
  620. "product": product_payload,
  621. "banners": banners,
  622. "parameterValues": parameter_values,
  623. "detailMedia": detail_media,
  624. })
  625. @router.post("/orders/from-cart", response_model=dict)
  626. def create_order_from_cart(
  627. request: dict,
  628. db: Session = Depends(get_db),
  629. current_user: dict = Depends(require_client),
  630. ):
  631. address_id = request.get("addressId")
  632. payment_method = order_crud.normalize_payment_method(request.get("paymentMethod", "online"))
  633. remark = request.get("remark")
  634. if not address_id:
  635. return error_response(400, "请选择配送地址")
  636. uid = current_user.get("user_id")
  637. user = user_crud.get(db, uid)
  638. if not user or not user.region_id:
  639. return error_response(400, "用户未绑定区域")
  640. creator = {"user_id": uid, "real_name": current_user.get("real_name")}
  641. client_cart_crud.sync_to_active_price_list(db, uid, user.region_id, creator)
  642. pl = price_list_crud.get_active(db, user.region_id)
  643. if not pl:
  644. return error_response(404, "当前区域无生效价格清单")
  645. lines = client_cart_crud.list_by_client(db, uid)
  646. if not lines:
  647. return error_response(400, "购物车为空")
  648. price_items = price_list_item_crud.get_by_price_list(db, pl.id)
  649. pmap = {i.product_id: i for i in price_items}
  650. address = address_crud.get(db, address_id)
  651. if not address or address.user_id != uid:
  652. return error_response(404, "地址不存在")
  653. order_items = []
  654. for ln in lines:
  655. if ln.product_id not in pmap:
  656. return error_response(
  657. 400,
  658. "购物车中含有已不在当前区域价格清单的商品,请返回购物车刷新后移除无效商品再下单",
  659. )
  660. if ln.price_list_id != pl.id:
  661. return error_response(400, "购物车价格已更新,请返回购物车刷新后再下单")
  662. order_items.append({
  663. "productId": ln.product_id,
  664. "unitId": ln.unit_id,
  665. "quantity": float(ln.quantity),
  666. "unitPrice": float(ln.unit_price),
  667. })
  668. price_list_id = pl.id
  669. full_address = f"{address.province or ''}{address.city or ''}{address.district or ''}{address.detail_address or ''}"
  670. order = order_crud.create_order(
  671. db,
  672. client_id=current_user.get("user_id"),
  673. region_id=user.region_id,
  674. price_list_id=price_list_id,
  675. price_version=pl.version,
  676. items=order_items,
  677. address_id=address_id,
  678. address_info={
  679. "contactPerson": address.contact_person,
  680. "contactPhone": address.contact_phone,
  681. "fullAddress": full_address,
  682. },
  683. payment_method=payment_method,
  684. remark=remark,
  685. creator=creator,
  686. )
  687. client_cart_crud.clear(db, current_user.get("user_id"), creator)
  688. return success_response({
  689. "orderId": order.id,
  690. "orderNo": order.order_no,
  691. "totalAmount": float(order.total_amount),
  692. "status": order.status,
  693. "paymentMethod": order.payment_method,
  694. })
  695. @router.get("/dashboard/statistics", response_model=dict)
  696. def client_dashboard_stats(
  697. db: Session = Depends(get_db),
  698. current_user: dict = Depends(require_client),
  699. ):
  700. cid = current_user.get("user_id")
  701. today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
  702. orders, _ = order_crud.get_multi_with_filters(db, client_id=cid, skip=0, limit=5000)
  703. pending_pay = sum(1 for o in orders if o.status == "pending_payment")
  704. delivering = sum(1 for o in orders if getattr(o, "order_status", None) == "pending_confirm")
  705. completed = sum(1 for o in orders if o.status == "completed")
  706. total_amt = sum(float(o.total_amount or 0) for o in orders if o.create_at and o.create_at >= today)
  707. return success_response({
  708. "pendingPayment": pending_pay,
  709. "delivering": delivering,
  710. "completed": completed,
  711. "todayOrderAmount": total_amt,
  712. })
  713. @router.get("/invoices", response_model=dict)
  714. def list_client_invoices(
  715. db: Session = Depends(get_db),
  716. current_user: dict = Depends(require_client),
  717. page: int = Query(1, ge=1),
  718. pageSize: int = Query(10, ge=1, le=100),
  719. status: Optional[str] = None,
  720. ):
  721. q = db.query(BizInvoice).filter(
  722. and_(BizInvoice.client_id == current_user.get("user_id"), BizInvoice.is_del == 0)
  723. )
  724. if status:
  725. q = q.filter(BizInvoice.status == status)
  726. total = q.count()
  727. rows = q.order_by(BizInvoice.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
  728. lst = [{
  729. "id": r.id,
  730. "invoiceNo": r.invoice_no,
  731. "orderId": r.order_id,
  732. "title": r.title,
  733. "invoiceAmount": float(r.invoice_amount) if r.invoice_amount else 0,
  734. "status": r.status,
  735. "createAt": str(r.create_at) if r.create_at else None,
  736. } for r in rows]
  737. return success_response({
  738. "list": lst,
  739. "total": total,
  740. "page": page,
  741. "pageSize": pageSize,
  742. "totalPages": (total + pageSize - 1) // pageSize if total else 0,
  743. })
  744. @router.get("/invoices/summary", response_model=dict)
  745. def invoice_summary(
  746. db: Session = Depends(get_db),
  747. current_user: dict = Depends(require_client),
  748. ):
  749. rows = db.query(BizInvoice).filter(
  750. and_(BizInvoice.client_id == current_user.get("user_id"), BizInvoice.is_del == 0)
  751. ).all()
  752. issued = [r for r in rows if r.status == "issued"]
  753. total_issued = sum(float(r.invoice_amount or 0) for r in issued)
  754. return success_response({
  755. "totalInvoicedAmount": total_issued,
  756. "invoiceCount": len(rows),
  757. })
  758. @router.get("/invoices/available-orders", response_model=dict)
  759. def invoices_available_orders(
  760. db: Session = Depends(get_db),
  761. current_user: dict = Depends(require_client),
  762. ):
  763. """可申请开票的已完成订单(简化:已完成且尚无发票)"""
  764. orders, _ = order_crud.get_multi_with_filters(
  765. db, client_id=current_user.get("user_id"), status="completed", skip=0, limit=200
  766. )
  767. out = []
  768. for o in orders:
  769. inv = db.query(BizInvoice).filter(
  770. and_(BizInvoice.order_id == o.id, BizInvoice.is_del == 0)
  771. ).first()
  772. if inv:
  773. continue
  774. out.append({"orderId": o.id, "orderNo": o.order_no, "totalAmount": float(o.total_amount or 0)})
  775. return success_response({"list": out})
  776. @router.get("/invoices/{invoice_id}", response_model=dict)
  777. def get_client_invoice(
  778. invoice_id: str,
  779. db: Session = Depends(get_db),
  780. current_user: dict = Depends(require_client),
  781. ):
  782. r = db.query(BizInvoice).filter(
  783. and_(BizInvoice.id == invoice_id, BizInvoice.client_id == current_user.get("user_id"), BizInvoice.is_del == 0)
  784. ).first()
  785. if not r:
  786. return error_response(404, "发票不存在")
  787. return success_response({
  788. "id": r.id,
  789. "invoiceNo": r.invoice_no,
  790. "orderId": r.order_id,
  791. "title": r.title,
  792. "invoiceAmount": float(r.invoice_amount) if r.invoice_amount else 0,
  793. "status": r.status,
  794. "invoiceUrl": r.invoice_url,
  795. "createAt": str(r.create_at) if r.create_at else None,
  796. })
  797. @router.post("/invoices", response_model=dict)
  798. def apply_invoice(
  799. request: dict,
  800. db: Session = Depends(get_db),
  801. current_user: dict = Depends(require_client),
  802. ):
  803. """申请开票(简化)"""
  804. order_id = request.get("orderId")
  805. title = request.get("title") or ""
  806. invoice_type = request.get("invoiceType") or "normal"
  807. if not order_id:
  808. return error_response(400, "缺少 orderId")
  809. order = order_crud.get(db, order_id)
  810. if not order or order.client_id != current_user.get("user_id"):
  811. return error_response(404, "订单不存在")
  812. if order.status != "completed":
  813. return error_response(400, "仅已完成订单可申请开票")
  814. inv = BizInvoice(
  815. id=generate_id(),
  816. invoice_no=f"INV{datetime.now().strftime('%Y%m%d%H%M%S')}{random.randint(1000, 9999)}",
  817. order_id=order.id,
  818. order_no=order.order_no,
  819. client_id=order.client_id,
  820. client_name=current_user.get("real_name") or "",
  821. invoice_type=invoice_type,
  822. title=title or "个人",
  823. invoice_amount=order.total_amount,
  824. tax_rate=Decimal("0"),
  825. tax_amount=Decimal("0"),
  826. status="pending",
  827. create_by=current_user.get("user_id"),
  828. create_name=current_user.get("real_name"),
  829. )
  830. db.add(inv)
  831. db.commit()
  832. db.refresh(inv)
  833. return success_response({"id": inv.id, "invoiceNo": inv.invoice_no, "status": inv.status})
  834. @router.get("/payment-config", response_model=dict)
  835. def client_payment_config():
  836. """小程序支付配置(不含密钥)"""
  837. return success_response({
  838. "appId": settings.WXPAY_MINI_APPID,
  839. "enabled": bool(settings.WXPAY_MCHID and settings.WXPAY_MINI_APPID),
  840. })
  841. @router.post("/orders/{order_id}/wechat-pay", response_model=dict)
  842. async def mini_order_wechat_pay(
  843. order_id: str,
  844. request: dict,
  845. db: Session = Depends(get_db),
  846. current_user: dict = Depends(require_client),
  847. ):
  848. """调起微信支付参数(需 openid)"""
  849. from app.core.wechat_pay import get_wechat_pay_v2
  850. openid = request.get("openid")
  851. if not openid:
  852. return error_response(400, "缺少 openid")
  853. order = order_crud.get(db, order_id)
  854. if not order or order.client_id != current_user.get("user_id"):
  855. return error_response(404, "订单不存在")
  856. if getattr(order, "payment_method", None) != "online":
  857. return error_response(400, "线下支付订单无需微信支付")
  858. if order.status != "pending_payment":
  859. return error_response(400, "订单状态不允许支付")
  860. total_amount = int(float(order.total_amount) * 100)
  861. if total_amount <= 0:
  862. return error_response(400, "订单金额必须大于0")
  863. pay_client = get_wechat_pay_v2()
  864. result = pay_client.unified_order(
  865. out_trade_no=order.order_no,
  866. total_fee=total_amount,
  867. body="叁强鲜配-订单支付",
  868. openid=openid,
  869. trade_type="JSAPI",
  870. )
  871. if result.get("success"):
  872. pp = result.get("pay_params") or {}
  873. return success_response({
  874. "orderId": order.id,
  875. "orderNo": order.order_no,
  876. "prepayId": result.get("prepay_id"),
  877. "appId": pp.get("appId"),
  878. "timeStamp": pp.get("timeStamp"),
  879. "nonceStr": pp.get("nonceStr"),
  880. "package": pp.get("package"),
  881. "signType": pp.get("signType"),
  882. "paySign": pp.get("paySign"),
  883. })
  884. return error_response(500, result.get("error", "创建支付失败"))