production_material.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335
  1. """生产物料库 API"""
  2. from typing import List, Optional
  3. from datetime import datetime
  4. from decimal import Decimal
  5. from fastapi import APIRouter, Depends, HTTPException, Query
  6. from pydantic import BaseModel, ConfigDict, Field
  7. from sqlalchemy.orm import Session
  8. from app.core.database import get_db
  9. from app.core.menu_guard import require_any_menu_code, require_menu_code
  10. from app.core.response import error_response, success_response
  11. from app.core.utils import parse_datetime
  12. from app.crud import production_material as mat_svc
  13. from app.crud import production_material_stock_batch as batch_svc
  14. from app.crud import production_material_stock_check as stock_check_svc
  15. from app.crud import production_material_usage as mu_svc
  16. from app.crud import production_material_formula as formula_svc
  17. from app.crud import production_material_prep as prep_svc
  18. from app.api.sample_reception import _get_current_user_id, _get_tenant_no, _page_payload
  19. router = APIRouter(tags=["生产物料库"])
  20. # ---------- 生产物料库 ----------
  21. class ProductionMaterialBody(BaseModel):
  22. model_config = ConfigDict(populate_by_name=True)
  23. material_type: str = Field(..., alias="materialType")
  24. material_code: str = Field(..., alias="materialCode")
  25. material_name: str = Field(..., alias="materialName")
  26. spec_model: Optional[str] = Field(None, alias="specModel")
  27. unit: Optional[str] = None
  28. warning_qty: Optional[float] = Field(0, alias="warningQty")
  29. status: str = "1"
  30. remark: Optional[str] = None
  31. class ProductionMaterialUpdateBody(BaseModel):
  32. model_config = ConfigDict(populate_by_name=True)
  33. material_type: Optional[str] = Field(None, alias="materialType")
  34. material_code: Optional[str] = Field(None, alias="materialCode")
  35. material_name: Optional[str] = Field(None, alias="materialName")
  36. spec_model: Optional[str] = Field(None, alias="specModel")
  37. unit: Optional[str] = None
  38. warning_qty: Optional[float] = Field(None, alias="warningQty")
  39. status: Optional[str] = None
  40. remark: Optional[str] = None
  41. class StockInBody(BaseModel):
  42. model_config = ConfigDict(populate_by_name=True)
  43. stock_in_no: str = Field(..., alias="stockInNo")
  44. stock_in_date: str = Field(..., alias="stockInDate")
  45. expire_date: str = Field(..., alias="expireDate")
  46. production_date: Optional[str] = Field(None, alias="productionDate")
  47. catalog_no: str = Field(..., alias="catalogNo")
  48. batch_no: str = Field(..., alias="batchNo")
  49. manufacturer: str
  50. storage_location: Optional[str] = Field(None, alias="storageLocation")
  51. unit_price: float = Field(..., alias="unitPrice")
  52. amount: float
  53. qty: float = Field(..., gt=0)
  54. remark: Optional[str] = None
  55. stock_in_time: Optional[str] = Field(None, alias="stockInTime")
  56. class BatchStockInItem(BaseModel):
  57. model_config = ConfigDict(populate_by_name=True)
  58. material_id: str = Field(..., alias="materialId")
  59. stock_in_no: str = Field(..., alias="stockInNo")
  60. stock_in_date: str = Field(..., alias="stockInDate")
  61. expire_date: str = Field(..., alias="expireDate")
  62. production_date: Optional[str] = Field(None, alias="productionDate")
  63. catalog_no: str = Field(..., alias="catalogNo")
  64. batch_no: str = Field(..., alias="batchNo")
  65. manufacturer: str
  66. storage_location: Optional[str] = Field(None, alias="storageLocation")
  67. unit_price: float = Field(..., alias="unitPrice")
  68. amount: float
  69. qty: float = Field(..., gt=0)
  70. remark: Optional[str] = None
  71. class BatchStockInBody(BaseModel):
  72. model_config = ConfigDict(populate_by_name=True)
  73. items: List[BatchStockInItem] = Field(..., min_length=1)
  74. remark: Optional[str] = None
  75. stock_in_time: Optional[str] = Field(None, alias="stockInTime")
  76. class BatchStockOutItem(BaseModel):
  77. model_config = ConfigDict(populate_by_name=True)
  78. batch_id: str = Field(..., alias="batchId")
  79. qty: float = Field(..., gt=0)
  80. remark: Optional[str] = None
  81. class BatchStockOutBody(BaseModel):
  82. model_config = ConfigDict(populate_by_name=True)
  83. items: List[BatchStockOutItem] = Field(..., min_length=1)
  84. receiver: Optional[str] = None
  85. purpose: Optional[str] = None
  86. remark: Optional[str] = None
  87. stock_out_time: Optional[str] = Field(None, alias="stockOutTime")
  88. class BatchDiscardItem(BaseModel):
  89. model_config = ConfigDict(populate_by_name=True)
  90. batch_id: str = Field(..., alias="batchId")
  91. qty: float = Field(..., gt=0)
  92. usage_unit: Optional[str] = Field(None, alias="usageUnit")
  93. remark: Optional[str] = None
  94. class BatchDiscardBody(BaseModel):
  95. model_config = ConfigDict(populate_by_name=True)
  96. items: List[BatchDiscardItem] = Field(..., min_length=1)
  97. discard_reason: str = Field(..., alias="discardReason")
  98. remark: Optional[str] = None
  99. discard_time: Optional[str] = Field(None, alias="discardTime")
  100. class ProductionMaterialBatchUpdateBody(BaseModel):
  101. model_config = ConfigDict(populate_by_name=True)
  102. stock_in_no: Optional[str] = Field(None, alias="stockInNo")
  103. stock_in_date: Optional[str] = Field(None, alias="stockInDate")
  104. expire_date: Optional[str] = Field(None, alias="expireDate")
  105. production_date: Optional[str] = Field(None, alias="productionDate")
  106. catalog_no: Optional[str] = Field(None, alias="catalogNo")
  107. batch_no: Optional[str] = Field(None, alias="batchNo")
  108. manufacturer: Optional[str] = None
  109. storage_location: Optional[str] = Field(None, alias="storageLocation")
  110. unit_price: Optional[float] = Field(None, alias="unitPrice")
  111. amount: Optional[float] = None
  112. remark: Optional[str] = None
  113. material_code: Optional[str] = Field(None, alias="materialCode")
  114. material_name: Optional[str] = Field(None, alias="materialName")
  115. spec_model: Optional[str] = Field(None, alias="specModel")
  116. unit: Optional[str] = None
  117. warning_qty: Optional[float] = Field(None, alias="warningQty")
  118. status: Optional[str] = None
  119. @router.get("/production-material-batches")
  120. async def list_production_material_batches(
  121. page: int = Query(1, ge=1),
  122. pageSize: int = Query(10, ge=1, le=100),
  123. keyword: Optional[str] = None,
  124. materialId: Optional[str] = None,
  125. materialType: Optional[str] = None,
  126. status: Optional[str] = None,
  127. db: Session = Depends(get_db),
  128. user: dict = Depends(require_menu_code("production:material:list")),
  129. ):
  130. try:
  131. tenant_no = _get_tenant_no(db, user)
  132. items, total = batch_svc.list_batches(
  133. db,
  134. page=page,
  135. page_size=pageSize,
  136. keyword=keyword,
  137. material_id=materialId,
  138. material_type=materialType,
  139. status=status,
  140. tenant_no=tenant_no,
  141. user_info=user,
  142. )
  143. return success_response(_page_payload(items, total, page, pageSize))
  144. except PermissionError as e:
  145. return error_response(403, str(e))
  146. except Exception as e:
  147. return error_response(400, str(e))
  148. @router.get("/production-material-batches/options")
  149. async def production_material_batch_options(
  150. keyword: Optional[str] = None,
  151. materialId: Optional[str] = None,
  152. materialType: Optional[str] = None,
  153. onlyAvailable: bool = Query(True, alias="onlyAvailable"),
  154. db: Session = Depends(get_db),
  155. user: dict = Depends(require_menu_code("production:material:list")),
  156. ):
  157. try:
  158. tenant_no = _get_tenant_no(db, user)
  159. return success_response(
  160. batch_svc.batch_options(
  161. db,
  162. tenant_no,
  163. keyword=keyword,
  164. material_id=materialId,
  165. material_type=materialType,
  166. only_available=onlyAvailable,
  167. )
  168. )
  169. except Exception as e:
  170. return error_response(400, str(e))
  171. @router.put("/production-material-batches/{batch_id}")
  172. async def update_production_material_batch(
  173. batch_id: str,
  174. body: ProductionMaterialBatchUpdateBody,
  175. db: Session = Depends(get_db),
  176. user: dict = Depends(require_menu_code("production:material:list")),
  177. ):
  178. try:
  179. tenant_no = _get_tenant_no(db, user)
  180. user_id = _get_current_user_id(user)
  181. data = body.model_dump(exclude_none=True)
  182. obj = batch_svc.update_batch(
  183. db,
  184. batch_id,
  185. tenant_no=tenant_no,
  186. user_id=user_id,
  187. **data,
  188. )
  189. material = mat_svc.get_material(db, obj.material_id, tenant_no=tenant_no)
  190. return success_response(
  191. batch_svc._batch_row_to_dict(obj, material),
  192. message="更新成功",
  193. )
  194. except ValueError as e:
  195. return error_response(400, str(e))
  196. except Exception as e:
  197. return error_response(400, str(e))
  198. @router.delete("/production-material-batches/{batch_id}")
  199. async def delete_production_material_batch(
  200. batch_id: str,
  201. db: Session = Depends(get_db),
  202. user: dict = Depends(require_menu_code("production:material:list")),
  203. ):
  204. try:
  205. tenant_no = _get_tenant_no(db, user)
  206. user_id = _get_current_user_id(user)
  207. batch_svc.delete_batch(db, batch_id, tenant_no=tenant_no, user_id=user_id)
  208. return success_response(None, message="删除成功")
  209. except ValueError as e:
  210. return error_response(400, str(e))
  211. except Exception as e:
  212. return error_response(400, str(e))
  213. @router.get("/production-materials")
  214. async def list_production_materials(
  215. page: int = Query(1, ge=1),
  216. pageSize: int = Query(10, ge=1, le=100),
  217. keyword: Optional[str] = None,
  218. materialType: Optional[str] = None,
  219. status: Optional[str] = None,
  220. db: Session = Depends(get_db),
  221. user: dict = Depends(require_menu_code("production:material:list")),
  222. ):
  223. try:
  224. tenant_no = _get_tenant_no(db, user)
  225. items, total = mat_svc.list_materials(
  226. db,
  227. page=page,
  228. page_size=pageSize,
  229. keyword=keyword,
  230. material_type=materialType,
  231. status=status,
  232. tenant_no=tenant_no,
  233. user_info=user,
  234. )
  235. return success_response(_page_payload(items, total, page, pageSize))
  236. except PermissionError as e:
  237. return error_response(403, str(e))
  238. except Exception as e:
  239. return error_response(400, str(e))
  240. @router.get("/production-materials/options")
  241. async def production_material_options(
  242. materialType: Optional[str] = None,
  243. db: Session = Depends(get_db),
  244. user: dict = Depends(require_menu_code("production:material:list")),
  245. ):
  246. try:
  247. tenant_no = _get_tenant_no(db, user)
  248. return success_response(
  249. mat_svc.options_list(db, tenant_no=tenant_no, material_type=materialType)
  250. )
  251. except Exception as e:
  252. return error_response(400, str(e))
  253. @router.post("/production-materials")
  254. async def create_production_material(
  255. body: ProductionMaterialBody,
  256. db: Session = Depends(get_db),
  257. user: dict = Depends(require_menu_code("production:material:list")),
  258. ):
  259. try:
  260. tenant_no = _get_tenant_no(db, user)
  261. user_id = _get_current_user_id(user)
  262. obj = mat_svc.create_material(
  263. db,
  264. material_type=body.material_type,
  265. material_code=body.material_code,
  266. material_name=body.material_name,
  267. spec_model=body.spec_model,
  268. unit=body.unit,
  269. warning_qty=Decimal(str(body.warning_qty))
  270. if body.warning_qty is not None
  271. else None,
  272. status=mat_svc._norm_status(body.status),
  273. remark=body.remark,
  274. tenant_no=tenant_no,
  275. user_id=user_id,
  276. )
  277. return success_response(mat_svc._row_to_dict(obj), message="创建成功")
  278. except ValueError as e:
  279. return error_response(400, str(e))
  280. except Exception as e:
  281. return error_response(400, str(e))
  282. @router.put("/production-materials/{material_id}")
  283. async def update_production_material(
  284. material_id: str,
  285. body: ProductionMaterialUpdateBody,
  286. db: Session = Depends(get_db),
  287. user: dict = Depends(require_menu_code("production:material:list")),
  288. ):
  289. try:
  290. tenant_no = _get_tenant_no(db, user)
  291. user_id = _get_current_user_id(user)
  292. data = body.model_dump(exclude_none=True)
  293. if "warning_qty" in data:
  294. data["warning_qty"] = Decimal(str(data["warning_qty"]))
  295. if "status" in data:
  296. data["status"] = mat_svc._norm_status(data["status"])
  297. obj = mat_svc.update_material(
  298. db,
  299. material_id,
  300. tenant_no=tenant_no,
  301. user_id=user_id,
  302. **data,
  303. )
  304. return success_response(mat_svc._row_to_dict(obj), message="更新成功")
  305. except ValueError as e:
  306. return error_response(400, str(e))
  307. except Exception as e:
  308. return error_response(400, str(e))
  309. @router.delete("/production-materials/{material_id}")
  310. async def delete_production_material(
  311. material_id: str,
  312. db: Session = Depends(get_db),
  313. user: dict = Depends(require_menu_code("production:material:list")),
  314. ):
  315. try:
  316. tenant_no = _get_tenant_no(db, user)
  317. user_id = _get_current_user_id(user)
  318. mat_svc.soft_delete_material(db, material_id, tenant_no=tenant_no)
  319. return success_response(None, 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.post("/production-materials/batch-stock-in")
  325. async def batch_stock_in_production_material(
  326. body: BatchStockInBody,
  327. db: Session = Depends(get_db),
  328. user: dict = Depends(require_menu_code("production:material:list")),
  329. ):
  330. try:
  331. tenant_no = _get_tenant_no(db, user)
  332. user_id = _get_current_user_id(user)
  333. operate_time = datetime.now()
  334. if body.stock_in_time:
  335. rt = parse_datetime(body.stock_in_time)
  336. if not rt:
  337. return error_response(400, "入库时间格式无效")
  338. operate_time = rt
  339. items = [
  340. {
  341. "material_id": x.material_id,
  342. "materialId": x.material_id,
  343. "stock_in_no": x.stock_in_no,
  344. "stockInNo": x.stock_in_no,
  345. "stock_in_date": x.stock_in_date,
  346. "stockInDate": x.stock_in_date,
  347. "expire_date": x.expire_date,
  348. "expireDate": x.expire_date,
  349. "catalog_no": x.catalog_no,
  350. "catalogNo": x.catalog_no,
  351. "batch_no": x.batch_no,
  352. "batchNo": x.batch_no,
  353. "manufacturer": x.manufacturer,
  354. "storage_location": x.storage_location,
  355. "storageLocation": x.storage_location,
  356. "unit_price": x.unit_price,
  357. "unitPrice": x.unit_price,
  358. "amount": x.amount,
  359. "qty": x.qty,
  360. "remark": x.remark,
  361. }
  362. for x in body.items
  363. ]
  364. count = batch_svc.batch_stock_in(
  365. db,
  366. items=items,
  367. remark=body.remark,
  368. tenant_no=tenant_no,
  369. user_id=user_id,
  370. operate_time=operate_time,
  371. )
  372. return success_response({"count": count}, message=f"批量入库成功,共 {count} 项")
  373. except ValueError as e:
  374. return error_response(400, str(e))
  375. except Exception as e:
  376. return error_response(400, str(e))
  377. @router.post("/production-materials/batch-stock-out")
  378. async def batch_stock_out_production_material(
  379. body: BatchStockOutBody,
  380. db: Session = Depends(get_db),
  381. user: dict = Depends(require_menu_code("production:material:list")),
  382. ):
  383. try:
  384. tenant_no = _get_tenant_no(db, user)
  385. user_id = _get_current_user_id(user)
  386. operate_time = datetime.now()
  387. if body.stock_out_time:
  388. rt = parse_datetime(body.stock_out_time)
  389. if not rt:
  390. return error_response(400, "出库时间格式无效")
  391. operate_time = rt
  392. items = [
  393. {"batch_id": x.batch_id, "batchId": x.batch_id, "qty": x.qty, "remark": x.remark}
  394. for x in body.items
  395. ]
  396. count = batch_svc.batch_stock_out(
  397. db,
  398. items=items,
  399. receiver=body.receiver,
  400. purpose=body.purpose,
  401. remark=body.remark,
  402. tenant_no=tenant_no,
  403. user_id=user_id,
  404. operate_time=operate_time,
  405. )
  406. return success_response({"count": count}, message=f"批量出库成功,共 {count} 项")
  407. except ValueError as e:
  408. return error_response(400, str(e))
  409. except Exception as e:
  410. return error_response(400, str(e))
  411. @router.post("/production-materials/batch-discard")
  412. async def batch_discard_production_material(
  413. body: BatchDiscardBody,
  414. db: Session = Depends(get_db),
  415. user: dict = Depends(require_menu_code("production:material:list")),
  416. ):
  417. try:
  418. tenant_no = _get_tenant_no(db, user)
  419. user_id = _get_current_user_id(user)
  420. operate_time = datetime.now()
  421. if body.discard_time:
  422. rt = parse_datetime(body.discard_time)
  423. if not rt:
  424. return error_response(400, "废弃时间格式无效")
  425. operate_time = rt
  426. items = [
  427. {
  428. "batch_id": x.batch_id,
  429. "batchId": x.batch_id,
  430. "qty": x.qty,
  431. "usageUnit": x.usage_unit,
  432. "usage_unit": x.usage_unit,
  433. "remark": x.remark,
  434. }
  435. for x in body.items
  436. ]
  437. count = batch_svc.batch_discard(
  438. db,
  439. items=items,
  440. discard_reason=body.discard_reason,
  441. remark=body.remark,
  442. tenant_no=tenant_no,
  443. user_id=user_id,
  444. operate_time=operate_time,
  445. )
  446. return success_response({"count": count}, message=f"批量废弃成功,共 {count} 项")
  447. except ValueError as e:
  448. return error_response(400, str(e))
  449. except Exception as e:
  450. return error_response(400, str(e))
  451. @router.post("/production-materials/{material_id}/stock-in")
  452. async def stock_in_production_material(
  453. material_id: str,
  454. body: StockInBody,
  455. db: Session = Depends(get_db),
  456. user: dict = Depends(require_menu_code("production:material:list")),
  457. ):
  458. try:
  459. tenant_no = _get_tenant_no(db, user)
  460. user_id = _get_current_user_id(user)
  461. operate_time = datetime.now()
  462. if body.stock_in_time:
  463. rt = parse_datetime(body.stock_in_time)
  464. if not rt:
  465. return error_response(400, "入库时间格式无效")
  466. operate_time = rt
  467. from decimal import Decimal
  468. obj = batch_svc.stock_in_one(
  469. db,
  470. material_id,
  471. stock_in_no=body.stock_in_no,
  472. stock_in_date=body.stock_in_date,
  473. expire_date=body.expire_date,
  474. catalog_no=body.catalog_no,
  475. batch_no=body.batch_no,
  476. manufacturer=body.manufacturer,
  477. storage_location=body.storage_location,
  478. unit_price=body.unit_price,
  479. amount=body.amount,
  480. qty=Decimal(str(body.qty)),
  481. remark=body.remark,
  482. production_date=body.production_date,
  483. tenant_no=tenant_no,
  484. user_id=user_id,
  485. operate_time=operate_time,
  486. )
  487. return success_response(batch_svc._batch_row_to_dict(obj), message="入库成功")
  488. except ValueError as e:
  489. return error_response(400, str(e))
  490. except Exception as e:
  491. return error_response(400, str(e))
  492. @router.post("/production-material-batches/batch-stock-in")
  493. async def batch_stock_in_production_material_batches(
  494. body: BatchStockInBody,
  495. db: Session = Depends(get_db),
  496. user: dict = Depends(require_menu_code("production:material:list")),
  497. ):
  498. """批量入库(与 /production-materials/batch-stock-in 等价)。"""
  499. return await batch_stock_in_production_material(body, db, user)
  500. @router.post("/production-material-batches/batch-stock-out")
  501. async def batch_stock_out_production_material_batches(
  502. body: BatchStockOutBody,
  503. db: Session = Depends(get_db),
  504. user: dict = Depends(require_menu_code("production:material:list")),
  505. ):
  506. """批量出库(与 /production-materials/batch-stock-out 等价)。"""
  507. return await batch_stock_out_production_material(body, db, user)
  508. @router.post("/production-material-batches/batch-discard")
  509. async def batch_discard_production_material_batches(
  510. body: BatchDiscardBody,
  511. db: Session = Depends(get_db),
  512. user: dict = Depends(require_menu_code("production:material:list")),
  513. ):
  514. """批量废弃(与 /production-materials/batch-discard 等价)。"""
  515. return await batch_discard_production_material(body, db, user)
  516. # ---------- 物料变动记录 ----------
  517. @router.get("/production-material-requisition-options")
  518. async def list_production_material_requisition_options(
  519. materialType: Optional[str] = None,
  520. keyword: Optional[str] = None,
  521. limit: int = Query(50, ge=1, le=100),
  522. db: Session = Depends(get_db),
  523. user: dict = Depends(
  524. require_any_menu_code(
  525. "production:work-order:list",
  526. "production:my-task:list",
  527. "production:material:list",
  528. )
  529. ),
  530. ):
  531. try:
  532. tenant_no = _get_tenant_no(db, user)
  533. items = mu_svc.list_requisition_options(
  534. db,
  535. tenant_no,
  536. keyword=keyword,
  537. material_type=materialType,
  538. limit=limit,
  539. user_info=user,
  540. )
  541. return success_response(items)
  542. except PermissionError as e:
  543. return error_response(403, str(e))
  544. except Exception as e:
  545. return error_response(400, str(e))
  546. @router.get("/production-material-usages")
  547. async def list_production_material_usages(
  548. page: int = Query(1, ge=1),
  549. pageSize: int = Query(10, ge=1, le=100),
  550. materialId: Optional[str] = None,
  551. materialType: Optional[str] = None,
  552. changeType: Optional[str] = None,
  553. startTime: Optional[str] = None,
  554. endTime: Optional[str] = None,
  555. keyword: Optional[str] = None,
  556. stockInNo: Optional[str] = None,
  557. db: Session = Depends(get_db),
  558. user: dict = Depends(require_menu_code("production:material:list")),
  559. ):
  560. try:
  561. tenant_no = _get_tenant_no(db, user)
  562. items, total = mu_svc.list_usages(
  563. db, tenant_no, page=page, page_size=pageSize,
  564. material_id=materialId, material_type=materialType, change_type=changeType,
  565. start_time=startTime, end_time=endTime,
  566. keyword=keyword, stock_in_no=stockInNo,
  567. user_info=user,
  568. )
  569. return success_response(_page_payload(items, total, page, pageSize))
  570. except PermissionError as e:
  571. return error_response(403, str(e))
  572. except Exception as e:
  573. return error_response(400, str(e))
  574. @router.post("/production-material-usages/{usage_id}/mark-fully-used")
  575. async def mark_production_material_usage_fully_used(
  576. usage_id: str,
  577. db: Session = Depends(get_db),
  578. user: dict = Depends(require_menu_code("production:material:list")),
  579. ):
  580. """标记领用记录已使用完(盘点登记 / 变动记录)。"""
  581. try:
  582. tenant_no = _get_tenant_no(db, user)
  583. user_id = _get_current_user_id(user)
  584. mu_svc.mark_requisition_fully_used(
  585. db,
  586. usage_id,
  587. tenant_no=tenant_no,
  588. user_id=user_id,
  589. user_info=user,
  590. )
  591. return success_response(None, message="已标记为使用完")
  592. except PermissionError as e:
  593. return error_response(403, str(e))
  594. except ValueError as e:
  595. return error_response(400, str(e))
  596. except Exception as e:
  597. return error_response(400, str(e))
  598. @router.get("/my-production-material-usages")
  599. async def list_my_production_material_usages(
  600. page: int = Query(1, ge=1),
  601. pageSize: int = Query(10, ge=1, le=100),
  602. materialId: Optional[str] = None,
  603. materialType: Optional[str] = None,
  604. changeType: Optional[str] = None,
  605. startTime: Optional[str] = None,
  606. endTime: Optional[str] = None,
  607. keyword: Optional[str] = None,
  608. stockInNo: Optional[str] = None,
  609. db: Session = Depends(get_db),
  610. user: dict = Depends(require_menu_code("production:my-material-usage:list")),
  611. ):
  612. """当前登录人的物料领用记录(手动出库领用人 / 任务使用填报人)。"""
  613. try:
  614. tenant_no = _get_tenant_no(db, user)
  615. items, total = mu_svc.list_my_usages(
  616. db,
  617. tenant_no,
  618. user_id=_get_current_user_id(user),
  619. nick_name=(user.get("real_name") or "").strip(),
  620. page=page,
  621. page_size=pageSize,
  622. material_id=materialId,
  623. material_type=materialType,
  624. change_type=changeType,
  625. start_time=startTime,
  626. end_time=endTime,
  627. keyword=keyword,
  628. stock_in_no=stockInNo,
  629. )
  630. return success_response(_page_payload(items, total, page, pageSize))
  631. except PermissionError as e:
  632. return error_response(403, str(e))
  633. except Exception as e:
  634. return error_response(400, str(e))
  635. @router.get("/production-material-usages/{usage_id}/linked-step-usages")
  636. async def list_production_material_linked_step_usages(
  637. usage_id: str,
  638. db: Session = Depends(get_db),
  639. user: dict = Depends(
  640. require_any_menu_code("production:material:list", "production:my-material-usage:list")
  641. ),
  642. ):
  643. """领用记录下关联的消耗明细(生产任务 step_save / 配制 prep_consume)。"""
  644. try:
  645. tenant_no = _get_tenant_no(db, user)
  646. items = mu_svc.list_linked_step_usages(
  647. db, usage_id, tenant_no, user_info=user
  648. )
  649. return success_response(items)
  650. except HTTPException as e:
  651. detail = e.detail
  652. msg = detail if isinstance(detail, str) else str(detail)
  653. return error_response(e.status_code, msg)
  654. except PermissionError as e:
  655. return error_response(403, str(e))
  656. except Exception as e:
  657. return error_response(400, str(e))
  658. # ---------- 库存盘点 ----------
  659. class StockCheckCreateBody(BaseModel):
  660. model_config = ConfigDict(populate_by_name=True)
  661. title: str
  662. check_time: Optional[str] = Field(None, alias="checkTime")
  663. remark: Optional[str] = None
  664. class StockCheckUnusedLineItem(BaseModel):
  665. model_config = ConfigDict(populate_by_name=True)
  666. usage_id: Optional[str] = Field(None, alias="usageId")
  667. batch_id: Optional[str] = Field(None, alias="batchId")
  668. material_code: Optional[str] = Field(None, alias="materialCode")
  669. material_name: str = Field(..., alias="materialName")
  670. spec_model: Optional[str] = Field(None, alias="specModel")
  671. unit: Optional[str] = None
  672. stock_in_no: Optional[str] = Field(None, alias="stockInNo")
  673. stock_in_date: Optional[str] = Field(None, alias="stockInDate")
  674. expire_date: Optional[str] = Field(None, alias="expireDate")
  675. catalog_no: Optional[str] = Field(None, alias="catalogNo")
  676. batch_no: Optional[str] = Field(None, alias="batchNo")
  677. manufacturer: Optional[str] = None
  678. unit_price: Optional[float] = Field(None, alias="unitPrice")
  679. holder: Optional[str] = None
  680. unused_qty: float = Field(..., alias="unusedQty", gt=0)
  681. remark: Optional[str] = None
  682. class StockCheckUnusedLinesBody(BaseModel):
  683. model_config = ConfigDict(populate_by_name=True)
  684. items: List[StockCheckUnusedLineItem] = Field(default_factory=list)
  685. @router.get("/production-material-stock-check-requisitions")
  686. async def list_production_material_stock_check_requisitions(
  687. page: int = Query(1, ge=1),
  688. pageSize: int = Query(50, ge=1, le=200),
  689. keyword: Optional[str] = None,
  690. db: Session = Depends(get_db),
  691. user: dict = Depends(require_menu_code("production:material:list")),
  692. ):
  693. """盘点登记:待处理领用记录(未标记已使用完)。"""
  694. try:
  695. tenant_no = _get_tenant_no(db, user)
  696. items, total = mu_svc.list_stock_check_requisitions(
  697. db,
  698. tenant_no,
  699. page=page,
  700. page_size=pageSize,
  701. keyword=keyword,
  702. user_info=user,
  703. )
  704. return success_response(_page_payload(items, total, page, pageSize))
  705. except PermissionError as e:
  706. return error_response(403, str(e))
  707. except Exception as e:
  708. return error_response(400, str(e))
  709. @router.get("/production-material-stock-checks")
  710. async def list_production_material_stock_checks(
  711. page: int = Query(1, ge=1),
  712. pageSize: int = Query(10, ge=1, le=100),
  713. keyword: Optional[str] = None,
  714. status: Optional[str] = None,
  715. startTime: Optional[str] = None,
  716. endTime: Optional[str] = None,
  717. db: Session = Depends(get_db),
  718. user: dict = Depends(require_menu_code("production:material:list")),
  719. ):
  720. try:
  721. tenant_no = _get_tenant_no(db, user)
  722. items, total = stock_check_svc.list_checks(
  723. db,
  724. tenant_no,
  725. page=page,
  726. page_size=pageSize,
  727. keyword=keyword,
  728. status=status,
  729. start_time=startTime,
  730. end_time=endTime,
  731. user_info=user,
  732. )
  733. return success_response(_page_payload(items, total, page, pageSize))
  734. except PermissionError as e:
  735. return error_response(403, str(e))
  736. except Exception as e:
  737. return error_response(400, str(e))
  738. @router.post("/production-material-stock-checks")
  739. async def create_production_material_stock_check(
  740. body: StockCheckCreateBody,
  741. db: Session = Depends(get_db),
  742. user: dict = Depends(require_menu_code("production:material:list")),
  743. ):
  744. try:
  745. tenant_no = _get_tenant_no(db, user)
  746. user_id = _get_current_user_id(user)
  747. dept_id = user.get("deptId") or user.get("dept_id") or ""
  748. res = stock_check_svc.create_check(
  749. db,
  750. title=body.title,
  751. tenant_no=tenant_no,
  752. user_id=user_id,
  753. dept_id=dept_id,
  754. check_time=body.check_time,
  755. remark=body.remark,
  756. )
  757. return success_response(res, message="盘点单创建成功")
  758. except ValueError as e:
  759. return error_response(400, str(e))
  760. except Exception as e:
  761. return error_response(400, str(e))
  762. @router.get("/production-material-stock-checks/{check_id}")
  763. async def get_production_material_stock_check(
  764. check_id: str,
  765. db: Session = Depends(get_db),
  766. user: dict = Depends(require_menu_code("production:material:list")),
  767. ):
  768. try:
  769. tenant_no = _get_tenant_no(db, user)
  770. data = stock_check_svc.get_check_detail(db, check_id, tenant_no)
  771. return success_response(data)
  772. except ValueError as e:
  773. return error_response(400, str(e))
  774. except Exception as e:
  775. return error_response(400, str(e))
  776. @router.put("/production-material-stock-checks/{check_id}/unused-lines")
  777. async def save_production_material_stock_check_unused_lines(
  778. check_id: str,
  779. body: StockCheckUnusedLinesBody,
  780. db: Session = Depends(get_db),
  781. user: dict = Depends(require_menu_code("production:material:list")),
  782. ):
  783. try:
  784. tenant_no = _get_tenant_no(db, user)
  785. user_id = _get_current_user_id(user)
  786. dept_id = user.get("deptId") or user.get("dept_id") or ""
  787. count = stock_check_svc.save_unused_lines(
  788. db,
  789. check_id,
  790. items=[x.model_dump(by_alias=False) for x in body.items],
  791. tenant_no=tenant_no,
  792. user_id=user_id,
  793. dept_id=dept_id,
  794. )
  795. return success_response({"count": count}, message="登记保存成功")
  796. except ValueError as e:
  797. return error_response(400, str(e))
  798. except Exception as e:
  799. return error_response(400, str(e))
  800. @router.post("/production-material-stock-checks/{check_id}/complete")
  801. async def complete_production_material_stock_check(
  802. check_id: str,
  803. db: Session = Depends(get_db),
  804. user: dict = Depends(require_menu_code("production:material:list")),
  805. ):
  806. try:
  807. tenant_no = _get_tenant_no(db, user)
  808. user_id = _get_current_user_id(user)
  809. stock_check_svc.complete_check(db, check_id, tenant_no=tenant_no, user_id=user_id)
  810. return success_response(None, message="盘点已完成")
  811. except ValueError as e:
  812. return error_response(400, str(e))
  813. except Exception as e:
  814. return error_response(400, str(e))
  815. @router.post("/production-material-stock-checks/{check_id}/cancel")
  816. async def cancel_production_material_stock_check(
  817. check_id: str,
  818. db: Session = Depends(get_db),
  819. user: dict = Depends(require_menu_code("production:material:list")),
  820. ):
  821. try:
  822. tenant_no = _get_tenant_no(db, user)
  823. user_id = _get_current_user_id(user)
  824. stock_check_svc.cancel_check(db, check_id, tenant_no=tenant_no, user_id=user_id)
  825. return success_response(None, message="盘点已作废")
  826. except ValueError as e:
  827. return error_response(400, str(e))
  828. except Exception as e:
  829. return error_response(400, str(e))
  830. # ---------- 配置试剂配方 ----------
  831. class FormulaLineBody(BaseModel):
  832. model_config = ConfigDict(populate_by_name=True)
  833. component_material_id: str = Field(..., alias="componentMaterialId")
  834. component_material_type: str = Field(..., alias="componentMaterialType")
  835. standard_qty: float = Field(..., gt=0, alias="standardQty")
  836. usage_unit: Optional[str] = Field(None, alias="usageUnit")
  837. remark: Optional[str] = None
  838. class FormulaReplaceBody(BaseModel):
  839. model_config = ConfigDict(populate_by_name=True)
  840. lines: List[FormulaLineBody] = Field(default_factory=list)
  841. @router.get("/production-materials/{material_id}/formula")
  842. async def get_production_material_formula(
  843. material_id: str,
  844. db: Session = Depends(get_db),
  845. user: dict = Depends(require_menu_code("production:material:list")),
  846. ):
  847. try:
  848. tenant_no = _get_tenant_no(db, user)
  849. items = formula_svc.get_formula(db, material_id, tenant_no)
  850. return success_response(items)
  851. except ValueError as e:
  852. return error_response(400, str(e))
  853. except Exception as e:
  854. return error_response(400, str(e))
  855. @router.put("/production-materials/{material_id}/formula")
  856. async def replace_production_material_formula(
  857. material_id: str,
  858. body: FormulaReplaceBody,
  859. db: Session = Depends(get_db),
  860. user: dict = Depends(require_menu_code("production:material:list")),
  861. ):
  862. try:
  863. tenant_no = _get_tenant_no(db, user)
  864. user_id = _get_current_user_id(user)
  865. dept_id = user.get("deptId") or user.get("dept_id") or ""
  866. lines = [ln.model_dump(by_alias=True) for ln in body.lines]
  867. items = formula_svc.replace_formula(
  868. db,
  869. material_id,
  870. lines=lines,
  871. tenant_no=tenant_no,
  872. user_id=user_id,
  873. dept_id=dept_id,
  874. )
  875. return success_response(items, message="配方已保存")
  876. except ValueError as e:
  877. return error_response(400, str(e))
  878. except Exception as e:
  879. return error_response(400, str(e))
  880. # ---------- 配置试剂配制单 ----------
  881. class PrepLineBody(BaseModel):
  882. model_config = ConfigDict(populate_by_name=True)
  883. component_material_id: str = Field(..., alias="componentMaterialId")
  884. component_material_type: str = Field(..., alias="componentMaterialType")
  885. requisition_usage_id: str = Field(..., alias="requisitionUsageId")
  886. actual_qty: float = Field(..., gt=0, alias="actualQty")
  887. usage_unit: Optional[str] = Field(None, alias="usageUnit")
  888. remark: Optional[str] = None
  889. class PrepBody(BaseModel):
  890. model_config = ConfigDict(populate_by_name=True)
  891. configured_material_id: str = Field(..., alias="configuredMaterialId")
  892. prep_time: Optional[str] = Field(None, alias="prepTime")
  893. output_qty: float = Field(..., gt=0, alias="outputQty")
  894. output_unit: Optional[str] = Field(None, alias="outputUnit")
  895. batch_no: str = Field(..., alias="batchNo")
  896. storage_location: Optional[str] = Field(None, alias="storageLocation")
  897. expire_date: str = Field(..., alias="expireDate")
  898. remark: Optional[str] = None
  899. lines: List[PrepLineBody] = Field(..., min_length=1)
  900. class PrepUpdateBody(BaseModel):
  901. model_config = ConfigDict(populate_by_name=True)
  902. prep_time: Optional[str] = Field(None, alias="prepTime")
  903. output_qty: Optional[float] = Field(None, gt=0, alias="outputQty")
  904. output_unit: Optional[str] = Field(None, alias="outputUnit")
  905. batch_no: Optional[str] = Field(None, alias="batchNo")
  906. storage_location: Optional[str] = Field(None, alias="storageLocation")
  907. expire_date: Optional[str] = Field(None, alias="expireDate")
  908. remark: Optional[str] = None
  909. lines: Optional[List[PrepLineBody]] = None
  910. @router.get("/production-material-preps")
  911. async def list_production_material_preps(
  912. page: int = Query(1, ge=1),
  913. pageSize: int = Query(10, ge=1, le=100),
  914. keyword: Optional[str] = None,
  915. status: Optional[str] = None,
  916. configuredMaterialId: Optional[str] = None,
  917. db: Session = Depends(get_db),
  918. user: dict = Depends(require_menu_code("production:material:list")),
  919. ):
  920. try:
  921. tenant_no = _get_tenant_no(db, user)
  922. items, total = prep_svc.list_preps(
  923. db,
  924. tenant_no,
  925. page=page,
  926. page_size=pageSize,
  927. keyword=keyword,
  928. status=status,
  929. configured_material_id=configuredMaterialId,
  930. )
  931. return success_response(_page_payload(items, total, page, pageSize))
  932. except Exception as e:
  933. return error_response(400, str(e))
  934. class AvailableMaterialBatch(BaseModel):
  935. model_config = ConfigDict(populate_by_name=True)
  936. requisitionUsageId: str = Field(..., alias="requisitionUsageId")
  937. batchId: str = Field(..., alias="batchId")
  938. materialId: str = Field(..., alias="materialId")
  939. materialType: str = Field(..., alias="materialType")
  940. materialCode: str = Field(..., alias="materialCode")
  941. materialName: str = Field(..., alias="materialName")
  942. specModel: Optional[str] = Field(None, alias="specModel")
  943. unit: Optional[str] = None
  944. stockInNo: str = Field(..., alias="stockInNo")
  945. stockInDate: Optional[str] = Field(None, alias="stockInDate")
  946. expireDate: Optional[str] = Field(None, alias="expireDate")
  947. catalogNo: Optional[str] = Field(None, alias="catalogNo")
  948. batchNo: str = Field(..., alias="batchNo")
  949. manufacturer: str
  950. storageLocation: Optional[str] = Field(None, alias="storageLocation")
  951. requisitionQty: float = Field(..., alias="requisitionQty")
  952. unitPrice: Optional[float] = Field(None, alias="unitPrice")
  953. receiver: Optional[str] = None
  954. receiverName: Optional[str] = Field(None, alias="receiverName")
  955. useTime: Optional[str] = Field(None, alias="useTime")
  956. @router.get("/production-material-preps/available-materials")
  957. async def list_available_materials_for_prep(
  958. page: int = Query(1, ge=1),
  959. pageSize: int = Query(50, ge=1, le=200),
  960. keyword: Optional[str] = None,
  961. materialType: Optional[str] = None,
  962. db: Session = Depends(get_db),
  963. user: dict = Depends(require_menu_code("production:material:list")),
  964. ):
  965. """获取可用于配制的领用记录(有剩余、未标记已用完,须注册在 {prep_id} 路由之前)。"""
  966. try:
  967. tenant_no = _get_tenant_no(db, user)
  968. items, total = prep_svc.list_available_material_batches(
  969. db,
  970. tenant_no,
  971. page=page,
  972. page_size=pageSize,
  973. keyword=keyword,
  974. material_type=materialType,
  975. user_info=user,
  976. )
  977. return success_response(_page_payload(items, total, page, pageSize))
  978. except Exception as e:
  979. return error_response(400, str(e))
  980. @router.get("/production-material-preps/{prep_id}")
  981. async def get_production_material_prep(
  982. prep_id: str,
  983. db: Session = Depends(get_db),
  984. user: dict = Depends(require_menu_code("production:material:list")),
  985. ):
  986. try:
  987. tenant_no = _get_tenant_no(db, user)
  988. data = prep_svc.get_prep_detail(db, prep_id, tenant_no)
  989. return success_response(data)
  990. except ValueError as e:
  991. return error_response(400, str(e))
  992. except Exception as e:
  993. return error_response(400, str(e))
  994. @router.post("/production-material-preps")
  995. async def create_production_material_prep(
  996. body: PrepBody,
  997. db: Session = Depends(get_db),
  998. user: dict = Depends(require_menu_code("production:material:list")),
  999. ):
  1000. try:
  1001. tenant_no = _get_tenant_no(db, user)
  1002. user_id = _get_current_user_id(user)
  1003. dept_id = user.get("deptId") or user.get("dept_id") or ""
  1004. data = prep_svc.create_prep(
  1005. db,
  1006. data=body.model_dump(by_alias=True),
  1007. tenant_no=tenant_no,
  1008. user_id=user_id,
  1009. dept_id=dept_id,
  1010. )
  1011. return success_response(data, message="配制单已创建")
  1012. except ValueError as e:
  1013. return error_response(400, str(e))
  1014. except Exception as e:
  1015. return error_response(400, str(e))
  1016. @router.put("/production-material-preps/{prep_id}")
  1017. async def update_production_material_prep(
  1018. prep_id: str,
  1019. body: PrepUpdateBody,
  1020. db: Session = Depends(get_db),
  1021. user: dict = Depends(require_menu_code("production:material:list")),
  1022. ):
  1023. try:
  1024. tenant_no = _get_tenant_no(db, user)
  1025. user_id = _get_current_user_id(user)
  1026. dept_id = user.get("deptId") or user.get("dept_id") or ""
  1027. data = prep_svc.update_prep(
  1028. db,
  1029. prep_id,
  1030. data=body.model_dump(by_alias=True, exclude_none=True),
  1031. tenant_no=tenant_no,
  1032. user_id=user_id,
  1033. dept_id=dept_id,
  1034. )
  1035. return success_response(data, message="配制单已更新")
  1036. except ValueError as e:
  1037. return error_response(400, str(e))
  1038. except Exception as e:
  1039. return error_response(400, str(e))
  1040. @router.post("/production-material-preps/{prep_id}/complete")
  1041. async def complete_production_material_prep(
  1042. prep_id: str,
  1043. db: Session = Depends(get_db),
  1044. user: dict = Depends(require_menu_code("production:material:list")),
  1045. ):
  1046. try:
  1047. tenant_no = _get_tenant_no(db, user)
  1048. user_id = _get_current_user_id(user)
  1049. dept_id = user.get("deptId") or user.get("dept_id") or ""
  1050. data = prep_svc.complete_prep(
  1051. db,
  1052. prep_id,
  1053. tenant_no=tenant_no,
  1054. user_id=user_id,
  1055. dept_id=dept_id,
  1056. )
  1057. return success_response(data, message="配制已完成")
  1058. except ValueError as e:
  1059. return error_response(400, str(e))
  1060. except Exception as e:
  1061. return error_response(400, str(e))
  1062. @router.post("/production-material-preps/{prep_id}/cancel")
  1063. async def cancel_production_material_prep(
  1064. prep_id: str,
  1065. db: Session = Depends(get_db),
  1066. user: dict = Depends(require_menu_code("production:material:list")),
  1067. ):
  1068. try:
  1069. tenant_no = _get_tenant_no(db, user)
  1070. user_id = _get_current_user_id(user)
  1071. prep_svc.cancel_prep(db, prep_id, tenant_no=tenant_no, user_id=user_id)
  1072. return success_response(None, message="配制单已作废")
  1073. except ValueError as e:
  1074. return error_response(400, str(e))
  1075. except Exception as e:
  1076. return error_response(400, str(e))
  1077. @router.delete("/production-material-preps/{prep_id}")
  1078. async def delete_production_material_prep(
  1079. prep_id: str,
  1080. db: Session = Depends(get_db),
  1081. user: dict = Depends(require_menu_code("production:material:list")),
  1082. ):
  1083. try:
  1084. tenant_no = _get_tenant_no(db, user)
  1085. user_id = _get_current_user_id(user)
  1086. prep_svc.delete_prep(db, prep_id, tenant_no=tenant_no, user_id=user_id)
  1087. return success_response(None, message="配制单已删除")
  1088. except ValueError as e:
  1089. return error_response(400, str(e))
  1090. except Exception as e:
  1091. return error_response(400, str(e))
  1092. # ---------- 一次性配制(简化流程)----------
  1093. class QuickPrepLineBody(BaseModel):
  1094. model_config = ConfigDict(populate_by_name=True)
  1095. requisitionUsageId: str = Field(..., alias="requisitionUsageId")
  1096. componentMaterialId: str = Field(..., alias="componentMaterialId")
  1097. componentMaterialType: str = Field(..., alias="componentMaterialType")
  1098. batchId: Optional[str] = Field(None, alias="batchId")
  1099. consumptionQty: float = Field(..., gt=0, alias="consumptionQty")
  1100. usageUnit: Optional[str] = Field(None, alias="usageUnit")
  1101. remark: Optional[str] = None
  1102. class QuickPrepBody(BaseModel):
  1103. model_config = ConfigDict(populate_by_name=True)
  1104. configuredMaterialName: str = Field(..., max_length=128, alias="configuredMaterialName")
  1105. outputQty: float = Field(..., gt=0, alias="outputQty")
  1106. lines: List[QuickPrepLineBody] = Field(..., min_length=1)
  1107. specModel: Optional[str] = Field(None, alias="specModel")
  1108. outputUnit: Optional[str] = Field(None, alias="outputUnit")
  1109. stockInNo: Optional[str] = Field(None, alias="stockInNo")
  1110. stockInDate: Optional[str] = Field(None, alias="stockInDate")
  1111. catalogNo: Optional[str] = Field(None, alias="catalogNo")
  1112. batchNo: Optional[str] = Field(None, alias="batchNo")
  1113. manufacturer: Optional[str] = Field(None, alias="manufacturer")
  1114. storageLocation: Optional[str] = Field(None, alias="storageLocation")
  1115. expireDate: Optional[str] = Field(None, alias="expireDate")
  1116. remark: Optional[str] = None
  1117. @router.post("/production-material-preps/quick")
  1118. async def quick_production_material_prep(
  1119. body: QuickPrepBody,
  1120. db: Session = Depends(get_db),
  1121. user: dict = Depends(require_menu_code("production:material:list")),
  1122. ):
  1123. """一次性配制:提交即完成(子物料扣减 → 配置试剂入库 → 自动出库)。"""
  1124. try:
  1125. tenant_no = _get_tenant_no(db, user)
  1126. user_id = _get_current_user_id(user)
  1127. dept_id = user.get("deptId") or user.get("dept_id") or ""
  1128. data = prep_svc.create_quick_prep(
  1129. db,
  1130. data=body.model_dump(by_alias=True),
  1131. tenant_no=tenant_no,
  1132. user_id=user_id,
  1133. dept_id=dept_id,
  1134. )
  1135. return success_response(data, message="配置试剂配制完成")
  1136. except ValueError as e:
  1137. return error_response(400, str(e))
  1138. except Exception as e:
  1139. return error_response(400, str(e))
  1140. @router.get("/production-material-consumptions")
  1141. async def list_production_material_consumptions(
  1142. page: int = Query(1, ge=1),
  1143. pageSize: int = Query(10, ge=1, le=100),
  1144. keyword: Optional[str] = None,
  1145. materialType: Optional[str] = None,
  1146. startTime: Optional[str] = None,
  1147. endTime: Optional[str] = None,
  1148. db: Session = Depends(get_db),
  1149. user: dict = Depends(require_menu_code("production:material:list")),
  1150. ):
  1151. """查询配制消耗明细(子物料消耗记录)。"""
  1152. try:
  1153. tenant_no = _get_tenant_no(db, user)
  1154. items, total = mu_svc.list_prep_consumptions(
  1155. db,
  1156. tenant_no,
  1157. page=page,
  1158. page_size=pageSize,
  1159. keyword=keyword,
  1160. material_type=materialType,
  1161. start_time=startTime,
  1162. end_time=endTime,
  1163. )
  1164. return success_response(_page_payload(items, total, page, pageSize))
  1165. except Exception as e:
  1166. return error_response(400, str(e))
  1167. @router.get("/my-production-material-consumptions")
  1168. async def list_my_production_material_consumptions(
  1169. page: int = Query(1, ge=1),
  1170. pageSize: int = Query(10, ge=1, le=100),
  1171. keyword: Optional[str] = None,
  1172. materialType: Optional[str] = None,
  1173. startTime: Optional[str] = None,
  1174. endTime: Optional[str] = None,
  1175. db: Session = Depends(get_db),
  1176. user: dict = Depends(require_menu_code("production:my-material-usage:list")),
  1177. ):
  1178. """当前登录人的配制消耗明细(我的物料领用记录 - 消耗明细 Tab)。"""
  1179. try:
  1180. tenant_no = _get_tenant_no(db, user)
  1181. items, total = mu_svc.list_my_prep_consumptions(
  1182. db,
  1183. tenant_no,
  1184. user_id=_get_current_user_id(user),
  1185. nick_name=(user.get("real_name") or "").strip(),
  1186. page=page,
  1187. page_size=pageSize,
  1188. keyword=keyword,
  1189. material_type=materialType,
  1190. start_time=startTime,
  1191. end_time=endTime,
  1192. )
  1193. return success_response(_page_payload(items, total, page, pageSize))
  1194. except Exception as e:
  1195. return error_response(400, str(e))