material.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844
  1. """市场物料库 API"""
  2. from datetime import datetime
  3. from decimal import Decimal
  4. from typing import List, Optional
  5. from fastapi import APIRouter, Depends, Query
  6. from pydantic import BaseModel, ConfigDict, Field
  7. from sqlalchemy.orm import Session
  8. from app.api.sample_reception import _get_current_user_id, _get_tenant_no, _page_payload
  9. from app.core.database import get_db
  10. from app.core.utils import parse_datetime
  11. from app.core.menu_guard import require_any_menu_code, require_menu_code
  12. from app.core.response import error_response, success_response
  13. from app.crud import material as mat_svc
  14. from app.crud import material_stock_batch as batch_svc
  15. from app.crud import material_stock_check as stock_check_svc
  16. from app.crud import material_usage as mu_svc
  17. from app.crud import material_stock_log as msl_svc
  18. from app.models.models import SysUser
  19. router = APIRouter(prefix="/web/admin", tags=["市场物料"])
  20. def _resolve_operator_name(db: Session, user: dict, operator_name: Optional[str]) -> str:
  21. name = (operator_name or "").strip()
  22. if name:
  23. return name
  24. user_id = _get_current_user_id(user)
  25. if user_id:
  26. row = (
  27. db.query(SysUser)
  28. .filter(SysUser.id == user_id, SysUser.del_flag == 0)
  29. .first()
  30. )
  31. if row:
  32. return (row.nick_name or row.user_no or "").strip()
  33. return (user.get("real_name") or user.get("username") or "").strip()
  34. # ---------- Body models ----------
  35. class MaterialBody(BaseModel):
  36. model_config = ConfigDict(populate_by_name=True)
  37. material_code: str = Field(..., alias="materialCode")
  38. material_name: str = Field(..., alias="materialName")
  39. category: Optional[str] = None
  40. spec_model: Optional[str] = Field(None, alias="specModel")
  41. unit: Optional[str] = None
  42. warning_qty: Optional[float] = Field(0, alias="warningQty")
  43. status: str = "1"
  44. order_num: Optional[int] = Field(None, alias="orderNum")
  45. remark: Optional[str] = None
  46. image_urls: Optional[str] = Field(None, alias="imageUrls")
  47. class MaterialUpdateBody(BaseModel):
  48. model_config = ConfigDict(populate_by_name=True)
  49. material_code: Optional[str] = Field(None, alias="materialCode")
  50. material_name: Optional[str] = Field(None, alias="materialName")
  51. category: Optional[str] = None
  52. spec_model: Optional[str] = Field(None, alias="specModel")
  53. unit: Optional[str] = None
  54. warning_qty: Optional[float] = Field(None, alias="warningQty")
  55. status: Optional[str] = None
  56. order_num: Optional[int] = Field(None, alias="orderNum")
  57. remark: Optional[str] = None
  58. image_urls: Optional[str] = Field(None, alias="imageUrls")
  59. class StockInBody(BaseModel):
  60. model_config = ConfigDict(populate_by_name=True)
  61. stock_in_no: Optional[str] = Field(None, alias="stockInNo")
  62. stock_in_date: Optional[str] = Field(None, alias="stockInDate")
  63. expire_date: Optional[str] = Field(None, alias="expireDate")
  64. catalog_no: Optional[str] = Field(None, alias="catalogNo")
  65. batch_no: Optional[str] = Field(None, alias="batchNo")
  66. manufacturer: Optional[str] = None
  67. unit_price: Optional[float] = Field(None, alias="unitPrice")
  68. amount: Optional[float] = None
  69. qty: Optional[float] = Field(None, ge=0)
  70. remark: Optional[str] = None
  71. stock_in_time: Optional[str] = Field(None, alias="stockInTime")
  72. operator_name: Optional[str] = Field(None, alias="operatorName")
  73. class BatchStockInItem(BaseModel):
  74. model_config = ConfigDict(populate_by_name=True)
  75. material_id: str = Field(..., alias="materialId")
  76. stock_in_no: Optional[str] = Field(None, alias="stockInNo")
  77. stock_in_date: Optional[str] = Field(None, alias="stockInDate")
  78. expire_date: Optional[str] = Field(None, alias="expireDate")
  79. catalog_no: Optional[str] = Field(None, alias="catalogNo")
  80. batch_no: Optional[str] = Field(None, alias="batchNo")
  81. manufacturer: Optional[str] = None
  82. unit_price: Optional[float] = Field(None, alias="unitPrice")
  83. amount: Optional[float] = None
  84. qty: Optional[float] = Field(None, ge=0)
  85. remark: Optional[str] = None
  86. class BatchStockInBody(BaseModel):
  87. model_config = ConfigDict(populate_by_name=True)
  88. items: List[BatchStockInItem] = Field(..., min_length=1)
  89. remark: Optional[str] = None
  90. stock_in_time: Optional[str] = Field(None, alias="stockInTime")
  91. operator_name: Optional[str] = Field(None, alias="operatorName")
  92. class BatchStockOutItem(BaseModel):
  93. model_config = ConfigDict(populate_by_name=True)
  94. batch_id: str = Field(..., alias="batchId")
  95. qty: float = Field(..., gt=0)
  96. remark: Optional[str] = None
  97. class BatchStockOutBody(BaseModel):
  98. model_config = ConfigDict(populate_by_name=True)
  99. items: List[BatchStockOutItem] = Field(..., min_length=1)
  100. receiver: Optional[str] = None
  101. purpose: Optional[str] = None
  102. remark: Optional[str] = None
  103. stock_out_time: Optional[str] = Field(None, alias="stockOutTime")
  104. operator_name: Optional[str] = Field(None, alias="operatorName")
  105. class BatchDiscardItem(BaseModel):
  106. model_config = ConfigDict(populate_by_name=True)
  107. batch_id: str = Field(..., alias="batchId")
  108. qty: float = Field(..., gt=0)
  109. usage_unit: Optional[str] = Field(None, alias="usageUnit")
  110. remark: Optional[str] = None
  111. class BatchDiscardBody(BaseModel):
  112. model_config = ConfigDict(populate_by_name=True)
  113. items: List[BatchDiscardItem] = Field(..., min_length=1)
  114. discard_reason: str = Field(..., alias="discardReason")
  115. remark: Optional[str] = None
  116. discard_time: Optional[str] = Field(None, alias="discardTime")
  117. operator_name: Optional[str] = Field(None, alias="operatorName")
  118. class MaterialBatchUpdateBody(BaseModel):
  119. model_config = ConfigDict(populate_by_name=True)
  120. stock_in_no: Optional[str] = Field(None, alias="stockInNo")
  121. stock_in_date: Optional[str] = Field(None, alias="stockInDate")
  122. expire_date: Optional[str] = Field(None, alias="expireDate")
  123. catalog_no: Optional[str] = Field(None, alias="catalogNo")
  124. batch_no: Optional[str] = Field(None, alias="batchNo")
  125. manufacturer: Optional[str] = None
  126. unit_price: Optional[float] = Field(None, alias="unitPrice")
  127. amount: Optional[float] = None
  128. remark: Optional[str] = None
  129. material_code: Optional[str] = Field(None, alias="materialCode")
  130. material_name: Optional[str] = Field(None, alias="materialName")
  131. category: Optional[str] = None
  132. spec_model: Optional[str] = Field(None, alias="specModel")
  133. unit: Optional[str] = None
  134. warning_qty: Optional[float] = Field(None, alias="warningQty")
  135. status: Optional[str] = None
  136. class StockCheckCreateBody(BaseModel):
  137. model_config = ConfigDict(populate_by_name=True)
  138. title: str
  139. check_time: Optional[str] = Field(None, alias="checkTime")
  140. remark: Optional[str] = None
  141. class StockCheckUnusedLineItem(BaseModel):
  142. model_config = ConfigDict(populate_by_name=True)
  143. usage_id: Optional[str] = Field(None, alias="usageId")
  144. batch_id: Optional[str] = Field(None, alias="batchId")
  145. material_code: Optional[str] = Field(None, alias="materialCode")
  146. material_name: str = Field(..., alias="materialName")
  147. spec_model: Optional[str] = Field(None, alias="specModel")
  148. unit: Optional[str] = None
  149. stock_in_no: Optional[str] = Field(None, alias="stockInNo")
  150. stock_in_date: Optional[str] = Field(None, alias="stockInDate")
  151. expire_date: Optional[str] = Field(None, alias="expireDate")
  152. catalog_no: Optional[str] = Field(None, alias="catalogNo")
  153. batch_no: Optional[str] = Field(None, alias="batchNo")
  154. manufacturer: Optional[str] = None
  155. unit_price: Optional[float] = Field(None, alias="unitPrice")
  156. holder: Optional[str] = None
  157. unused_qty: float = Field(..., alias="unusedQty", gt=0)
  158. remark: Optional[str] = None
  159. class StockCheckUnusedLinesBody(BaseModel):
  160. model_config = ConfigDict(populate_by_name=True)
  161. items: List[StockCheckUnusedLineItem] = Field(default_factory=list)
  162. # ---------- 入库批次 ----------
  163. @router.get("/material-batches")
  164. async def list_material_batches(
  165. page: int = Query(1, ge=1),
  166. pageSize: int = Query(10, ge=1, le=100),
  167. keyword: Optional[str] = None,
  168. materialId: Optional[str] = None,
  169. category: Optional[str] = None,
  170. status: Optional[str] = None,
  171. db: Session = Depends(get_db),
  172. user: dict = Depends(require_menu_code("biz:material:list")),
  173. ):
  174. try:
  175. tenant_no = _get_tenant_no(db, user)
  176. items, total = batch_svc.list_batches(
  177. db,
  178. page=page,
  179. page_size=pageSize,
  180. keyword=keyword,
  181. material_id=materialId,
  182. category=category,
  183. status=status,
  184. tenant_no=tenant_no,
  185. user_info=user,
  186. )
  187. return success_response(_page_payload(items, total, page, pageSize))
  188. except PermissionError as e:
  189. return error_response(403, str(e))
  190. except Exception as e:
  191. return error_response(400, str(e))
  192. @router.get("/material-batches/options")
  193. async def material_batch_options(
  194. keyword: Optional[str] = None,
  195. materialId: Optional[str] = None,
  196. onlyAvailable: bool = Query(True, alias="onlyAvailable"),
  197. db: Session = Depends(get_db),
  198. user: dict = Depends(require_menu_code("biz:material:list")),
  199. ):
  200. try:
  201. tenant_no = _get_tenant_no(db, user)
  202. return success_response(
  203. batch_svc.batch_options(
  204. db,
  205. tenant_no,
  206. keyword=keyword,
  207. material_id=materialId,
  208. only_available=onlyAvailable,
  209. )
  210. )
  211. except Exception as e:
  212. return error_response(400, str(e))
  213. @router.put("/material-batches/{batch_id}")
  214. async def update_material_batch(
  215. batch_id: str,
  216. body: MaterialBatchUpdateBody,
  217. db: Session = Depends(get_db),
  218. user: dict = Depends(require_menu_code("biz:material:list")),
  219. ):
  220. try:
  221. tenant_no = _get_tenant_no(db, user)
  222. user_id = _get_current_user_id(user)
  223. data = body.model_dump(exclude_none=True)
  224. obj = batch_svc.update_batch(
  225. db,
  226. batch_id,
  227. tenant_no=tenant_no,
  228. user_id=user_id,
  229. **data,
  230. )
  231. material = mat_svc.get_material_entity(db, obj.material_id, tenant_no)
  232. return success_response(
  233. batch_svc._batch_row_to_dict(obj, material),
  234. message="更新成功",
  235. )
  236. except ValueError as e:
  237. return error_response(400, str(e))
  238. except Exception as e:
  239. return error_response(400, str(e))
  240. @router.delete("/material-batches/{batch_id}")
  241. async def delete_material_batch(
  242. batch_id: str,
  243. db: Session = Depends(get_db),
  244. user: dict = Depends(require_menu_code("biz:material:list")),
  245. ):
  246. try:
  247. tenant_no = _get_tenant_no(db, user)
  248. user_id = _get_current_user_id(user)
  249. batch_svc.delete_batch(db, batch_id, tenant_no=tenant_no, user_id=user_id)
  250. return success_response(None, message="删除成功")
  251. except ValueError as e:
  252. return error_response(400, str(e))
  253. except Exception as e:
  254. return error_response(400, str(e))
  255. # ---------- 物料 CRUD ----------
  256. @router.get("/materials")
  257. async def list_materials(
  258. page: int = Query(1, ge=1),
  259. pageSize: int = Query(10, ge=1, le=100),
  260. keyword: Optional[str] = None,
  261. category: Optional[str] = None,
  262. status: Optional[str] = None,
  263. db: Session = Depends(get_db),
  264. user: dict = Depends(require_menu_code("biz:material:list")),
  265. ):
  266. try:
  267. tenant_no = _get_tenant_no(db, user)
  268. items, total = mat_svc.list_materials(
  269. db, tenant_no, page=page, page_size=pageSize,
  270. keyword=keyword, category=category, status=status, user_info=user,
  271. )
  272. return success_response(_page_payload(items, total, page, pageSize))
  273. except PermissionError as e:
  274. return error_response(403, str(e))
  275. except Exception as e:
  276. return error_response(400, str(e))
  277. @router.get("/materials/options")
  278. async def material_options(
  279. db: Session = Depends(get_db),
  280. user: dict = Depends(require_menu_code("biz:material:list")),
  281. ):
  282. try:
  283. tenant_no = _get_tenant_no(db, user)
  284. return success_response(mat_svc.options_list(db, tenant_no=tenant_no))
  285. except Exception as e:
  286. return error_response(400, str(e))
  287. @router.post("/materials")
  288. async def create_material(
  289. body: MaterialBody,
  290. db: Session = Depends(get_db),
  291. user: dict = Depends(require_menu_code("biz:material:list")),
  292. ):
  293. try:
  294. tenant_no = _get_tenant_no(db, user)
  295. user_id = _get_current_user_id(user)
  296. obj = mat_svc.create_material(
  297. db, body.model_dump(by_alias=True, exclude_none=True),
  298. tenant_no=tenant_no, user_id=user_id,
  299. )
  300. return success_response(obj, message="创建成功")
  301. except ValueError as e:
  302. return error_response(400, str(e))
  303. except Exception as e:
  304. return error_response(400, str(e))
  305. @router.put("/materials/{material_id}")
  306. async def update_material(
  307. material_id: str,
  308. body: MaterialUpdateBody,
  309. db: Session = Depends(get_db),
  310. user: dict = Depends(require_menu_code("biz:material:list")),
  311. ):
  312. try:
  313. tenant_no = _get_tenant_no(db, user)
  314. user_id = _get_current_user_id(user)
  315. obj = mat_svc.update_material(
  316. db, material_id, body.model_dump(by_alias=True, exclude_none=True),
  317. tenant_no=tenant_no, user_id=user_id,
  318. )
  319. return success_response(obj, message="更新成功")
  320. except ValueError as e:
  321. return error_response(400, str(e))
  322. except Exception as e:
  323. return error_response(400, str(e))
  324. @router.delete("/materials/{material_id}")
  325. async def delete_material(
  326. material_id: str,
  327. db: Session = Depends(get_db),
  328. user: dict = Depends(require_menu_code("biz:material:list")),
  329. ):
  330. try:
  331. tenant_no = _get_tenant_no(db, user)
  332. user_id = _get_current_user_id(user)
  333. mat_svc.soft_delete_material(db, material_id, tenant_no=tenant_no, user_id=user_id)
  334. return success_response(None, message="删除成功")
  335. except ValueError as e:
  336. return error_response(400, str(e))
  337. except Exception as e:
  338. return error_response(400, str(e))
  339. @router.post("/materials/batch-stock-in")
  340. async def batch_stock_in_material(
  341. body: BatchStockInBody,
  342. db: Session = Depends(get_db),
  343. user: dict = Depends(require_menu_code("biz:material:list")),
  344. ):
  345. try:
  346. tenant_no = _get_tenant_no(db, user)
  347. user_id = _get_current_user_id(user)
  348. operate_time = datetime.now()
  349. if body.stock_in_time:
  350. rt = parse_datetime(body.stock_in_time)
  351. if not rt:
  352. return error_response(400, "入库时间格式无效")
  353. operate_time = rt
  354. items = [
  355. {
  356. "material_id": x.material_id,
  357. "materialId": x.material_id,
  358. "stock_in_no": x.stock_in_no,
  359. "stockInNo": x.stock_in_no,
  360. "stock_in_date": x.stock_in_date,
  361. "stockInDate": x.stock_in_date,
  362. "expire_date": x.expire_date,
  363. "expireDate": x.expire_date,
  364. "catalog_no": x.catalog_no,
  365. "catalogNo": x.catalog_no,
  366. "batch_no": x.batch_no,
  367. "batchNo": x.batch_no,
  368. "manufacturer": x.manufacturer,
  369. "unit_price": x.unit_price,
  370. "unitPrice": x.unit_price,
  371. "amount": x.amount,
  372. "qty": x.qty,
  373. "remark": x.remark,
  374. }
  375. for x in body.items
  376. ]
  377. count = batch_svc.batch_stock_in(
  378. db,
  379. items=items,
  380. remark=body.remark,
  381. operator_name=_resolve_operator_name(db, user, body.operator_name),
  382. tenant_no=tenant_no,
  383. user_id=user_id,
  384. operate_time=operate_time,
  385. )
  386. return success_response({"count": count}, message=f"批量入库成功,共 {count} 项")
  387. except ValueError as e:
  388. return error_response(400, str(e))
  389. except Exception as e:
  390. return error_response(400, str(e))
  391. @router.post("/materials/batch-stock-out")
  392. async def batch_stock_out_material(
  393. body: BatchStockOutBody,
  394. db: Session = Depends(get_db),
  395. user: dict = Depends(require_menu_code("biz:material:list")),
  396. ):
  397. try:
  398. tenant_no = _get_tenant_no(db, user)
  399. user_id = _get_current_user_id(user)
  400. operate_time = datetime.now()
  401. if body.stock_out_time:
  402. rt = parse_datetime(body.stock_out_time)
  403. if not rt:
  404. return error_response(400, "出库时间格式无效")
  405. operate_time = rt
  406. items = [
  407. {"batch_id": x.batch_id, "batchId": x.batch_id, "qty": x.qty, "remark": x.remark}
  408. for x in body.items
  409. ]
  410. count = batch_svc.batch_stock_out(
  411. db,
  412. items=items,
  413. receiver=body.receiver,
  414. purpose=body.purpose,
  415. remark=body.remark,
  416. operator_name=_resolve_operator_name(db, user, body.operator_name),
  417. tenant_no=tenant_no,
  418. user_id=user_id,
  419. operate_time=operate_time,
  420. )
  421. return success_response({"count": count}, message=f"批量出库成功,共 {count} 项")
  422. except ValueError as e:
  423. return error_response(400, str(e))
  424. except Exception as e:
  425. return error_response(400, str(e))
  426. @router.post("/materials/batch-discard")
  427. async def batch_discard_material(
  428. body: BatchDiscardBody,
  429. db: Session = Depends(get_db),
  430. user: dict = Depends(require_menu_code("biz:material:list")),
  431. ):
  432. try:
  433. tenant_no = _get_tenant_no(db, user)
  434. user_id = _get_current_user_id(user)
  435. operate_time = datetime.now()
  436. if body.discard_time:
  437. rt = parse_datetime(body.discard_time)
  438. if not rt:
  439. return error_response(400, "废弃时间格式无效")
  440. operate_time = rt
  441. items = [
  442. {
  443. "batch_id": x.batch_id,
  444. "batchId": x.batch_id,
  445. "qty": x.qty,
  446. "usageUnit": x.usage_unit,
  447. "usage_unit": x.usage_unit,
  448. "remark": x.remark,
  449. }
  450. for x in body.items
  451. ]
  452. count = batch_svc.batch_discard(
  453. db,
  454. items=items,
  455. discard_reason=body.discard_reason,
  456. remark=body.remark,
  457. operator_name=_resolve_operator_name(db, user, body.operator_name),
  458. tenant_no=tenant_no,
  459. user_id=user_id,
  460. operate_time=operate_time,
  461. )
  462. return success_response({"count": count}, message=f"批量废弃成功,共 {count} 项")
  463. except ValueError as e:
  464. return error_response(400, str(e))
  465. except Exception as e:
  466. return error_response(400, str(e))
  467. @router.post("/materials/{material_id}/stock-in")
  468. async def stock_in_material(
  469. material_id: str,
  470. body: StockInBody,
  471. db: Session = Depends(get_db),
  472. user: dict = Depends(require_menu_code("biz:material:list")),
  473. ):
  474. try:
  475. tenant_no = _get_tenant_no(db, user)
  476. user_id = _get_current_user_id(user)
  477. operate_time = datetime.now()
  478. if body.stock_in_time:
  479. rt = parse_datetime(body.stock_in_time)
  480. if not rt:
  481. return error_response(400, "入库时间格式无效")
  482. operate_time = rt
  483. obj = batch_svc.stock_in_one(
  484. db,
  485. material_id,
  486. stock_in_no=body.stock_in_no,
  487. stock_in_date=body.stock_in_date,
  488. expire_date=body.expire_date,
  489. catalog_no=body.catalog_no,
  490. batch_no=body.batch_no,
  491. manufacturer=body.manufacturer,
  492. unit_price=body.unit_price,
  493. amount=body.amount,
  494. qty=body.qty,
  495. remark=body.remark,
  496. operator_name=_resolve_operator_name(db, user, body.operator_name),
  497. tenant_no=tenant_no,
  498. user_id=user_id,
  499. operate_time=operate_time,
  500. )
  501. return success_response(batch_svc._batch_row_to_dict(obj), message="入库成功")
  502. except ValueError as e:
  503. return error_response(400, str(e))
  504. except Exception as e:
  505. return error_response(400, str(e))
  506. @router.post("/material-batches/batch-stock-in")
  507. async def batch_stock_in_material_batches(
  508. body: BatchStockInBody,
  509. db: Session = Depends(get_db),
  510. user: dict = Depends(require_menu_code("biz:material:list")),
  511. ):
  512. return await batch_stock_in_material(body, db, user)
  513. @router.post("/material-batches/batch-stock-out")
  514. async def batch_stock_out_material_batches(
  515. body: BatchStockOutBody,
  516. db: Session = Depends(get_db),
  517. user: dict = Depends(require_menu_code("biz:material:list")),
  518. ):
  519. return await batch_stock_out_material(body, db, user)
  520. @router.post("/material-batches/batch-discard")
  521. async def batch_discard_material_batches(
  522. body: BatchDiscardBody,
  523. db: Session = Depends(get_db),
  524. user: dict = Depends(require_menu_code("biz:material:list")),
  525. ):
  526. return await batch_discard_material(body, db, user)
  527. # ---------- 物料变动记录 ----------
  528. @router.get("/material-usages")
  529. async def list_material_usages(
  530. page: int = Query(1, ge=1),
  531. pageSize: int = Query(10, ge=1, le=100),
  532. materialId: Optional[str] = None,
  533. changeType: Optional[str] = None,
  534. startTime: Optional[str] = None,
  535. endTime: Optional[str] = None,
  536. keyword: Optional[str] = None,
  537. stockInNo: Optional[str] = None,
  538. db: Session = Depends(get_db),
  539. user: dict = Depends(require_menu_code("biz:materialStockLog:list")),
  540. ):
  541. try:
  542. tenant_no = _get_tenant_no(db, user)
  543. items, total = mu_svc.list_usages(
  544. db, tenant_no, page=page, page_size=pageSize,
  545. material_id=materialId, change_type=changeType,
  546. start_time=startTime, end_time=endTime,
  547. keyword=keyword, stock_in_no=stockInNo,
  548. user_info=user,
  549. )
  550. return success_response(_page_payload(items, total, page, pageSize))
  551. except PermissionError as e:
  552. return error_response(403, str(e))
  553. except Exception as e:
  554. return error_response(400, str(e))
  555. @router.get("/material-stock-logs")
  556. async def list_stock_logs(
  557. page: int = Query(1, ge=1),
  558. pageSize: int = Query(10, ge=1, le=100),
  559. materialId: Optional[str] = None,
  560. changeType: Optional[str] = None,
  561. startTime: Optional[str] = None,
  562. endTime: Optional[str] = None,
  563. keyword: Optional[str] = None,
  564. db: Session = Depends(get_db),
  565. user: dict = Depends(require_menu_code("biz:materialStockLog:list")),
  566. ):
  567. """历史库存变动(biz_material_stock_log,只读)。"""
  568. try:
  569. tenant_no = _get_tenant_no(db, user)
  570. items, total = msl_svc.list_stock_logs(
  571. db, tenant_no, page=page, page_size=pageSize,
  572. material_id=materialId, change_type=changeType,
  573. start_time=startTime, end_time=endTime, keyword=keyword, user_info=user,
  574. )
  575. return success_response(_page_payload(items, total, page, pageSize))
  576. except PermissionError as e:
  577. return error_response(403, str(e))
  578. except Exception as e:
  579. return error_response(400, str(e))
  580. @router.post("/material-usages/{usage_id}/mark-fully-used")
  581. async def mark_material_usage_fully_used(
  582. usage_id: str,
  583. db: Session = Depends(get_db),
  584. user: dict = Depends(
  585. require_any_menu_code("biz:material:list", "market:material-usage:list")
  586. ),
  587. ):
  588. """标记领用记录已使用完(盘点登记 / 变动记录)。"""
  589. try:
  590. tenant_no = _get_tenant_no(db, user)
  591. user_id = _get_current_user_id(user)
  592. mu_svc.mark_requisition_fully_used(
  593. db,
  594. usage_id,
  595. tenant_no=tenant_no,
  596. user_id=user_id,
  597. user_info=user,
  598. )
  599. return success_response(None, message="已标记为使用完")
  600. except PermissionError as e:
  601. return error_response(403, str(e))
  602. except ValueError as e:
  603. return error_response(400, str(e))
  604. except Exception as e:
  605. return error_response(400, str(e))
  606. # ---------- 库存盘点 ----------
  607. @router.get("/material-stock-check-requisitions")
  608. async def list_material_stock_check_requisitions(
  609. page: int = Query(1, ge=1),
  610. pageSize: int = Query(50, ge=1, le=200),
  611. keyword: Optional[str] = None,
  612. db: Session = Depends(get_db),
  613. user: dict = Depends(require_menu_code("biz:material:list")),
  614. ):
  615. """盘点登记:待处理领用记录(未标记已使用完)。"""
  616. try:
  617. tenant_no = _get_tenant_no(db, user)
  618. items, total = mu_svc.list_stock_check_requisitions(
  619. db,
  620. tenant_no,
  621. page=page,
  622. page_size=pageSize,
  623. keyword=keyword,
  624. user_info=user,
  625. )
  626. return success_response(_page_payload(items, total, page, pageSize))
  627. except PermissionError as e:
  628. return error_response(403, str(e))
  629. except Exception as e:
  630. return error_response(400, str(e))
  631. @router.get("/material-stock-checks")
  632. async def list_material_stock_checks(
  633. page: int = Query(1, ge=1),
  634. pageSize: int = Query(10, ge=1, le=100),
  635. keyword: Optional[str] = None,
  636. status: Optional[str] = None,
  637. startTime: Optional[str] = None,
  638. endTime: Optional[str] = None,
  639. db: Session = Depends(get_db),
  640. user: dict = Depends(require_menu_code("biz:material:list")),
  641. ):
  642. try:
  643. tenant_no = _get_tenant_no(db, user)
  644. items, total = stock_check_svc.list_checks(
  645. db,
  646. tenant_no,
  647. page=page,
  648. page_size=pageSize,
  649. keyword=keyword,
  650. status=status,
  651. start_time=startTime,
  652. end_time=endTime,
  653. user_info=user,
  654. )
  655. return success_response(_page_payload(items, total, page, pageSize))
  656. except PermissionError as e:
  657. return error_response(403, str(e))
  658. except Exception as e:
  659. return error_response(400, str(e))
  660. @router.post("/material-stock-checks")
  661. async def create_material_stock_check(
  662. body: StockCheckCreateBody,
  663. db: Session = Depends(get_db),
  664. user: dict = Depends(require_menu_code("biz:material:list")),
  665. ):
  666. try:
  667. tenant_no = _get_tenant_no(db, user)
  668. user_id = _get_current_user_id(user)
  669. dept_id = user.get("deptId") or user.get("dept_id") or ""
  670. res = stock_check_svc.create_check(
  671. db,
  672. title=body.title,
  673. tenant_no=tenant_no,
  674. user_id=user_id,
  675. dept_id=dept_id,
  676. check_time=body.check_time,
  677. remark=body.remark,
  678. )
  679. return success_response(res, message="盘点单创建成功")
  680. except ValueError as e:
  681. return error_response(400, str(e))
  682. except Exception as e:
  683. return error_response(400, str(e))
  684. @router.get("/material-stock-checks/{check_id}")
  685. async def get_material_stock_check(
  686. check_id: str,
  687. db: Session = Depends(get_db),
  688. user: dict = Depends(require_menu_code("biz:material:list")),
  689. ):
  690. try:
  691. tenant_no = _get_tenant_no(db, user)
  692. data = stock_check_svc.get_check_detail(db, check_id, tenant_no)
  693. return success_response(data)
  694. except ValueError as e:
  695. return error_response(400, str(e))
  696. except Exception as e:
  697. return error_response(400, str(e))
  698. @router.put("/material-stock-checks/{check_id}/unused-lines")
  699. async def save_material_stock_check_unused_lines(
  700. check_id: str,
  701. body: StockCheckUnusedLinesBody,
  702. db: Session = Depends(get_db),
  703. user: dict = Depends(require_menu_code("biz:material:list")),
  704. ):
  705. try:
  706. tenant_no = _get_tenant_no(db, user)
  707. user_id = _get_current_user_id(user)
  708. dept_id = user.get("deptId") or user.get("dept_id") or ""
  709. count = stock_check_svc.save_unused_lines(
  710. db,
  711. check_id,
  712. items=[x.model_dump(by_alias=False) for x in body.items],
  713. tenant_no=tenant_no,
  714. user_id=user_id,
  715. dept_id=dept_id,
  716. )
  717. return success_response({"count": count}, message="登记保存成功")
  718. except ValueError as e:
  719. return error_response(400, str(e))
  720. except Exception as e:
  721. return error_response(400, str(e))
  722. @router.post("/material-stock-checks/{check_id}/complete")
  723. async def complete_material_stock_check(
  724. check_id: str,
  725. db: Session = Depends(get_db),
  726. user: dict = Depends(require_menu_code("biz:material:list")),
  727. ):
  728. try:
  729. tenant_no = _get_tenant_no(db, user)
  730. user_id = _get_current_user_id(user)
  731. stock_check_svc.complete_check(db, check_id, tenant_no=tenant_no, user_id=user_id)
  732. return success_response(None, message="盘点已完成")
  733. except ValueError as e:
  734. return error_response(400, str(e))
  735. except Exception as e:
  736. return error_response(400, str(e))
  737. @router.post("/material-stock-checks/{check_id}/cancel")
  738. async def cancel_material_stock_check(
  739. check_id: str,
  740. db: Session = Depends(get_db),
  741. user: dict = Depends(require_menu_code("biz:material:list")),
  742. ):
  743. try:
  744. tenant_no = _get_tenant_no(db, user)
  745. user_id = _get_current_user_id(user)
  746. stock_check_svc.cancel_check(db, check_id, tenant_no=tenant_no, user_id=user_id)
  747. return success_response(None, message="盘点已作废")
  748. except ValueError as e:
  749. return error_response(400, str(e))
  750. except Exception as e:
  751. return error_response(400, str(e))