mini_supplier.py 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435
  1. """小程序端供应商接口"""
  2. from decimal import Decimal
  3. from typing import Optional
  4. from datetime import datetime
  5. import logging
  6. from fastapi import APIRouter, Depends, Request, Query
  7. from sqlalchemy.orm import Session
  8. from app.core.database import get_db
  9. from app.core.auth import get_current_user, get_client_ip
  10. from app.core.response import success_response, error_response
  11. from app.core.config import settings
  12. from app.core import api_crypto
  13. from app.core.request_payload import resolve_request_payload
  14. from app.models.models import BasAddress
  15. from app.crud.address import address_crud
  16. from app.crud.user import user_crud
  17. from app.crud.region import region_crud
  18. from app.crud.attachment import attachment_crud
  19. from app.models.models import BizAttachment
  20. router = APIRouter(prefix="/api/mini/supplier", tags=["小程序端-供应商接口"])
  21. logger = logging.getLogger("sqxp")
  22. def _supplier_quote_unit_yuan_from_pli(pli) -> Optional[float]:
  23. """价格清单行 price 为面向客户的最终单价;pricing_coefficient 为调价系数(%),供货单价 = price / (1 + 系数/100)。"""
  24. if pli is None or getattr(pli, "price", None) is None:
  25. return None
  26. final_price = Decimal(str(pli.price))
  27. raw_coeff = getattr(pli, "pricing_coefficient", None)
  28. if raw_coeff is None:
  29. coeff_pct = Decimal("0")
  30. else:
  31. try:
  32. coeff_pct = Decimal(str(raw_coeff))
  33. except Exception:
  34. coeff_pct = Decimal("0")
  35. factor = Decimal("1") + coeff_pct / Decimal("100")
  36. if factor <= Decimal("0"):
  37. factor = Decimal("1")
  38. supplier_unit = (final_price / factor).quantize(Decimal("0.01"))
  39. return float(supplier_unit)
  40. def require_supplier(current_user: dict = Depends(get_current_user)):
  41. """验证供应商权限"""
  42. if current_user.get("role") != "supplier":
  43. return error_response(403, "需要供应商权限")
  44. return current_user
  45. def _resolve_request_payload(request_body: dict, current_user: dict) -> dict:
  46. """兼容明文与密文信封,返回业务层 payload。"""
  47. return resolve_request_payload(request_body, current_user, source="mini_supplier")
  48. def _normalize_image_urls(raw) -> list[str]:
  49. out: list[str] = []
  50. if isinstance(raw, list):
  51. for x in raw:
  52. s = str(x or "").strip()
  53. if s:
  54. out.append(s)
  55. elif isinstance(raw, str):
  56. s = raw.strip()
  57. if s:
  58. out.append(s)
  59. return out
  60. def _replace_delivery_prepare_attachments(
  61. db: Session,
  62. *,
  63. task_id: str,
  64. image_urls: list[str],
  65. uploader_id: Optional[str],
  66. uploader_name: Optional[str],
  67. ):
  68. rows = db.query(BizAttachment).filter(
  69. BizAttachment.business_type == "delivery_prepare",
  70. BizAttachment.business_id == task_id,
  71. BizAttachment.is_del == 0,
  72. ).all()
  73. now = datetime.now()
  74. for r in rows:
  75. r.is_del = 1
  76. r.update_at = now
  77. r.update_by = uploader_id
  78. db.flush()
  79. for idx, url in enumerate(image_urls, start=1):
  80. attachment_crud.create_attachment(
  81. db=db,
  82. business_type="delivery_prepare",
  83. business_id=task_id,
  84. file_name=f"delivery_prepare_{idx}.jpg",
  85. file_url=url,
  86. file_type="image/jpeg",
  87. upload_by=uploader_id,
  88. upload_by_name=uploader_name,
  89. )
  90. # ============ 地址管理 ============
  91. @router.get("/addresses", response_model=dict)
  92. def list_addresses(
  93. db: Session = Depends(get_db),
  94. current_user: dict = Depends(require_supplier)
  95. ):
  96. """获取供应商取货地址列表"""
  97. addresses = address_crud.get_by_user(db, current_user.get("user_id"))
  98. return success_response([{
  99. "id": addr.id,
  100. "userId": addr.user_id,
  101. "addressType": addr.address_type,
  102. "contactPerson": addr.contact_person,
  103. "contactPhone": addr.contact_phone,
  104. "province": addr.province,
  105. "city": addr.city,
  106. "district": addr.district,
  107. "detailAddress": addr.detail_address,
  108. "isDefault": addr.is_default,
  109. "createAt": str(addr.create_at) if addr.create_at else None
  110. } for addr in addresses])
  111. @router.get("/pickup-addresses", response_model=dict)
  112. def list_pickup_addresses(
  113. db: Session = Depends(get_db),
  114. current_user: dict = Depends(require_supplier)
  115. ):
  116. """获取供应商取货地址列表"""
  117. addresses = address_crud.get_by_user_and_type(db, current_user.get("user_id"), "pickup")
  118. return success_response([{
  119. "id": addr.id,
  120. "userId": addr.user_id,
  121. "addressType": addr.address_type,
  122. "contactPerson": addr.contact_person,
  123. "contactPhone": addr.contact_phone,
  124. "province": addr.province,
  125. "city": addr.city,
  126. "district": addr.district,
  127. "detailAddress": addr.detail_address,
  128. "isDefault": addr.is_default
  129. } for addr in addresses])
  130. @router.get("/addresses/{address_id}", response_model=dict)
  131. def get_address(
  132. address_id: str,
  133. db: Session = Depends(get_db),
  134. current_user: dict = Depends(require_supplier)
  135. ):
  136. """获取地址详情"""
  137. address = address_crud.get(db, address_id)
  138. if not address or address.user_id != current_user.get("user_id"):
  139. return error_response(404, "地址不存在")
  140. return success_response({
  141. "id": address.id,
  142. "userId": address.user_id,
  143. "addressType": address.address_type,
  144. "contactPerson": address.contact_person,
  145. "contactPhone": address.contact_phone,
  146. "province": address.province,
  147. "city": address.city,
  148. "district": address.district,
  149. "detailAddress": address.detail_address,
  150. "isDefault": address.is_default,
  151. "createAt": str(address.create_at) if address.create_at else None
  152. })
  153. @router.post("/addresses", response_model=dict)
  154. def create_address(
  155. request: dict,
  156. db: Session = Depends(get_db),
  157. current_user: dict = Depends(require_supplier)
  158. ):
  159. """创建地址"""
  160. payload = _resolve_request_payload(request, current_user)
  161. if not payload.get("addressType") and not payload.get("address_type"):
  162. return error_response(400, "地址类型不能为空")
  163. payload["user_id"] = current_user.get("user_id")
  164. creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  165. # 如果设置为默认,先取消其他默认
  166. if payload.get("isDefault"):
  167. address_crud.set_default(db, current_user.get("user_id"), "")
  168. address = address_crud.create(db, payload, creator)
  169. return success_response({
  170. "id": address.id,
  171. "addressType": address.address_type,
  172. "contactPerson": address.contact_person,
  173. "contactPhone": address.contact_phone
  174. })
  175. @router.put("/addresses/{address_id}", response_model=dict)
  176. def update_address(
  177. address_id: str,
  178. request: dict,
  179. db: Session = Depends(get_db),
  180. current_user: dict = Depends(require_supplier)
  181. ):
  182. """更新地址"""
  183. address = address_crud.get(db, address_id)
  184. if not address or address.user_id != current_user.get("user_id"):
  185. return error_response(404, "地址不存在")
  186. payload = _resolve_request_payload(request, current_user)
  187. # 如果设置为默认,先取消其他默认
  188. if payload.get("isDefault"):
  189. address_crud.set_default(db, current_user.get("user_id"), address_id)
  190. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  191. address = address_crud.update_with_history(db, address_id, payload, updater)
  192. return success_response({
  193. "id": address.id,
  194. "addressType": address.address_type,
  195. "contactPerson": address.contact_person,
  196. "contactPhone": address.contact_phone
  197. })
  198. @router.delete("/addresses/{address_id}", response_model=dict)
  199. def delete_address(
  200. address_id: str,
  201. db: Session = Depends(get_db),
  202. current_user: dict = Depends(require_supplier)
  203. ):
  204. """删除地址"""
  205. address = address_crud.get(db, address_id)
  206. if not address or address.user_id != current_user.get("user_id"):
  207. return error_response(404, "地址不存在")
  208. success = address_crud.soft_delete_with_history(db, address_id)
  209. return success_response({"id": address_id})
  210. @router.post("/addresses/{address_id}/set-default", response_model=dict)
  211. def set_default_address(
  212. address_id: str,
  213. db: Session = Depends(get_db),
  214. current_user: dict = Depends(require_supplier)
  215. ):
  216. """设置默认地址"""
  217. address = address_crud.get(db, address_id)
  218. if not address or address.user_id != current_user.get("user_id"):
  219. return error_response(404, "地址不存在")
  220. success = address_crud.set_default(db, current_user.get("user_id"), address_id)
  221. if success:
  222. return success_response({"message": "设置成功"})
  223. return error_response(400, "设置失败")
  224. # ============ 个人资料 ============
  225. @router.get("/profile", response_model=dict)
  226. def get_profile(
  227. db: Session = Depends(get_db),
  228. current_user: dict = Depends(require_supplier)
  229. ):
  230. """获取供应商个人资料"""
  231. user = user_crud.get(db, current_user.get("user_id"))
  232. if not user:
  233. return error_response(404, "用户不存在")
  234. region_name = None
  235. if user.region_id:
  236. region = region_crud.get(db, user.region_id)
  237. region_name = region.name if region else None
  238. # 获取默认取货地址
  239. default_address = address_crud.get_default(db, user.id, "pickup")
  240. address_info = None
  241. if default_address:
  242. address_info = {
  243. "contactPerson": default_address.contact_person,
  244. "contactPhone": default_address.contact_phone,
  245. "province": default_address.province,
  246. "city": default_address.city,
  247. "district": default_address.district,
  248. "detailAddress": default_address.detail_address
  249. }
  250. return success_response({
  251. "id": user.id,
  252. "username": user.username,
  253. "realName": user.real_name,
  254. "phone": user.phone,
  255. "role": user.role,
  256. "regionId": user.region_id,
  257. "regionName": region_name,
  258. "address": address_info
  259. })
  260. @router.put("/profile", response_model=dict)
  261. def update_profile(
  262. request: dict,
  263. db: Session = Depends(get_db),
  264. current_user: dict = Depends(require_supplier)
  265. ):
  266. """更新供应商个人资料"""
  267. user = user_crud.get(db, current_user.get("user_id"))
  268. if not user:
  269. return error_response(404, "用户不存在")
  270. payload = _resolve_request_payload(request, current_user)
  271. updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")}
  272. user = user_crud.update_with_history(db, current_user.get("user_id"), payload, updater)
  273. return success_response({
  274. "id": user.id,
  275. "realName": user.real_name,
  276. "phone": user.phone
  277. })
  278. # ============ 询价单管理 ============
  279. @router.get("/inquiries", response_model=dict)
  280. def list_inquiries(
  281. page: int = Query(1, ge=1, description="页码"),
  282. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  283. db: Session = Depends(get_db),
  284. current_user: dict = Depends(require_supplier)
  285. ):
  286. """获取供应商可响应的询价单列表"""
  287. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  288. from app.crud.quotation import quotation_crud
  289. user = user_crud.get(db, current_user.get("user_id"))
  290. if not user or not user.region_id:
  291. return error_response(400, "用户未绑定区域")
  292. inquiries, total = inquiry_crud.get_multi_with_filters(
  293. db,
  294. region_id=user.region_id,
  295. status="published",
  296. skip=(page - 1) * pageSize,
  297. limit=pageSize
  298. )
  299. inquiry_list = []
  300. for inquiry in inquiries:
  301. has_quoted = quotation_crud.get_latest_by_inquiry_and_supplier(db, inquiry.id, user.id) is not None
  302. inquiry_list.append({
  303. "id": inquiry.id,
  304. "title": inquiry.title,
  305. "regionId": inquiry.region_id,
  306. "status": inquiry.status,
  307. "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None,
  308. "closeTime": str(inquiry.close_time) if inquiry.close_time else None,
  309. "remark": inquiry.remark,
  310. "createAt": str(inquiry.create_at) if inquiry.create_at else None,
  311. "hasQuoted": has_quoted
  312. })
  313. return success_response({
  314. "list": inquiry_list,
  315. "total": total,
  316. "page": page,
  317. "pageSize": pageSize,
  318. "totalPages": (total + pageSize - 1) // pageSize
  319. })
  320. @router.get("/inquiries/{inquiry_id}", response_model=dict)
  321. def get_inquiry_detail(
  322. inquiry_id: str,
  323. db: Session = Depends(get_db),
  324. current_user: dict = Depends(require_supplier)
  325. ):
  326. """获取询价单详情"""
  327. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  328. from app.crud.product import product_crud
  329. from app.crud.category import category_crud
  330. from app.crud.unit import unit_crud
  331. from app.crud.quotation import quotation_crud
  332. user = user_crud.get(db, current_user.get("user_id"))
  333. if not user or not user.region_id:
  334. return error_response(400, "用户未绑定区域")
  335. inquiry = inquiry_crud.get(db, inquiry_id)
  336. if not inquiry:
  337. return error_response(404, "询价单不存在")
  338. if inquiry.region_id != user.region_id:
  339. return error_response(403, "无权查看此询价单")
  340. items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
  341. quotation = quotation_crud.get_latest_by_inquiry_and_supplier(db, inquiry_id, user.id)
  342. latest_quote_price_map = {}
  343. if quotation:
  344. from app.crud.quotation import quotation_item_crud
  345. quotation_items = quotation_item_crud.get_by_quotation(db, quotation.id)
  346. for quote_item in quotation_items:
  347. latest_quote_price_map[quote_item.product_id] = float(quote_item.price) if quote_item.price else 0
  348. item_details = []
  349. for item in items:
  350. product = product_crud.get(db, item.product_id)
  351. category_name = None
  352. first_category_id = None
  353. first_category_name = None
  354. first_category_image = None
  355. first_category_sort_order = 0
  356. second_category_id = None
  357. second_category_name = None
  358. second_category_image = None
  359. second_category_sort_order = 0
  360. product_sort_order = int(getattr(product, "sort_order", None) or 0) if product else 0
  361. unit_name = None
  362. if product and product.category_id:
  363. category = category_crud.get(db, product.category_id)
  364. category_name = category.name if category else None
  365. if category:
  366. if category.parent_id:
  367. parent_category = category_crud.get(db, category.parent_id)
  368. first_category_id = parent_category.id if parent_category else category.id
  369. first_category_name = parent_category.name if parent_category else category.name
  370. first_category_image = parent_category.image_url if parent_category else category.image_url
  371. first_category_sort_order = int(parent_category.sort_order or 0) if parent_category else int(category.sort_order or 0)
  372. second_category_id = category.id
  373. second_category_name = category.name
  374. second_category_image = category.image_url
  375. second_category_sort_order = int(category.sort_order or 0)
  376. else:
  377. first_category_id = category.id
  378. first_category_name = category.name
  379. first_category_image = category.image_url
  380. first_category_sort_order = int(category.sort_order or 0)
  381. second_category_sort_order = int(category.sort_order or 0)
  382. if product and product.unit_id:
  383. unit = unit_crud.get(db, product.unit_id)
  384. unit_name = unit.name if unit else None
  385. item_details.append({
  386. "id": item.id,
  387. "productId": item.product_id,
  388. "productName": product.name if product else None,
  389. "description": product.description if product else None,
  390. "imageUrl": product.image_url if product else None,
  391. "categoryId": product.category_id if product else None,
  392. "categoryName": category_name,
  393. "firstCategoryId": first_category_id,
  394. "firstCategoryName": first_category_name,
  395. "firstCategoryImage": first_category_image,
  396. "secondCategoryId": second_category_id,
  397. "secondCategoryName": second_category_name,
  398. "secondCategoryImage": second_category_image,
  399. "unitId": product.unit_id if product else None,
  400. "unitName": unit_name,
  401. "measureValue": float(product.measure_value) if product and product.measure_value is not None else None,
  402. "lastPrice": latest_quote_price_map.get(item.product_id),
  403. "createAt": str(item.create_at) if item.create_at else None,
  404. "_firstCategorySortOrder": first_category_sort_order,
  405. "_secondCategorySortOrder": second_category_sort_order,
  406. "_productSortOrder": product_sort_order
  407. })
  408. # 与客户端首页商品区一致:一级分类排序 -> 二级分类排序 -> 商品排序 -> 名称 -> id
  409. item_details.sort(
  410. key=lambda row: (
  411. int(row.get("_firstCategorySortOrder") or 0),
  412. int(row.get("_secondCategorySortOrder") or 0),
  413. int(row.get("_productSortOrder") or 0),
  414. (row.get("productName") or "").lower(),
  415. row.get("productId") or "",
  416. )
  417. )
  418. for row in item_details:
  419. row.pop("_firstCategorySortOrder", None)
  420. row.pop("_secondCategorySortOrder", None)
  421. row.pop("_productSortOrder", None)
  422. return success_response({
  423. "id": inquiry.id,
  424. "title": inquiry.title,
  425. "regionId": inquiry.region_id,
  426. "status": inquiry.status,
  427. "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None,
  428. "closeTime": str(inquiry.close_time) if inquiry.close_time else None,
  429. "remark": inquiry.remark,
  430. "createAt": str(inquiry.create_at) if inquiry.create_at else None,
  431. "items": item_details,
  432. "hasQuoted": quotation is not None,
  433. "quotationId": quotation.id if quotation else None,
  434. "quotationStatus": quotation.status if quotation else None
  435. })
  436. # ============ 报价单管理 ============
  437. @router.post("/quotations", response_model=dict)
  438. def submit_quotation(
  439. request: dict,
  440. raw_request: Request,
  441. db: Session = Depends(get_db),
  442. current_user: dict = Depends(require_supplier)
  443. ):
  444. """提交报价单"""
  445. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  446. from app.crud.quotation import quotation_crud, quotation_item_crud
  447. from app.crud.product import product_crud
  448. from decimal import Decimal
  449. user = user_crud.get(db, current_user.get("user_id"))
  450. if not user or not user.region_id:
  451. return error_response(400, "用户未绑定区域")
  452. req_payload = request if isinstance(request, dict) else {}
  453. # 兜底:若中间件未生效导致路由仍收到密文信封,则在路由内自行解密
  454. if int(req_payload.get("enc", 0) or 0) == 1 and isinstance(req_payload.get("edata"), str):
  455. try:
  456. user_id_for_crypto = str(current_user.get("user_id") or "")
  457. ts = int(req_payload.get("ts"))
  458. nonce = str(req_payload.get("nonce") or "")
  459. if not user_id_for_crypto or not nonce:
  460. return error_response(400, "加密报文缺少必要字段")
  461. if not settings.API_ENCRYPT_FIXED_SECRET:
  462. return error_response(500, "服务端加密配置缺失")
  463. key = api_crypto.derive_key(
  464. fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
  465. user_id=user_id_for_crypto,
  466. ts=ts,
  467. nonce=nonce,
  468. )
  469. plain = api_crypto.decrypt_json(str(req_payload.get("edata")), key)
  470. req_payload = plain.get("data", plain) if isinstance(plain, dict) else {}
  471. if not isinstance(req_payload, dict):
  472. req_payload = {}
  473. except Exception as exc:
  474. logger.warning("submit_quotation decrypt fallback failed: err=%s", exc)
  475. return error_response(400, "密文解密失败")
  476. def _extract_payload(d: dict) -> dict:
  477. """兼容不同层级的请求体结构:优先返回包含 inquiryId/items 的那层 dict。"""
  478. if not isinstance(d, dict):
  479. return {}
  480. if "inquiryId" in d or "items" in d:
  481. return d
  482. for k in ("data", "payload", "body"):
  483. v = d.get(k)
  484. if isinstance(v, dict):
  485. found = _extract_payload(v)
  486. if found:
  487. return found
  488. return d
  489. payload = _extract_payload(req_payload)
  490. inquiry_id = payload.get("inquiryId")
  491. items = payload.get("items", [])
  492. # 诊断:强制打印到 stdout(会落到 logs/app.log),仅输出 keys/长度/数量
  493. try:
  494. keys = list(payload.keys()) if isinstance(payload, dict) else []
  495. iid_len = len(str(inquiry_id)) if inquiry_id is not None else None
  496. items_len = len(items) if isinstance(items, list) else None
  497. print(
  498. f"[submit_quotation debug] keys={keys} inquiryId={repr(inquiry_id)} inquiryId_len={iid_len} items_len={items_len}",
  499. flush=True,
  500. )
  501. except Exception:
  502. pass
  503. if not inquiry_id:
  504. return error_response(400, "询价单ID不能为空")
  505. if not items:
  506. return error_response(400, "报价项不能为空")
  507. inquiry = inquiry_crud.get(db, inquiry_id)
  508. if not inquiry:
  509. return error_response(404, "询价单不存在")
  510. if inquiry.region_id != user.region_id:
  511. return error_response(403, "无权对此询价单报价")
  512. if inquiry.status != "published":
  513. return error_response(400, "询价单已关闭")
  514. # 支持同一询价单多次报价:每次提交都创建一张新报价单
  515. inquiry_items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
  516. inquiry_product_ids = set(item.product_id for item in inquiry_items)
  517. submitted_product_ids = set(item.get("productId") for item in items)
  518. if submitted_product_ids != inquiry_product_ids:
  519. return error_response(400, "报价商品必须与询价商品一致")
  520. creator = {"user_id": user.id, "real_name": user.real_name}
  521. quotation_data = {
  522. "inquiry_id": inquiry_id,
  523. "supplier_id": user.id,
  524. "region_id": user.region_id,
  525. "status": "submitted",
  526. "submit_time": datetime.now()
  527. }
  528. quotation_items_data = []
  529. total_amount = Decimal("0")
  530. for item in items:
  531. product_id = item.get("productId")
  532. unit_id = item.get("unitId")
  533. if not unit_id:
  534. product = product_crud.get(db, product_id) if product_id else None
  535. unit_id = product.unit_id if product else None
  536. if not unit_id:
  537. return error_response(400, f"商品 {product_id} 缺少单位信息,请传 unitId")
  538. price = Decimal(str(item.get("price", 0)))
  539. total_amount += price
  540. quotation_items_data.append({
  541. "product_id": product_id,
  542. "unit_id": unit_id,
  543. "price": price
  544. })
  545. quotation_data["total_amount"] = total_amount
  546. quotation = quotation_crud.create(db, quotation_data, creator)
  547. quotation_item_crud.create_items(db, quotation.id, quotation_items_data, creator)
  548. return success_response({
  549. "id": quotation.id,
  550. "totalAmount": float(quotation.total_amount),
  551. "status": quotation.status,
  552. "submitTime": str(quotation.submit_time) if quotation.submit_time else None
  553. })
  554. @router.get("/quotations", response_model=dict)
  555. def list_my_quotations(
  556. page: int = Query(1, ge=1, description="页码"),
  557. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  558. inquiryId: Optional[str] = Query(None, description="询价单ID"),
  559. status: Optional[str] = Query(None, description="报价状态,如 selected=已采纳"),
  560. db: Session = Depends(get_db),
  561. current_user: dict = Depends(require_supplier)
  562. ):
  563. """获取我的报价列表"""
  564. from app.crud.inquiry import inquiry_crud
  565. from app.crud.quotation import quotation_crud
  566. user = user_crud.get(db, current_user.get("user_id"))
  567. if not user:
  568. return error_response(404, "用户不存在")
  569. quotations, total = quotation_crud.get_multi_with_filters(
  570. db,
  571. supplier_id=user.id,
  572. inquiry_id=inquiryId,
  573. status=status,
  574. skip=(page - 1) * pageSize,
  575. limit=pageSize
  576. )
  577. inquiry_map = {}
  578. quotation_list = []
  579. for q in quotations:
  580. if q.inquiry_id and q.inquiry_id not in inquiry_map:
  581. inquiry = inquiry_crud.get(db, q.inquiry_id)
  582. inquiry_map[q.inquiry_id] = inquiry.title if inquiry else None
  583. quotation_list.append({
  584. "id": q.id,
  585. "inquiryId": q.inquiry_id,
  586. "inquiryTitle": inquiry_map.get(q.inquiry_id),
  587. "status": q.status,
  588. "totalAmount": float(q.total_amount) if q.total_amount else 0,
  589. "submitTime": str(q.submit_time) if q.submit_time else None,
  590. })
  591. return success_response({
  592. "list": quotation_list,
  593. "total": total,
  594. "page": page,
  595. "pageSize": pageSize,
  596. "totalPages": (total + pageSize - 1) // pageSize
  597. })
  598. @router.get("/quotations/{quotation_id}", response_model=dict)
  599. def get_my_quotation(
  600. quotation_id: str,
  601. db: Session = Depends(get_db),
  602. current_user: dict = Depends(require_supplier)
  603. ):
  604. """获取我的报价单详情"""
  605. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  606. from app.crud.quotation import quotation_crud, quotation_item_crud
  607. from app.crud.product import product_crud
  608. user = user_crud.get(db, current_user.get("user_id"))
  609. if not user:
  610. return error_response(404, "用户不存在")
  611. quotation = quotation_crud.get(db, quotation_id)
  612. if not quotation:
  613. return error_response(404, "报价单不存在")
  614. if quotation.supplier_id != user.id:
  615. return error_response(403, "无权查看此报价单")
  616. items = quotation_item_crud.get_by_quotation(db, quotation_id)
  617. inquiry_title = None
  618. if quotation.inquiry_id:
  619. inquiry = inquiry_crud.get(db, quotation.inquiry_id)
  620. inquiry_title = inquiry.title if inquiry else None
  621. item_details = []
  622. for item in items:
  623. product = product_crud.get(db, item.product_id)
  624. item_details.append({
  625. "id": item.id,
  626. "productId": item.product_id,
  627. "productName": product.name if product else None,
  628. "unitId": item.unit_id,
  629. "price": float(item.price) if item.price else 0,
  630. "createAt": str(item.create_at) if item.create_at else None
  631. })
  632. return success_response({
  633. "id": quotation.id,
  634. "inquiryId": quotation.inquiry_id,
  635. "inquiryTitle": inquiry_title,
  636. "status": quotation.status,
  637. "totalAmount": float(quotation.total_amount) if quotation.total_amount else 0,
  638. "submitTime": str(quotation.submit_time) if quotation.submit_time else None,
  639. "items": item_details
  640. })
  641. # ============ 价格清单 - 供应商查看 ============
  642. @router.get("/price-lists", response_model=dict)
  643. def list_my_price_lists(
  644. page: int = Query(1, ge=1, description="页码"),
  645. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  646. status: Optional[str] = Query(None, description="状态"),
  647. db: Session = Depends(get_db),
  648. current_user: dict = Depends(require_supplier)
  649. ):
  650. """获取供应商的价格清单列表"""
  651. from app.crud.price_list import price_list_crud
  652. from app.crud.region import region_crud
  653. user = user_crud.get(db, current_user.get("user_id"))
  654. if not user:
  655. return error_response(404, "用户不存在")
  656. price_lists, total = price_list_crud.get_multi_with_filters(
  657. db,
  658. supplier_id=user.id,
  659. status=status,
  660. skip=(page - 1) * pageSize,
  661. limit=pageSize
  662. )
  663. region_map = {}
  664. for pl in price_lists:
  665. if pl.region_id and pl.region_id not in region_map:
  666. region = region_crud.get(db, pl.region_id)
  667. region_map[pl.region_id] = region.name if region else None
  668. return success_response({
  669. "list": [{
  670. "id": pl.id,
  671. "regionId": pl.region_id,
  672. "regionName": region_map.get(pl.region_id),
  673. "version": pl.version,
  674. "status": pl.status,
  675. "effectiveTime": str(pl.effective_time) if pl.effective_time else None,
  676. "publishedTime": str(pl.published_time) if pl.published_time else None,
  677. "remark": pl.remark,
  678. "createAt": str(pl.create_at) if pl.create_at else None
  679. } for pl in price_lists],
  680. "total": total,
  681. "page": page,
  682. "pageSize": pageSize,
  683. "totalPages": (total + pageSize - 1) // pageSize
  684. })
  685. @router.get("/price-lists/{price_list_id}", response_model=dict)
  686. def get_my_price_list_detail(
  687. price_list_id: str,
  688. db: Session = Depends(get_db),
  689. current_user: dict = Depends(require_supplier)
  690. ):
  691. """获取供应商价格清单详情"""
  692. from app.crud.price_list import price_list_crud, price_list_item_crud
  693. from app.crud.region import region_crud
  694. from app.crud.product import product_crud
  695. from app.crud.unit import unit_crud
  696. user = user_crud.get(db, current_user.get("user_id"))
  697. if not user:
  698. return error_response(404, "用户不存在")
  699. price_list = price_list_crud.get(db, price_list_id)
  700. if not price_list:
  701. return error_response(404, "价格清单不存在")
  702. if price_list.supplier_id != user.id:
  703. return error_response(403, "无权查看此价格清单")
  704. items = price_list_item_crud.get_by_price_list(db, price_list_id)
  705. # 获取关联信息
  706. region = region_crud.get(db, price_list.region_id)
  707. region_name = region.name if region else None
  708. item_details = []
  709. for item in items:
  710. product = product_crud.get(db, item.product_id)
  711. category_name = None
  712. if product and product.category_id:
  713. category = category_crud.get(db, product.category_id)
  714. category_name = category.name if category else None
  715. unit_name = None
  716. if item.unit_id:
  717. u = unit_crud.get(db, item.unit_id)
  718. unit_name = u.name if u else None
  719. item_details.append({
  720. "productId": item.product_id,
  721. "productName": product.name if product else None,
  722. "categoryName": category_name,
  723. "unitId": item.unit_id,
  724. "unitName": unit_name,
  725. "price": float(item.price) if item.price else 0,
  726. "imageUrl": product.image_url if product else None
  727. })
  728. return success_response({
  729. "id": price_list.id,
  730. "regionId": price_list.region_id,
  731. "regionName": region_name,
  732. "version": price_list.version,
  733. "status": price_list.status,
  734. "effectiveTime": str(price_list.effective_time) if price_list.effective_time else None,
  735. "publishedTime": str(price_list.published_time) if price_list.published_time else None,
  736. "remark": price_list.remark,
  737. "createAt": str(price_list.create_at) if price_list.create_at else None,
  738. "items": item_details
  739. })
  740. # ============ 配送任务管理 ============
  741. @router.get("/tasks", response_model=dict)
  742. def list_supplier_tasks(
  743. page: int = Query(1, ge=1, description="页码"),
  744. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  745. supplierPrepareStatus: Optional[str] = Query(
  746. None,
  747. description="供应商备货状态 supplier_prepare_status;逗号多选,如 stocking,prepared",
  748. ),
  749. status: Optional[str] = Query(
  750. None,
  751. description="兼容:任务主状态 status;未传 supplierPrepareStatus 时生效",
  752. ),
  753. keyword: Optional[str] = Query(None, description="检索:配送单号、订单号、联系人、电话"),
  754. db: Session = Depends(get_db),
  755. current_user: dict = Depends(require_supplier)
  756. ):
  757. """获取供应商的配送任务列表(含商品缩略列表;供货合计按价格清单还原供货单价后汇总)"""
  758. from sqlalchemy import and_
  759. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  760. from app.crud.order import order_crud, order_item_crud
  761. from app.crud.product import product_crud
  762. from app.crud.unit import unit_crud
  763. from app.models.models import BizPriceListItem
  764. user = user_crud.get(db, current_user.get("user_id"))
  765. if not user:
  766. return error_response(404, "用户不存在")
  767. tasks, total = delivery_task_crud.get_multi_with_filters(
  768. db,
  769. supplier_id=user.id,
  770. supplier_prepare_status=supplierPrepareStatus,
  771. status=None if supplierPrepareStatus else status,
  772. keyword=keyword,
  773. skip=(page - 1) * pageSize,
  774. limit=pageSize
  775. )
  776. result = []
  777. for task in tasks:
  778. region_name = None
  779. if task.region_id:
  780. region = region_crud.get(db, task.region_id)
  781. region_name = region.name if region else None
  782. # 任务行 + 供货小计(与任务详情中价格清单逻辑一致)
  783. task_items = delivery_task_item_crud.get_by_task(db, task.id)
  784. total_items = len(task_items)
  785. delivered_items = len([i for i in task_items if i.status == "delivered"])
  786. order = order_crud.get(db, task.order_id) if task.order_id else None
  787. line_items = []
  788. total_quantity = 0.0
  789. supplier_quote_total = 0.0
  790. for ti in task_items:
  791. product = product_crud.get(db, ti.product_id)
  792. unit_name = None
  793. supplier_quote_line = None
  794. oi = None
  795. if ti.order_item_id:
  796. oi = order_item_crud.get(db, ti.order_item_id)
  797. if oi and oi.unit_id:
  798. u = unit_crud.get(db, oi.unit_id)
  799. unit_name = u.name if u else None
  800. if order and oi:
  801. pli = db.query(BizPriceListItem).filter(
  802. and_(
  803. BizPriceListItem.price_list_id == order.price_list_id,
  804. BizPriceListItem.product_id == oi.product_id,
  805. BizPriceListItem.unit_id == oi.unit_id,
  806. BizPriceListItem.is_del == 0,
  807. )
  808. ).first()
  809. if pli is not None and pli.price is not None:
  810. unit_price = _supplier_quote_unit_yuan_from_pli(pli)
  811. if unit_price is not None:
  812. qty = float(ti.quantity) if ti.quantity else 0.0
  813. supplier_quote_line = round(qty * unit_price, 2)
  814. supplier_quote_total += supplier_quote_line
  815. qty = float(ti.quantity) if ti.quantity else 0.0
  816. total_quantity += qty
  817. line_items.append({
  818. "id": ti.id,
  819. "productId": ti.product_id,
  820. "productName": product.name if product else None,
  821. "quantity": qty,
  822. "unitName": unit_name,
  823. "imageUrl": product.image_url if product else None,
  824. })
  825. # 获取配送员信息(列表不返回客户信息,仅展示配送员对接用)
  826. deliver_name = None
  827. deliver_phone = None
  828. if task.deliver_id:
  829. deliver = user_crud.get(db, task.deliver_id)
  830. if deliver:
  831. deliver_name = deliver.real_name
  832. deliver_phone = deliver.phone
  833. result.append({
  834. "id": task.id,
  835. "taskNo": task.task_no,
  836. "orderId": task.order_id,
  837. "regionId": task.region_id,
  838. "regionName": region_name,
  839. "supplierId": task.supplier_id,
  840. "deliverId": task.deliver_id,
  841. "deliverName": deliver_name,
  842. "deliverPhone": deliver_phone,
  843. "status": task.status,
  844. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  845. "effectiveTime": str(task.effective_time) if task.effective_time else None,
  846. "pickupTime": str(task.pickup_time) if task.pickup_time else None,
  847. "pickupRemark": task.pickup_remark,
  848. "deliveryTime": str(task.delivery_time) if task.delivery_time else None,
  849. "totalItems": total_items,
  850. "deliveredItems": delivered_items,
  851. "totalQuantity": round(total_quantity, 3),
  852. "supplierQuoteTotal": round(supplier_quote_total, 2),
  853. "items": line_items,
  854. "createAt": str(task.create_at) if task.create_at else None,
  855. "supplierSettlementStatus": (
  856. (getattr(order, "supplier_settlement_status", None) or "pending")
  857. if order
  858. else "pending"
  859. ),
  860. })
  861. return success_response({
  862. "list": result,
  863. "total": total,
  864. "page": page,
  865. "pageSize": pageSize,
  866. "totalPages": (total + pageSize - 1) // pageSize
  867. })
  868. @router.get("/tasks/{task_id}", response_model=dict)
  869. def get_supplier_task_detail(
  870. task_id: str,
  871. db: Session = Depends(get_db),
  872. current_user: dict = Depends(require_supplier)
  873. ):
  874. """获取供应商配送任务详情(订单仅返回客户订单号;商品行含主图与规格;单价由价格清单行 price(客户最终单价)与 pricing_coefficient 反推供货价)"""
  875. from sqlalchemy import and_
  876. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  877. from app.crud.order import order_crud, order_item_crud
  878. from app.crud.product import product_crud
  879. from app.crud.unit import unit_crud
  880. from app.models.models import BizPriceListItem
  881. user = user_crud.get(db, current_user.get("user_id"))
  882. if not user:
  883. return error_response(404, "用户不存在")
  884. task = delivery_task_crud.get(db, task_id)
  885. if not task:
  886. return error_response(404, "任务不存在")
  887. if task.supplier_id != user.id:
  888. return error_response(403, "无权查看此任务")
  889. order = None
  890. order_info = None
  891. if task.order_id:
  892. order = order_crud.get(db, task.order_id)
  893. if order:
  894. # 供应商备货详情仅展示客户订单号,不下发客户联系人、地址等
  895. order_info = {
  896. "orderNo": order.order_no,
  897. "supplierSettlementStatus": getattr(order, "supplier_settlement_status", None) or "pending",
  898. "supplierSettlementAt": str(order.supplier_settlement_at)
  899. if getattr(order, "supplier_settlement_at", None)
  900. else None,
  901. }
  902. deliver_info = None
  903. if task.deliver_id:
  904. deliver = user_crud.get(db, task.deliver_id)
  905. if deliver:
  906. deliver_info = {
  907. "deliverId": deliver.id,
  908. "deliverName": deliver.real_name,
  909. "phone": deliver.phone
  910. }
  911. task_items = delivery_task_item_crud.get_by_task(db, task_id)
  912. item_details = []
  913. for item in task_items:
  914. product = product_crud.get(db, item.product_id)
  915. unit_name = None
  916. supplier_quote_unit = None
  917. supplier_quote_line = None
  918. product_image_url = None
  919. product_measure_value = None
  920. product_unit_name = None
  921. if product:
  922. product_image_url = product.image_url
  923. if product.measure_value is not None:
  924. product_measure_value = float(product.measure_value)
  925. if product.unit_id:
  926. pu = unit_crud.get(db, product.unit_id)
  927. product_unit_name = pu.name if pu else None
  928. oi = None
  929. if item.order_item_id:
  930. oi = order_item_crud.get(db, item.order_item_id)
  931. if oi and oi.unit_id:
  932. u = unit_crud.get(db, oi.unit_id)
  933. unit_name = u.name if u else None
  934. if order and oi:
  935. pli = db.query(BizPriceListItem).filter(
  936. and_(
  937. BizPriceListItem.price_list_id == order.price_list_id,
  938. BizPriceListItem.product_id == oi.product_id,
  939. BizPriceListItem.unit_id == oi.unit_id,
  940. BizPriceListItem.is_del == 0,
  941. )
  942. ).first()
  943. if pli is not None and pli.price is not None:
  944. supplier_quote_unit = _supplier_quote_unit_yuan_from_pli(pli)
  945. if supplier_quote_unit is not None:
  946. qty = float(item.quantity) if item.quantity else 0.0
  947. supplier_quote_line = round(qty * supplier_quote_unit, 2)
  948. item_details.append({
  949. "id": item.id,
  950. "orderItemId": item.order_item_id,
  951. "productId": item.product_id,
  952. "productName": product.name if product else None,
  953. "imageUrl": product_image_url,
  954. "measureValue": product_measure_value,
  955. "productUnitName": product_unit_name,
  956. "unitName": unit_name,
  957. "quantity": float(item.quantity) if item.quantity else 0,
  958. "deliveredQuantity": float(item.delivered_quantity) if item.delivered_quantity else 0,
  959. "supplierQuoteUnit": supplier_quote_unit,
  960. "supplierQuoteLine": supplier_quote_line,
  961. "status": item.status,
  962. "deliveryTime": str(item.delivery_time) if item.delivery_time else None
  963. })
  964. prepared_images = [
  965. a.file_url
  966. for a in attachment_crud.get_by_business(db, "delivery_prepare", task_id)
  967. if getattr(a, "file_url", None)
  968. ]
  969. if (not prepared_images) and getattr(task, "pickup_image_url", None):
  970. prepared_images = [s.strip() for s in str(task.pickup_image_url).split(",") if s and str(s).strip()]
  971. return success_response({
  972. "id": task.id,
  973. "taskNo": task.task_no,
  974. "orderId": task.order_id,
  975. "regionId": task.region_id,
  976. "status": task.status,
  977. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  978. "effectiveTime": str(task.effective_time) if task.effective_time else None,
  979. "pickupTime": str(task.pickup_time) if task.pickup_time else None,
  980. "pickupRemark": task.pickup_remark,
  981. "pickupImageUrl": task.pickup_image_url,
  982. "deliveryTime": str(task.delivery_time) if task.delivery_time else None,
  983. "deliveryRemark": task.delivery_remark,
  984. "deliveryImageUrl": task.delivery_image_url,
  985. "preparedImages": prepared_images,
  986. "order": order_info,
  987. "deliver": deliver_info,
  988. "items": item_details,
  989. "createAt": str(task.create_at) if task.create_at else None
  990. })
  991. def _notify_deliver_stock_ready(db, task, user, remark: str = None) -> bool:
  992. """备货完成后通知配送员(微信订阅消息,失败不阻断主流程)"""
  993. if not task.deliver_id:
  994. return False
  995. try:
  996. from app.core.wechat_message import WechatMessageService
  997. deliver = user_crud.get(db, task.deliver_id)
  998. if deliver and hasattr(deliver, "openid"):
  999. WechatMessageService.send_delivery_notice(
  1000. openid=deliver.openid,
  1001. task_no=task.task_no,
  1002. supplier_name=user.real_name or "供应商",
  1003. address="商品已备好,请尽快取货",
  1004. remark=remark or "供应商已备货完成,请及时取货",
  1005. )
  1006. return True
  1007. except Exception:
  1008. pass
  1009. return False
  1010. @router.post("/tasks/{task_id}/start-stock", response_model=dict)
  1011. def supplier_start_stock(
  1012. task_id: str,
  1013. request: dict,
  1014. db: Session = Depends(get_db),
  1015. current_user: dict = Depends(require_supplier),
  1016. ):
  1017. """供应商开始备货:assigned -> supplier_stocking,并写入订单状态日志(配送进度)"""
  1018. from app.crud.delivery import delivery_task_crud
  1019. user = user_crud.get(db, current_user.get("user_id"))
  1020. if not user:
  1021. return error_response(404, "用户不存在")
  1022. task = delivery_task_crud.get(db, task_id)
  1023. if not task:
  1024. return error_response(404, "任务不存在")
  1025. if task.supplier_id != user.id:
  1026. return error_response(403, "无权操作此任务")
  1027. if (getattr(task, "supplier_prepare_status", None) or "pending_prepare") != "pending_prepare":
  1028. return error_response(400, "当前状态不可开始备货(需为待备货/已分配)")
  1029. payload = _resolve_request_payload(request, current_user)
  1030. remark = (payload.get("remark") or "").strip() or None
  1031. task = delivery_task_crud.update_status(
  1032. db,
  1033. task_id=task_id,
  1034. new_status="supplier_stocking",
  1035. operator_id=user.id,
  1036. remark=remark,
  1037. operator_real_name=user.real_name,
  1038. operator_role="supplier",
  1039. )
  1040. if not task:
  1041. return error_response(400, "操作失败")
  1042. return success_response({
  1043. "id": task.id,
  1044. "taskNo": task.task_no,
  1045. "status": task.status,
  1046. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  1047. "delivererStatus": getattr(task, "deliverer_status", None),
  1048. "message": "已开始备货",
  1049. })
  1050. @router.post("/tasks/{task_id}/finish-stock", response_model=dict)
  1051. def supplier_finish_stock(
  1052. task_id: str,
  1053. request: dict,
  1054. db: Session = Depends(get_db),
  1055. current_user: dict = Depends(require_supplier),
  1056. ):
  1057. """供应商备货完成:supplier_stocking -> supplier_ready(待配送员取货),写入订单日志并可通知配送员"""
  1058. from app.crud.delivery import delivery_task_crud
  1059. user = user_crud.get(db, current_user.get("user_id"))
  1060. if not user:
  1061. return error_response(404, "用户不存在")
  1062. task = delivery_task_crud.get(db, task_id)
  1063. if not task:
  1064. return error_response(404, "任务不存在")
  1065. if task.supplier_id != user.id:
  1066. return error_response(403, "无权操作此任务")
  1067. ss = getattr(task, "supplier_prepare_status", None) or "pending_prepare"
  1068. if ss == "pending_prepare":
  1069. return error_response(400, "请先开始备货后再确认备货完成")
  1070. if ss not in ("stocking", "prepared"):
  1071. return error_response(400, "当前状态不可确认备货完成")
  1072. if ss == "prepared":
  1073. ds = (getattr(task, "deliverer_status", None) or "").strip().lower()
  1074. if ds != "pending_pickup":
  1075. return error_response(400, "配送员已取货,备货凭证不可再修改")
  1076. payload = _resolve_request_payload(request, current_user)
  1077. remark = (payload.get("remark") or payload.get("preparedRemark") or "").strip() or None
  1078. image_urls = _normalize_image_urls(payload.get("imageUrls"))
  1079. if not image_urls:
  1080. prep_img = payload.get("preparedImageUrl") or payload.get("pickupImageUrl")
  1081. image_urls = _normalize_image_urls(prep_img)
  1082. if not image_urls:
  1083. return error_response(400, "请上传备货完成凭证照片")
  1084. if ss == "stocking":
  1085. task = delivery_task_crud.update_status(
  1086. db,
  1087. task_id=task_id,
  1088. new_status="supplier_ready",
  1089. operator_id=user.id,
  1090. remark=remark,
  1091. image_urls=image_urls,
  1092. operator_real_name=user.real_name,
  1093. operator_role="supplier",
  1094. )
  1095. if not task:
  1096. return error_response(400, "操作失败")
  1097. else:
  1098. task = delivery_task_crud.get(db, task_id)
  1099. if not task:
  1100. return error_response(404, "任务不存在")
  1101. _replace_delivery_prepare_attachments(
  1102. db,
  1103. task_id=task_id,
  1104. image_urls=image_urls,
  1105. uploader_id=user.id,
  1106. uploader_name=user.real_name,
  1107. )
  1108. db.commit()
  1109. deliver_notified = _notify_deliver_stock_ready(db, task, user, remark) if ss == "stocking" else False
  1110. return success_response({
  1111. "id": task.id,
  1112. "taskNo": task.task_no,
  1113. "status": task.status,
  1114. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  1115. "delivererStatus": getattr(task, "deliverer_status", None),
  1116. "message": ("已确认备货完成" if ss == "stocking" else "备货凭证已更新")
  1117. + (",已通知配送员" if deliver_notified else ""),
  1118. })
  1119. # 兼容旧路径:与 start-stock / finish-stock 行为一致
  1120. @router.post("/tasks/{task_id}/confirm-pickup", response_model=dict)
  1121. def supplier_confirm_pickup_deprecated(
  1122. task_id: str,
  1123. request: dict,
  1124. db: Session = Depends(get_db),
  1125. current_user: dict = Depends(require_supplier),
  1126. ):
  1127. """[兼容] 请改用 POST /tasks/{id}/start-stock"""
  1128. return supplier_start_stock(task_id, request, db, current_user)
  1129. @router.post("/tasks/{task_id}/confirm-prepared", response_model=dict)
  1130. def supplier_confirm_prepared_deprecated(
  1131. task_id: str,
  1132. request: dict,
  1133. db: Session = Depends(get_db),
  1134. current_user: dict = Depends(require_supplier),
  1135. ):
  1136. """[兼容] 请改用 POST /tasks/{id}/finish-stock"""
  1137. return supplier_finish_stock(task_id, request, db, current_user)
  1138. @router.get("/dashboard/statistics", response_model=dict)
  1139. def supplier_dashboard_statistics(
  1140. db: Session = Depends(get_db),
  1141. current_user: dict = Depends(require_supplier)
  1142. ):
  1143. """供应商首页统计(询价/报价/任务概览)"""
  1144. from app.crud.delivery import delivery_task_crud
  1145. from app.models.models import BizInquiry, BizQuotation
  1146. from sqlalchemy import and_
  1147. uid = current_user.get("user_id")
  1148. user = user_crud.get(db, uid)
  1149. rid = user.region_id if user else None
  1150. q_inq = db.query(BizInquiry).filter(
  1151. and_(BizInquiry.is_del == 0, BizInquiry.status == "published")
  1152. )
  1153. if rid:
  1154. q_inq = q_inq.filter(BizInquiry.region_id == rid)
  1155. pending_inq = q_inq.count()
  1156. my_quo = db.query(BizQuotation).filter(
  1157. and_(BizQuotation.is_del == 0, BizQuotation.supplier_id == uid)
  1158. ).count()
  1159. pending_tasks, _ = delivery_task_crud.get_by_supplier(
  1160. db, supplier_id=uid, status="assigned", skip=0, limit=500
  1161. )
  1162. stocking, _ = delivery_task_crud.get_by_supplier(
  1163. db, supplier_id=uid, status="supplier_stocking", skip=0, limit=500
  1164. )
  1165. return success_response({
  1166. "pendingInquiries": pending_inq,
  1167. "myQuotations": my_quo,
  1168. "pendingTasks": len(pending_tasks) + len(stocking),
  1169. "todayDeliveries": 0,
  1170. })
  1171. @router.get("/products/{product_id}", response_model=dict)
  1172. def get_supplier_product_detail(
  1173. product_id: str,
  1174. db: Session = Depends(get_db),
  1175. current_user: dict = Depends(require_supplier)
  1176. ):
  1177. """供应商端商品详情:轮播、基础信息、参数、富文本、详情媒体"""
  1178. from app.crud.product import product_crud
  1179. from app.crud.unit import unit_crud
  1180. from app.crud.product_banner import product_banner
  1181. from app.crud.product_parameter_value import product_parameter_value
  1182. from app.crud.product_detail_media import product_detail_media
  1183. product = product_crud.get(db, product_id)
  1184. if not product or getattr(product, "is_del", 0) == 1:
  1185. return error_response(404, "商品不存在")
  1186. unit = unit_crud.get(db, product.unit_id) if product.unit_id else None
  1187. banners_raw = product_banner.get_by_product(db, product_id)
  1188. banners = [{
  1189. "mediaType": b.media_type,
  1190. "fileUrl": b.file_url,
  1191. "thumbnailUrl": b.thumbnail_url,
  1192. "sortOrder": b.sort_order,
  1193. } for b in banners_raw]
  1194. pv_rows = product_parameter_value.get_by_product_with_detail(db, product_id)
  1195. parameter_values = []
  1196. for row in pv_rows:
  1197. val = row.get("param_value")
  1198. if val is None or str(val).strip() == "":
  1199. continue
  1200. parameter_values.append({
  1201. "parameterId": row.get("parameter_id"),
  1202. "parameterName": row.get("parameter_name") or "",
  1203. "paramValue": str(val),
  1204. })
  1205. media_raw = product_detail_media.get_by_product(db, product_id)
  1206. detail_media = []
  1207. for m in media_raw:
  1208. if m.content_type not in ("image", "video"):
  1209. continue
  1210. if not m.file_url:
  1211. continue
  1212. detail_media.append({
  1213. "id": m.id,
  1214. "contentType": m.content_type,
  1215. "fileUrl": m.file_url,
  1216. "thumbnailUrl": m.thumbnail_url,
  1217. "description": m.description,
  1218. "sortOrder": m.sort_order,
  1219. })
  1220. return success_response({
  1221. "product": {
  1222. "id": product.id,
  1223. "name": product.name,
  1224. "unitId": product.unit_id,
  1225. "unitName": unit.name if unit else None,
  1226. "measureValue": float(product.measure_value) if product.measure_value is not None else None,
  1227. "detailContent": product.detail_content or "",
  1228. },
  1229. "banners": banners,
  1230. "parameterValues": parameter_values,
  1231. "detailMedia": detail_media,
  1232. })