mini_supplier.py 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445
  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. "spec": getattr(product, "spec", None) if product else None,
  403. "lastPrice": latest_quote_price_map.get(item.product_id),
  404. "suggestedSupplierPrice": (
  405. float(product.suggested_supplier_price)
  406. if product and product.suggested_supplier_price is not None
  407. else None
  408. ),
  409. "createAt": str(item.create_at) if item.create_at else None,
  410. "_firstCategorySortOrder": first_category_sort_order,
  411. "_secondCategorySortOrder": second_category_sort_order,
  412. "_productSortOrder": product_sort_order
  413. })
  414. # 与客户端首页商品区一致:一级分类排序 -> 二级分类排序 -> 商品排序 -> 名称 -> id
  415. item_details.sort(
  416. key=lambda row: (
  417. int(row.get("_firstCategorySortOrder") or 0),
  418. int(row.get("_secondCategorySortOrder") or 0),
  419. int(row.get("_productSortOrder") or 0),
  420. (row.get("productName") or "").lower(),
  421. row.get("productId") or "",
  422. )
  423. )
  424. for row in item_details:
  425. row.pop("_firstCategorySortOrder", None)
  426. row.pop("_secondCategorySortOrder", None)
  427. row.pop("_productSortOrder", None)
  428. return success_response({
  429. "id": inquiry.id,
  430. "title": inquiry.title,
  431. "regionId": inquiry.region_id,
  432. "status": inquiry.status,
  433. "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None,
  434. "closeTime": str(inquiry.close_time) if inquiry.close_time else None,
  435. "remark": inquiry.remark,
  436. "createAt": str(inquiry.create_at) if inquiry.create_at else None,
  437. "items": item_details,
  438. "hasQuoted": quotation is not None,
  439. "quotationId": quotation.id if quotation else None,
  440. "quotationStatus": quotation.status if quotation else None
  441. })
  442. # ============ 报价单管理 ============
  443. @router.post("/quotations", response_model=dict)
  444. def submit_quotation(
  445. request: dict,
  446. raw_request: Request,
  447. db: Session = Depends(get_db),
  448. current_user: dict = Depends(require_supplier)
  449. ):
  450. """提交报价单"""
  451. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  452. from app.crud.quotation import quotation_crud, quotation_item_crud
  453. from app.crud.product import product_crud
  454. from decimal import Decimal
  455. user = user_crud.get(db, current_user.get("user_id"))
  456. if not user or not user.region_id:
  457. return error_response(400, "用户未绑定区域")
  458. req_payload = request if isinstance(request, dict) else {}
  459. # 兜底:若中间件未生效导致路由仍收到密文信封,则在路由内自行解密
  460. if int(req_payload.get("enc", 0) or 0) == 1 and isinstance(req_payload.get("edata"), str):
  461. try:
  462. user_id_for_crypto = str(current_user.get("user_id") or "")
  463. ts = int(req_payload.get("ts"))
  464. nonce = str(req_payload.get("nonce") or "")
  465. if not user_id_for_crypto or not nonce:
  466. return error_response(400, "加密报文缺少必要字段")
  467. if not settings.API_ENCRYPT_FIXED_SECRET:
  468. return error_response(500, "服务端加密配置缺失")
  469. key = api_crypto.derive_key(
  470. fixed_secret=settings.API_ENCRYPT_FIXED_SECRET,
  471. user_id=user_id_for_crypto,
  472. ts=ts,
  473. nonce=nonce,
  474. )
  475. plain = api_crypto.decrypt_json(str(req_payload.get("edata")), key)
  476. req_payload = plain.get("data", plain) if isinstance(plain, dict) else {}
  477. if not isinstance(req_payload, dict):
  478. req_payload = {}
  479. except Exception as exc:
  480. logger.warning("submit_quotation decrypt fallback failed: err=%s", exc)
  481. return error_response(400, "密文解密失败")
  482. def _extract_payload(d: dict) -> dict:
  483. """兼容不同层级的请求体结构:优先返回包含 inquiryId/items 的那层 dict。"""
  484. if not isinstance(d, dict):
  485. return {}
  486. if "inquiryId" in d or "items" in d:
  487. return d
  488. for k in ("data", "payload", "body"):
  489. v = d.get(k)
  490. if isinstance(v, dict):
  491. found = _extract_payload(v)
  492. if found:
  493. return found
  494. return d
  495. payload = _extract_payload(req_payload)
  496. inquiry_id = payload.get("inquiryId")
  497. items = payload.get("items", [])
  498. # 诊断:强制打印到 stdout(会落到 logs/app.log),仅输出 keys/长度/数量
  499. try:
  500. keys = list(payload.keys()) if isinstance(payload, dict) else []
  501. iid_len = len(str(inquiry_id)) if inquiry_id is not None else None
  502. items_len = len(items) if isinstance(items, list) else None
  503. print(
  504. f"[submit_quotation debug] keys={keys} inquiryId={repr(inquiry_id)} inquiryId_len={iid_len} items_len={items_len}",
  505. flush=True,
  506. )
  507. except Exception:
  508. pass
  509. if not inquiry_id:
  510. return error_response(400, "询价单ID不能为空")
  511. if not items:
  512. return error_response(400, "报价项不能为空")
  513. inquiry = inquiry_crud.get(db, inquiry_id)
  514. if not inquiry:
  515. return error_response(404, "询价单不存在")
  516. if inquiry.region_id != user.region_id:
  517. return error_response(403, "无权对此询价单报价")
  518. if inquiry.status != "published":
  519. return error_response(400, "询价单已关闭")
  520. # 支持同一询价单多次报价:每次提交都创建一张新报价单
  521. inquiry_items = inquiry_item_crud.get_by_inquiry(db, inquiry_id)
  522. inquiry_product_ids = set(item.product_id for item in inquiry_items)
  523. submitted_product_ids = set(item.get("productId") for item in items)
  524. if submitted_product_ids != inquiry_product_ids:
  525. return error_response(400, "报价商品必须与询价商品一致")
  526. creator = {"user_id": user.id, "real_name": user.real_name}
  527. quotation_data = {
  528. "inquiry_id": inquiry_id,
  529. "supplier_id": user.id,
  530. "region_id": user.region_id,
  531. "status": "submitted",
  532. "submit_time": datetime.now()
  533. }
  534. quotation_items_data = []
  535. total_amount = Decimal("0")
  536. for item in items:
  537. product_id = item.get("productId")
  538. unit_id = item.get("unitId")
  539. if not unit_id:
  540. product = product_crud.get(db, product_id) if product_id else None
  541. unit_id = product.unit_id if product else None
  542. if not unit_id:
  543. return error_response(400, f"商品 {product_id} 缺少单位信息,请传 unitId")
  544. price = Decimal(str(item.get("price", 0)))
  545. total_amount += price
  546. quotation_items_data.append({
  547. "product_id": product_id,
  548. "unit_id": unit_id,
  549. "price": price
  550. })
  551. quotation_data["total_amount"] = total_amount
  552. quotation = quotation_crud.create(db, quotation_data, creator)
  553. quotation_item_crud.create_items(db, quotation.id, quotation_items_data, creator)
  554. return success_response({
  555. "id": quotation.id,
  556. "totalAmount": float(quotation.total_amount),
  557. "status": quotation.status,
  558. "submitTime": str(quotation.submit_time) if quotation.submit_time else None
  559. })
  560. @router.get("/quotations", response_model=dict)
  561. def list_my_quotations(
  562. page: int = Query(1, ge=1, description="页码"),
  563. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  564. inquiryId: Optional[str] = Query(None, description="询价单ID"),
  565. status: Optional[str] = Query(None, description="报价状态,如 selected=已采纳"),
  566. db: Session = Depends(get_db),
  567. current_user: dict = Depends(require_supplier)
  568. ):
  569. """获取我的报价列表"""
  570. from app.crud.inquiry import inquiry_crud
  571. from app.crud.quotation import quotation_crud
  572. user = user_crud.get(db, current_user.get("user_id"))
  573. if not user:
  574. return error_response(404, "用户不存在")
  575. quotations, total = quotation_crud.get_multi_with_filters(
  576. db,
  577. supplier_id=user.id,
  578. inquiry_id=inquiryId,
  579. status=status,
  580. skip=(page - 1) * pageSize,
  581. limit=pageSize
  582. )
  583. inquiry_map = {}
  584. quotation_list = []
  585. for q in quotations:
  586. if q.inquiry_id and q.inquiry_id not in inquiry_map:
  587. inquiry = inquiry_crud.get(db, q.inquiry_id)
  588. inquiry_map[q.inquiry_id] = inquiry.title if inquiry else None
  589. quotation_list.append({
  590. "id": q.id,
  591. "inquiryId": q.inquiry_id,
  592. "inquiryTitle": inquiry_map.get(q.inquiry_id),
  593. "status": q.status,
  594. "totalAmount": float(q.total_amount) if q.total_amount else 0,
  595. "submitTime": str(q.submit_time) if q.submit_time else None,
  596. })
  597. return success_response({
  598. "list": quotation_list,
  599. "total": total,
  600. "page": page,
  601. "pageSize": pageSize,
  602. "totalPages": (total + pageSize - 1) // pageSize
  603. })
  604. @router.get("/quotations/{quotation_id}", response_model=dict)
  605. def get_my_quotation(
  606. quotation_id: str,
  607. db: Session = Depends(get_db),
  608. current_user: dict = Depends(require_supplier)
  609. ):
  610. """获取我的报价单详情"""
  611. from app.crud.inquiry import inquiry_crud, inquiry_item_crud
  612. from app.crud.quotation import quotation_crud, quotation_item_crud
  613. from app.crud.product import product_crud
  614. user = user_crud.get(db, current_user.get("user_id"))
  615. if not user:
  616. return error_response(404, "用户不存在")
  617. quotation = quotation_crud.get(db, quotation_id)
  618. if not quotation:
  619. return error_response(404, "报价单不存在")
  620. if quotation.supplier_id != user.id:
  621. return error_response(403, "无权查看此报价单")
  622. items = quotation_item_crud.get_by_quotation(db, quotation_id)
  623. inquiry_title = None
  624. if quotation.inquiry_id:
  625. inquiry = inquiry_crud.get(db, quotation.inquiry_id)
  626. inquiry_title = inquiry.title if inquiry else None
  627. item_details = []
  628. for item in items:
  629. product = product_crud.get(db, item.product_id)
  630. item_details.append({
  631. "id": item.id,
  632. "productId": item.product_id,
  633. "productName": product.name if product else None,
  634. "unitId": item.unit_id,
  635. "price": float(item.price) if item.price else 0,
  636. "createAt": str(item.create_at) if item.create_at else None
  637. })
  638. return success_response({
  639. "id": quotation.id,
  640. "inquiryId": quotation.inquiry_id,
  641. "inquiryTitle": inquiry_title,
  642. "status": quotation.status,
  643. "totalAmount": float(quotation.total_amount) if quotation.total_amount else 0,
  644. "submitTime": str(quotation.submit_time) if quotation.submit_time else None,
  645. "items": item_details
  646. })
  647. # ============ 价格清单 - 供应商查看 ============
  648. @router.get("/price-lists", response_model=dict)
  649. def list_my_price_lists(
  650. page: int = Query(1, ge=1, description="页码"),
  651. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  652. status: Optional[str] = Query(None, description="状态"),
  653. db: Session = Depends(get_db),
  654. current_user: dict = Depends(require_supplier)
  655. ):
  656. """获取供应商的价格清单列表"""
  657. from app.crud.price_list import price_list_crud
  658. from app.crud.region import region_crud
  659. user = user_crud.get(db, current_user.get("user_id"))
  660. if not user:
  661. return error_response(404, "用户不存在")
  662. price_lists, total = price_list_crud.get_multi_with_filters(
  663. db,
  664. supplier_id=user.id,
  665. status=status,
  666. skip=(page - 1) * pageSize,
  667. limit=pageSize
  668. )
  669. region_map = {}
  670. for pl in price_lists:
  671. if pl.region_id and pl.region_id not in region_map:
  672. region = region_crud.get(db, pl.region_id)
  673. region_map[pl.region_id] = region.name if region else None
  674. return success_response({
  675. "list": [{
  676. "id": pl.id,
  677. "regionId": pl.region_id,
  678. "regionName": region_map.get(pl.region_id),
  679. "version": pl.version,
  680. "status": pl.status,
  681. "effectiveTime": str(pl.effective_time) if pl.effective_time else None,
  682. "publishedTime": str(pl.published_time) if pl.published_time else None,
  683. "remark": pl.remark,
  684. "createAt": str(pl.create_at) if pl.create_at else None
  685. } for pl in price_lists],
  686. "total": total,
  687. "page": page,
  688. "pageSize": pageSize,
  689. "totalPages": (total + pageSize - 1) // pageSize
  690. })
  691. @router.get("/price-lists/{price_list_id}", response_model=dict)
  692. def get_my_price_list_detail(
  693. price_list_id: str,
  694. db: Session = Depends(get_db),
  695. current_user: dict = Depends(require_supplier)
  696. ):
  697. """获取供应商价格清单详情"""
  698. from app.crud.price_list import price_list_crud, price_list_item_crud
  699. from app.crud.region import region_crud
  700. from app.crud.product import product_crud
  701. from app.crud.unit import unit_crud
  702. user = user_crud.get(db, current_user.get("user_id"))
  703. if not user:
  704. return error_response(404, "用户不存在")
  705. price_list = price_list_crud.get(db, price_list_id)
  706. if not price_list:
  707. return error_response(404, "价格清单不存在")
  708. if price_list.supplier_id != user.id:
  709. return error_response(403, "无权查看此价格清单")
  710. items = price_list_item_crud.get_by_price_list(db, price_list_id)
  711. # 获取关联信息
  712. region = region_crud.get(db, price_list.region_id)
  713. region_name = region.name if region else None
  714. item_details = []
  715. for item in items:
  716. product = product_crud.get(db, item.product_id)
  717. category_name = None
  718. if product and product.category_id:
  719. category = category_crud.get(db, product.category_id)
  720. category_name = category.name if category else None
  721. unit_name = None
  722. if item.unit_id:
  723. u = unit_crud.get(db, item.unit_id)
  724. unit_name = u.name if u else None
  725. item_details.append({
  726. "productId": item.product_id,
  727. "productName": product.name if product else None,
  728. "categoryName": category_name,
  729. "unitId": item.unit_id,
  730. "unitName": unit_name,
  731. "price": float(item.price) if item.price else 0,
  732. "imageUrl": product.image_url if product else None
  733. })
  734. return success_response({
  735. "id": price_list.id,
  736. "regionId": price_list.region_id,
  737. "regionName": region_name,
  738. "version": price_list.version,
  739. "status": price_list.status,
  740. "effectiveTime": str(price_list.effective_time) if price_list.effective_time else None,
  741. "publishedTime": str(price_list.published_time) if price_list.published_time else None,
  742. "remark": price_list.remark,
  743. "createAt": str(price_list.create_at) if price_list.create_at else None,
  744. "items": item_details
  745. })
  746. # ============ 配送任务管理 ============
  747. @router.get("/tasks", response_model=dict)
  748. def list_supplier_tasks(
  749. page: int = Query(1, ge=1, description="页码"),
  750. pageSize: int = Query(10, ge=1, le=100, description="每页条数"),
  751. supplierPrepareStatus: Optional[str] = Query(
  752. None,
  753. description="供应商备货状态 supplier_prepare_status;逗号多选,如 stocking,prepared",
  754. ),
  755. status: Optional[str] = Query(
  756. None,
  757. description="兼容:任务主状态 status;未传 supplierPrepareStatus 时生效",
  758. ),
  759. keyword: Optional[str] = Query(None, description="检索:配送单号、订单号、联系人、电话"),
  760. db: Session = Depends(get_db),
  761. current_user: dict = Depends(require_supplier)
  762. ):
  763. """获取供应商的配送任务列表(含商品缩略列表;供货合计按价格清单还原供货单价后汇总)"""
  764. from sqlalchemy import and_
  765. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  766. from app.crud.order import order_crud, order_item_crud
  767. from app.crud.product import product_crud
  768. from app.crud.unit import unit_crud
  769. from app.models.models import BizPriceListItem
  770. user = user_crud.get(db, current_user.get("user_id"))
  771. if not user:
  772. return error_response(404, "用户不存在")
  773. tasks, total = delivery_task_crud.get_multi_with_filters(
  774. db,
  775. supplier_id=user.id,
  776. supplier_prepare_status=supplierPrepareStatus,
  777. status=None if supplierPrepareStatus else status,
  778. keyword=keyword,
  779. skip=(page - 1) * pageSize,
  780. limit=pageSize
  781. )
  782. result = []
  783. for task in tasks:
  784. region_name = None
  785. if task.region_id:
  786. region = region_crud.get(db, task.region_id)
  787. region_name = region.name if region else None
  788. # 任务行 + 供货小计(与任务详情中价格清单逻辑一致)
  789. task_items = delivery_task_item_crud.get_by_task(db, task.id)
  790. total_items = len(task_items)
  791. delivered_items = len([i for i in task_items if i.status == "delivered"])
  792. order = order_crud.get(db, task.order_id) if task.order_id else None
  793. line_items = []
  794. total_quantity = 0.0
  795. supplier_quote_total = 0.0
  796. for ti in task_items:
  797. product = product_crud.get(db, ti.product_id)
  798. unit_name = None
  799. supplier_quote_line = None
  800. oi = None
  801. if ti.order_item_id:
  802. oi = order_item_crud.get(db, ti.order_item_id)
  803. if oi and oi.unit_id:
  804. u = unit_crud.get(db, oi.unit_id)
  805. unit_name = u.name if u else None
  806. if order and oi:
  807. pli = db.query(BizPriceListItem).filter(
  808. and_(
  809. BizPriceListItem.price_list_id == order.price_list_id,
  810. BizPriceListItem.product_id == oi.product_id,
  811. BizPriceListItem.unit_id == oi.unit_id,
  812. BizPriceListItem.is_del == 0,
  813. )
  814. ).first()
  815. if pli is not None and pli.price is not None:
  816. unit_price = _supplier_quote_unit_yuan_from_pli(pli)
  817. if unit_price is not None:
  818. qty = float(ti.quantity) if ti.quantity else 0.0
  819. supplier_quote_line = round(qty * unit_price, 2)
  820. supplier_quote_total += supplier_quote_line
  821. qty = float(ti.quantity) if ti.quantity else 0.0
  822. total_quantity += qty
  823. line_items.append({
  824. "id": ti.id,
  825. "productId": ti.product_id,
  826. "productName": product.name if product else None,
  827. "quantity": qty,
  828. "unitName": unit_name,
  829. "imageUrl": product.image_url if product else None,
  830. })
  831. # 获取配送员信息(列表不返回客户信息,仅展示配送员对接用)
  832. deliver_name = None
  833. deliver_phone = None
  834. if task.deliver_id:
  835. deliver = user_crud.get(db, task.deliver_id)
  836. if deliver:
  837. deliver_name = deliver.real_name
  838. deliver_phone = deliver.phone
  839. result.append({
  840. "id": task.id,
  841. "taskNo": task.task_no,
  842. "orderId": task.order_id,
  843. "regionId": task.region_id,
  844. "regionName": region_name,
  845. "supplierId": task.supplier_id,
  846. "deliverId": task.deliver_id,
  847. "deliverName": deliver_name,
  848. "deliverPhone": deliver_phone,
  849. "status": task.status,
  850. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  851. "effectiveTime": str(task.effective_time) if task.effective_time else None,
  852. "pickupTime": str(task.pickup_time) if task.pickup_time else None,
  853. "pickupRemark": task.pickup_remark,
  854. "deliveryTime": str(task.delivery_time) if task.delivery_time else None,
  855. "totalItems": total_items,
  856. "deliveredItems": delivered_items,
  857. "totalQuantity": round(total_quantity, 3),
  858. "supplierQuoteTotal": round(supplier_quote_total, 2),
  859. "items": line_items,
  860. "createAt": str(task.create_at) if task.create_at else None,
  861. "supplierSettlementStatus": (
  862. (getattr(order, "supplier_settlement_status", None) or "pending")
  863. if order
  864. else "pending"
  865. ),
  866. })
  867. return success_response({
  868. "list": result,
  869. "total": total,
  870. "page": page,
  871. "pageSize": pageSize,
  872. "totalPages": (total + pageSize - 1) // pageSize
  873. })
  874. @router.get("/tasks/{task_id}", response_model=dict)
  875. def get_supplier_task_detail(
  876. task_id: str,
  877. db: Session = Depends(get_db),
  878. current_user: dict = Depends(require_supplier)
  879. ):
  880. """获取供应商配送任务详情(订单仅返回客户订单号;商品行含主图与规格;单价由价格清单行 price(客户最终单价)与 pricing_coefficient 反推供货价)"""
  881. from sqlalchemy import and_
  882. from app.crud.delivery import delivery_task_crud, delivery_task_item_crud
  883. from app.crud.order import order_crud, order_item_crud
  884. from app.crud.product import product_crud
  885. from app.crud.unit import unit_crud
  886. from app.models.models import BizPriceListItem
  887. user = user_crud.get(db, current_user.get("user_id"))
  888. if not user:
  889. return error_response(404, "用户不存在")
  890. task = delivery_task_crud.get(db, task_id)
  891. if not task:
  892. return error_response(404, "任务不存在")
  893. if task.supplier_id != user.id:
  894. return error_response(403, "无权查看此任务")
  895. order = None
  896. order_info = None
  897. if task.order_id:
  898. order = order_crud.get(db, task.order_id)
  899. if order:
  900. # 供应商备货详情仅展示客户订单号,不下发客户联系人、地址等
  901. order_info = {
  902. "orderNo": order.order_no,
  903. "supplierSettlementStatus": getattr(order, "supplier_settlement_status", None) or "pending",
  904. "supplierSettlementAt": str(order.supplier_settlement_at)
  905. if getattr(order, "supplier_settlement_at", None)
  906. else None,
  907. }
  908. deliver_info = None
  909. if task.deliver_id:
  910. deliver = user_crud.get(db, task.deliver_id)
  911. if deliver:
  912. deliver_info = {
  913. "deliverId": deliver.id,
  914. "deliverName": deliver.real_name,
  915. "phone": deliver.phone
  916. }
  917. task_items = delivery_task_item_crud.get_by_task(db, task_id)
  918. item_details = []
  919. for item in task_items:
  920. product = product_crud.get(db, item.product_id)
  921. unit_name = None
  922. supplier_quote_unit = None
  923. supplier_quote_line = None
  924. product_image_url = None
  925. product_measure_value = None
  926. product_spec = None
  927. product_unit_name = None
  928. if product:
  929. product_image_url = product.image_url
  930. product_spec = getattr(product, "spec", None) or None
  931. if product.measure_value is not None:
  932. product_measure_value = float(product.measure_value)
  933. if product.unit_id:
  934. pu = unit_crud.get(db, product.unit_id)
  935. product_unit_name = pu.name if pu else None
  936. oi = None
  937. if item.order_item_id:
  938. oi = order_item_crud.get(db, item.order_item_id)
  939. if oi and oi.unit_id:
  940. u = unit_crud.get(db, oi.unit_id)
  941. unit_name = u.name if u else None
  942. if order and oi:
  943. pli = db.query(BizPriceListItem).filter(
  944. and_(
  945. BizPriceListItem.price_list_id == order.price_list_id,
  946. BizPriceListItem.product_id == oi.product_id,
  947. BizPriceListItem.unit_id == oi.unit_id,
  948. BizPriceListItem.is_del == 0,
  949. )
  950. ).first()
  951. if pli is not None and pli.price is not None:
  952. supplier_quote_unit = _supplier_quote_unit_yuan_from_pli(pli)
  953. if supplier_quote_unit is not None:
  954. qty = float(item.quantity) if item.quantity else 0.0
  955. supplier_quote_line = round(qty * supplier_quote_unit, 2)
  956. item_details.append({
  957. "id": item.id,
  958. "orderItemId": item.order_item_id,
  959. "productId": item.product_id,
  960. "productName": product.name if product else None,
  961. "imageUrl": product_image_url,
  962. "measureValue": product_measure_value,
  963. "spec": product_spec,
  964. "productUnitName": product_unit_name,
  965. "unitName": unit_name,
  966. "quantity": float(item.quantity) if item.quantity else 0,
  967. "deliveredQuantity": float(item.delivered_quantity) if item.delivered_quantity else 0,
  968. "supplierQuoteUnit": supplier_quote_unit,
  969. "supplierQuoteLine": supplier_quote_line,
  970. "status": item.status,
  971. "deliveryTime": str(item.delivery_time) if item.delivery_time else None
  972. })
  973. prepared_images = [
  974. a.file_url
  975. for a in attachment_crud.get_by_business(db, "delivery_prepare", task_id)
  976. if getattr(a, "file_url", None)
  977. ]
  978. if (not prepared_images) and getattr(task, "pickup_image_url", None):
  979. prepared_images = [s.strip() for s in str(task.pickup_image_url).split(",") if s and str(s).strip()]
  980. return success_response({
  981. "id": task.id,
  982. "taskNo": task.task_no,
  983. "orderId": task.order_id,
  984. "regionId": task.region_id,
  985. "status": task.status,
  986. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  987. "effectiveTime": str(task.effective_time) if task.effective_time else None,
  988. "pickupTime": str(task.pickup_time) if task.pickup_time else None,
  989. "pickupRemark": task.pickup_remark,
  990. "pickupImageUrl": task.pickup_image_url,
  991. "deliveryTime": str(task.delivery_time) if task.delivery_time else None,
  992. "deliveryRemark": task.delivery_remark,
  993. "deliveryImageUrl": task.delivery_image_url,
  994. "preparedImages": prepared_images,
  995. "order": order_info,
  996. "deliver": deliver_info,
  997. "items": item_details,
  998. "createAt": str(task.create_at) if task.create_at else None
  999. })
  1000. def _notify_deliver_stock_ready(db, task, user, remark: str = None) -> bool:
  1001. """备货完成后通知配送员(微信订阅消息,失败不阻断主流程)"""
  1002. if not task.deliver_id:
  1003. return False
  1004. try:
  1005. from app.core.wechat_message import WechatMessageService
  1006. deliver = user_crud.get(db, task.deliver_id)
  1007. if deliver and hasattr(deliver, "openid"):
  1008. WechatMessageService.send_delivery_notice(
  1009. openid=deliver.openid,
  1010. task_no=task.task_no,
  1011. supplier_name=user.real_name or "供应商",
  1012. address="商品已备好,请尽快取货",
  1013. remark=remark or "供应商已备货完成,请及时取货",
  1014. )
  1015. return True
  1016. except Exception:
  1017. pass
  1018. return False
  1019. @router.post("/tasks/{task_id}/start-stock", response_model=dict)
  1020. def supplier_start_stock(
  1021. task_id: str,
  1022. request: dict,
  1023. db: Session = Depends(get_db),
  1024. current_user: dict = Depends(require_supplier),
  1025. ):
  1026. """供应商开始备货:assigned -> supplier_stocking,并写入订单状态日志(配送进度)"""
  1027. from app.crud.delivery import delivery_task_crud
  1028. user = user_crud.get(db, current_user.get("user_id"))
  1029. if not user:
  1030. return error_response(404, "用户不存在")
  1031. task = delivery_task_crud.get(db, task_id)
  1032. if not task:
  1033. return error_response(404, "任务不存在")
  1034. if task.supplier_id != user.id:
  1035. return error_response(403, "无权操作此任务")
  1036. if (getattr(task, "supplier_prepare_status", None) or "pending_prepare") != "pending_prepare":
  1037. return error_response(400, "当前状态不可开始备货(需为待备货/已分配)")
  1038. payload = _resolve_request_payload(request, current_user)
  1039. remark = (payload.get("remark") or "").strip() or None
  1040. task = delivery_task_crud.update_status(
  1041. db,
  1042. task_id=task_id,
  1043. new_status="supplier_stocking",
  1044. operator_id=user.id,
  1045. remark=remark,
  1046. operator_real_name=user.real_name,
  1047. operator_role="supplier",
  1048. )
  1049. if not task:
  1050. return error_response(400, "操作失败")
  1051. return success_response({
  1052. "id": task.id,
  1053. "taskNo": task.task_no,
  1054. "status": task.status,
  1055. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  1056. "delivererStatus": getattr(task, "deliverer_status", None),
  1057. "message": "已开始备货",
  1058. })
  1059. @router.post("/tasks/{task_id}/finish-stock", response_model=dict)
  1060. def supplier_finish_stock(
  1061. task_id: str,
  1062. request: dict,
  1063. db: Session = Depends(get_db),
  1064. current_user: dict = Depends(require_supplier),
  1065. ):
  1066. """供应商备货完成:supplier_stocking -> supplier_ready(待配送员取货),写入订单日志并可通知配送员"""
  1067. from app.crud.delivery import delivery_task_crud
  1068. user = user_crud.get(db, current_user.get("user_id"))
  1069. if not user:
  1070. return error_response(404, "用户不存在")
  1071. task = delivery_task_crud.get(db, task_id)
  1072. if not task:
  1073. return error_response(404, "任务不存在")
  1074. if task.supplier_id != user.id:
  1075. return error_response(403, "无权操作此任务")
  1076. ss = getattr(task, "supplier_prepare_status", None) or "pending_prepare"
  1077. if ss == "pending_prepare":
  1078. return error_response(400, "请先开始备货后再确认备货完成")
  1079. if ss not in ("stocking", "prepared"):
  1080. return error_response(400, "当前状态不可确认备货完成")
  1081. if ss == "prepared":
  1082. ds = (getattr(task, "deliverer_status", None) or "").strip().lower()
  1083. if ds != "pending_pickup":
  1084. return error_response(400, "配送员已取货,备货凭证不可再修改")
  1085. payload = _resolve_request_payload(request, current_user)
  1086. remark = (payload.get("remark") or payload.get("preparedRemark") or "").strip() or None
  1087. image_urls = _normalize_image_urls(payload.get("imageUrls"))
  1088. if not image_urls:
  1089. prep_img = payload.get("preparedImageUrl") or payload.get("pickupImageUrl")
  1090. image_urls = _normalize_image_urls(prep_img)
  1091. if not image_urls:
  1092. return error_response(400, "请上传备货完成凭证照片")
  1093. if ss == "stocking":
  1094. task = delivery_task_crud.update_status(
  1095. db,
  1096. task_id=task_id,
  1097. new_status="supplier_ready",
  1098. operator_id=user.id,
  1099. remark=remark,
  1100. image_urls=image_urls,
  1101. operator_real_name=user.real_name,
  1102. operator_role="supplier",
  1103. )
  1104. if not task:
  1105. return error_response(400, "操作失败")
  1106. else:
  1107. task = delivery_task_crud.get(db, task_id)
  1108. if not task:
  1109. return error_response(404, "任务不存在")
  1110. _replace_delivery_prepare_attachments(
  1111. db,
  1112. task_id=task_id,
  1113. image_urls=image_urls,
  1114. uploader_id=user.id,
  1115. uploader_name=user.real_name,
  1116. )
  1117. db.commit()
  1118. deliver_notified = _notify_deliver_stock_ready(db, task, user, remark) if ss == "stocking" else False
  1119. return success_response({
  1120. "id": task.id,
  1121. "taskNo": task.task_no,
  1122. "status": task.status,
  1123. "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None),
  1124. "delivererStatus": getattr(task, "deliverer_status", None),
  1125. "message": ("已确认备货完成" if ss == "stocking" else "备货凭证已更新")
  1126. + (",已通知配送员" if deliver_notified else ""),
  1127. })
  1128. # 兼容旧路径:与 start-stock / finish-stock 行为一致
  1129. @router.post("/tasks/{task_id}/confirm-pickup", response_model=dict)
  1130. def supplier_confirm_pickup_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}/start-stock"""
  1137. return supplier_start_stock(task_id, request, db, current_user)
  1138. @router.post("/tasks/{task_id}/confirm-prepared", response_model=dict)
  1139. def supplier_confirm_prepared_deprecated(
  1140. task_id: str,
  1141. request: dict,
  1142. db: Session = Depends(get_db),
  1143. current_user: dict = Depends(require_supplier),
  1144. ):
  1145. """[兼容] 请改用 POST /tasks/{id}/finish-stock"""
  1146. return supplier_finish_stock(task_id, request, db, current_user)
  1147. @router.get("/dashboard/statistics", response_model=dict)
  1148. def supplier_dashboard_statistics(
  1149. db: Session = Depends(get_db),
  1150. current_user: dict = Depends(require_supplier)
  1151. ):
  1152. """供应商首页统计(询价/报价/任务概览)"""
  1153. from app.crud.delivery import delivery_task_crud
  1154. from app.models.models import BizInquiry, BizQuotation
  1155. from sqlalchemy import and_
  1156. uid = current_user.get("user_id")
  1157. user = user_crud.get(db, uid)
  1158. rid = user.region_id if user else None
  1159. q_inq = db.query(BizInquiry).filter(
  1160. and_(BizInquiry.is_del == 0, BizInquiry.status == "published")
  1161. )
  1162. if rid:
  1163. q_inq = q_inq.filter(BizInquiry.region_id == rid)
  1164. pending_inq = q_inq.count()
  1165. my_quo = db.query(BizQuotation).filter(
  1166. and_(BizQuotation.is_del == 0, BizQuotation.supplier_id == uid)
  1167. ).count()
  1168. pending_tasks, _ = delivery_task_crud.get_by_supplier(
  1169. db, supplier_id=uid, status="assigned", skip=0, limit=500
  1170. )
  1171. stocking, _ = delivery_task_crud.get_by_supplier(
  1172. db, supplier_id=uid, status="supplier_stocking", skip=0, limit=500
  1173. )
  1174. return success_response({
  1175. "pendingInquiries": pending_inq,
  1176. "myQuotations": my_quo,
  1177. "pendingTasks": len(pending_tasks) + len(stocking),
  1178. "todayDeliveries": 0,
  1179. })
  1180. @router.get("/products/{product_id}", response_model=dict)
  1181. def get_supplier_product_detail(
  1182. product_id: str,
  1183. db: Session = Depends(get_db),
  1184. current_user: dict = Depends(require_supplier)
  1185. ):
  1186. """供应商端商品详情:轮播、基础信息、参数、富文本、详情媒体"""
  1187. from app.crud.product import product_crud
  1188. from app.crud.unit import unit_crud
  1189. from app.crud.product_banner import product_banner
  1190. from app.crud.product_parameter_value import product_parameter_value
  1191. from app.crud.product_detail_media import product_detail_media
  1192. product = product_crud.get(db, product_id)
  1193. if not product or getattr(product, "is_del", 0) == 1:
  1194. return error_response(404, "商品不存在")
  1195. unit = unit_crud.get(db, product.unit_id) if product.unit_id else None
  1196. banners_raw = product_banner.get_by_product(db, product_id)
  1197. banners = [{
  1198. "mediaType": b.media_type,
  1199. "fileUrl": b.file_url,
  1200. "thumbnailUrl": b.thumbnail_url,
  1201. "sortOrder": b.sort_order,
  1202. } for b in banners_raw]
  1203. pv_rows = product_parameter_value.get_by_product_with_detail(db, product_id)
  1204. parameter_values = []
  1205. for row in pv_rows:
  1206. val = row.get("param_value")
  1207. if val is None or str(val).strip() == "":
  1208. continue
  1209. parameter_values.append({
  1210. "parameterId": row.get("parameter_id"),
  1211. "parameterName": row.get("parameter_name") or "",
  1212. "paramValue": str(val),
  1213. })
  1214. media_raw = product_detail_media.get_by_product(db, product_id)
  1215. detail_media = []
  1216. for m in media_raw:
  1217. if m.content_type not in ("image", "video"):
  1218. continue
  1219. if not m.file_url:
  1220. continue
  1221. detail_media.append({
  1222. "id": m.id,
  1223. "contentType": m.content_type,
  1224. "fileUrl": m.file_url,
  1225. "thumbnailUrl": m.thumbnail_url,
  1226. "description": m.description,
  1227. "sortOrder": m.sort_order,
  1228. })
  1229. return success_response({
  1230. "product": {
  1231. "id": product.id,
  1232. "name": product.name,
  1233. "unitId": product.unit_id,
  1234. "unitName": unit.name if unit else None,
  1235. "measureValue": float(product.measure_value) if product.measure_value is not None else None,
  1236. "spec": getattr(product, "spec", None) or None,
  1237. "detailContent": product.detail_content or "",
  1238. },
  1239. "banners": banners,
  1240. "parameterValues": parameter_values,
  1241. "detailMedia": detail_media,
  1242. })