settlement_reports.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050
  1. """结算报表导出行数据组装(01–06)。
  2. 无系统数据的字段返回 None,禁止用 0 冒充已付/欠款/费率等空列。
  3. """
  4. from __future__ import annotations
  5. from datetime import datetime
  6. from decimal import Decimal
  7. from typing import Any, Dict, List, Optional, Sequence, Tuple
  8. from sqlalchemy import func, or_
  9. from sqlalchemy.orm import Session
  10. from app.models.models import (
  11. BasProduct,
  12. BasProductUnit,
  13. BizDeliveryTask,
  14. BizDeliveryTaskItem,
  15. BizOrder,
  16. BizOrderItem,
  17. BizPriceList,
  18. BizPriceListItem,
  19. SysUser,
  20. SysUserHis,
  21. )
  22. EXPORT_ROW_LIMIT = 20000
  23. _ORDER_STATUS_TEXT = {
  24. "pending_payment": "待支付",
  25. "pending_confirm": "待确认收货",
  26. "completed": "已完结",
  27. "cancelled": "已取消",
  28. }
  29. _SUPPLIER_SETTLEMENT_TEXT = {
  30. "pending": "待供应商结算",
  31. "settled": "已与供应商结算",
  32. }
  33. _DELIVERY_STATUS_TEXT = {
  34. "pending": "待取货",
  35. "assigned": "已分配",
  36. "supplier_stocking": "供应商备货中",
  37. "supplier_ready": "备货完成(待取货)",
  38. "picking": "取货中",
  39. "delivering": "配送中",
  40. "completed": "已完成",
  41. "cancelled": "已取消",
  42. }
  43. def _user_code_name(user: Optional[SysUser]) -> Tuple[Optional[str], Optional[str]]:
  44. if not user:
  45. return None, None
  46. code = (user.username or "").strip() or user.id
  47. name = (user.real_name or user.username or "").strip() or None
  48. return code, name
  49. def _person_display_name(user: Optional[Any]) -> Optional[str]:
  50. """人员展示名:真实姓名优先,其次用户名。"""
  51. if not user:
  52. return None
  53. return (getattr(user, "real_name", None) or getattr(user, "username", None) or "").strip() or None
  54. def _supplier_display_name(user: Optional[Any]) -> Optional[str]:
  55. """供应商名称:用户名(商户名)优先,其次真实姓名。"""
  56. if not user:
  57. return None
  58. return (getattr(user, "username", None) or getattr(user, "real_name", None) or "").strip() or None
  59. def _client_site_display_name(
  60. user: Optional[Any],
  61. *,
  62. contact_person: Optional[str] = None,
  63. ) -> Optional[str]:
  64. """客户名称/站点:用户名(站点)优先,其次真实姓名,再回退联系人。"""
  65. if user:
  66. un = (getattr(user, "username", None) or "").strip() or None
  67. rn = (getattr(user, "real_name", None) or "").strip() or None
  68. if un and un != "客户":
  69. return un
  70. if rn and rn != "客户":
  71. return rn
  72. if un or rn:
  73. return un or rn
  74. return (contact_person or "").strip() or None
  75. def _user_map_by_ids(db: Session, user_ids: Sequence[Optional[str]]) -> Dict[str, Any]:
  76. """按 ID 取用户;含已软删;缺失时回退 sys_user_his 最新快照。"""
  77. ids = list({str(x).strip() for x in user_ids if x and str(x).strip()})
  78. if not ids:
  79. return {}
  80. out: Dict[str, Any] = {
  81. u.id: u for u in db.query(SysUser).filter(SysUser.id.in_(ids)).all()
  82. }
  83. missing = [i for i in ids if i not in out]
  84. if not missing:
  85. return out
  86. his_rows = (
  87. db.query(SysUserHis)
  88. .filter(SysUserHis.id.in_(missing))
  89. .order_by(
  90. SysUserHis.update_at.desc(),
  91. SysUserHis.his_create_at.desc(),
  92. )
  93. .all()
  94. )
  95. for h in his_rows:
  96. if h.id not in out:
  97. out[h.id] = h
  98. return out
  99. def _supplier_unit_yuan_from_pli(pli: Optional[BizPriceListItem]) -> Optional[float]:
  100. """与 admin 结算列表一致:供货单价 = price / (1 + 调价系数/100)。"""
  101. if pli is None or getattr(pli, "price", None) is None:
  102. return None
  103. final_price = Decimal(str(pli.price))
  104. raw_coeff = getattr(pli, "pricing_coefficient", None)
  105. if raw_coeff is None:
  106. coeff_pct = Decimal("0")
  107. else:
  108. try:
  109. coeff_pct = Decimal(str(raw_coeff))
  110. except Exception:
  111. coeff_pct = Decimal("0")
  112. factor = Decimal("1") + coeff_pct / Decimal("100")
  113. if factor <= Decimal("0"):
  114. factor = Decimal("1")
  115. return float((final_price / factor).quantize(Decimal("0.01")))
  116. def _price_list_item_map(db: Session, price_list_ids: List[str]) -> dict:
  117. if not price_list_ids:
  118. return {}
  119. rows = (
  120. db.query(BizPriceListItem)
  121. .filter(BizPriceListItem.price_list_id.in_(price_list_ids), BizPriceListItem.is_del == 0)
  122. .all()
  123. )
  124. return {(r.price_list_id, r.product_id, r.unit_id): r for r in rows}
  125. def _task_quantity_total(items: List[BizDeliveryTaskItem]) -> Optional[float]:
  126. if not items:
  127. return None
  128. use_delivered = any(float(it.delivered_quantity or 0) > 0 for it in items)
  129. if use_delivered:
  130. total = sum(float(it.delivered_quantity or 0) for it in items)
  131. else:
  132. total = sum(float(it.quantity or 0) for it in items)
  133. return total
  134. def _task_effective_dt(task: BizDeliveryTask) -> Optional[datetime]:
  135. return task.delivery_time or task.create_at
  136. def _order_supply_dt(order: BizOrder) -> Optional[datetime]:
  137. return order.completed_time or order.create_at
  138. def parse_year_month(year_month: str) -> Tuple[datetime, datetime]:
  139. """year_month: 'YYYY-MM' -> [month_start, next_month_start)"""
  140. parts = (year_month or "").strip().split("-")
  141. if len(parts) != 2:
  142. raise ValueError("yearMonth 格式应为 YYYY-MM")
  143. try:
  144. y, m = int(parts[0]), int(parts[1])
  145. except ValueError as e:
  146. raise ValueError("yearMonth 格式应为 YYYY-MM") from e
  147. if m < 1 or m > 12:
  148. raise ValueError("yearMonth 月份无效")
  149. start = datetime(y, m, 1, 0, 0, 0)
  150. if m == 12:
  151. end = datetime(y + 1, 1, 1, 0, 0, 0)
  152. else:
  153. end = datetime(y, m + 1, 1, 0, 0, 0)
  154. return start, end
  155. def resolve_year_month_range(
  156. *,
  157. year_month: Optional[str] = None,
  158. year_month_start: Optional[str] = None,
  159. year_month_end: Optional[str] = None,
  160. ) -> Tuple[datetime, datetime, str]:
  161. """解析月份或月份区间,返回 [range_start, range_end_exclusive) 与展示标签。
  162. - 仅 year_month:等同起止同月
  163. - year_month_start / year_month_end:闭区间月份(结束月整月含入)
  164. - 起止都有时优先区间;起止缺一时用另一端补齐;再缺则回退 year_month
  165. 标签:同月为 YYYY-MM,跨月为 YYYY-MM~YYYY-MM
  166. """
  167. ym = (year_month or "").strip() or None
  168. ys = (year_month_start or "").strip() or None
  169. ye = (year_month_end or "").strip() or None
  170. if ys or ye:
  171. if not ys:
  172. ys = ye
  173. if not ye:
  174. ye = ys
  175. assert ys and ye
  176. start, _ = parse_year_month(ys)
  177. _, end = parse_year_month(ye)
  178. if start >= end:
  179. raise ValueError("起始月份不能晚于结束月份")
  180. label = ys if ys == ye else f"{ys}~{ye}"
  181. return start, end, label
  182. if ym:
  183. start, end = parse_year_month(ym)
  184. return start, end, ym
  185. raise ValueError("请提供 yearMonth 或 yearMonthStart/yearMonthEnd")
  186. def _parse_optional_dt(value: Optional[Any]) -> Optional[datetime]:
  187. if value is None or value == "":
  188. return None
  189. if isinstance(value, datetime):
  190. return value
  191. s = str(value).strip().replace("Z", "+00:00")
  192. try:
  193. return datetime.fromisoformat(s)
  194. except ValueError:
  195. for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
  196. try:
  197. return datetime.strptime(s[:19] if len(s) >= 19 else s, fmt)
  198. except ValueError:
  199. continue
  200. raise ValueError(f"无法解析日期时间: {value}")
  201. def _raise_if_over_limit(n: int) -> None:
  202. if n > EXPORT_ROW_LIMIT:
  203. raise ValueError("导出数据超过 20000 行,请缩小筛选范围")
  204. def _client_display(user: Optional[SysUser]) -> Tuple[Optional[str], Optional[str]]:
  205. """返回 (客户编号, 客户名称)。编号优先 username。兼容旧调用。"""
  206. return _user_code_name(user)
  207. def _client_id_and_name(
  208. client_id: Optional[str],
  209. user: Optional[SysUser],
  210. *,
  211. contact_person: Optional[str] = None,
  212. ) -> Tuple[Optional[str], Optional[str]]:
  213. """客户编号=客户id;客户名称优先用户真实姓名/用户名,缺省回退订单联系人。"""
  214. code = (str(client_id).strip() if client_id is not None else "") or None
  215. name = None
  216. if user:
  217. rn = (user.real_name or "").strip() or None
  218. un = (user.username or "").strip() or None
  219. # real_name 仅为占位「客户」时改用 username
  220. if rn and rn != "客户":
  221. name = rn
  222. else:
  223. name = un or rn
  224. if not name:
  225. name = (contact_person or "").strip() or None
  226. return code, name
  227. def _filter_client_orders(
  228. db: Session,
  229. *,
  230. region_id: Optional[str],
  231. client_id: Optional[str] = None,
  232. keyword: Optional[str] = None,
  233. start: Optional[Any] = None,
  234. end: Optional[Any] = None,
  235. order_ids: Optional[Sequence[str]] = None,
  236. ) -> List[BizOrder]:
  237. q = db.query(BizOrder).filter(
  238. BizOrder.is_del == 0,
  239. BizOrder.order_status != "cancelled",
  240. )
  241. if region_id:
  242. q = q.filter(BizOrder.region_id == region_id)
  243. ids = [x.strip() for x in (order_ids or []) if x and str(x).strip()]
  244. if ids:
  245. q = q.filter(BizOrder.id.in_(ids))
  246. else:
  247. if client_id:
  248. q = q.filter(BizOrder.client_id == client_id)
  249. if keyword:
  250. q = q.filter(BizOrder.order_no.like(f"%{keyword.strip()}%"))
  251. dt_start = _parse_optional_dt(start)
  252. dt_end = _parse_optional_dt(end)
  253. if dt_start:
  254. q = q.filter(BizOrder.create_at >= dt_start)
  255. if dt_end:
  256. q = q.filter(BizOrder.create_at <= dt_end)
  257. return q.order_by(BizOrder.create_at.desc(), BizOrder.id.desc()).all()
  258. def build_client_order_lines(
  259. db: Session,
  260. *,
  261. region_id: Optional[str],
  262. client_id: Optional[str] = None,
  263. keyword: Optional[str] = None,
  264. start: Optional[Any] = None,
  265. end: Optional[Any] = None,
  266. order_ids: Optional[Sequence[str]] = None,
  267. year_month: Optional[str] = None,
  268. year_month_start: Optional[str] = None,
  269. year_month_end: Optional[str] = None,
  270. ) -> List[Dict[str, Any]]:
  271. """01 客户下单清单:一行 = 一个订单商品行。
  272. 提供 year_month 或 year_month_start/end 时:按订单 create_at 落在区间,忽略筛选/勾选。
  273. """
  274. use_month = bool(
  275. (year_month or "").strip()
  276. or (year_month_start or "").strip()
  277. or (year_month_end or "").strip()
  278. )
  279. if use_month:
  280. month_start, month_end, _ = resolve_year_month_range(
  281. year_month=year_month,
  282. year_month_start=year_month_start,
  283. year_month_end=year_month_end,
  284. )
  285. q = db.query(BizOrder).filter(
  286. BizOrder.is_del == 0,
  287. BizOrder.order_status != "cancelled",
  288. BizOrder.create_at >= month_start,
  289. BizOrder.create_at < month_end,
  290. )
  291. if region_id:
  292. q = q.filter(BizOrder.region_id == region_id)
  293. orders = q.order_by(BizOrder.create_at.desc(), BizOrder.id.desc()).all()
  294. else:
  295. orders = _filter_client_orders(
  296. db,
  297. region_id=region_id,
  298. client_id=client_id,
  299. keyword=keyword,
  300. start=start,
  301. end=end,
  302. order_ids=order_ids,
  303. )
  304. if not orders:
  305. return []
  306. order_ids_list = [o.id for o in orders]
  307. lines = (
  308. db.query(BizOrderItem)
  309. .filter(BizOrderItem.order_id.in_(order_ids_list), BizOrderItem.is_del == 0)
  310. .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
  311. .all()
  312. )
  313. _raise_if_over_limit(len(lines))
  314. by_oid: Dict[str, List[BizOrderItem]] = {}
  315. for ln in lines:
  316. by_oid.setdefault(ln.order_id, []).append(ln)
  317. product_ids = list({ln.product_id for ln in lines if ln.product_id})
  318. unit_ids = list({ln.unit_id for ln in lines if ln.unit_id})
  319. client_ids = list({o.client_id for o in orders if o.client_id})
  320. product_map = {
  321. p.id: p
  322. for p in (
  323. db.query(BasProduct).filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0).all()
  324. if product_ids
  325. else []
  326. )
  327. }
  328. unit_map = {
  329. u.id: u
  330. for u in (
  331. db.query(BasProductUnit)
  332. .filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0)
  333. .all()
  334. if unit_ids
  335. else []
  336. )
  337. }
  338. client_map = {
  339. u.id: u
  340. for u in (
  341. # 与结算订单列表一致:按 id 批量取,不过滤 is_del,避免名称缺失
  342. db.query(SysUser).filter(SysUser.id.in_(client_ids)).all()
  343. if client_ids
  344. else []
  345. )
  346. }
  347. out: List[Dict[str, Any]] = []
  348. for o in orders:
  349. c_code, c_name = _client_id_and_name(
  350. o.client_id,
  351. client_map.get(o.client_id),
  352. contact_person=getattr(o, "contact_person", None),
  353. )
  354. status_text = _ORDER_STATUS_TEXT.get(o.order_status) or o.order_status
  355. order_date = o.create_at.strftime("%Y-%m-%d %H:%M:%S") if o.create_at else None
  356. for ln in by_oid.get(o.id, []):
  357. p = product_map.get(ln.product_id)
  358. u = unit_map.get(ln.unit_id)
  359. qty = float(ln.quantity) if ln.quantity is not None else None
  360. sell_u = float(ln.unit_price) if ln.unit_price is not None else None
  361. if ln.amount is not None:
  362. sell_amt: Optional[float] = float(ln.amount)
  363. elif qty is not None and sell_u is not None:
  364. sell_amt = round(qty * sell_u, 2)
  365. else:
  366. sell_amt = None
  367. out.append(
  368. {
  369. "orderNo": o.order_no,
  370. "orderDate": order_date,
  371. "clientId": c_code,
  372. "clientName": c_name,
  373. "productName": p.name if p else None,
  374. "spec": getattr(p, "spec", None) if p else None,
  375. "unitName": u.name if u else None,
  376. "quantity": qty,
  377. "unitPrice": sell_u,
  378. "amount": sell_amt,
  379. "orderStatusText": status_text,
  380. "remark": (o.remark or None) if getattr(o, "remark", None) else None,
  381. }
  382. )
  383. _raise_if_over_limit(len(out))
  384. return out
  385. def build_client_monthly_summary(
  386. db: Session,
  387. *,
  388. region_id: Optional[str],
  389. year_month: Optional[str] = None,
  390. year_month_start: Optional[str] = None,
  391. year_month_end: Optional[str] = None,
  392. ) -> List[Dict[str, Any]]:
  393. """04 月度客户结算汇总:一行 = 区间内一个客户(跨月合计)。"""
  394. range_start, range_end, period_label = resolve_year_month_range(
  395. year_month=year_month,
  396. year_month_start=year_month_start,
  397. year_month_end=year_month_end,
  398. )
  399. q = db.query(BizOrder).filter(
  400. BizOrder.is_del == 0,
  401. BizOrder.order_status != "cancelled",
  402. BizOrder.create_at >= range_start,
  403. BizOrder.create_at < range_end,
  404. )
  405. if region_id:
  406. q = q.filter(BizOrder.region_id == region_id)
  407. orders = q.order_by(BizOrder.create_at.asc()).all()
  408. if not orders:
  409. return []
  410. order_ids = [o.id for o in orders]
  411. lines = (
  412. db.query(BizOrderItem)
  413. .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
  414. .all()
  415. )
  416. qty_by_oid: Dict[str, float] = {}
  417. for ln in lines:
  418. qty_by_oid[ln.order_id] = qty_by_oid.get(ln.order_id, 0.0) + float(ln.quantity or 0)
  419. # client_id -> agg
  420. agg: Dict[str, Dict[str, Any]] = {}
  421. for o in orders:
  422. cid = o.client_id or ""
  423. bucket = agg.get(cid)
  424. if not bucket:
  425. bucket = {
  426. "clientIdRaw": o.client_id,
  427. "contactPerson": (getattr(o, "contact_person", None) or "").strip() or None,
  428. "orderIds": set(),
  429. "quantitySum": 0.0,
  430. "receivableAmount": 0.0,
  431. }
  432. agg[cid] = bucket
  433. elif not bucket.get("contactPerson"):
  434. cp = (getattr(o, "contact_person", None) or "").strip() or None
  435. if cp:
  436. bucket["contactPerson"] = cp
  437. bucket["orderIds"].add(o.id)
  438. bucket["quantitySum"] += qty_by_oid.get(o.id, 0.0)
  439. if o.total_amount is not None:
  440. bucket["receivableAmount"] += float(o.total_amount)
  441. client_ids = [b["clientIdRaw"] for b in agg.values() if b["clientIdRaw"]]
  442. client_map = {
  443. u.id: u
  444. for u in (
  445. db.query(SysUser).filter(SysUser.id.in_(client_ids)).all()
  446. if client_ids
  447. else []
  448. )
  449. }
  450. rows: List[Dict[str, Any]] = []
  451. for cid, bucket in sorted(agg.items(), key=lambda x: x[0]):
  452. c_code, c_name = _client_id_and_name(
  453. bucket["clientIdRaw"],
  454. client_map.get(bucket["clientIdRaw"]),
  455. contact_person=bucket.get("contactPerson"),
  456. )
  457. order_count = len(bucket["orderIds"])
  458. qty_sum = bucket["quantitySum"]
  459. recv = bucket["receivableAmount"]
  460. rows.append(
  461. {
  462. "settlementMonth": period_label,
  463. "clientId": c_code,
  464. "clientName": c_name,
  465. "orderCount": order_count,
  466. "quantitySum": qty_sum if qty_sum else None,
  467. "receivableAmount": round(recv, 2) if recv else None,
  468. # 以下无系统对账数据:一律 None
  469. "discountAdjust": None,
  470. "actualReceived": None,
  471. "paidAmount": None,
  472. "debtAmount": None,
  473. "settlementStatus": None,
  474. "reconcileDate": None,
  475. "remark": None,
  476. }
  477. )
  478. _raise_if_over_limit(len(rows))
  479. return rows
  480. def build_supplier_supply_lines(
  481. db: Session,
  482. *,
  483. region_id: Optional[str],
  484. supplier_id: Optional[str] = None,
  485. keyword: Optional[str] = None,
  486. start: Optional[Any] = None,
  487. end: Optional[Any] = None,
  488. order_ids: Optional[Sequence[str]] = None,
  489. year_month: Optional[str] = None,
  490. year_month_start: Optional[str] = None,
  491. year_month_end: Optional[str] = None,
  492. ) -> List[Dict[str, Any]]:
  493. """02 供货商供货结算:一行 = 订单商品行(供货价)。
  494. 提供 year_month 或 year_month_start/end 时:按订单 create_at 落在区间,忽略筛选/勾选。
  495. """
  496. use_month = bool(
  497. (year_month or "").strip()
  498. or (year_month_start or "").strip()
  499. or (year_month_end or "").strip()
  500. )
  501. if use_month:
  502. month_start, month_end, _ = resolve_year_month_range(
  503. year_month=year_month,
  504. year_month_start=year_month_start,
  505. year_month_end=year_month_end,
  506. )
  507. q = (
  508. db.query(BizOrder, BizPriceList.supplier_id)
  509. .join(BizPriceList, BizPriceList.id == BizOrder.price_list_id)
  510. .filter(
  511. BizOrder.is_del == 0,
  512. BizOrder.order_status != "cancelled",
  513. BizOrder.create_at >= month_start,
  514. BizOrder.create_at < month_end,
  515. )
  516. )
  517. if region_id:
  518. q = q.filter(BizOrder.region_id == region_id)
  519. else:
  520. supply_dt = func.coalesce(BizOrder.completed_time, BizOrder.create_at)
  521. q = (
  522. db.query(BizOrder, BizPriceList.supplier_id)
  523. .join(BizPriceList, BizPriceList.id == BizOrder.price_list_id)
  524. .filter(BizOrder.is_del == 0, BizOrder.order_status != "cancelled")
  525. )
  526. if region_id:
  527. q = q.filter(BizOrder.region_id == region_id)
  528. ids = [x.strip() for x in (order_ids or []) if x and str(x).strip()]
  529. if ids:
  530. q = q.filter(BizOrder.id.in_(ids))
  531. else:
  532. if supplier_id:
  533. q = q.filter(BizPriceList.supplier_id == supplier_id)
  534. if keyword:
  535. q = q.filter(BizOrder.order_no.like(f"%{keyword.strip()}%"))
  536. dt_start = _parse_optional_dt(start)
  537. dt_end = _parse_optional_dt(end)
  538. if dt_start:
  539. q = q.filter(supply_dt >= dt_start)
  540. if dt_end:
  541. q = q.filter(supply_dt <= dt_end)
  542. pairs = q.order_by(BizOrder.create_at.desc(), BizOrder.id.desc()).all()
  543. if not pairs:
  544. return []
  545. orders = [o for o, _ in pairs]
  546. supplier_by_oid = {o.id: sid for o, sid in pairs}
  547. order_id_list = [o.id for o in orders]
  548. lines = (
  549. db.query(BizOrderItem)
  550. .filter(BizOrderItem.order_id.in_(order_id_list), BizOrderItem.is_del == 0)
  551. .order_by(BizOrderItem.order_id.asc(), BizOrderItem.create_at.asc())
  552. .all()
  553. )
  554. _raise_if_over_limit(len(lines))
  555. by_oid: Dict[str, List[BizOrderItem]] = {}
  556. for ln in lines:
  557. by_oid.setdefault(ln.order_id, []).append(ln)
  558. product_ids = list({ln.product_id for ln in lines if ln.product_id})
  559. unit_ids = list({ln.unit_id for ln in lines if ln.unit_id})
  560. supplier_ids = list({sid for sid in supplier_by_oid.values() if sid})
  561. pl_ids = list({o.price_list_id for o in orders if o.price_list_id})
  562. product_map = {
  563. p.id: p
  564. for p in (
  565. db.query(BasProduct).filter(BasProduct.id.in_(product_ids), BasProduct.is_del == 0).all()
  566. if product_ids
  567. else []
  568. )
  569. }
  570. unit_map = {
  571. u.id: u
  572. for u in (
  573. db.query(BasProductUnit)
  574. .filter(BasProductUnit.id.in_(unit_ids), BasProductUnit.is_del == 0)
  575. .all()
  576. if unit_ids
  577. else []
  578. )
  579. }
  580. supplier_map = {
  581. u.id: u
  582. for u in (
  583. db.query(SysUser).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
  584. if supplier_ids
  585. else []
  586. )
  587. }
  588. pli_map = _price_list_item_map(db, pl_ids)
  589. out: List[Dict[str, Any]] = []
  590. for o in orders:
  591. sid = supplier_by_oid.get(o.id)
  592. s_code, s_name = _user_code_name(supplier_map.get(sid) if sid else None)
  593. settle_text = _SUPPLIER_SETTLEMENT_TEXT.get(
  594. getattr(o, "supplier_settlement_status", None) or "pending"
  595. )
  596. supply_date = _order_supply_dt(o)
  597. supply_date_s = supply_date.strftime("%Y-%m-%d %H:%M:%S") if supply_date else None
  598. for ln in by_oid.get(o.id, []):
  599. p = product_map.get(ln.product_id)
  600. u = unit_map.get(ln.unit_id)
  601. qty = float(ln.quantity) if ln.quantity is not None else None
  602. pli = pli_map.get((o.price_list_id, ln.product_id, ln.unit_id))
  603. sup_u = _supplier_unit_yuan_from_pli(pli)
  604. if sup_u is None and ln.unit_price is not None:
  605. # 无清单价时回退订单行单价(与结算列表一致)
  606. try:
  607. sup_u = float(ln.unit_price)
  608. except (TypeError, ValueError):
  609. sup_u = None
  610. if qty is not None and sup_u is not None:
  611. sup_amt: Optional[float] = round(qty * float(sup_u), 2)
  612. else:
  613. sup_amt = None
  614. out.append(
  615. {
  616. "supplyNo": o.order_no,
  617. "orderNo": o.order_no,
  618. "supplyDate": supply_date_s,
  619. "supplierId": s_code,
  620. "supplierName": s_name,
  621. "skuCode": p.sku_code if p else None,
  622. "productName": p.name if p else None,
  623. "spec": getattr(p, "spec", None) if p else None,
  624. "unitName": u.name if u else None,
  625. "quantity": qty,
  626. "unitPrice": float(sup_u) if sup_u is not None else None,
  627. "amount": sup_amt,
  628. "acceptStatus": None,
  629. "deduction": None,
  630. "actualAmount": None,
  631. "settlementStatus": settle_text,
  632. "remark": (ln.note or None) if getattr(ln, "note", None) else None,
  633. }
  634. )
  635. _raise_if_over_limit(len(out))
  636. return out
  637. def build_supplier_monthly_summary(
  638. db: Session,
  639. *,
  640. region_id: Optional[str],
  641. year_month: Optional[str] = None,
  642. year_month_start: Optional[str] = None,
  643. year_month_end: Optional[str] = None,
  644. ) -> List[Dict[str, Any]]:
  645. """05 月度供货商结算汇总:一行 = 区间内一个供应商;笔数=订单笔数(跨月合计)。"""
  646. range_start, range_end, period_label = resolve_year_month_range(
  647. year_month=year_month,
  648. year_month_start=year_month_start,
  649. year_month_end=year_month_end,
  650. )
  651. pairs = (
  652. db.query(BizOrder, BizPriceList.supplier_id)
  653. .join(BizPriceList, BizPriceList.id == BizOrder.price_list_id)
  654. .filter(
  655. BizOrder.is_del == 0,
  656. BizOrder.order_status != "cancelled",
  657. BizOrder.create_at >= range_start,
  658. BizOrder.create_at < range_end,
  659. )
  660. )
  661. if region_id:
  662. pairs = pairs.filter(BizOrder.region_id == region_id)
  663. pairs = pairs.all()
  664. if not pairs:
  665. return []
  666. orders = [o for o, _ in pairs]
  667. supplier_by_oid = {o.id: sid for o, sid in pairs}
  668. order_ids = [o.id for o in orders]
  669. lines = (
  670. db.query(BizOrderItem)
  671. .filter(BizOrderItem.order_id.in_(order_ids), BizOrderItem.is_del == 0)
  672. .all()
  673. )
  674. by_oid: Dict[str, List[BizOrderItem]] = {}
  675. for ln in lines:
  676. by_oid.setdefault(ln.order_id, []).append(ln)
  677. pl_ids = list({o.price_list_id for o in orders if o.price_list_id})
  678. pli_map = _price_list_item_map(db, pl_ids)
  679. agg: Dict[str, Dict[str, Any]] = {}
  680. for o in orders:
  681. sid = supplier_by_oid.get(o.id) or ""
  682. bucket = agg.get(sid)
  683. if not bucket:
  684. bucket = {
  685. "supplierIdRaw": supplier_by_oid.get(o.id),
  686. "orderIds": set(),
  687. "quantitySum": 0.0,
  688. "payableAmount": 0.0,
  689. }
  690. agg[sid] = bucket
  691. bucket["orderIds"].add(o.id)
  692. for ln in by_oid.get(o.id, []):
  693. qty = float(ln.quantity or 0)
  694. bucket["quantitySum"] += qty
  695. pli = pli_map.get((o.price_list_id, ln.product_id, ln.unit_id))
  696. sup_u = _supplier_unit_yuan_from_pli(pli)
  697. if sup_u is None and ln.unit_price is not None:
  698. try:
  699. sup_u = float(ln.unit_price)
  700. except (TypeError, ValueError):
  701. sup_u = None
  702. if sup_u is not None:
  703. bucket["payableAmount"] += qty * float(sup_u)
  704. supplier_ids = [b["supplierIdRaw"] for b in agg.values() if b["supplierIdRaw"]]
  705. supplier_map = {
  706. u.id: u
  707. for u in (
  708. db.query(SysUser).filter(SysUser.id.in_(supplier_ids), SysUser.is_del == 0).all()
  709. if supplier_ids
  710. else []
  711. )
  712. }
  713. rows: List[Dict[str, Any]] = []
  714. for sid, bucket in sorted(agg.items(), key=lambda x: x[0]):
  715. s_code, s_name = _user_code_name(supplier_map.get(bucket["supplierIdRaw"]))
  716. pay = bucket["payableAmount"]
  717. qty_sum = bucket["quantitySum"]
  718. rows.append(
  719. {
  720. "settlementMonth": period_label,
  721. "supplierId": s_code,
  722. "supplierName": s_name,
  723. "orderCount": len(bucket["orderIds"]),
  724. "quantitySum": qty_sum if qty_sum else None,
  725. "payableAmount": round(pay, 2) if pay else None,
  726. "deduction": None,
  727. "actualPaid": None,
  728. "paidAmount": None,
  729. "debtAmount": None,
  730. "settlementStatus": None,
  731. "reconcileDate": None,
  732. "remark": None,
  733. }
  734. )
  735. _raise_if_over_limit(len(rows))
  736. return rows
  737. def build_delivery_lines(
  738. db: Session,
  739. *,
  740. region_id: Optional[str],
  741. keyword: Optional[str] = None,
  742. status: Optional[str] = None,
  743. deliver_id: Optional[str] = None,
  744. start: Optional[Any] = None,
  745. end: Optional[Any] = None,
  746. year_month: Optional[str] = None,
  747. year_month_start: Optional[str] = None,
  748. year_month_end: Optional[str] = None,
  749. ) -> List[Dict[str, Any]]:
  750. """03 配送商配送结算:一行 = 一个配送任务。
  751. 提供 year_month 或 year_month_start/end 时:按 eff_dt 落在区间,忽略筛选参数。
  752. """
  753. eff_dt = func.coalesce(BizDeliveryTask.delivery_time, BizDeliveryTask.create_at)
  754. use_month = bool(
  755. (year_month or "").strip()
  756. or (year_month_start or "").strip()
  757. or (year_month_end or "").strip()
  758. )
  759. q = db.query(BizDeliveryTask).filter(
  760. BizDeliveryTask.is_del == 0,
  761. BizDeliveryTask.status != "cancelled",
  762. )
  763. if region_id:
  764. q = q.filter(BizDeliveryTask.region_id == region_id)
  765. if use_month:
  766. month_start, month_end, _ = resolve_year_month_range(
  767. year_month=year_month,
  768. year_month_start=year_month_start,
  769. year_month_end=year_month_end,
  770. )
  771. q = q.filter(eff_dt >= month_start, eff_dt < month_end)
  772. else:
  773. if status:
  774. q = q.filter(BizDeliveryTask.status == status)
  775. if deliver_id:
  776. q = q.filter(BizDeliveryTask.deliver_id == deliver_id)
  777. kw = (keyword or "").strip()
  778. if kw:
  779. order_ids_kw = [
  780. r[0]
  781. for r in db.query(BizOrder.id)
  782. .filter(BizOrder.is_del == 0, BizOrder.order_no.like(f"%{kw}%"))
  783. .all()
  784. ]
  785. conds = [BizDeliveryTask.task_no.like(f"%{kw}%")]
  786. if order_ids_kw:
  787. conds.append(BizDeliveryTask.order_id.in_(order_ids_kw))
  788. q = q.filter(or_(*conds))
  789. dt_start = _parse_optional_dt(start)
  790. dt_end = _parse_optional_dt(end)
  791. if dt_start:
  792. q = q.filter(eff_dt >= dt_start)
  793. if dt_end:
  794. q = q.filter(eff_dt <= dt_end)
  795. tasks = q.order_by(BizDeliveryTask.create_at.desc(), BizDeliveryTask.id.desc()).all()
  796. _raise_if_over_limit(len(tasks))
  797. if not tasks:
  798. return []
  799. task_ids = [t.id for t in tasks]
  800. order_ids = list({t.order_id for t in tasks if t.order_id})
  801. deliver_ids = list({t.deliver_id for t in tasks if t.deliver_id})
  802. supplier_ids = list({t.supplier_id for t in tasks if t.supplier_id})
  803. items = (
  804. db.query(BizDeliveryTaskItem)
  805. .filter(BizDeliveryTaskItem.task_id.in_(task_ids), BizDeliveryTaskItem.is_del == 0)
  806. .all()
  807. )
  808. items_by_tid: Dict[str, List[BizDeliveryTaskItem]] = {}
  809. for it in items:
  810. items_by_tid.setdefault(it.task_id, []).append(it)
  811. orders = (
  812. db.query(BizOrder).filter(BizOrder.id.in_(order_ids), BizOrder.is_del == 0).all()
  813. if order_ids
  814. else []
  815. )
  816. order_map = {o.id: o for o in orders}
  817. # 订单删了也能从配送明细拿客户
  818. client_ids = list(
  819. {
  820. *(o.client_id for o in orders if o.client_id),
  821. *(it.client_id for it in items if it.client_id),
  822. }
  823. )
  824. user_ids = list({*deliver_ids, *supplier_ids, *client_ids})
  825. user_map = _user_map_by_ids(db, user_ids)
  826. out: List[Dict[str, Any]] = []
  827. for t in tasks:
  828. o = order_map.get(t.order_id)
  829. deliver_user = user_map.get(t.deliver_id) if t.deliver_id else None
  830. client_id = None
  831. contact_person = None
  832. if o and o.client_id:
  833. client_id = o.client_id
  834. contact_person = (o.contact_person or "").strip() or None
  835. elif items_by_tid.get(t.id):
  836. first_item = items_by_tid[t.id][0]
  837. client_id = first_item.client_id or None
  838. contact_person = (first_item.contact_person or "").strip() or None
  839. client_user = user_map.get(client_id) if client_id else None
  840. supplier_user = user_map.get(t.supplier_id) if t.supplier_id else None
  841. # 配送商编号=用户id;配送商名称=真实姓名/用户名
  842. deliver_id_out = t.deliver_id or None
  843. deliver_name_out = _person_display_name(deliver_user)
  844. # 客户编号=客户id;客户名称/站点=站点用户名优先,缺省联系人
  845. client_name_out = _client_site_display_name(client_user, contact_person=contact_person)
  846. # 起点(供货商/仓)= 供应商名称(用户名/商户名优先)
  847. origin_name_out = _supplier_display_name(supplier_user)
  848. dest = None
  849. if o:
  850. dest = (o.delivery_address or "").strip() or client_name_out
  851. elif items_by_tid.get(t.id):
  852. dest = (items_by_tid[t.id][0].delivery_address or "").strip() or client_name_out
  853. eff = _task_effective_dt(t)
  854. qty = _task_quantity_total(items_by_tid.get(t.id, []))
  855. out.append(
  856. {
  857. "taskNo": t.task_no,
  858. "orderNo": o.order_no if o else None,
  859. "deliveryDate": eff.strftime("%Y-%m-%d %H:%M:%S") if eff else None,
  860. "deliverId": deliver_id_out,
  861. "deliverName": deliver_name_out,
  862. "clientId": client_id,
  863. "clientName": client_name_out,
  864. "originName": origin_name_out,
  865. "destName": dest,
  866. "quantityTotal": qty,
  867. "rate": None,
  868. "payableFee": None,
  869. "deduction": None,
  870. "actualFee": None,
  871. "settlementStatus": _DELIVERY_STATUS_TEXT.get(t.status) or t.status,
  872. "remark": (t.delivery_remark or t.dispatch_remark or None),
  873. }
  874. )
  875. return out
  876. def build_delivery_monthly_summary(
  877. db: Session,
  878. *,
  879. region_id: Optional[str],
  880. year_month: Optional[str] = None,
  881. year_month_start: Optional[str] = None,
  882. year_month_end: Optional[str] = None,
  883. ) -> List[Dict[str, Any]]:
  884. """06 月度配送商结算汇总:一行 = 区间内一个配送员;金额列全空。"""
  885. range_start, range_end, period_label = resolve_year_month_range(
  886. year_month=year_month,
  887. year_month_start=year_month_start,
  888. year_month_end=year_month_end,
  889. )
  890. eff_dt = func.coalesce(BizDeliveryTask.delivery_time, BizDeliveryTask.create_at)
  891. q = db.query(BizDeliveryTask).filter(
  892. BizDeliveryTask.is_del == 0,
  893. BizDeliveryTask.status != "cancelled",
  894. eff_dt >= range_start,
  895. eff_dt < range_end,
  896. )
  897. if region_id:
  898. q = q.filter(BizDeliveryTask.region_id == region_id)
  899. tasks = q.all()
  900. if not tasks:
  901. return []
  902. task_ids = [t.id for t in tasks]
  903. items = (
  904. db.query(BizDeliveryTaskItem)
  905. .filter(BizDeliveryTaskItem.task_id.in_(task_ids), BizDeliveryTaskItem.is_del == 0)
  906. .all()
  907. )
  908. items_by_tid: Dict[str, List[BizDeliveryTaskItem]] = {}
  909. for it in items:
  910. items_by_tid.setdefault(it.task_id, []).append(it)
  911. agg: Dict[str, Dict[str, Any]] = {}
  912. for t in tasks:
  913. key = t.deliver_id or ""
  914. bucket = agg.get(key)
  915. if not bucket:
  916. bucket = {
  917. "deliverIdRaw": t.deliver_id,
  918. "taskCount": 0,
  919. "quantitySum": 0.0,
  920. }
  921. agg[key] = bucket
  922. bucket["taskCount"] += 1
  923. qtot = _task_quantity_total(items_by_tid.get(t.id, []))
  924. if qtot is not None:
  925. bucket["quantitySum"] += qtot
  926. deliver_ids = [b["deliverIdRaw"] for b in agg.values() if b["deliverIdRaw"]]
  927. deliver_map = _user_map_by_ids(db, deliver_ids)
  928. rows: List[Dict[str, Any]] = []
  929. for key, bucket in sorted(agg.items(), key=lambda x: x[0]):
  930. if bucket["deliverIdRaw"]:
  931. deliver_user = deliver_map.get(bucket["deliverIdRaw"])
  932. d_code = bucket["deliverIdRaw"]
  933. d_name = _person_display_name(deliver_user)
  934. else:
  935. d_code, d_name = None, "未分配"
  936. qty_sum = bucket["quantitySum"]
  937. rows.append(
  938. {
  939. "settlementMonth": period_label,
  940. "deliverId": d_code,
  941. "deliverName": d_name,
  942. "taskCount": bucket["taskCount"],
  943. "quantitySum": qty_sum if qty_sum else None,
  944. "payableFee": None,
  945. "deduction": None,
  946. "actualFee": None,
  947. "paidAmount": None,
  948. "debtAmount": None,
  949. "settlementStatus": None,
  950. "reconcileDate": None,
  951. "remark": None,
  952. }
  953. )
  954. _raise_if_over_limit(len(rows))
  955. return rows