"""小程序端供应商接口""" from decimal import Decimal from typing import Optional from datetime import datetime import logging from fastapi import APIRouter, Depends, Request, Query from sqlalchemy.orm import Session from app.core.database import get_db from app.core.auth import get_current_user, get_client_ip from app.core.response import success_response, error_response from app.core.config import settings from app.core import api_crypto from app.core.request_payload import resolve_request_payload from app.models.models import BasAddress from app.crud.address import address_crud from app.crud.user import user_crud from app.crud.region import region_crud from app.crud.attachment import attachment_crud from app.models.models import BizAttachment router = APIRouter(prefix="/api/mini/supplier", tags=["小程序端-供应商接口"]) logger = logging.getLogger("sqxp") def _supplier_quote_unit_yuan_from_pli(pli) -> Optional[float]: """价格清单行 price 为面向客户的最终单价;pricing_coefficient 为调价系数(%),供货单价 = price / (1 + 系数/100)。""" if pli is None or getattr(pli, "price", None) is None: return None final_price = Decimal(str(pli.price)) raw_coeff = getattr(pli, "pricing_coefficient", None) if raw_coeff is None: coeff_pct = Decimal("0") else: try: coeff_pct = Decimal(str(raw_coeff)) except Exception: coeff_pct = Decimal("0") factor = Decimal("1") + coeff_pct / Decimal("100") if factor <= Decimal("0"): factor = Decimal("1") supplier_unit = (final_price / factor).quantize(Decimal("0.01")) return float(supplier_unit) def require_supplier(current_user: dict = Depends(get_current_user)): """验证供应商权限""" if current_user.get("role") != "supplier": return error_response(403, "需要供应商权限") return current_user def _resolve_request_payload(request_body: dict, current_user: dict) -> dict: """兼容明文与密文信封,返回业务层 payload。""" return resolve_request_payload(request_body, current_user, source="mini_supplier") def _normalize_image_urls(raw) -> list[str]: out: list[str] = [] if isinstance(raw, list): for x in raw: s = str(x or "").strip() if s: out.append(s) elif isinstance(raw, str): s = raw.strip() if s: out.append(s) return out def _replace_delivery_prepare_attachments( db: Session, *, task_id: str, image_urls: list[str], uploader_id: Optional[str], uploader_name: Optional[str], ): rows = db.query(BizAttachment).filter( BizAttachment.business_type == "delivery_prepare", BizAttachment.business_id == task_id, BizAttachment.is_del == 0, ).all() now = datetime.now() for r in rows: r.is_del = 1 r.update_at = now r.update_by = uploader_id db.flush() for idx, url in enumerate(image_urls, start=1): attachment_crud.create_attachment( db=db, business_type="delivery_prepare", business_id=task_id, file_name=f"delivery_prepare_{idx}.jpg", file_url=url, file_type="image/jpeg", upload_by=uploader_id, upload_by_name=uploader_name, ) # ============ 地址管理 ============ @router.get("/addresses", response_model=dict) def list_addresses( db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商取货地址列表""" addresses = address_crud.get_by_user(db, current_user.get("user_id")) return success_response([{ "id": addr.id, "userId": addr.user_id, "addressType": addr.address_type, "contactPerson": addr.contact_person, "contactPhone": addr.contact_phone, "province": addr.province, "city": addr.city, "district": addr.district, "detailAddress": addr.detail_address, "isDefault": addr.is_default, "createAt": str(addr.create_at) if addr.create_at else None } for addr in addresses]) @router.get("/pickup-addresses", response_model=dict) def list_pickup_addresses( db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商取货地址列表""" addresses = address_crud.get_by_user_and_type(db, current_user.get("user_id"), "pickup") return success_response([{ "id": addr.id, "userId": addr.user_id, "addressType": addr.address_type, "contactPerson": addr.contact_person, "contactPhone": addr.contact_phone, "province": addr.province, "city": addr.city, "district": addr.district, "detailAddress": addr.detail_address, "isDefault": addr.is_default } for addr in addresses]) @router.get("/addresses/{address_id}", response_model=dict) def get_address( address_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取地址详情""" address = address_crud.get(db, address_id) if not address or address.user_id != current_user.get("user_id"): return error_response(404, "地址不存在") return success_response({ "id": address.id, "userId": address.user_id, "addressType": address.address_type, "contactPerson": address.contact_person, "contactPhone": address.contact_phone, "province": address.province, "city": address.city, "district": address.district, "detailAddress": address.detail_address, "isDefault": address.is_default, "createAt": str(address.create_at) if address.create_at else None }) @router.post("/addresses", response_model=dict) def create_address( request: dict, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """创建地址""" payload = _resolve_request_payload(request, current_user) if not payload.get("addressType") and not payload.get("address_type"): return error_response(400, "地址类型不能为空") payload["user_id"] = current_user.get("user_id") creator = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")} # 如果设置为默认,先取消其他默认 if payload.get("isDefault"): address_crud.set_default(db, current_user.get("user_id"), "") address = address_crud.create(db, payload, creator) return success_response({ "id": address.id, "addressType": address.address_type, "contactPerson": address.contact_person, "contactPhone": address.contact_phone }) @router.put("/addresses/{address_id}", response_model=dict) def update_address( address_id: str, request: dict, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """更新地址""" address = address_crud.get(db, address_id) if not address or address.user_id != current_user.get("user_id"): return error_response(404, "地址不存在") payload = _resolve_request_payload(request, current_user) # 如果设置为默认,先取消其他默认 if payload.get("isDefault"): address_crud.set_default(db, current_user.get("user_id"), address_id) updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")} address = address_crud.update_with_history(db, address_id, payload, updater) return success_response({ "id": address.id, "addressType": address.address_type, "contactPerson": address.contact_person, "contactPhone": address.contact_phone }) @router.delete("/addresses/{address_id}", response_model=dict) def delete_address( address_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """删除地址""" address = address_crud.get(db, address_id) if not address or address.user_id != current_user.get("user_id"): return error_response(404, "地址不存在") success = address_crud.soft_delete_with_history(db, address_id) return success_response({"id": address_id}) @router.post("/addresses/{address_id}/set-default", response_model=dict) def set_default_address( address_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """设置默认地址""" address = address_crud.get(db, address_id) if not address or address.user_id != current_user.get("user_id"): return error_response(404, "地址不存在") success = address_crud.set_default(db, current_user.get("user_id"), address_id) if success: return success_response({"message": "设置成功"}) return error_response(400, "设置失败") # ============ 个人资料 ============ @router.get("/profile", response_model=dict) def get_profile( db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商个人资料""" user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") region_name = None if user.region_id: region = region_crud.get(db, user.region_id) region_name = region.name if region else None # 获取默认取货地址 default_address = address_crud.get_default(db, user.id, "pickup") address_info = None if default_address: address_info = { "contactPerson": default_address.contact_person, "contactPhone": default_address.contact_phone, "province": default_address.province, "city": default_address.city, "district": default_address.district, "detailAddress": default_address.detail_address } return success_response({ "id": user.id, "username": user.username, "realName": user.real_name, "phone": user.phone, "role": user.role, "regionId": user.region_id, "regionName": region_name, "address": address_info }) @router.put("/profile", response_model=dict) def update_profile( request: dict, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """更新供应商个人资料""" user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") payload = _resolve_request_payload(request, current_user) updater = {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")} user = user_crud.update_with_history(db, current_user.get("user_id"), payload, updater) return success_response({ "id": user.id, "realName": user.real_name, "phone": user.phone }) # ============ 询价单管理 ============ @router.get("/inquiries", response_model=dict) def list_inquiries( page: int = Query(1, ge=1, description="页码"), pageSize: int = Query(10, ge=1, le=100, description="每页条数"), db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商可响应的询价单列表""" from app.crud.inquiry import inquiry_crud, inquiry_item_crud from app.crud.quotation import quotation_crud user = user_crud.get(db, current_user.get("user_id")) if not user or not user.region_id: return error_response(400, "用户未绑定区域") inquiries, total = inquiry_crud.get_multi_with_filters( db, region_id=user.region_id, status="published", skip=(page - 1) * pageSize, limit=pageSize ) inquiry_list = [] for inquiry in inquiries: has_quoted = quotation_crud.get_latest_by_inquiry_and_supplier(db, inquiry.id, user.id) is not None inquiry_list.append({ "id": inquiry.id, "title": inquiry.title, "regionId": inquiry.region_id, "status": inquiry.status, "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None, "closeTime": str(inquiry.close_time) if inquiry.close_time else None, "remark": inquiry.remark, "createAt": str(inquiry.create_at) if inquiry.create_at else None, "hasQuoted": has_quoted }) return success_response({ "list": inquiry_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": (total + pageSize - 1) // pageSize }) @router.get("/inquiries/{inquiry_id}", response_model=dict) def get_inquiry_detail( inquiry_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取询价单详情""" from app.crud.inquiry import inquiry_crud, inquiry_item_crud from app.crud.product import product_crud from app.crud.category import category_crud from app.crud.unit import unit_crud from app.crud.quotation import quotation_crud user = user_crud.get(db, current_user.get("user_id")) if not user or not user.region_id: return error_response(400, "用户未绑定区域") inquiry = inquiry_crud.get(db, inquiry_id) if not inquiry: return error_response(404, "询价单不存在") if inquiry.region_id != user.region_id: return error_response(403, "无权查看此询价单") items = inquiry_item_crud.get_by_inquiry(db, inquiry_id) quotation = quotation_crud.get_latest_by_inquiry_and_supplier(db, inquiry_id, user.id) latest_quote_price_map = {} if quotation: from app.crud.quotation import quotation_item_crud quotation_items = quotation_item_crud.get_by_quotation(db, quotation.id) for quote_item in quotation_items: latest_quote_price_map[quote_item.product_id] = float(quote_item.price) if quote_item.price else 0 item_details = [] for item in items: product = product_crud.get(db, item.product_id) category_name = None first_category_id = None first_category_name = None first_category_image = None first_category_sort_order = 0 second_category_id = None second_category_name = None second_category_image = None second_category_sort_order = 0 product_sort_order = int(getattr(product, "sort_order", None) or 0) if product else 0 unit_name = None if product and product.category_id: category = category_crud.get(db, product.category_id) category_name = category.name if category else None if category: if category.parent_id: parent_category = category_crud.get(db, category.parent_id) first_category_id = parent_category.id if parent_category else category.id first_category_name = parent_category.name if parent_category else category.name first_category_image = parent_category.image_url if parent_category else category.image_url first_category_sort_order = int(parent_category.sort_order or 0) if parent_category else int(category.sort_order or 0) second_category_id = category.id second_category_name = category.name second_category_image = category.image_url second_category_sort_order = int(category.sort_order or 0) else: first_category_id = category.id first_category_name = category.name first_category_image = category.image_url first_category_sort_order = int(category.sort_order or 0) second_category_sort_order = int(category.sort_order or 0) if product and product.unit_id: unit = unit_crud.get(db, product.unit_id) unit_name = unit.name if unit else None item_details.append({ "id": item.id, "productId": item.product_id, "productName": product.name if product else None, "description": product.description if product else None, "imageUrl": product.image_url if product else None, "categoryId": product.category_id if product else None, "categoryName": category_name, "firstCategoryId": first_category_id, "firstCategoryName": first_category_name, "firstCategoryImage": first_category_image, "secondCategoryId": second_category_id, "secondCategoryName": second_category_name, "secondCategoryImage": second_category_image, "unitId": product.unit_id if product else None, "unitName": unit_name, "measureValue": float(product.measure_value) if product and product.measure_value is not None else None, "lastPrice": latest_quote_price_map.get(item.product_id), "createAt": str(item.create_at) if item.create_at else None, "_firstCategorySortOrder": first_category_sort_order, "_secondCategorySortOrder": second_category_sort_order, "_productSortOrder": product_sort_order }) # 与客户端首页商品区一致:一级分类排序 -> 二级分类排序 -> 商品排序 -> 名称 -> id item_details.sort( key=lambda row: ( int(row.get("_firstCategorySortOrder") or 0), int(row.get("_secondCategorySortOrder") or 0), int(row.get("_productSortOrder") or 0), (row.get("productName") or "").lower(), row.get("productId") or "", ) ) for row in item_details: row.pop("_firstCategorySortOrder", None) row.pop("_secondCategorySortOrder", None) row.pop("_productSortOrder", None) return success_response({ "id": inquiry.id, "title": inquiry.title, "regionId": inquiry.region_id, "status": inquiry.status, "publishTime": str(inquiry.publish_time) if inquiry.publish_time else None, "closeTime": str(inquiry.close_time) if inquiry.close_time else None, "remark": inquiry.remark, "createAt": str(inquiry.create_at) if inquiry.create_at else None, "items": item_details, "hasQuoted": quotation is not None, "quotationId": quotation.id if quotation else None, "quotationStatus": quotation.status if quotation else None }) # ============ 报价单管理 ============ @router.post("/quotations", response_model=dict) def submit_quotation( request: dict, raw_request: Request, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """提交报价单""" from app.crud.inquiry import inquiry_crud, inquiry_item_crud from app.crud.quotation import quotation_crud, quotation_item_crud from app.crud.product import product_crud from decimal import Decimal user = user_crud.get(db, current_user.get("user_id")) if not user or not user.region_id: return error_response(400, "用户未绑定区域") req_payload = request if isinstance(request, dict) else {} # 兜底:若中间件未生效导致路由仍收到密文信封,则在路由内自行解密 if int(req_payload.get("enc", 0) or 0) == 1 and isinstance(req_payload.get("edata"), str): try: user_id_for_crypto = str(current_user.get("user_id") or "") ts = int(req_payload.get("ts")) nonce = str(req_payload.get("nonce") or "") if not user_id_for_crypto or not nonce: return error_response(400, "加密报文缺少必要字段") if not settings.API_ENCRYPT_FIXED_SECRET: return error_response(500, "服务端加密配置缺失") key = api_crypto.derive_key( fixed_secret=settings.API_ENCRYPT_FIXED_SECRET, user_id=user_id_for_crypto, ts=ts, nonce=nonce, ) plain = api_crypto.decrypt_json(str(req_payload.get("edata")), key) req_payload = plain.get("data", plain) if isinstance(plain, dict) else {} if not isinstance(req_payload, dict): req_payload = {} except Exception as exc: logger.warning("submit_quotation decrypt fallback failed: err=%s", exc) return error_response(400, "密文解密失败") def _extract_payload(d: dict) -> dict: """兼容不同层级的请求体结构:优先返回包含 inquiryId/items 的那层 dict。""" if not isinstance(d, dict): return {} if "inquiryId" in d or "items" in d: return d for k in ("data", "payload", "body"): v = d.get(k) if isinstance(v, dict): found = _extract_payload(v) if found: return found return d payload = _extract_payload(req_payload) inquiry_id = payload.get("inquiryId") items = payload.get("items", []) # 诊断:强制打印到 stdout(会落到 logs/app.log),仅输出 keys/长度/数量 try: keys = list(payload.keys()) if isinstance(payload, dict) else [] iid_len = len(str(inquiry_id)) if inquiry_id is not None else None items_len = len(items) if isinstance(items, list) else None print( f"[submit_quotation debug] keys={keys} inquiryId={repr(inquiry_id)} inquiryId_len={iid_len} items_len={items_len}", flush=True, ) except Exception: pass if not inquiry_id: return error_response(400, "询价单ID不能为空") if not items: return error_response(400, "报价项不能为空") inquiry = inquiry_crud.get(db, inquiry_id) if not inquiry: return error_response(404, "询价单不存在") if inquiry.region_id != user.region_id: return error_response(403, "无权对此询价单报价") if inquiry.status != "published": return error_response(400, "询价单已关闭") # 支持同一询价单多次报价:每次提交都创建一张新报价单 inquiry_items = inquiry_item_crud.get_by_inquiry(db, inquiry_id) inquiry_product_ids = set(item.product_id for item in inquiry_items) submitted_product_ids = set(item.get("productId") for item in items) if submitted_product_ids != inquiry_product_ids: return error_response(400, "报价商品必须与询价商品一致") creator = {"user_id": user.id, "real_name": user.real_name} quotation_data = { "inquiry_id": inquiry_id, "supplier_id": user.id, "region_id": user.region_id, "status": "submitted", "submit_time": datetime.now() } quotation_items_data = [] total_amount = Decimal("0") for item in items: product_id = item.get("productId") unit_id = item.get("unitId") if not unit_id: product = product_crud.get(db, product_id) if product_id else None unit_id = product.unit_id if product else None if not unit_id: return error_response(400, f"商品 {product_id} 缺少单位信息,请传 unitId") price = Decimal(str(item.get("price", 0))) total_amount += price quotation_items_data.append({ "product_id": product_id, "unit_id": unit_id, "price": price }) quotation_data["total_amount"] = total_amount quotation = quotation_crud.create(db, quotation_data, creator) quotation_item_crud.create_items(db, quotation.id, quotation_items_data, creator) return success_response({ "id": quotation.id, "totalAmount": float(quotation.total_amount), "status": quotation.status, "submitTime": str(quotation.submit_time) if quotation.submit_time else None }) @router.get("/quotations", response_model=dict) def list_my_quotations( page: int = Query(1, ge=1, description="页码"), pageSize: int = Query(10, ge=1, le=100, description="每页条数"), inquiryId: Optional[str] = Query(None, description="询价单ID"), status: Optional[str] = Query(None, description="报价状态,如 selected=已采纳"), db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取我的报价列表""" from app.crud.inquiry import inquiry_crud from app.crud.quotation import quotation_crud user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") quotations, total = quotation_crud.get_multi_with_filters( db, supplier_id=user.id, inquiry_id=inquiryId, status=status, skip=(page - 1) * pageSize, limit=pageSize ) inquiry_map = {} quotation_list = [] for q in quotations: if q.inquiry_id and q.inquiry_id not in inquiry_map: inquiry = inquiry_crud.get(db, q.inquiry_id) inquiry_map[q.inquiry_id] = inquiry.title if inquiry else None quotation_list.append({ "id": q.id, "inquiryId": q.inquiry_id, "inquiryTitle": inquiry_map.get(q.inquiry_id), "status": q.status, "totalAmount": float(q.total_amount) if q.total_amount else 0, "submitTime": str(q.submit_time) if q.submit_time else None, }) return success_response({ "list": quotation_list, "total": total, "page": page, "pageSize": pageSize, "totalPages": (total + pageSize - 1) // pageSize }) @router.get("/quotations/{quotation_id}", response_model=dict) def get_my_quotation( quotation_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取我的报价单详情""" from app.crud.inquiry import inquiry_crud, inquiry_item_crud from app.crud.quotation import quotation_crud, quotation_item_crud from app.crud.product import product_crud user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") quotation = quotation_crud.get(db, quotation_id) if not quotation: return error_response(404, "报价单不存在") if quotation.supplier_id != user.id: return error_response(403, "无权查看此报价单") items = quotation_item_crud.get_by_quotation(db, quotation_id) inquiry_title = None if quotation.inquiry_id: inquiry = inquiry_crud.get(db, quotation.inquiry_id) inquiry_title = inquiry.title if inquiry else None item_details = [] for item in items: product = product_crud.get(db, item.product_id) item_details.append({ "id": item.id, "productId": item.product_id, "productName": product.name if product else None, "unitId": item.unit_id, "price": float(item.price) if item.price else 0, "createAt": str(item.create_at) if item.create_at else None }) return success_response({ "id": quotation.id, "inquiryId": quotation.inquiry_id, "inquiryTitle": inquiry_title, "status": quotation.status, "totalAmount": float(quotation.total_amount) if quotation.total_amount else 0, "submitTime": str(quotation.submit_time) if quotation.submit_time else None, "items": item_details }) # ============ 价格清单 - 供应商查看 ============ @router.get("/price-lists", response_model=dict) def list_my_price_lists( page: int = Query(1, ge=1, description="页码"), pageSize: int = Query(10, ge=1, le=100, description="每页条数"), status: Optional[str] = Query(None, description="状态"), db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商的价格清单列表""" from app.crud.price_list import price_list_crud from app.crud.region import region_crud user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") price_lists, total = price_list_crud.get_multi_with_filters( db, supplier_id=user.id, status=status, skip=(page - 1) * pageSize, limit=pageSize ) region_map = {} for pl in price_lists: if pl.region_id and pl.region_id not in region_map: region = region_crud.get(db, pl.region_id) region_map[pl.region_id] = region.name if region else None return success_response({ "list": [{ "id": pl.id, "regionId": pl.region_id, "regionName": region_map.get(pl.region_id), "version": pl.version, "status": pl.status, "effectiveTime": str(pl.effective_time) if pl.effective_time else None, "publishedTime": str(pl.published_time) if pl.published_time else None, "remark": pl.remark, "createAt": str(pl.create_at) if pl.create_at else None } for pl in price_lists], "total": total, "page": page, "pageSize": pageSize, "totalPages": (total + pageSize - 1) // pageSize }) @router.get("/price-lists/{price_list_id}", response_model=dict) def get_my_price_list_detail( price_list_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商价格清单详情""" from app.crud.price_list import price_list_crud, price_list_item_crud from app.crud.region import region_crud from app.crud.product import product_crud from app.crud.unit import unit_crud user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") price_list = price_list_crud.get(db, price_list_id) if not price_list: return error_response(404, "价格清单不存在") if price_list.supplier_id != user.id: return error_response(403, "无权查看此价格清单") items = price_list_item_crud.get_by_price_list(db, price_list_id) # 获取关联信息 region = region_crud.get(db, price_list.region_id) region_name = region.name if region else None item_details = [] for item in items: product = product_crud.get(db, item.product_id) category_name = None if product and product.category_id: category = category_crud.get(db, product.category_id) category_name = category.name if category else None unit_name = None if item.unit_id: u = unit_crud.get(db, item.unit_id) unit_name = u.name if u else None item_details.append({ "productId": item.product_id, "productName": product.name if product else None, "categoryName": category_name, "unitId": item.unit_id, "unitName": unit_name, "price": float(item.price) if item.price else 0, "imageUrl": product.image_url if product else None }) return success_response({ "id": price_list.id, "regionId": price_list.region_id, "regionName": region_name, "version": price_list.version, "status": price_list.status, "effectiveTime": str(price_list.effective_time) if price_list.effective_time else None, "publishedTime": str(price_list.published_time) if price_list.published_time else None, "remark": price_list.remark, "createAt": str(price_list.create_at) if price_list.create_at else None, "items": item_details }) # ============ 配送任务管理 ============ @router.get("/tasks", response_model=dict) def list_supplier_tasks( page: int = Query(1, ge=1, description="页码"), pageSize: int = Query(10, ge=1, le=100, description="每页条数"), supplierPrepareStatus: Optional[str] = Query( None, description="供应商备货状态 supplier_prepare_status;逗号多选,如 stocking,prepared", ), status: Optional[str] = Query( None, description="兼容:任务主状态 status;未传 supplierPrepareStatus 时生效", ), keyword: Optional[str] = Query(None, description="检索:配送单号、订单号、联系人、电话"), db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商的配送任务列表(含商品缩略列表;供货合计按价格清单还原供货单价后汇总)""" from sqlalchemy import and_ from app.crud.delivery import delivery_task_crud, delivery_task_item_crud from app.crud.order import order_crud, order_item_crud from app.crud.product import product_crud from app.crud.unit import unit_crud from app.models.models import BizPriceListItem user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") tasks, total = delivery_task_crud.get_multi_with_filters( db, supplier_id=user.id, supplier_prepare_status=supplierPrepareStatus, status=None if supplierPrepareStatus else status, keyword=keyword, skip=(page - 1) * pageSize, limit=pageSize ) result = [] for task in tasks: region_name = None if task.region_id: region = region_crud.get(db, task.region_id) region_name = region.name if region else None # 任务行 + 供货小计(与任务详情中价格清单逻辑一致) task_items = delivery_task_item_crud.get_by_task(db, task.id) total_items = len(task_items) delivered_items = len([i for i in task_items if i.status == "delivered"]) order = order_crud.get(db, task.order_id) if task.order_id else None line_items = [] total_quantity = 0.0 supplier_quote_total = 0.0 for ti in task_items: product = product_crud.get(db, ti.product_id) unit_name = None supplier_quote_line = None oi = None if ti.order_item_id: oi = order_item_crud.get(db, ti.order_item_id) if oi and oi.unit_id: u = unit_crud.get(db, oi.unit_id) unit_name = u.name if u else None if order and oi: pli = db.query(BizPriceListItem).filter( and_( BizPriceListItem.price_list_id == order.price_list_id, BizPriceListItem.product_id == oi.product_id, BizPriceListItem.unit_id == oi.unit_id, BizPriceListItem.is_del == 0, ) ).first() if pli is not None and pli.price is not None: unit_price = _supplier_quote_unit_yuan_from_pli(pli) if unit_price is not None: qty = float(ti.quantity) if ti.quantity else 0.0 supplier_quote_line = round(qty * unit_price, 2) supplier_quote_total += supplier_quote_line qty = float(ti.quantity) if ti.quantity else 0.0 total_quantity += qty line_items.append({ "id": ti.id, "productId": ti.product_id, "productName": product.name if product else None, "quantity": qty, "unitName": unit_name, "imageUrl": product.image_url if product else None, }) # 获取配送员信息(列表不返回客户信息,仅展示配送员对接用) deliver_name = None deliver_phone = None if task.deliver_id: deliver = user_crud.get(db, task.deliver_id) if deliver: deliver_name = deliver.real_name deliver_phone = deliver.phone result.append({ "id": task.id, "taskNo": task.task_no, "orderId": task.order_id, "regionId": task.region_id, "regionName": region_name, "supplierId": task.supplier_id, "deliverId": task.deliver_id, "deliverName": deliver_name, "deliverPhone": deliver_phone, "status": task.status, "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None), "effectiveTime": str(task.effective_time) if task.effective_time else None, "pickupTime": str(task.pickup_time) if task.pickup_time else None, "pickupRemark": task.pickup_remark, "deliveryTime": str(task.delivery_time) if task.delivery_time else None, "totalItems": total_items, "deliveredItems": delivered_items, "totalQuantity": round(total_quantity, 3), "supplierQuoteTotal": round(supplier_quote_total, 2), "items": line_items, "createAt": str(task.create_at) if task.create_at else None, "supplierSettlementStatus": ( (getattr(order, "supplier_settlement_status", None) or "pending") if order else "pending" ), }) return success_response({ "list": result, "total": total, "page": page, "pageSize": pageSize, "totalPages": (total + pageSize - 1) // pageSize }) @router.get("/tasks/{task_id}", response_model=dict) def get_supplier_task_detail( task_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """获取供应商配送任务详情(订单仅返回客户订单号;商品行含主图与规格;单价由价格清单行 price(客户最终单价)与 pricing_coefficient 反推供货价)""" from sqlalchemy import and_ from app.crud.delivery import delivery_task_crud, delivery_task_item_crud from app.crud.order import order_crud, order_item_crud from app.crud.product import product_crud from app.crud.unit import unit_crud from app.models.models import BizPriceListItem user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") task = delivery_task_crud.get(db, task_id) if not task: return error_response(404, "任务不存在") if task.supplier_id != user.id: return error_response(403, "无权查看此任务") order = None order_info = None if task.order_id: order = order_crud.get(db, task.order_id) if order: # 供应商备货详情仅展示客户订单号,不下发客户联系人、地址等 order_info = { "orderNo": order.order_no, "supplierSettlementStatus": getattr(order, "supplier_settlement_status", None) or "pending", "supplierSettlementAt": str(order.supplier_settlement_at) if getattr(order, "supplier_settlement_at", None) else None, } deliver_info = None if task.deliver_id: deliver = user_crud.get(db, task.deliver_id) if deliver: deliver_info = { "deliverId": deliver.id, "deliverName": deliver.real_name, "phone": deliver.phone } task_items = delivery_task_item_crud.get_by_task(db, task_id) item_details = [] for item in task_items: product = product_crud.get(db, item.product_id) unit_name = None supplier_quote_unit = None supplier_quote_line = None product_image_url = None product_measure_value = None product_unit_name = None if product: product_image_url = product.image_url if product.measure_value is not None: product_measure_value = float(product.measure_value) if product.unit_id: pu = unit_crud.get(db, product.unit_id) product_unit_name = pu.name if pu else None oi = None if item.order_item_id: oi = order_item_crud.get(db, item.order_item_id) if oi and oi.unit_id: u = unit_crud.get(db, oi.unit_id) unit_name = u.name if u else None if order and oi: pli = db.query(BizPriceListItem).filter( and_( BizPriceListItem.price_list_id == order.price_list_id, BizPriceListItem.product_id == oi.product_id, BizPriceListItem.unit_id == oi.unit_id, BizPriceListItem.is_del == 0, ) ).first() if pli is not None and pli.price is not None: supplier_quote_unit = _supplier_quote_unit_yuan_from_pli(pli) if supplier_quote_unit is not None: qty = float(item.quantity) if item.quantity else 0.0 supplier_quote_line = round(qty * supplier_quote_unit, 2) item_details.append({ "id": item.id, "orderItemId": item.order_item_id, "productId": item.product_id, "productName": product.name if product else None, "imageUrl": product_image_url, "measureValue": product_measure_value, "productUnitName": product_unit_name, "unitName": unit_name, "quantity": float(item.quantity) if item.quantity else 0, "deliveredQuantity": float(item.delivered_quantity) if item.delivered_quantity else 0, "supplierQuoteUnit": supplier_quote_unit, "supplierQuoteLine": supplier_quote_line, "status": item.status, "deliveryTime": str(item.delivery_time) if item.delivery_time else None }) prepared_images = [ a.file_url for a in attachment_crud.get_by_business(db, "delivery_prepare", task_id) if getattr(a, "file_url", None) ] if (not prepared_images) and getattr(task, "pickup_image_url", None): prepared_images = [s.strip() for s in str(task.pickup_image_url).split(",") if s and str(s).strip()] return success_response({ "id": task.id, "taskNo": task.task_no, "orderId": task.order_id, "regionId": task.region_id, "status": task.status, "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None), "effectiveTime": str(task.effective_time) if task.effective_time else None, "pickupTime": str(task.pickup_time) if task.pickup_time else None, "pickupRemark": task.pickup_remark, "pickupImageUrl": task.pickup_image_url, "deliveryTime": str(task.delivery_time) if task.delivery_time else None, "deliveryRemark": task.delivery_remark, "deliveryImageUrl": task.delivery_image_url, "preparedImages": prepared_images, "order": order_info, "deliver": deliver_info, "items": item_details, "createAt": str(task.create_at) if task.create_at else None }) def _notify_deliver_stock_ready(db, task, user, remark: str = None) -> bool: """备货完成后通知配送员(微信订阅消息,失败不阻断主流程)""" if not task.deliver_id: return False try: from app.core.wechat_message import WechatMessageService deliver = user_crud.get(db, task.deliver_id) if deliver and hasattr(deliver, "openid"): WechatMessageService.send_delivery_notice( openid=deliver.openid, task_no=task.task_no, supplier_name=user.real_name or "供应商", address="商品已备好,请尽快取货", remark=remark or "供应商已备货完成,请及时取货", ) return True except Exception: pass return False @router.post("/tasks/{task_id}/start-stock", response_model=dict) def supplier_start_stock( task_id: str, request: dict, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier), ): """供应商开始备货:assigned -> supplier_stocking,并写入订单状态日志(配送进度)""" from app.crud.delivery import delivery_task_crud user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") task = delivery_task_crud.get(db, task_id) if not task: return error_response(404, "任务不存在") if task.supplier_id != user.id: return error_response(403, "无权操作此任务") if (getattr(task, "supplier_prepare_status", None) or "pending_prepare") != "pending_prepare": return error_response(400, "当前状态不可开始备货(需为待备货/已分配)") payload = _resolve_request_payload(request, current_user) remark = (payload.get("remark") or "").strip() or None task = delivery_task_crud.update_status( db, task_id=task_id, new_status="supplier_stocking", operator_id=user.id, remark=remark, operator_real_name=user.real_name, operator_role="supplier", ) if not task: return error_response(400, "操作失败") return success_response({ "id": task.id, "taskNo": task.task_no, "status": task.status, "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None), "delivererStatus": getattr(task, "deliverer_status", None), "message": "已开始备货", }) @router.post("/tasks/{task_id}/finish-stock", response_model=dict) def supplier_finish_stock( task_id: str, request: dict, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier), ): """供应商备货完成:supplier_stocking -> supplier_ready(待配送员取货),写入订单日志并可通知配送员""" from app.crud.delivery import delivery_task_crud user = user_crud.get(db, current_user.get("user_id")) if not user: return error_response(404, "用户不存在") task = delivery_task_crud.get(db, task_id) if not task: return error_response(404, "任务不存在") if task.supplier_id != user.id: return error_response(403, "无权操作此任务") ss = getattr(task, "supplier_prepare_status", None) or "pending_prepare" if ss == "pending_prepare": return error_response(400, "请先开始备货后再确认备货完成") if ss not in ("stocking", "prepared"): return error_response(400, "当前状态不可确认备货完成") if ss == "prepared": ds = (getattr(task, "deliverer_status", None) or "").strip().lower() if ds != "pending_pickup": return error_response(400, "配送员已取货,备货凭证不可再修改") payload = _resolve_request_payload(request, current_user) remark = (payload.get("remark") or payload.get("preparedRemark") or "").strip() or None image_urls = _normalize_image_urls(payload.get("imageUrls")) if not image_urls: prep_img = payload.get("preparedImageUrl") or payload.get("pickupImageUrl") image_urls = _normalize_image_urls(prep_img) if not image_urls: return error_response(400, "请上传备货完成凭证照片") if ss == "stocking": task = delivery_task_crud.update_status( db, task_id=task_id, new_status="supplier_ready", operator_id=user.id, remark=remark, image_urls=image_urls, operator_real_name=user.real_name, operator_role="supplier", ) if not task: return error_response(400, "操作失败") else: task = delivery_task_crud.get(db, task_id) if not task: return error_response(404, "任务不存在") _replace_delivery_prepare_attachments( db, task_id=task_id, image_urls=image_urls, uploader_id=user.id, uploader_name=user.real_name, ) db.commit() deliver_notified = _notify_deliver_stock_ready(db, task, user, remark) if ss == "stocking" else False return success_response({ "id": task.id, "taskNo": task.task_no, "status": task.status, "supplierPrepareStatus": getattr(task, "supplier_prepare_status", None), "delivererStatus": getattr(task, "deliverer_status", None), "message": ("已确认备货完成" if ss == "stocking" else "备货凭证已更新") + (",已通知配送员" if deliver_notified else ""), }) # 兼容旧路径:与 start-stock / finish-stock 行为一致 @router.post("/tasks/{task_id}/confirm-pickup", response_model=dict) def supplier_confirm_pickup_deprecated( task_id: str, request: dict, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier), ): """[兼容] 请改用 POST /tasks/{id}/start-stock""" return supplier_start_stock(task_id, request, db, current_user) @router.post("/tasks/{task_id}/confirm-prepared", response_model=dict) def supplier_confirm_prepared_deprecated( task_id: str, request: dict, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier), ): """[兼容] 请改用 POST /tasks/{id}/finish-stock""" return supplier_finish_stock(task_id, request, db, current_user) @router.get("/dashboard/statistics", response_model=dict) def supplier_dashboard_statistics( db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """供应商首页统计(询价/报价/任务概览)""" from app.crud.delivery import delivery_task_crud from app.models.models import BizInquiry, BizQuotation from sqlalchemy import and_ uid = current_user.get("user_id") user = user_crud.get(db, uid) rid = user.region_id if user else None q_inq = db.query(BizInquiry).filter( and_(BizInquiry.is_del == 0, BizInquiry.status == "published") ) if rid: q_inq = q_inq.filter(BizInquiry.region_id == rid) pending_inq = q_inq.count() my_quo = db.query(BizQuotation).filter( and_(BizQuotation.is_del == 0, BizQuotation.supplier_id == uid) ).count() pending_tasks, _ = delivery_task_crud.get_by_supplier( db, supplier_id=uid, status="assigned", skip=0, limit=500 ) stocking, _ = delivery_task_crud.get_by_supplier( db, supplier_id=uid, status="supplier_stocking", skip=0, limit=500 ) return success_response({ "pendingInquiries": pending_inq, "myQuotations": my_quo, "pendingTasks": len(pending_tasks) + len(stocking), "todayDeliveries": 0, }) @router.get("/products/{product_id}", response_model=dict) def get_supplier_product_detail( product_id: str, db: Session = Depends(get_db), current_user: dict = Depends(require_supplier) ): """供应商端商品详情:轮播、基础信息、参数、富文本、详情媒体""" from app.crud.product import product_crud from app.crud.unit import unit_crud from app.crud.product_banner import product_banner from app.crud.product_parameter_value import product_parameter_value from app.crud.product_detail_media import product_detail_media product = product_crud.get(db, product_id) if not product or getattr(product, "is_del", 0) == 1: return error_response(404, "商品不存在") unit = unit_crud.get(db, product.unit_id) if product.unit_id else None banners_raw = product_banner.get_by_product(db, product_id) banners = [{ "mediaType": b.media_type, "fileUrl": b.file_url, "thumbnailUrl": b.thumbnail_url, "sortOrder": b.sort_order, } for b in banners_raw] pv_rows = product_parameter_value.get_by_product_with_detail(db, product_id) parameter_values = [] for row in pv_rows: val = row.get("param_value") if val is None or str(val).strip() == "": continue parameter_values.append({ "parameterId": row.get("parameter_id"), "parameterName": row.get("parameter_name") or "", "paramValue": str(val), }) media_raw = product_detail_media.get_by_product(db, product_id) detail_media = [] for m in media_raw: if m.content_type not in ("image", "video"): continue if not m.file_url: continue detail_media.append({ "id": m.id, "contentType": m.content_type, "fileUrl": m.file_url, "thumbnailUrl": m.thumbnail_url, "description": m.description, "sortOrder": m.sort_order, }) return success_response({ "product": { "id": product.id, "name": product.name, "unitId": product.unit_id, "unitName": unit.name if unit else None, "measureValue": float(product.measure_value) if product.measure_value is not None else None, "detailContent": product.detail_content or "", }, "banners": banners, "parameterValues": parameter_values, "detailMedia": detail_media, })