mini_client_extra.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173
  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, BizPriceListItem, BasProductUnit
  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. @router.get("/price-list/active-version", response_model=dict)
  350. def get_active_price_list_version(
  351. db: Session = Depends(get_db),
  352. current_user: dict = Depends(require_client),
  353. ):
  354. """轻量接口:获取当前区域生效价格清单的版本信息(用于客户端兜底校验/去重刷新)。"""
  355. user = user_crud.get(db, current_user.get("user_id"))
  356. if not user or not user.region_id:
  357. return error_response(400, "用户未绑定区域")
  358. pl = price_list_crud.get_active(db, user.region_id)
  359. if not pl:
  360. return success_response(None)
  361. return success_response({
  362. "priceListId": pl.id,
  363. "priceVersion": getattr(pl, "version", 0) or 0,
  364. "publishedTime": str(getattr(pl, "published_time", None)) if getattr(pl, "published_time", None) else None,
  365. })
  366. def _sort_category_dicts_locally(cats: List[dict]) -> List[dict]:
  367. def _key(c: dict):
  368. return (
  369. int(c.get("sortOrder") or 0),
  370. (c.get("name") or "").lower(),
  371. c.get("id") or "",
  372. )
  373. return sorted(cats, key=_key)
  374. def _client_aggregated_categories(db: Session, current_user: dict) -> Tuple[Optional[List[dict]], Optional[str]]:
  375. """返回 (分类 dict 列表, 错误信息)。无清单时返回 ([], None)。"""
  376. user = user_crud.get(db, current_user.get("user_id"))
  377. if not user or not user.region_id:
  378. return None, "用户未绑定区域"
  379. pl = price_list_crud.get_active(db, user.region_id)
  380. if not pl:
  381. return [], None
  382. items = price_list_item_crud.get_by_price_list(db, pl.id)
  383. pmap: Dict[str, dict] = {}
  384. for it in items:
  385. product = product_crud.get(db, it.product_id)
  386. if not product or not product.category_id:
  387. continue
  388. cid = product.category_id
  389. if cid not in pmap:
  390. cat = category_crud.get(db, cid)
  391. pmap[cid] = {
  392. "id": cid,
  393. "name": cat.name if cat else "",
  394. "parentId": cat.parent_id if cat else None,
  395. "sortOrder": cat.sort_order if cat else 0,
  396. "isActive": True,
  397. "imageUrl": cat.image_url if cat else None,
  398. }
  399. return list(pmap.values()), None
  400. @router.get("/product-categories", response_model=dict)
  401. def list_product_categories(
  402. db: Session = Depends(get_db),
  403. current_user: dict = Depends(require_client),
  404. ):
  405. """基于当前生效价格清单中的商品,聚合分类(全量,兼容旧调用)"""
  406. cats, err = _client_aggregated_categories(db, current_user)
  407. if err:
  408. return error_response(400, err)
  409. return success_response(cats)
  410. @router.get("/product-categories/first-level", response_model=dict)
  411. def list_product_categories_first_level(
  412. db: Session = Depends(get_db),
  413. current_user: dict = Depends(require_client),
  414. page: int = Query(1, ge=1),
  415. pageSize: int = Query(8, ge=1, le=50),
  416. ):
  417. """首页一级分类(swiper),服务端分页"""
  418. cats, err = _client_aggregated_categories(db, current_user)
  419. if err:
  420. return error_response(400, err)
  421. sorted_all = _sort_category_dicts_locally(cats or [])
  422. roots = [c for c in sorted_all if not c.get("parentId")]
  423. total = len(roots)
  424. start = (page - 1) * pageSize
  425. slice_roots = roots[start : start + pageSize]
  426. out = [
  427. {
  428. "id": c["id"],
  429. "name": c["name"],
  430. "imageUrl": c.get("imageUrl") or "",
  431. }
  432. for c in slice_roots
  433. ]
  434. total_pages = (total + pageSize - 1) // pageSize if total else 0
  435. return success_response({
  436. "list": out,
  437. "total": total,
  438. "page": page,
  439. "pageSize": pageSize,
  440. "totalPages": total_pages,
  441. })
  442. @router.get("/product-categories/sidebar", response_model=dict)
  443. def list_product_categories_sidebar(
  444. db: Session = Depends(get_db),
  445. current_user: dict = Depends(require_client),
  446. firstCategoryId: str = Query("all", description="一级分类 id,all 表示顶部选「全部」"),
  447. page: int = Query(1, ge=1),
  448. pageSize: int = Query(30, ge=1, le=100),
  449. ):
  450. """首页左侧二级分类栏选项,服务端分页(含「全部」行)"""
  451. cats, err = _client_aggregated_categories(db, current_user)
  452. if err:
  453. return error_response(400, err)
  454. sorted_all = _sort_category_dicts_locally(cats or [])
  455. roots = [c for c in sorted_all if not c.get("parentId")]
  456. rows: List[dict] = [{"id": "all", "name": "全部"}]
  457. fid = (firstCategoryId or "all").strip()
  458. if fid == "all":
  459. rows.extend({"id": c["id"], "name": c["name"]} for c in roots)
  460. else:
  461. children = [c for c in sorted_all if c.get("parentId") == fid]
  462. rows.extend({"id": c["id"], "name": c["name"]} for c in children)
  463. total = len(rows)
  464. start = (page - 1) * pageSize
  465. chunk = rows[start : start + pageSize]
  466. total_pages = (total + pageSize - 1) // pageSize if total else 0
  467. return success_response({
  468. "list": chunk,
  469. "total": total,
  470. "page": page,
  471. "pageSize": pageSize,
  472. "totalPages": total_pages,
  473. })
  474. def _root_category_id_for_leaf(db: Session, category_id: Optional[str], memo: Dict[str, Optional[str]]) -> Optional[str]:
  475. """商品分类叶子节点向上解析一级(根)分类 id,与小程序端 getFirstCategoryId 一致。"""
  476. if not category_id:
  477. return None
  478. if category_id in memo:
  479. return memo[category_id]
  480. cur: Optional[str] = category_id
  481. chain: List[str] = []
  482. while cur and len(chain) < 32:
  483. chain.append(cur)
  484. cat = category_crud.get(db, cur)
  485. if not cat:
  486. root = chain[-1]
  487. for cid in chain:
  488. memo[cid] = root
  489. return root
  490. if not cat.parent_id:
  491. root = cat.id
  492. for cid in chain:
  493. memo[cid] = root
  494. return root
  495. cur = cat.parent_id
  496. root = chain[-1] if chain else None
  497. for cid in chain:
  498. memo[cid] = root
  499. return root
  500. @router.get("/products", response_model=dict)
  501. def list_products(
  502. db: Session = Depends(get_db),
  503. current_user: dict = Depends(require_client),
  504. page: int = Query(1, ge=1),
  505. pageSize: int = Query(20, ge=1, le=100),
  506. categoryId: Optional[str] = None,
  507. rootCategoryId: Optional[str] = None,
  508. keyword: Optional[str] = None,
  509. ):
  510. """分页查询当前价目表中的商品(与 price-list/products 一致逻辑)"""
  511. user = user_crud.get(db, current_user.get("user_id"))
  512. if not user or not user.region_id:
  513. return error_response(400, "用户未绑定区域")
  514. pl = price_list_crud.get_active(db, user.region_id)
  515. if not pl:
  516. return success_response({"list": [], "total": 0, "page": page, "pageSize": pageSize, "totalPages": 0})
  517. item_rows = (
  518. db.query(
  519. BizPriceListItem.product_id,
  520. BizPriceListItem.unit_id,
  521. BizPriceListItem.price,
  522. BasProduct.id.label("pid"),
  523. BasProduct.name.label("product_name"),
  524. BasProduct.category_id.label("category_id"),
  525. BasProduct.measure_value.label("measure_value"),
  526. BasProduct.description.label("description"),
  527. BasProduct.image_url.label("image_url"),
  528. BasProductUnit.name.label("unit_name"),
  529. )
  530. .join(
  531. BasProduct,
  532. and_(BizPriceListItem.product_id == BasProduct.id, BasProduct.is_del == 0),
  533. )
  534. .outerjoin(
  535. BasProductUnit,
  536. and_(BizPriceListItem.unit_id == BasProductUnit.id, BasProductUnit.is_del == 0),
  537. )
  538. .filter(BizPriceListItem.price_list_id == pl.id, BizPriceListItem.is_del == 0)
  539. .all()
  540. )
  541. category_ids = {r.category_id for r in item_rows if getattr(r, "category_id", None)}
  542. cat_rows = (
  543. db.query(BasProductCategory)
  544. .filter(BasProductCategory.id.in_(category_ids), BasProductCategory.is_del == 0)
  545. .all()
  546. if category_ids
  547. else []
  548. )
  549. cat_cache: Dict[str, Optional[BasProductCategory]] = {c.id: c for c in cat_rows}
  550. # 补齐父链缓存,避免排序/根分类判断时再次打 DB
  551. pending = [c for c in cat_rows if getattr(c, "parent_id", None)]
  552. while pending:
  553. parent_ids = {c.parent_id for c in pending if c.parent_id and c.parent_id not in cat_cache}
  554. if not parent_ids:
  555. break
  556. parents = (
  557. db.query(BasProductCategory)
  558. .filter(BasProductCategory.id.in_(parent_ids), BasProductCategory.is_del == 0)
  559. .all()
  560. )
  561. if not parents:
  562. break
  563. for p in parents:
  564. cat_cache[p.id] = p
  565. pending = [p for p in parents if getattr(p, "parent_id", None)]
  566. def _root_category_id_from_cache(cid: Optional[str]) -> Optional[str]:
  567. if not cid:
  568. return None
  569. cur = cid
  570. seen = set()
  571. for _ in range(32):
  572. if not cur or cur in seen:
  573. break
  574. seen.add(cur)
  575. c = cat_cache.get(cur)
  576. if not c:
  577. return cur
  578. if not getattr(c, "parent_id", None):
  579. return c.id
  580. cur = c.parent_id
  581. return cur
  582. rows = []
  583. kw = keyword.lower() if keyword else None
  584. for item in item_rows:
  585. if categoryId and item.category_id != categoryId:
  586. continue
  587. if rootCategoryId and _root_category_id_from_cache(item.category_id) != rootCategoryId:
  588. continue
  589. if kw and kw not in (item.product_name or "").lower():
  590. continue
  591. cat = cat_cache.get(item.category_id) if item.category_id else None
  592. rows.append({
  593. "id": item.pid,
  594. "name": item.product_name,
  595. "categoryId": item.category_id,
  596. "categoryName": cat.name if cat else "",
  597. "unitId": item.unit_id,
  598. "unitName": item.unit_name or "",
  599. "measureValue": float(item.measure_value) if item.measure_value is not None else None,
  600. "description": item.description,
  601. "imageUrl": item.image_url,
  602. "price": float(item.price) if item.price else 0,
  603. "isActive": True,
  604. "sortOrder": 0,
  605. })
  606. def _sort_tuple(r: dict) -> Tuple:
  607. cid = r.get("categoryId")
  608. first_ord = 0
  609. second_ord = 0
  610. if cid:
  611. chain: List[BasProductCategory] = []
  612. cur: Optional[str] = cid
  613. for _ in range(32):
  614. c = cat_cache.get(cur) if cur else None
  615. if not c:
  616. break
  617. chain.append(c)
  618. if not c.parent_id:
  619. break
  620. cur = c.parent_id
  621. if chain:
  622. second_ord = int(chain[0].sort_order or 0)
  623. first_ord = int(chain[-1].sort_order or 0)
  624. name = (r.get("name") or "").lower()
  625. sid = r.get("id") or ""
  626. return (first_ord, second_ord, int(r.get("sortOrder") or 0), name, sid)
  627. rows.sort(key=_sort_tuple)
  628. total = len(rows)
  629. start = (page - 1) * pageSize
  630. paginated = rows[start : start + pageSize]
  631. total_pages = (total + pageSize - 1) // pageSize if total else 0
  632. return success_response({
  633. "list": paginated,
  634. "total": total,
  635. "page": page,
  636. "pageSize": pageSize,
  637. "totalPages": total_pages,
  638. })
  639. @router.get("/products/{product_id}", response_model=dict)
  640. def get_client_product_detail(
  641. product_id: str,
  642. db: Session = Depends(get_db),
  643. current_user: dict = Depends(require_client),
  644. ):
  645. """客户端商品详情:价目表内商品 + 轮播图 + 参数值 + 富文本 + 详情媒体(图/视频)"""
  646. user = user_crud.get(db, current_user.get("user_id"))
  647. if not user or not user.region_id:
  648. return error_response(400, "用户未绑定区域")
  649. pl = price_list_crud.get_active(db, user.region_id)
  650. if not pl:
  651. return error_response(404, "暂无生效价格清单")
  652. items = price_list_item_crud.get_by_price_list(db, pl.id)
  653. line = next((it for it in items if it.product_id == product_id), None)
  654. if not line:
  655. return error_response(404, "商品不存在或不在当前价目表")
  656. product = product_crud.get(db, product_id)
  657. if not product or getattr(product, "is_del", 0) == 1:
  658. return error_response(404, "商品不存在")
  659. unit = unit_crud.get(db, line.unit_id) if line.unit_id else None
  660. cat_name = ""
  661. if product.category_id:
  662. cat = category_crud.get(db, product.category_id)
  663. cat_name = cat.name if cat else ""
  664. banners_raw = product_banner.get_by_product(db, product_id)
  665. banners = [{
  666. "mediaType": b.media_type,
  667. "fileUrl": b.file_url,
  668. "thumbnailUrl": b.thumbnail_url,
  669. "sortOrder": b.sort_order,
  670. } for b in banners_raw]
  671. pv_rows = product_parameter_value.get_by_product_with_detail(db, product_id)
  672. parameter_values = []
  673. for row in pv_rows:
  674. val = row.get("param_value")
  675. if val is None or str(val).strip() == "":
  676. continue
  677. parameter_values.append({
  678. "parameterId": row.get("parameter_id"),
  679. "parameterName": row.get("parameter_name") or "",
  680. "paramValue": str(val),
  681. })
  682. media_raw = product_detail_media.get_by_product(db, product_id)
  683. detail_media = []
  684. for m in media_raw:
  685. if m.content_type not in ("image", "video"):
  686. continue
  687. if not m.file_url:
  688. continue
  689. detail_media.append({
  690. "id": m.id,
  691. "contentType": m.content_type,
  692. "fileUrl": m.file_url,
  693. "thumbnailUrl": m.thumbnail_url,
  694. "description": m.description,
  695. "sortOrder": m.sort_order,
  696. })
  697. product_payload = {
  698. "id": product.id,
  699. "name": product.name,
  700. "skuCode": product.sku_code,
  701. "categoryId": product.category_id,
  702. "categoryName": cat_name,
  703. "unitId": line.unit_id,
  704. "unitName": unit.name if unit else None,
  705. "measureValue": float(product.measure_value) if getattr(product, "measure_value", None) is not None else None,
  706. "price": float(line.price) if line.price else 0,
  707. "imageUrl": product.image_url,
  708. "description": product.description or "",
  709. "detailContent": product.detail_content or "",
  710. "isActive": bool(getattr(product, "is_active", 1)),
  711. }
  712. return success_response({
  713. "product": product_payload,
  714. "banners": banners,
  715. "parameterValues": parameter_values,
  716. "detailMedia": detail_media,
  717. })
  718. @router.get("/products-ro/{product_id}", response_model=dict)
  719. def get_client_product_detail_readonly(
  720. product_id: str,
  721. db: Session = Depends(get_db),
  722. current_user: dict = Depends(require_client),
  723. ):
  724. """客户端只读商品详情:用于订单历史等场景,不依赖区域价目表。"""
  725. product = product_crud.get(db, product_id)
  726. if not product or getattr(product, "is_del", 0) == 1:
  727. return error_response(404, "商品不存在")
  728. unit = unit_crud.get(db, product.unit_id) if getattr(product, "unit_id", None) else None
  729. cat_name = ""
  730. if getattr(product, "category_id", None):
  731. cat = category_crud.get(db, product.category_id)
  732. cat_name = cat.name if cat else ""
  733. banners_raw = product_banner.get_by_product(db, product_id)
  734. banners = [{
  735. "mediaType": b.media_type,
  736. "fileUrl": b.file_url,
  737. "thumbnailUrl": b.thumbnail_url,
  738. "sortOrder": b.sort_order,
  739. } for b in banners_raw]
  740. pv_rows = product_parameter_value.get_by_product_with_detail(db, product_id)
  741. parameter_values = []
  742. for row in pv_rows:
  743. val = row.get("param_value")
  744. if val is None or str(val).strip() == "":
  745. continue
  746. parameter_values.append({
  747. "parameterId": row.get("parameter_id"),
  748. "parameterName": row.get("parameter_name") or "",
  749. "paramValue": str(val),
  750. })
  751. media_raw = product_detail_media.get_by_product(db, product_id)
  752. detail_media = []
  753. for m in media_raw:
  754. if m.content_type not in ("image", "video"):
  755. continue
  756. if not m.file_url:
  757. continue
  758. detail_media.append({
  759. "id": m.id,
  760. "contentType": m.content_type,
  761. "fileUrl": m.file_url,
  762. "thumbnailUrl": m.thumbnail_url,
  763. "description": m.description,
  764. "sortOrder": m.sort_order,
  765. })
  766. product_payload = {
  767. "id": product.id,
  768. "name": product.name,
  769. "skuCode": product.sku_code,
  770. "categoryId": product.category_id,
  771. "categoryName": cat_name,
  772. "unitId": getattr(product, "unit_id", None),
  773. "unitName": unit.name if unit else None,
  774. "measureValue": float(product.measure_value) if getattr(product, "measure_value", None) is not None else None,
  775. # 价格由前端在只读订单场景使用历史成交价(fallbackPrice)展示,这里不做价目表绑定
  776. "price": 0,
  777. "imageUrl": product.image_url,
  778. "description": product.description or "",
  779. "detailContent": product.detail_content or "",
  780. "isActive": bool(getattr(product, "is_active", 1)),
  781. }
  782. return success_response({
  783. "product": product_payload,
  784. "banners": banners,
  785. "parameterValues": parameter_values,
  786. "detailMedia": detail_media,
  787. })
  788. @router.post("/orders/from-cart", response_model=dict)
  789. def create_order_from_cart(
  790. request: dict,
  791. db: Session = Depends(get_db),
  792. current_user: dict = Depends(require_client),
  793. ):
  794. address_id = request.get("addressId")
  795. payment_method = order_crud.normalize_payment_method(request.get("paymentMethod", "online"))
  796. remark = request.get("remark")
  797. if not address_id:
  798. return error_response(400, "请选择配送地址")
  799. uid = current_user.get("user_id")
  800. user = user_crud.get(db, uid)
  801. if not user or not user.region_id:
  802. return error_response(400, "用户未绑定区域")
  803. creator = {"user_id": uid, "real_name": current_user.get("real_name")}
  804. client_cart_crud.sync_to_active_price_list(db, uid, user.region_id, creator)
  805. pl = price_list_crud.get_active(db, user.region_id)
  806. if not pl:
  807. return error_response(404, "当前区域无生效价格清单")
  808. lines = client_cart_crud.list_by_client(db, uid)
  809. if not lines:
  810. return error_response(400, "购物车为空")
  811. price_items = price_list_item_crud.get_by_price_list(db, pl.id)
  812. pmap = {i.product_id: i for i in price_items}
  813. address = address_crud.get(db, address_id)
  814. if not address or address.user_id != uid:
  815. return error_response(404, "地址不存在")
  816. order_items = []
  817. for ln in lines:
  818. if ln.product_id not in pmap:
  819. return error_response(
  820. 400,
  821. "购物车中含有已不在当前区域价格清单的商品,请返回购物车刷新后移除无效商品再下单",
  822. )
  823. if ln.price_list_id != pl.id:
  824. return error_response(400, "购物车价格已更新,请返回购物车刷新后再下单")
  825. order_items.append({
  826. "productId": ln.product_id,
  827. "unitId": ln.unit_id,
  828. "quantity": float(ln.quantity),
  829. "unitPrice": float(ln.unit_price),
  830. })
  831. price_list_id = pl.id
  832. full_address = f"{address.province or ''}{address.city or ''}{address.district or ''}{address.detail_address or ''}"
  833. order = order_crud.create_order(
  834. db,
  835. client_id=current_user.get("user_id"),
  836. region_id=user.region_id,
  837. price_list_id=price_list_id,
  838. price_version=pl.version,
  839. items=order_items,
  840. address_id=address_id,
  841. address_info={
  842. "contactPerson": address.contact_person,
  843. "contactPhone": address.contact_phone,
  844. "fullAddress": full_address,
  845. },
  846. payment_method=payment_method,
  847. remark=remark,
  848. creator=creator,
  849. )
  850. client_cart_crud.clear(db, current_user.get("user_id"), creator)
  851. return success_response({
  852. "orderId": order.id,
  853. "orderNo": order.order_no,
  854. "totalAmount": float(order.total_amount),
  855. "status": order.status,
  856. "paymentMethod": order.payment_method,
  857. })
  858. @router.get("/dashboard/statistics", response_model=dict)
  859. def client_dashboard_stats(
  860. db: Session = Depends(get_db),
  861. current_user: dict = Depends(require_client),
  862. ):
  863. cid = current_user.get("user_id")
  864. today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
  865. orders, _ = order_crud.get_multi_with_filters(db, client_id=cid, skip=0, limit=5000)
  866. pending_pay = sum(1 for o in orders if o.status == "pending_payment")
  867. delivering = sum(1 for o in orders if getattr(o, "order_status", None) == "pending_confirm")
  868. completed = sum(1 for o in orders if o.status == "completed")
  869. total_amt = sum(float(o.total_amount or 0) for o in orders if o.create_at and o.create_at >= today)
  870. return success_response({
  871. "pendingPayment": pending_pay,
  872. "delivering": delivering,
  873. "completed": completed,
  874. "todayOrderAmount": total_amt,
  875. })
  876. @router.get("/invoices", response_model=dict)
  877. def list_client_invoices(
  878. db: Session = Depends(get_db),
  879. current_user: dict = Depends(require_client),
  880. page: int = Query(1, ge=1),
  881. pageSize: int = Query(10, ge=1, le=100),
  882. status: Optional[str] = None,
  883. ):
  884. q = db.query(BizInvoice).filter(
  885. and_(BizInvoice.client_id == current_user.get("user_id"), BizInvoice.is_del == 0)
  886. )
  887. if status:
  888. q = q.filter(BizInvoice.status == status)
  889. total = q.count()
  890. rows = q.order_by(BizInvoice.create_at.desc()).offset((page - 1) * pageSize).limit(pageSize).all()
  891. lst = [{
  892. "id": r.id,
  893. "invoiceNo": r.invoice_no,
  894. "orderId": r.order_id,
  895. "title": r.title,
  896. "invoiceAmount": float(r.invoice_amount) if r.invoice_amount else 0,
  897. "status": r.status,
  898. "createAt": str(r.create_at) if r.create_at else None,
  899. } for r in rows]
  900. return success_response({
  901. "list": lst,
  902. "total": total,
  903. "page": page,
  904. "pageSize": pageSize,
  905. "totalPages": (total + pageSize - 1) // pageSize if total else 0,
  906. })
  907. @router.get("/invoices/summary", response_model=dict)
  908. def invoice_summary(
  909. db: Session = Depends(get_db),
  910. current_user: dict = Depends(require_client),
  911. ):
  912. rows = db.query(BizInvoice).filter(
  913. and_(BizInvoice.client_id == current_user.get("user_id"), BizInvoice.is_del == 0)
  914. ).all()
  915. issued = [r for r in rows if r.status == "issued"]
  916. total_issued = sum(float(r.invoice_amount or 0) for r in issued)
  917. return success_response({
  918. "totalInvoicedAmount": total_issued,
  919. "invoiceCount": len(rows),
  920. })
  921. @router.get("/invoices/available-orders", response_model=dict)
  922. def invoices_available_orders(
  923. db: Session = Depends(get_db),
  924. current_user: dict = Depends(require_client),
  925. ):
  926. """可申请开票的已完成订单(简化:已完成且尚无发票)"""
  927. orders, _ = order_crud.get_multi_with_filters(
  928. db, client_id=current_user.get("user_id"), status="completed", skip=0, limit=200
  929. )
  930. out = []
  931. for o in orders:
  932. inv = db.query(BizInvoice).filter(
  933. and_(BizInvoice.order_id == o.id, BizInvoice.is_del == 0)
  934. ).first()
  935. if inv:
  936. continue
  937. out.append({"orderId": o.id, "orderNo": o.order_no, "totalAmount": float(o.total_amount or 0)})
  938. return success_response({"list": out})
  939. @router.get("/invoices/{invoice_id}", response_model=dict)
  940. def get_client_invoice(
  941. invoice_id: str,
  942. db: Session = Depends(get_db),
  943. current_user: dict = Depends(require_client),
  944. ):
  945. r = db.query(BizInvoice).filter(
  946. and_(BizInvoice.id == invoice_id, BizInvoice.client_id == current_user.get("user_id"), BizInvoice.is_del == 0)
  947. ).first()
  948. if not r:
  949. return error_response(404, "发票不存在")
  950. return success_response({
  951. "id": r.id,
  952. "invoiceNo": r.invoice_no,
  953. "orderId": r.order_id,
  954. "title": r.title,
  955. "invoiceAmount": float(r.invoice_amount) if r.invoice_amount else 0,
  956. "status": r.status,
  957. "invoiceUrl": r.invoice_url,
  958. "createAt": str(r.create_at) if r.create_at else None,
  959. })
  960. @router.post("/invoices", response_model=dict)
  961. def apply_invoice(
  962. request: dict,
  963. db: Session = Depends(get_db),
  964. current_user: dict = Depends(require_client),
  965. ):
  966. """申请开票(简化)"""
  967. order_id = request.get("orderId")
  968. title = request.get("title") or ""
  969. invoice_type = request.get("invoiceType") or "normal"
  970. if not order_id:
  971. return error_response(400, "缺少 orderId")
  972. order = order_crud.get(db, order_id)
  973. if not order or order.client_id != current_user.get("user_id"):
  974. return error_response(404, "订单不存在")
  975. if order.status != "completed":
  976. return error_response(400, "仅已完成订单可申请开票")
  977. inv = BizInvoice(
  978. id=generate_id(),
  979. invoice_no=f"INV{datetime.now().strftime('%Y%m%d%H%M%S')}{random.randint(1000, 9999)}",
  980. order_id=order.id,
  981. order_no=order.order_no,
  982. client_id=order.client_id,
  983. client_name=current_user.get("real_name") or "",
  984. invoice_type=invoice_type,
  985. title=title or "个人",
  986. invoice_amount=order.total_amount,
  987. tax_rate=Decimal("0"),
  988. tax_amount=Decimal("0"),
  989. status="pending",
  990. create_by=current_user.get("user_id"),
  991. create_name=current_user.get("real_name"),
  992. )
  993. db.add(inv)
  994. db.commit()
  995. db.refresh(inv)
  996. return success_response({"id": inv.id, "invoiceNo": inv.invoice_no, "status": inv.status})
  997. @router.get("/payment-config", response_model=dict)
  998. def client_payment_config():
  999. """小程序支付配置(不含密钥)"""
  1000. return success_response({
  1001. "appId": settings.WXPAY_MINI_APPID,
  1002. "enabled": bool(settings.WXPAY_MCHID and settings.WXPAY_MINI_APPID),
  1003. })
  1004. @router.post("/orders/{order_id}/wechat-pay", response_model=dict)
  1005. async def mini_order_wechat_pay(
  1006. order_id: str,
  1007. request: dict,
  1008. db: Session = Depends(get_db),
  1009. current_user: dict = Depends(require_client),
  1010. ):
  1011. """调起微信支付参数(需 openid)"""
  1012. from app.core.wechat_pay import get_wechat_pay_v2
  1013. openid = request.get("openid")
  1014. if not openid:
  1015. return error_response(400, "缺少 openid")
  1016. order = order_crud.get(db, order_id)
  1017. if not order or order.client_id != current_user.get("user_id"):
  1018. return error_response(404, "订单不存在")
  1019. if getattr(order, "payment_method", None) != "online":
  1020. return error_response(400, "线下支付订单无需微信支付")
  1021. if order.status != "pending_payment":
  1022. return error_response(400, "订单状态不允许支付")
  1023. total_amount = int(float(order.total_amount) * 100)
  1024. if total_amount <= 0:
  1025. return error_response(400, "订单金额必须大于0")
  1026. pay_client = get_wechat_pay_v2()
  1027. result = pay_client.unified_order(
  1028. out_trade_no=order.order_no,
  1029. total_fee=total_amount,
  1030. body="陇耘鲜达-订单支付",
  1031. openid=openid,
  1032. trade_type="JSAPI",
  1033. )
  1034. if result.get("success"):
  1035. pp = result.get("pay_params") or {}
  1036. _upsert_wechat_pay_pending(
  1037. db,
  1038. order_id=str(order.id),
  1039. out_trade_no=order.order_no,
  1040. total_fee=total_amount,
  1041. operator_id=current_user.get("user_id"),
  1042. operator_name=current_user.get("real_name"),
  1043. )
  1044. db.commit()
  1045. return success_response({
  1046. "orderId": order.id,
  1047. "orderNo": order.order_no,
  1048. "prepayId": result.get("prepay_id"),
  1049. "appId": pp.get("appId"),
  1050. "timeStamp": pp.get("timeStamp"),
  1051. "nonceStr": pp.get("nonceStr"),
  1052. "package": pp.get("package"),
  1053. "signType": pp.get("signType"),
  1054. "paySign": pp.get("paySign"),
  1055. })
  1056. return error_response(500, result.get("error", "创建支付失败"))