weighing.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. from typing import Optional
  2. from datetime import datetime
  3. from fastapi import APIRouter, Depends, HTTPException, Query, status
  4. from pydantic import BaseModel
  5. from sqlalchemy import func, or_
  6. from sqlalchemy.orm import Session
  7. from app.core.database import get_db
  8. from app.core.db_auth import DbUserContext, get_db_current_user
  9. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  10. from app.core.tenant_context import get_tenant_no
  11. from app.core.utils import generate_uuid
  12. from app.services.db_data_scope import apply_db_scope, assert_db_row_in_scope
  13. from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
  14. from app.schemas.db.inventory import InboundOutboundRead
  15. from app.schemas.db.plans import PurchasePlanRead
  16. from app.schemas.db.weighing import WeighingSlipCreate, WeighingSlipRead, WeighingSlipUpdate
  17. from app.services.db.mappers import (
  18. datetime_end_of_day,
  19. inventory_date_to_api,
  20. inventory_to_read,
  21. parse_date_str,
  22. parse_datetime_str,
  23. plan_to_read,
  24. weighing_to_read,
  25. )
  26. from app.services.db.weighing_logic import process_weighing_business_logic
  27. from app.services.db.product_helpers import (
  28. apply_product_snapshot_to_weighing,
  29. resolve_crm_product,
  30. )
  31. router = APIRouter(prefix="/weighing", tags=["weighing"])
  32. _WEIGHING_REMARK_FIELD = "remarks"
  33. def _base_query(db: Session, tenant_no: str):
  34. return db.query(DbWeighingSlip).filter(
  35. DbWeighingSlip.tenant_no == tenant_no,
  36. DbWeighingSlip.del_flag == 0,
  37. DbWeighingSlip.is_voided == 0,
  38. )
  39. def _get_weighing(
  40. db: Session, tenant_no: str, weighing_id: str, user: DbUserContext
  41. ) -> DbWeighingSlip:
  42. row = (
  43. db.query(DbWeighingSlip)
  44. .filter(
  45. DbWeighingSlip.id == weighing_id,
  46. DbWeighingSlip.tenant_no == tenant_no,
  47. DbWeighingSlip.del_flag == 0,
  48. )
  49. .one_or_none()
  50. )
  51. if row is None:
  52. raise HTTPException(
  53. status_code=status.HTTP_404_NOT_FOUND,
  54. detail=f"Weighing {weighing_id} not found",
  55. )
  56. assert_db_row_in_scope(db, row, DbWeighingSlip, user, tenant_no)
  57. return row
  58. def _apply_weighing_product_update(
  59. db: Session, tenant_no: str, row: DbWeighingSlip, payload_dict: dict
  60. ) -> None:
  61. if "cargo_name" in payload_dict and "product_id" not in payload_dict:
  62. raise HTTPException(
  63. status_code=status.HTTP_400_BAD_REQUEST,
  64. detail="请从 CRM 重新选择产品",
  65. )
  66. product_id = payload_dict.pop("product_id", None)
  67. payload_dict.pop("cargo_name", None)
  68. payload_dict.pop("specification", None)
  69. if product_id is not None:
  70. snapshot = resolve_crm_product(db, tenant_no, product_id)
  71. apply_product_snapshot_to_weighing(row, snapshot)
  72. def _apply_weighing_update(row: DbWeighingSlip, payload_dict: dict) -> None:
  73. for key, value in payload_dict.items():
  74. if key == _WEIGHING_REMARK_FIELD:
  75. if isinstance(value, str):
  76. value = value.strip()
  77. row.remark = value
  78. continue
  79. if key in {"gross_weight_time", "tare_weight_time"}:
  80. if value is None or (isinstance(value, str) and not str(value).strip()):
  81. if key == "tare_weight_time":
  82. row.tare_weight_time = None
  83. else:
  84. parsed = parse_datetime_str(str(value))
  85. if parsed:
  86. setattr(row, key, parsed)
  87. continue
  88. if isinstance(value, str):
  89. value = value.strip()
  90. setattr(row, key, value)
  91. @router.get("/", response_model=list[WeighingSlipRead])
  92. def list_weighing(
  93. q: Optional[str] = Query(default=None, description="模糊搜索关键词"),
  94. date_from: Optional[str] = Query(default=None, alias="dateFrom", description="开始日期"),
  95. date_to: Optional[str] = Query(default=None, alias="dateTo", description="结束日期"),
  96. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划号"),
  97. batch_no: Optional[str] = Query(default=None, alias="batchNo", description="出入库单号"),
  98. vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo", description="车牌号"),
  99. cargo_name: Optional[str] = Query(default=None, alias="cargoName", description="货物名称"),
  100. supplier: Optional[str] = Query(default=None, description="供货单位"),
  101. consignee: Optional[str] = Query(default=None, description="收货单位"),
  102. business_type: Optional[str] = Query(default=None, alias="businessType", description="业务类型"),
  103. db: Session = Depends(get_db),
  104. user: DbUserContext = Depends(get_db_current_user),
  105. ) -> list[WeighingSlipRead]:
  106. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  107. query = _base_query(db, tenant_no).order_by(DbWeighingSlip.gross_weight_time.desc())
  108. if q:
  109. keyword = q.lower()
  110. query = query.filter(
  111. or_(
  112. func.lower(DbWeighingSlip.vehicle_no).contains(keyword),
  113. func.lower(DbWeighingSlip.plan_no).contains(keyword),
  114. func.lower(DbWeighingSlip.batch_no).contains(keyword),
  115. func.lower(DbWeighingSlip.cargo_name).contains(keyword),
  116. func.lower(DbWeighingSlip.supplier).contains(keyword),
  117. func.lower(DbWeighingSlip.consignee).contains(keyword),
  118. )
  119. )
  120. if date_from:
  121. start_date = parse_date_str(date_from)
  122. if start_date:
  123. query = query.filter(
  124. DbWeighingSlip.gross_weight_time >= datetime.combine(start_date, datetime.min.time())
  125. )
  126. if date_to:
  127. end_date = parse_date_str(date_to)
  128. if end_date:
  129. query = query.filter(DbWeighingSlip.gross_weight_time <= datetime_end_of_day(end_date))
  130. if plan_no:
  131. query = query.filter(DbWeighingSlip.plan_no.contains(plan_no))
  132. if batch_no:
  133. query = query.filter(DbWeighingSlip.batch_no.contains(batch_no))
  134. if vehicle_no:
  135. query = query.filter(DbWeighingSlip.vehicle_no.contains(vehicle_no))
  136. if cargo_name:
  137. query = query.filter(DbWeighingSlip.cargo_name.contains(cargo_name))
  138. if supplier:
  139. query = query.filter(func.lower(DbWeighingSlip.supplier).contains(supplier.lower()))
  140. if consignee:
  141. query = query.filter(func.lower(DbWeighingSlip.consignee).contains(consignee.lower()))
  142. if business_type:
  143. query = query.filter(DbWeighingSlip.business_type == business_type)
  144. query = apply_db_scope(query, DbWeighingSlip, user, db, tenant_no)
  145. weighings = query.all()
  146. inventory_ids = [w.inbound_outbound_id for w in weighings if w.inbound_outbound_id]
  147. inventory_date_map: dict[str, str] = {}
  148. if inventory_ids:
  149. inventories = (
  150. db.query(DbInboundOutbound)
  151. .filter(
  152. DbInboundOutbound.id.in_(inventory_ids),
  153. DbInboundOutbound.del_flag == 0,
  154. )
  155. .all()
  156. )
  157. for inventory in inventories:
  158. inventory_date_map[inventory.id] = inventory_date_to_api(inventory.date)
  159. return [
  160. weighing_to_read(
  161. weighing,
  162. inventory_date=inventory_date_map.get(weighing.inbound_outbound_id or ""),
  163. )
  164. for weighing in weighings
  165. ]
  166. @router.post("/", response_model=WeighingSlipRead, status_code=status.HTTP_201_CREATED)
  167. def create_weighing(
  168. payload: WeighingSlipCreate,
  169. db: Session = Depends(get_db),
  170. user: DbUserContext = Depends(get_db_current_user),
  171. ) -> WeighingSlipRead:
  172. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  173. inbound_outbound_id = payload.inbound_outbound_id
  174. if inbound_outbound_id:
  175. existing = (
  176. db.query(DbWeighingSlip)
  177. .filter(
  178. DbWeighingSlip.inbound_outbound_id == inbound_outbound_id,
  179. DbWeighingSlip.is_voided == 0,
  180. DbWeighingSlip.del_flag == 0,
  181. )
  182. .first()
  183. )
  184. if existing:
  185. raise HTTPException(
  186. status_code=status.HTTP_400_BAD_REQUEST,
  187. detail="该出入库单已经存在关联的地磅单,无法重复创建",
  188. )
  189. planned_quantity = payload.planned_quantity
  190. current_quantity = payload.current_quantity
  191. purchase_plan_id = payload.purchase_plan_id
  192. batch_no = payload.batch_no.strip() if payload.batch_no else None
  193. if inbound_outbound_id:
  194. inventory = (
  195. db.query(DbInboundOutbound)
  196. .filter(
  197. DbInboundOutbound.id == inbound_outbound_id,
  198. DbInboundOutbound.tenant_no == tenant_no,
  199. DbInboundOutbound.del_flag == 0,
  200. )
  201. .one_or_none()
  202. )
  203. if inventory:
  204. if current_quantity is None:
  205. current_quantity = inventory.quantity * 1000
  206. if purchase_plan_id is None and inventory.purchase_plan_id:
  207. purchase_plan_id = inventory.purchase_plan_id
  208. if purchase_plan_id:
  209. plan = (
  210. db.query(DbPurchasePlan)
  211. .filter(DbPurchasePlan.id == purchase_plan_id)
  212. .one_or_none()
  213. )
  214. if plan and planned_quantity is None:
  215. planned_quantity = plan.planned_quantity * 1000
  216. if not batch_no and inventory.batch_no:
  217. batch_no = inventory.batch_no
  218. gross_time = parse_datetime_str(payload.gross_weight_time) if payload.gross_weight_time else None
  219. tare_time = (
  220. parse_datetime_str(payload.tare_weight_time)
  221. if payload.tare_weight_time and payload.tare_weight_time.strip()
  222. else None
  223. )
  224. snapshot = resolve_crm_product(db, tenant_no, payload.product_id)
  225. row = DbWeighingSlip(
  226. id=generate_uuid(),
  227. tenant_no=tenant_no,
  228. vehicle_no=payload.vehicle_no.strip(),
  229. plan_no=payload.plan_no.strip() if payload.plan_no else None,
  230. business_type=payload.business_type,
  231. batch_no=batch_no,
  232. supplier=payload.supplier.strip(),
  233. consignee=payload.consignee.strip(),
  234. gross_weight=payload.gross_weight or 0,
  235. gross_weight_time=gross_time or datetime.now(),
  236. tare_weight=payload.tare_weight or 0,
  237. tare_weight_time=tare_time,
  238. deduction=payload.deduction or 0,
  239. net_weight=payload.net_weight or 0,
  240. planned_quantity=planned_quantity,
  241. executed_quantity=payload.executed_quantity,
  242. purchase_plan_id=purchase_plan_id,
  243. inbound_outbound_id=inbound_outbound_id,
  244. weighing_point=payload.weighing_point or "山峡化工",
  245. remark=payload.remarks,
  246. current_quantity=current_quantity,
  247. is_voided=0,
  248. create_by=user.id,
  249. create_dept_id=user.dept_id,
  250. )
  251. apply_product_snapshot_to_weighing(row, snapshot)
  252. db.add(row)
  253. db.commit()
  254. db.refresh(row)
  255. process_weighing_business_logic(db, row)
  256. db.refresh(row)
  257. return weighing_to_read(row)
  258. @router.get("/{id}", response_model=WeighingSlipRead)
  259. def get_weighing(
  260. id: str,
  261. db: Session = Depends(get_db),
  262. user: DbUserContext = Depends(get_db_current_user),
  263. ) -> WeighingSlipRead:
  264. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  265. row = _get_weighing(db, tenant_no, id, user)
  266. return weighing_to_read(row)
  267. @router.patch("/{id}", response_model=WeighingSlipRead)
  268. def update_weighing(
  269. id: str,
  270. payload: WeighingSlipUpdate,
  271. db: Session = Depends(get_db),
  272. user: DbUserContext = Depends(get_db_current_user),
  273. ) -> WeighingSlipRead:
  274. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  275. row = _get_weighing(db, tenant_no, id, user)
  276. if row.is_voided == 1:
  277. raise HTTPException(
  278. status_code=status.HTTP_400_BAD_REQUEST,
  279. detail="Cannot update voided weighing record",
  280. )
  281. payload_dict = payload.model_dump(exclude_unset=True)
  282. _apply_weighing_product_update(db, tenant_no, row, payload_dict)
  283. _apply_weighing_update(row, payload_dict)
  284. row.update_by = user.id
  285. db.commit()
  286. db.refresh(row)
  287. process_weighing_business_logic(db, row)
  288. db.refresh(row)
  289. return weighing_to_read(row)
  290. @router.post("/{id}/void", status_code=status.HTTP_204_NO_CONTENT)
  291. def void_weighing(
  292. id: str,
  293. db: Session = Depends(get_db),
  294. user: DbUserContext = Depends(get_db_current_user),
  295. ) -> None:
  296. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  297. row = _get_weighing(db, tenant_no, id, user)
  298. if row.is_voided == 1:
  299. raise HTTPException(
  300. status_code=status.HTTP_400_BAD_REQUEST,
  301. detail="Weighing record is already voided",
  302. )
  303. row.is_voided = 1
  304. row.update_by = user.id
  305. db.commit()
  306. process_weighing_business_logic(db, row)
  307. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  308. def delete_weighing(
  309. id: str,
  310. db: Session = Depends(get_db),
  311. user: DbUserContext = Depends(get_db_current_user),
  312. ) -> None:
  313. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  314. row = _get_weighing(db, tenant_no, id, user)
  315. assert_row_deletable_by_creator(row, user_info_from_context(user))
  316. row.is_voided = 1
  317. row.update_by = user.id
  318. db.commit()
  319. process_weighing_business_logic(db, row)
  320. class WeighingRelatedData(BaseModel):
  321. plan: Optional[PurchasePlanRead]
  322. inventory: Optional[InboundOutboundRead]
  323. @router.get("/{id}/related", response_model=WeighingRelatedData)
  324. def get_weighing_related(
  325. id: str,
  326. db: Session = Depends(get_db),
  327. user: DbUserContext = Depends(get_db_current_user),
  328. ) -> WeighingRelatedData:
  329. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  330. row = _get_weighing(db, tenant_no, id, user)
  331. plan = None
  332. if row.purchase_plan_id:
  333. plan = (
  334. db.query(DbPurchasePlan)
  335. .filter(
  336. DbPurchasePlan.id == row.purchase_plan_id,
  337. DbPurchasePlan.del_flag == 0,
  338. )
  339. .one_or_none()
  340. )
  341. elif row.plan_no:
  342. plan = (
  343. db.query(DbPurchasePlan)
  344. .filter(
  345. DbPurchasePlan.plan_no == row.plan_no,
  346. DbPurchasePlan.tenant_no == tenant_no,
  347. DbPurchasePlan.del_flag == 0,
  348. )
  349. .limit(1)
  350. .first()
  351. )
  352. inventory = None
  353. if row.inbound_outbound_id:
  354. inventory = (
  355. db.query(DbInboundOutbound)
  356. .filter(
  357. DbInboundOutbound.id == row.inbound_outbound_id,
  358. DbInboundOutbound.del_flag == 0,
  359. )
  360. .one_or_none()
  361. )
  362. return WeighingRelatedData(
  363. plan=plan_to_read(plan) if plan else None,
  364. inventory=inventory_to_read(inventory) if inventory else None,
  365. )