mini_client_extra.py 43 KB

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