inventory.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849
  1. from typing import Any, List, Optional, Tuple
  2. import re
  3. from datetime import datetime
  4. from types import SimpleNamespace
  5. from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
  6. from fastapi.responses import JSONResponse
  7. from pydantic import BaseModel, ConfigDict, Field
  8. from sqlalchemy import func, or_
  9. from sqlalchemy.orm import Session
  10. from app.core.database import get_db
  11. from app.core.db_auth import DbUserContext, get_db_current_user, get_db_current_user_optional
  12. from app.core.delete_guard import assert_row_deletable_by_creator, user_info_from_context
  13. from app.core.tenant_context import get_tenant_no
  14. from app.services.db_data_scope import apply_db_scope, assert_db_row_in_scope
  15. from app.services.db.inventory_id import next_inbound_outbound_id
  16. from app.services.db.inventory_list_filters import build_inventory_column_filters
  17. from app.services.db.list_sort import order_inventory, parse_sort_params
  18. from app.models.db_models import DbInboundOutbound, DbPurchasePlan, DbWeighingSlip
  19. from app.schemas.db.inventory import (
  20. CreateLinkForPlanPayload,
  21. InboundOutboundCreate,
  22. InboundOutboundListResponse,
  23. InboundOutboundRead,
  24. InboundOutboundUpdate,
  25. )
  26. from app.schemas.db.public_inventory import InventorySiteUrlRead
  27. from app.schemas.db.plans import PurchasePlanRead
  28. from app.schemas.db.weighing import WeighingSlipRead
  29. from app.services.db.mappers import (
  30. EMPTY_INVENTORY_DATE,
  31. inventory_to_read,
  32. is_empty_inventory_date,
  33. load_user_display_name_map,
  34. parse_date_str,
  35. plan_to_read,
  36. weighing_to_read,
  37. )
  38. from app.services.db.product_helpers import (
  39. apply_product_snapshot_to_inventory,
  40. resolve_crm_product,
  41. )
  42. from app.services.db_inventory_site_token import build_site_inventory_url, ensure_site_access_token
  43. from app.utils.excel_export import (
  44. EXPORT_MAX_ROWS,
  45. ExportColumn,
  46. ExportValidationError,
  47. export_rows_to_xlsx_response,
  48. filter_export_columns,
  49. )
  50. router = APIRouter(prefix="/inventory", tags=["inventory"])
  51. STATUS_THIRD_PARTY_FILL = "待三方填写"
  52. _INVENTORY_REMARK_FIELD = "remarks"
  53. def _base_query(db: Session, tenant_no: str):
  54. return db.query(DbInboundOutbound).filter(
  55. DbInboundOutbound.tenant_no == tenant_no,
  56. DbInboundOutbound.del_flag == 0,
  57. )
  58. def _generate_batch_no(db: Session, tenant_no: str) -> str:
  59. rows = (
  60. db.query(DbInboundOutbound.batch_no)
  61. .filter(
  62. DbInboundOutbound.tenant_no == tenant_no,
  63. DbInboundOutbound.del_flag == 0,
  64. DbInboundOutbound.batch_no.like("CRKDH%"),
  65. )
  66. .all()
  67. )
  68. max_seq = 0
  69. pattern = re.compile(r"^CRKDH(\d+)$")
  70. for (batch_no,) in rows:
  71. if batch_no:
  72. match = pattern.match(batch_no)
  73. if match:
  74. max_seq = max(max_seq, int(match.group(1)))
  75. return f"CRKDH{max_seq + 1:07d}"
  76. def _resolve_tenant_no(
  77. db: Session,
  78. user: Optional[DbUserContext],
  79. purchase_plan_id: Optional[str] = None,
  80. ) -> str:
  81. if user:
  82. return get_tenant_no(db, user.to_tenant_user_dict())
  83. if purchase_plan_id:
  84. plan = (
  85. db.query(DbPurchasePlan)
  86. .filter(DbPurchasePlan.id == purchase_plan_id, DbPurchasePlan.del_flag == 0)
  87. .one_or_none()
  88. )
  89. if plan:
  90. return plan.tenant_no
  91. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无法确定租户")
  92. def _get_inventory_by_id(db: Session, inventory_id: str) -> DbInboundOutbound:
  93. row = (
  94. db.query(DbInboundOutbound)
  95. .filter(DbInboundOutbound.id == inventory_id, DbInboundOutbound.del_flag == 0)
  96. .one_or_none()
  97. )
  98. if row is None:
  99. raise HTTPException(
  100. status_code=status.HTTP_404_NOT_FOUND,
  101. detail=f"Inventory {inventory_id} not found",
  102. )
  103. return row
  104. def _get_inventory(
  105. db: Session, tenant_no: str, inventory_id: str, user: DbUserContext
  106. ) -> DbInboundOutbound:
  107. row = (
  108. _base_query(db, tenant_no)
  109. .filter(DbInboundOutbound.id == inventory_id)
  110. .one_or_none()
  111. )
  112. if row is None:
  113. raise HTTPException(
  114. status_code=status.HTTP_404_NOT_FOUND,
  115. detail=f"Inventory {inventory_id} not found",
  116. )
  117. assert_db_row_in_scope(db, row, DbInboundOutbound, user, tenant_no)
  118. return row
  119. def _apply_inventory_product_update(
  120. db: Session, tenant_no: str, row: DbInboundOutbound, payload_dict: dict
  121. ) -> None:
  122. if "product" in payload_dict and "product_id" not in payload_dict:
  123. raise HTTPException(
  124. status_code=status.HTTP_400_BAD_REQUEST,
  125. detail="请从 CRM 重新选择产品",
  126. )
  127. product_id = payload_dict.pop("product_id", None)
  128. payload_dict.pop("product", None)
  129. payload_dict.pop("specification", None)
  130. if product_id is not None:
  131. snapshot = resolve_crm_product(db, tenant_no, product_id)
  132. apply_product_snapshot_to_inventory(row, snapshot)
  133. def _normalize_plan_no(value: Any) -> Optional[str]:
  134. if value is None:
  135. return None
  136. text = str(value).strip()
  137. return text or None
  138. def _pick_plan_for_inventory(plans: list, preferred_id: Optional[str]):
  139. """同一计划号命中多条时,优先已关联的计划,其次唯一产品。"""
  140. if preferred_id:
  141. for plan in plans:
  142. if plan.id == preferred_id:
  143. return plan
  144. if len(plans) == 1:
  145. return plans[0]
  146. product_ids = {plan.product_id for plan in plans if plan.product_id}
  147. if len(product_ids) == 1 and all(plan.product_id for plan in plans):
  148. return plans[0]
  149. return None
  150. def _text(value: Any) -> str:
  151. if value is None:
  152. return ""
  153. return str(value).strip()
  154. def _enforce_inventory_plan_on_edit(db: Session, row: DbInboundOutbound, payload_dict: dict) -> None:
  155. """新增和编辑:有计划号则必须存在,且产品、往来单位、业务类型与计划一致;没有计划号不限制。"""
  156. watched = ("plan_no", "product_id", "partner_company", "business_type")
  157. if not any(key in payload_dict for key in watched):
  158. return
  159. if "plan_no" in payload_dict:
  160. plan_no = _normalize_plan_no(payload_dict.get("plan_no"))
  161. payload_dict["plan_no"] = plan_no
  162. else:
  163. plan_no = _normalize_plan_no(row.plan_no)
  164. if not plan_no:
  165. if "plan_no" in payload_dict:
  166. payload_dict["purchase_plan_id"] = None
  167. return
  168. plans = (
  169. db.query(DbPurchasePlan)
  170. .filter(
  171. DbPurchasePlan.tenant_no == row.tenant_no,
  172. DbPurchasePlan.del_flag == 0,
  173. DbPurchasePlan.plan_no == plan_no,
  174. )
  175. .all()
  176. )
  177. if not plans:
  178. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划号不存在")
  179. preferred_id = payload_dict.get("purchase_plan_id") or row.purchase_plan_id
  180. plan = _pick_plan_for_inventory(plans, preferred_id)
  181. if plan is None:
  182. raise HTTPException(
  183. status_code=status.HTTP_400_BAD_REQUEST,
  184. detail="计划号对应多条计划,无法确定产品",
  185. )
  186. if not plan.product_id:
  187. raise HTTPException(
  188. status_code=status.HTTP_400_BAD_REQUEST,
  189. detail="该计划未关联产品,请先编辑计划并选择产品",
  190. )
  191. submitted_product_id = (
  192. payload_dict.get("product_id") if "product_id" in payload_dict else row.product_id
  193. )
  194. if submitted_product_id != plan.product_id:
  195. raise HTTPException(
  196. status_code=status.HTTP_400_BAD_REQUEST,
  197. detail="有计划号时,产品只能选择该计划的产品",
  198. )
  199. submitted_partner = (
  200. _text(payload_dict.get("partner_company"))
  201. if "partner_company" in payload_dict
  202. else _text(row.partner_company)
  203. )
  204. if submitted_partner != _text(plan.customer_name):
  205. raise HTTPException(
  206. status_code=status.HTTP_400_BAD_REQUEST,
  207. detail="有计划号时,往来单位必须与该计划一致",
  208. )
  209. submitted_business_type = (
  210. _text(payload_dict.get("business_type"))
  211. if "business_type" in payload_dict
  212. else _text(row.business_type)
  213. )
  214. if submitted_business_type != _text(plan.business_type):
  215. raise HTTPException(
  216. status_code=status.HTTP_400_BAD_REQUEST,
  217. detail="有计划号时,业务类型必须与该计划一致",
  218. )
  219. payload_dict["plan_no"] = (plan.plan_no or "").strip() or plan_no
  220. payload_dict["product_id"] = plan.product_id
  221. payload_dict["partner_company"] = _text(plan.customer_name)
  222. payload_dict["business_type"] = _text(plan.business_type)
  223. payload_dict["purchase_plan_id"] = plan.id
  224. def _apply_inventory_update(row: DbInboundOutbound, payload_dict: dict) -> None:
  225. for key, value in payload_dict.items():
  226. if key == _INVENTORY_REMARK_FIELD:
  227. if isinstance(value, str):
  228. value = value.strip()
  229. row.remark = value
  230. continue
  231. if key == "date":
  232. if value is None or (isinstance(value, str) and not value.strip()):
  233. row.date = EMPTY_INVENTORY_DATE
  234. else:
  235. row.date = parse_date_str(str(value)) or EMPTY_INVENTORY_DATE
  236. continue
  237. if isinstance(value, str):
  238. value = value.strip()
  239. setattr(row, key, value)
  240. def _maybe_promote_third_party_status(row: DbInboundOutbound) -> None:
  241. if row.status != STATUS_THIRD_PARTY_FILL:
  242. return
  243. has_date = not is_empty_inventory_date(row.date)
  244. has_qty = row.quantity is not None and row.quantity != 0
  245. has_vehicle = bool(row.vehicle_no and row.vehicle_no.strip())
  246. if has_date and has_qty and has_vehicle:
  247. row.status = "待入库" if row.business_type == "入库" else "待出库"
  248. def _merge_query(request: Request, **known) -> dict:
  249. out = {k: v for k, v in known.items() if v is not None and v != ""}
  250. for k, v in request.query_params.multi_items():
  251. if k in ("page", "pageSize"):
  252. continue
  253. if k not in out and v not in (None, ""):
  254. out[k] = v
  255. return out
  256. def _param_get(params: dict, *keys: str) -> Any:
  257. for key in keys:
  258. if key in params and params[key] not in (None, ""):
  259. return params[key]
  260. return None
  261. def _filters_from_params(params: dict) -> list:
  262. return build_inventory_column_filters(
  263. plan_no=_param_get(params, "planNo", "plan_no"),
  264. plan_no_in=_param_get(params, "planNoIn", "plan_no_in"),
  265. batch_no=_param_get(params, "batchNo", "batch_no"),
  266. batch_no_in=_param_get(params, "batchNoIn", "batch_no_in"),
  267. partner_company=_param_get(params, "partnerCompany", "partner_company"),
  268. partner_company_in=_param_get(params, "partnerCompanyIn", "partner_company_in"),
  269. product=_param_get(params, "product"),
  270. product_in=_param_get(params, "productIn", "product_in"),
  271. specification=_param_get(params, "specification"),
  272. specification_in=_param_get(params, "specificationIn", "specification_in"),
  273. vehicle_no=_param_get(params, "vehicleNo", "vehicle_no"),
  274. vehicle_no_in=_param_get(params, "vehicleNoIn", "vehicle_no_in"),
  275. business_type=_param_get(params, "businessType", "business_type"),
  276. business_type_in=_param_get(params, "businessTypeIn", "business_type_in"),
  277. status=_param_get(params, "status"),
  278. status_in=_param_get(params, "statusIn", "status_in"),
  279. date_from=_param_get(params, "dateFrom", "date_from"),
  280. date_to=_param_get(params, "dateTo", "date_to"),
  281. quantity_op=_param_get(params, "quantityOp", "quantity_op"),
  282. quantity=_param_get(params, "quantity"),
  283. quantity_min=_param_get(params, "quantityMin", "quantity_min"),
  284. quantity_max=_param_get(params, "quantityMax", "quantity_max"),
  285. )
  286. def _body_to_params(body: "InventoryExportBody") -> dict:
  287. data = body.model_dump(by_alias=True, exclude_none=True)
  288. data.pop("ids", None)
  289. data.pop("columns", None)
  290. extra = getattr(body, "model_extra", None) or {}
  291. for key, value in extra.items():
  292. if key not in data and value not in (None, ""):
  293. data[key] = value
  294. return data
  295. def _inventory_query(db: Session, user: DbUserContext, params: dict):
  296. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  297. query = _base_query(db, tenant_no)
  298. q = _param_get(params, "q")
  299. if q:
  300. keyword = str(q).lower()
  301. query = query.filter(
  302. or_(
  303. func.lower(DbInboundOutbound.partner_company).contains(keyword),
  304. func.lower(DbInboundOutbound.product).contains(keyword),
  305. func.lower(DbInboundOutbound.plan_no).contains(keyword),
  306. func.lower(DbInboundOutbound.batch_no).contains(keyword),
  307. func.lower(DbInboundOutbound.vehicle_no).contains(keyword),
  308. )
  309. )
  310. column_filters = _filters_from_params(params)
  311. if column_filters:
  312. query = query.filter(*column_filters)
  313. query = apply_db_scope(query, DbInboundOutbound, user, db, tenant_no)
  314. sf, so = parse_sort_params(params)
  315. query = query.order_by(
  316. order_inventory(sf, so, default=DbInboundOutbound.date.desc()),
  317. DbInboundOutbound.id.desc(),
  318. )
  319. return query, tenant_no
  320. def _map_inventories(
  321. db: Session, tenant_no: str, inventories: list
  322. ) -> list[InboundOutboundRead]:
  323. inventory_ids = [inv.id for inv in inventories]
  324. weighing_map: dict[str, DbWeighingSlip] = {}
  325. if inventory_ids:
  326. weighings = (
  327. db.query(DbWeighingSlip)
  328. .filter(
  329. DbWeighingSlip.tenant_no == tenant_no,
  330. DbWeighingSlip.del_flag == 0,
  331. DbWeighingSlip.inbound_outbound_id.in_(inventory_ids),
  332. DbWeighingSlip.is_voided == 0,
  333. )
  334. .order_by(DbWeighingSlip.id.asc())
  335. .all()
  336. )
  337. for weighing in weighings:
  338. if (
  339. weighing.inbound_outbound_id
  340. and weighing.inbound_outbound_id not in weighing_map
  341. ):
  342. weighing_map[weighing.inbound_outbound_id] = weighing
  343. result_list: list[InboundOutboundRead] = []
  344. user_name_map = load_user_display_name_map(db, [inv.create_by for inv in inventories])
  345. for inventory in inventories:
  346. try:
  347. result_list.append(
  348. inventory_to_read(
  349. inventory,
  350. weighing=weighing_map.get(inventory.id),
  351. user_name_map=user_name_map,
  352. )
  353. )
  354. except Exception:
  355. continue
  356. return result_list
  357. def _fetch_inventory(
  358. db: Session,
  359. user: DbUserContext,
  360. params: dict,
  361. *,
  362. page: Optional[int] = None,
  363. page_size: Optional[int] = None,
  364. ) -> Tuple[list[InboundOutboundRead], int]:
  365. query, tenant_no = _inventory_query(db, user, params)
  366. if page is not None and page_size is not None:
  367. total = query.order_by(None).count()
  368. inventories = query.offset((page - 1) * page_size).limit(page_size).all()
  369. return _map_inventories(db, tenant_no, inventories), total
  370. inventories = query.all()
  371. items = _map_inventories(db, tenant_no, inventories)
  372. return items, len(items)
  373. @router.get("/")
  374. def list_inventory(
  375. request: Request,
  376. q: Optional[str] = Query(default=None, description="模糊搜索关键词"),
  377. plan_no: Optional[str] = Query(default=None, alias="planNo", description="计划号"),
  378. batch_no: Optional[str] = Query(default=None, alias="batchNo", description="出入库单号"),
  379. partner_company: Optional[str] = Query(
  380. default=None, alias="partnerCompany", description="往来单位"
  381. ),
  382. product: Optional[str] = Query(default=None, description="产品"),
  383. vehicle_no: Optional[str] = Query(default=None, alias="vehicleNo", description="车牌号"),
  384. date_from: Optional[str] = Query(default=None, alias="dateFrom", description="开始日期"),
  385. date_to: Optional[str] = Query(default=None, alias="dateTo", description="结束日期"),
  386. status_filter: Optional[str] = Query(default=None, alias="status", description="状态筛选"),
  387. business_type: Optional[str] = Query(default=None, alias="businessType", description="业务类型"),
  388. page: Optional[int] = Query(default=None, ge=1, description="页码;传入则返回分页结构"),
  389. page_size: Optional[int] = Query(
  390. default=None, ge=1, le=500, alias="pageSize", description="每页条数"
  391. ),
  392. db: Session = Depends(get_db),
  393. user: DbUserContext = Depends(get_db_current_user),
  394. ):
  395. params = _merge_query(
  396. request,
  397. q=q,
  398. planNo=plan_no,
  399. batchNo=batch_no,
  400. partnerCompany=partner_company,
  401. product=product,
  402. vehicleNo=vehicle_no,
  403. dateFrom=date_from,
  404. dateTo=date_to,
  405. status=status_filter,
  406. businessType=business_type,
  407. )
  408. paged = page is not None
  409. size = page_size if page_size is not None else 20
  410. page_num = page if page is not None else 1
  411. items, total = _fetch_inventory(
  412. db,
  413. user,
  414. params,
  415. page=page_num if paged else None,
  416. page_size=size if paged else None,
  417. )
  418. if paged:
  419. body = InboundOutboundListResponse(
  420. items=items, total=total, page=page_num, pageSize=size
  421. )
  422. return JSONResponse(content=body.model_dump(by_alias=True))
  423. return JSONResponse(content=[item.model_dump(by_alias=True) for item in items])
  424. class InventoryColumnFacetBody(BaseModel):
  425. """出入库单列头可选值(去重;可附带列表上下文)"""
  426. model_config = ConfigDict(populate_by_name=True, extra="allow")
  427. field: str = Field(..., description="planNo | batchNo | partnerCompany | product | specification | vehicleNo")
  428. keyword: Optional[str] = Field(default=None, description="可选值关键字")
  429. limit: Optional[int] = Field(default=500, ge=1, le=500, description="返回条数上限")
  430. _INVENTORY_FACET_COLUMNS = {
  431. "planNo": DbInboundOutbound.plan_no,
  432. "batchNo": DbInboundOutbound.batch_no,
  433. "partnerCompany": DbInboundOutbound.partner_company,
  434. "product": DbInboundOutbound.product,
  435. "specification": DbInboundOutbound.specification,
  436. "vehicleNo": DbInboundOutbound.vehicle_no,
  437. }
  438. @router.post("/column-facet-values")
  439. def inventory_column_facet_values(
  440. body: InventoryColumnFacetBody,
  441. db: Session = Depends(get_db),
  442. user: DbUserContext = Depends(get_db_current_user),
  443. ):
  444. """出入库单列头可选值:在列表上下文(排除本列)下去重。"""
  445. from app.services.column_facet import list_column_facet_values
  446. from app.services.pl.facet_context import strip_simple_facet_self_params
  447. column = _INVENTORY_FACET_COLUMNS.get(body.field)
  448. if column is None:
  449. raise HTTPException(status_code=400, detail="不支持的列")
  450. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  451. params = strip_simple_facet_self_params(_body_to_params(body), body.field)
  452. extra_filters = list(_filters_from_params(params))
  453. qv = _param_get(params, "q")
  454. if qv:
  455. keyword = str(qv).lower()
  456. extra_filters.append(
  457. or_(
  458. func.lower(DbInboundOutbound.partner_company).contains(keyword),
  459. func.lower(DbInboundOutbound.product).contains(keyword),
  460. func.lower(DbInboundOutbound.plan_no).contains(keyword),
  461. func.lower(DbInboundOutbound.batch_no).contains(keyword),
  462. func.lower(DbInboundOutbound.vehicle_no).contains(keyword),
  463. )
  464. )
  465. rows = list_column_facet_values(
  466. db,
  467. DbInboundOutbound,
  468. column,
  469. tenant_no=tenant_no,
  470. keyword=body.keyword,
  471. limit=body.limit if body.limit is not None else 500,
  472. apply_scope=lambda q: apply_db_scope(q, DbInboundOutbound, user, db, tenant_no),
  473. extra_filters=extra_filters or None,
  474. )
  475. return {"list": rows}
  476. INVENTORY_EXPORT_COLUMNS = [
  477. ExportColumn(title="ID", field="id"),
  478. ExportColumn(title="计划号", field="planNo"),
  479. ExportColumn(title="出入库单号", field="batchNo"),
  480. ExportColumn(title="日期", field="date"),
  481. ExportColumn(title="往来单位", field="partnerCompany"),
  482. ExportColumn(title="业务类型", field="businessType"),
  483. ExportColumn(title="状态", field="status"),
  484. ExportColumn(title="产品", field="product"),
  485. ExportColumn(title="规格", field="specification"),
  486. ExportColumn(title="车牌号", field="vehicleNo"),
  487. ExportColumn(title="数量(吨)", field="quantity"),
  488. ExportColumn(title="净重(吨)", field="netWeight"),
  489. ExportColumn(title="毛重(吨)", field="grossWeight"),
  490. ExportColumn(title="毛重时间", field="grossWeightTime"),
  491. ExportColumn(title="皮重(吨)", field="tareWeight"),
  492. ExportColumn(title="皮重时间", field="tareWeightTime"),
  493. ExportColumn(title="扣杂(吨)", field="deduction"),
  494. ExportColumn(title="备注", field="remarks"),
  495. ExportColumn(title="创建人", field="createByName"),
  496. ]
  497. class InventoryExportBody(BaseModel):
  498. q: Optional[str] = None
  499. plan_no: Optional[str] = Field(default=None, alias="planNo")
  500. batch_no: Optional[str] = Field(default=None, alias="batchNo")
  501. partner_company: Optional[str] = Field(default=None, alias="partnerCompany")
  502. product: Optional[str] = None
  503. vehicle_no: Optional[str] = Field(default=None, alias="vehicleNo")
  504. date_from: Optional[str] = Field(default=None, alias="dateFrom")
  505. date_to: Optional[str] = Field(default=None, alias="dateTo")
  506. status: Optional[str] = None
  507. business_type: Optional[str] = Field(default=None, alias="businessType")
  508. sort_field: Optional[str] = Field(default=None, alias="sortField")
  509. sort_order: Optional[str] = Field(default=None, alias="sortOrder")
  510. ids: Optional[List[str]] = None
  511. columns: Optional[List[str]] = None
  512. model_config = {"populate_by_name": True, "extra": "allow"}
  513. @router.post("/export")
  514. def export_inventory(
  515. body: InventoryExportBody,
  516. db: Session = Depends(get_db),
  517. user: DbUserContext = Depends(get_db_current_user),
  518. ):
  519. """导出出入库列表(当前筛选全量,或 ids 已选)。"""
  520. try:
  521. items, _total = _fetch_inventory(db, user, _body_to_params(body))
  522. if body.ids:
  523. id_set = set(body.ids)
  524. items = [x for x in items if x.id in id_set]
  525. if len(items) > EXPORT_MAX_ROWS:
  526. raise ExportValidationError(f"导出超过 {EXPORT_MAX_ROWS} 条,请缩小筛选范围")
  527. rows = []
  528. for item in items:
  529. data = item.model_dump(by_alias=True)
  530. if not data.get("createByName"):
  531. data["createByName"] = data.get("createBy") or ""
  532. rows.append(data)
  533. cols = filter_export_columns(INVENTORY_EXPORT_COLUMNS, body.columns)
  534. date_str = datetime.now().strftime("%Y%m%d")
  535. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  536. return export_rows_to_xlsx_response(
  537. db,
  538. tenant_no=tenant_no,
  539. sheet_title="出入库列表",
  540. columns=cols,
  541. rows=rows,
  542. filename_stem=f"出入库列表_{date_str}",
  543. )
  544. except ExportValidationError as e:
  545. raise HTTPException(status_code=400, detail=str(e)) from e
  546. @router.post("/", response_model=InboundOutboundRead, status_code=status.HTTP_201_CREATED)
  547. def create_inventory(
  548. payload: InboundOutboundCreate,
  549. db: Session = Depends(get_db),
  550. user: Optional[DbUserContext] = Depends(get_db_current_user_optional),
  551. ) -> InboundOutboundRead:
  552. tenant_no = _resolve_tenant_no(db, user, payload.purchase_plan_id)
  553. plan_fields = {
  554. "plan_no": payload.plan_no,
  555. "product_id": payload.product_id,
  556. "partner_company": payload.partner_company,
  557. "business_type": payload.business_type,
  558. "purchase_plan_id": payload.purchase_plan_id,
  559. }
  560. _enforce_inventory_plan_on_edit(
  561. db,
  562. SimpleNamespace(
  563. tenant_no=tenant_no,
  564. plan_no=None,
  565. product_id=None,
  566. partner_company=None,
  567. business_type=None,
  568. purchase_plan_id=None,
  569. ),
  570. plan_fields,
  571. )
  572. batch_no = payload.batch_no.strip() if payload.batch_no else None
  573. if not batch_no:
  574. batch_no = _generate_batch_no(db, tenant_no)
  575. status_value = (
  576. payload.status.strip()
  577. if payload.status and str(payload.status).strip()
  578. else ("待入库" if payload.business_type == "入库" else "待出库")
  579. )
  580. snapshot = resolve_crm_product(db, tenant_no, plan_fields["product_id"])
  581. row = DbInboundOutbound(
  582. id=next_inbound_outbound_id(db, tenant_no),
  583. tenant_no=tenant_no,
  584. date=parse_date_str(payload.date) or datetime.now().date(),
  585. partner_company=_text(plan_fields.get("partner_company")),
  586. business_type=_text(plan_fields.get("business_type")) or payload.business_type,
  587. quantity=payload.quantity,
  588. plan_no=plan_fields.get("plan_no"),
  589. batch_no=batch_no,
  590. vehicle_no=payload.vehicle_no.strip(),
  591. status=status_value,
  592. remark=payload.remarks,
  593. purchase_plan_id=plan_fields.get("purchase_plan_id"),
  594. create_by=user.id if user else None,
  595. create_dept_id=user.dept_id if user else "",
  596. )
  597. apply_product_snapshot_to_inventory(row, snapshot)
  598. ensure_site_access_token(row)
  599. db.add(row)
  600. db.commit()
  601. db.refresh(row)
  602. return inventory_to_read(row, db=db)
  603. @router.post(
  604. "/create-link-for-plan",
  605. response_model=InboundOutboundRead,
  606. status_code=status.HTTP_201_CREATED,
  607. )
  608. def create_link_for_plan(
  609. payload: CreateLinkForPlanPayload,
  610. db: Session = Depends(get_db),
  611. user: DbUserContext = Depends(get_db_current_user),
  612. ) -> InboundOutboundRead:
  613. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  614. plan = (
  615. db.query(DbPurchasePlan)
  616. .filter(
  617. DbPurchasePlan.id == payload.purchase_plan_id,
  618. DbPurchasePlan.tenant_no == tenant_no,
  619. DbPurchasePlan.del_flag == 0,
  620. )
  621. .one_or_none()
  622. )
  623. if not plan:
  624. raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found")
  625. if not plan.product_id:
  626. raise HTTPException(
  627. status_code=status.HTTP_400_BAD_REQUEST,
  628. detail="计划未关联 CRM 产品,请先编辑计划并选择产品",
  629. )
  630. row = DbInboundOutbound(
  631. id=next_inbound_outbound_id(db, tenant_no),
  632. tenant_no=tenant_no,
  633. date=EMPTY_INVENTORY_DATE,
  634. partner_company=(plan.customer_name or "").strip() or "待填写",
  635. business_type=plan.business_type,
  636. product_id=plan.product_id,
  637. product=(plan.product or "").strip(),
  638. specification=plan.specification,
  639. quantity=0.0,
  640. plan_no=(plan.plan_no or "").strip() or None,
  641. batch_no=_generate_batch_no(db, tenant_no),
  642. vehicle_no="",
  643. status=STATUS_THIRD_PARTY_FILL,
  644. remark=payload.remarks,
  645. purchase_plan_id=plan.id,
  646. create_by=user.id,
  647. create_dept_id=user.dept_id,
  648. )
  649. ensure_site_access_token(row)
  650. db.add(row)
  651. db.commit()
  652. db.refresh(row)
  653. return inventory_to_read(row, db=db)
  654. @router.get("/{id}/site-url", response_model=InventorySiteUrlRead)
  655. def get_inventory_site_url(
  656. id: str,
  657. db: Session = Depends(get_db),
  658. user: DbUserContext = Depends(get_db_current_user),
  659. ) -> InventorySiteUrlRead:
  660. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  661. row = _get_inventory(db, tenant_no, id, user)
  662. token = ensure_site_access_token(row)
  663. db.commit()
  664. return InventorySiteUrlRead(site_url=build_site_inventory_url(token))
  665. @router.get("/{id}", response_model=InboundOutboundRead)
  666. def get_inventory(
  667. id: str,
  668. db: Session = Depends(get_db),
  669. ) -> InboundOutboundRead:
  670. """单条查询(三方 H5 填写页无需门户登录)。"""
  671. row = _get_inventory_by_id(db, id)
  672. weighing = (
  673. db.query(DbWeighingSlip)
  674. .filter(
  675. DbWeighingSlip.inbound_outbound_id == row.id,
  676. DbWeighingSlip.is_voided == 0,
  677. DbWeighingSlip.del_flag == 0,
  678. )
  679. .order_by(DbWeighingSlip.id.asc())
  680. .first()
  681. )
  682. return inventory_to_read(row, weighing=weighing, db=db)
  683. @router.patch("/{id}", response_model=InboundOutboundRead)
  684. def update_inventory(
  685. id: str,
  686. payload: InboundOutboundUpdate,
  687. db: Session = Depends(get_db),
  688. ) -> InboundOutboundRead:
  689. """更新(三方 H5 填写页无需门户登录)。"""
  690. row = _get_inventory_by_id(db, id)
  691. payload_dict = payload.model_dump(exclude_unset=True)
  692. _enforce_inventory_plan_on_edit(db, row, payload_dict)
  693. _apply_inventory_product_update(db, row.tenant_no, row, payload_dict)
  694. _apply_inventory_update(row, payload_dict)
  695. _maybe_promote_third_party_status(row)
  696. db.commit()
  697. db.refresh(row)
  698. return inventory_to_read(row, db=db)
  699. @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
  700. def delete_inventory(
  701. id: str,
  702. db: Session = Depends(get_db),
  703. user: DbUserContext = Depends(get_db_current_user),
  704. ) -> None:
  705. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  706. row = _get_inventory(db, tenant_no, id, user)
  707. if row.status == "已完成":
  708. raise HTTPException(
  709. status_code=status.HTTP_400_BAD_REQUEST,
  710. detail="已完成的记录不能删除",
  711. )
  712. assert_row_deletable_by_creator(row, user_info_from_context(user))
  713. row.del_flag = 1
  714. row.update_by = user.id
  715. db.commit()
  716. class InventoryRelatedData(BaseModel):
  717. plan: Optional[PurchasePlanRead]
  718. weighing: Optional[WeighingSlipRead]
  719. @router.get("/{id}/related", response_model=InventoryRelatedData)
  720. def get_inventory_related(
  721. id: str,
  722. db: Session = Depends(get_db),
  723. ) -> InventoryRelatedData:
  724. row = _get_inventory_by_id(db, id)
  725. plan = None
  726. if row.purchase_plan_id:
  727. plan = (
  728. db.query(DbPurchasePlan)
  729. .filter(
  730. DbPurchasePlan.id == row.purchase_plan_id,
  731. DbPurchasePlan.del_flag == 0,
  732. )
  733. .one_or_none()
  734. )
  735. weighing = (
  736. db.query(DbWeighingSlip)
  737. .filter(
  738. DbWeighingSlip.inbound_outbound_id == row.id,
  739. DbWeighingSlip.is_voided == 0,
  740. DbWeighingSlip.del_flag == 0,
  741. )
  742. .limit(1)
  743. .first()
  744. )
  745. return InventoryRelatedData(
  746. plan=plan_to_read(plan, db=db) if plan else None,
  747. weighing=weighing_to_read(weighing, db=db) if weighing else None,
  748. )