mini_client_extra.py 41 KB

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