invoice.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import mimetypes
  2. from typing import Any, List, Optional, Set
  3. from fastapi import APIRouter, Depends, HTTPException, Request, status, Query
  4. from pydantic import BaseModel, ConfigDict, Field
  5. from sqlalchemy.orm import Session
  6. from starlette.datastructures import UploadFile as StarletteUploadFile
  7. from app.api.bx.export_common import (
  8. EXPORT_MAX_ROWS,
  9. ExportColumn,
  10. ExportValidationError,
  11. BxExportBodyBase,
  12. dump_export_row,
  13. respond_xlsx,
  14. )
  15. from app.core.bx_auth import BxUserContext, get_bx_current_user
  16. from app.core.tenant_context import get_tenant_no
  17. from app.core.database import get_db
  18. from app.models.bx_models import BxInvoice
  19. from app.schemas.bx.common import ApiResponse, PagedData
  20. from app.schemas.bx.invoice import InvoiceOut, LedgerOut, WalletSummaryOut
  21. from app.services.bx.invoice import InvoiceService
  22. from app.services.bx.invoice_list_filters import (
  23. build_invoice_column_filters,
  24. build_ledger_column_filters,
  25. build_wallet_column_filters,
  26. )
  27. from app.services.bx.list_sort import order_invoice, order_ledger, parse_sort_params
  28. router = APIRouter(tags=["发票夹"])
  29. def _merge_query(request: Request, **known) -> dict:
  30. out = {k: v for k, v in known.items() if v is not None and v != ""}
  31. for k, v in request.query_params.multi_items():
  32. if k in ("page", "pageSize"):
  33. continue
  34. if k not in out and v not in (None, ""):
  35. out[k] = v
  36. return out
  37. def _param_get(params: dict, *keys: str) -> Any:
  38. for key in keys:
  39. if key in params and params[key] not in (None, ""):
  40. return params[key]
  41. return None
  42. def _wallet_filters_from_params(params: dict) -> list:
  43. return build_wallet_column_filters(
  44. keyword=str(_param_get(params, "keyword") or ""),
  45. owner_user_name=_param_get(params, "ownerUserName", "owner_user_name"),
  46. total_amount_op=_param_get(params, "totalAmountOp", "total_amount_op"),
  47. total_amount=_param_get(params, "totalAmount", "total_amount"),
  48. total_amount_min=_param_get(params, "totalAmountMin", "total_amount_min"),
  49. total_amount_max=_param_get(params, "totalAmountMax", "total_amount_max"),
  50. used_amount_op=_param_get(params, "usedAmountOp", "used_amount_op"),
  51. used_amount=_param_get(params, "usedAmount", "used_amount"),
  52. used_amount_min=_param_get(params, "usedAmountMin", "used_amount_min"),
  53. used_amount_max=_param_get(params, "usedAmountMax", "used_amount_max"),
  54. balance_op=_param_get(params, "balanceOp", "balance_op"),
  55. balance=_param_get(params, "balance"),
  56. balance_min=_param_get(params, "balanceMin", "balance_min"),
  57. balance_max=_param_get(params, "balanceMax", "balance_max"),
  58. invoice_count_op=_param_get(params, "invoiceCountOp", "invoice_count_op"),
  59. invoice_count=_param_get(params, "invoiceCount", "invoice_count"),
  60. invoice_count_min=_param_get(params, "invoiceCountMin", "invoice_count_min"),
  61. invoice_count_max=_param_get(params, "invoiceCountMax", "invoice_count_max"),
  62. )
  63. def _invoice_filters_from_params(params: dict, *, tenant_no: str = "") -> list:
  64. return build_invoice_column_filters(
  65. tenant_no=tenant_no,
  66. keyword=str(_param_get(params, "keyword") or ""),
  67. amount_op=_param_get(params, "amountOp", "amount_op"),
  68. amount=_param_get(params, "amount"),
  69. amount_min=_param_get(params, "amountMin", "amount_min"),
  70. amount_max=_param_get(params, "amountMax", "amount_max"),
  71. remark=_param_get(params, "remark"),
  72. remark_in=_param_get(params, "remarkIn", "remark_in"),
  73. upload_by_name=_param_get(params, "uploadByName", "upload_by_name"),
  74. upload_by_name_in=_param_get(params, "uploadByNameIn", "upload_by_name_in"),
  75. create_time_from=_param_get(params, "createTimeFrom", "create_time_from"),
  76. create_time_to=_param_get(params, "createTimeTo", "create_time_to"),
  77. )
  78. def _ledger_filters_from_params(params: dict) -> list:
  79. return build_ledger_column_filters(
  80. keyword=str(_param_get(params, "keyword") or ""),
  81. entry_type=_param_get(params, "entryType", "entry_type"),
  82. entry_type_in=_param_get(params, "entryTypeIn", "entry_type_in"),
  83. amount_op=_param_get(params, "amountOp", "amount_op"),
  84. amount=_param_get(params, "amount"),
  85. amount_min=_param_get(params, "amountMin", "amount_min"),
  86. amount_max=_param_get(params, "amountMax", "amount_max"),
  87. remark=_param_get(params, "remark"),
  88. create_time_from=_param_get(params, "createTimeFrom", "create_time_from"),
  89. create_time_to=_param_get(params, "createTimeTo", "create_time_to"),
  90. )
  91. def _body_to_params(body: BxExportBodyBase) -> dict:
  92. data = body.model_dump(by_alias=True, exclude_none=True)
  93. extra = getattr(body, "model_extra", None) or {}
  94. for key, value in extra.items():
  95. if key not in data and value not in (None, ""):
  96. data[key] = value
  97. return data
  98. class InvoiceColumnFacetBody(BaseModel):
  99. """发票档案列头可选值(去重;可附带列表上下文)"""
  100. model_config = ConfigDict(populate_by_name=True, extra="allow")
  101. field: str = Field(..., description="remark | uploadByName")
  102. user_id: Optional[str] = Field(
  103. default=None,
  104. alias="userId",
  105. description="钱包归属人;省略则当前用户(与发票列表一致)",
  106. )
  107. keyword: Optional[str] = Field(default=None, description="可选值关键字")
  108. limit: Optional[int] = Field(default=500, ge=1, le=500, description="返回条数上限")
  109. _INVOICE_FACET_COLUMNS = {
  110. "remark": BxInvoice.remark,
  111. "uploadByName": BxInvoice.upload_by_name,
  112. }
  113. @router.post("/invoices/column-facet-values", response_model=ApiResponse[dict])
  114. def invoice_column_facet_values(
  115. body: InvoiceColumnFacetBody,
  116. db: Session = Depends(get_db),
  117. user: BxUserContext = Depends(get_bx_current_user),
  118. ):
  119. """发票档案列头可选值:按钱包归属人 + 列表上下文(排除本列)去重。"""
  120. from app.core.tenant_context import get_tenant_no
  121. from app.services.column_facet import list_column_facet_values
  122. from app.services.pl.facet_context import strip_simple_facet_self_params
  123. column = _INVOICE_FACET_COLUMNS.get(body.field)
  124. if column is None:
  125. raise HTTPException(status_code=400, detail="不支持的列")
  126. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  127. list_keyword = (body.keyword or "").strip() or None
  128. params = strip_simple_facet_self_params(_body_to_params(body), body.field)
  129. params.pop("userId", None)
  130. params.pop("user_id", None)
  131. params.pop("field", None)
  132. params.pop("limit", None)
  133. if list_keyword:
  134. params["keyword"] = list_keyword
  135. owner_id = InvoiceService._resolve_owner_for_query(
  136. db, user, body.user_id or _param_get(params, "userId", "user_id")
  137. )
  138. extra_filters = list(_invoice_filters_from_params(params, tenant_no=tenant_no))
  139. extra_filters.append(BxInvoice.owner_user_id == owner_id)
  140. rows = list_column_facet_values(
  141. db,
  142. BxInvoice,
  143. column,
  144. tenant_no=tenant_no,
  145. keyword=None,
  146. limit=body.limit if body.limit is not None else 500,
  147. extra_filters=extra_filters or None,
  148. )
  149. return ApiResponse(data={"list": rows})
  150. @router.get("/invoice-wallets/mine/summary", response_model=ApiResponse[WalletSummaryOut])
  151. def get_mine_wallet_summary(
  152. userId: Optional[str] = None,
  153. db: Session = Depends(get_db),
  154. user: BxUserContext = Depends(get_bx_current_user),
  155. ):
  156. data = InvoiceService.get_mine_summary(db, user=user, owner_user_id=userId)
  157. return ApiResponse(data=WalletSummaryOut.model_validate(data))
  158. @router.get("/invoice-wallets", response_model=ApiResponse[PagedData[WalletSummaryOut]])
  159. def list_invoice_wallets(
  160. request: Request,
  161. page: int = 1,
  162. pageSize: int = Query(10, ge=1, le=500),
  163. keyword: str = "",
  164. db: Session = Depends(get_db),
  165. user: BxUserContext = Depends(get_bx_current_user),
  166. ):
  167. params = _merge_query(request, keyword=keyword or None)
  168. extra_filters = _wallet_filters_from_params(params)
  169. rows, total = InvoiceService.list_wallets(
  170. db,
  171. user=user,
  172. page=page,
  173. page_size=pageSize,
  174. keyword="",
  175. extra_filters=extra_filters or None,
  176. )
  177. out_rows = [WalletSummaryOut.model_validate(InvoiceService._summary_to_dict(r)) for r in rows]
  178. return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
  179. WALLET_EXPORT_COLUMNS = [
  180. ExportColumn(title="员工", field="ownerUserName"),
  181. ExportColumn(title="总额(元)", field="totalAmount"),
  182. ExportColumn(title="已报销占用(元)", field="usedAmount"),
  183. ExportColumn(title="余额(元)", field="balance"),
  184. ExportColumn(title="发票张数", field="invoiceCount"),
  185. ]
  186. class WalletExportBody(BxExportBodyBase):
  187. model_config = ConfigDict(populate_by_name=True, extra="allow")
  188. keyword: str = ""
  189. @router.post("/invoice-wallets/export")
  190. def export_invoice_wallets(
  191. body: WalletExportBody,
  192. db: Session = Depends(get_db),
  193. user: BxUserContext = Depends(get_bx_current_user),
  194. ):
  195. try:
  196. tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
  197. params = _body_to_params(body)
  198. extra_filters = _wallet_filters_from_params(params)
  199. rows, _ = InvoiceService.list_wallets(
  200. db,
  201. user=user,
  202. page=1,
  203. page_size=EXPORT_MAX_ROWS,
  204. keyword="",
  205. extra_filters=extra_filters or None,
  206. )
  207. outs = [WalletSummaryOut.model_validate(InvoiceService._summary_to_dict(r)) for r in rows]
  208. if body.ids:
  209. id_set = {str(i) for i in body.ids}
  210. # wallet may key by ownerUserId
  211. outs = [o for o in outs if str(getattr(o, "owner_user_id", None) or getattr(o, "id", "")) in id_set or str(o.model_dump(by_alias=True).get("ownerUserId") or o.model_dump(by_alias=True).get("id") or "") in id_set]
  212. export_rows = [dump_export_row(o, status_fields=()) for o in outs]
  213. return respond_xlsx(
  214. db, tenant_no=tenant_no, sheet_title="发票夹", columns=WALLET_EXPORT_COLUMNS,
  215. rows=export_rows, filename_stem="发票夹汇总", requested_columns=body.columns,
  216. )
  217. except ExportValidationError as e:
  218. raise HTTPException(status_code=400, detail=str(e)) from e
  219. @router.get("/invoices", response_model=ApiResponse[PagedData[InvoiceOut]])
  220. def list_invoices(
  221. request: Request,
  222. page: int = 1,
  223. pageSize: int = Query(10, ge=1, le=500),
  224. userId: Optional[str] = None,
  225. keyword: str = "",
  226. db: Session = Depends(get_db),
  227. user: BxUserContext = Depends(get_bx_current_user),
  228. ):
  229. tenant_no = InvoiceService._tenant_no(db, user)
  230. params = _merge_query(request, userId=userId, keyword=keyword or None)
  231. extra_filters = _invoice_filters_from_params(params, tenant_no=tenant_no)
  232. sf, so = parse_sort_params(params)
  233. rows, total = InvoiceService.list_invoices(
  234. db,
  235. user=user,
  236. owner_user_id=userId,
  237. page=page,
  238. page_size=pageSize,
  239. extra_filters=extra_filters or None,
  240. order_by=order_invoice(sf, so),
  241. )
  242. out_rows = InvoiceService.out_rows_with_attachments(db, tenant_no, list(rows))
  243. return ApiResponse(
  244. data=PagedData(
  245. rows=[InvoiceOut.model_validate(r) for r in out_rows],
  246. total=total,
  247. page=page,
  248. page_size=pageSize,
  249. )
  250. )
  251. @router.post("/invoices", response_model=ApiResponse[InvoiceOut])
  252. async def create_invoice(
  253. request: Request,
  254. db: Session = Depends(get_db),
  255. user: BxUserContext = Depends(get_bx_current_user),
  256. ):
  257. """一次上传一条发票:多文件挂同一档案,金额为总额,只写一条额度流水。
  258. 使用 request.form().getlist,兼容只传 1 个文件时 FastAPI List[UploadFile] 的 422。
  259. """
  260. form = await request.form()
  261. amount_raw = form.get("amount")
  262. if amount_raw is None or str(amount_raw).strip() == "":
  263. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写金额")
  264. try:
  265. amount = float(amount_raw)
  266. except (TypeError, ValueError) as exc:
  267. raise HTTPException(
  268. status_code=status.HTTP_400_BAD_REQUEST, detail="金额格式不正确"
  269. ) from exc
  270. remark = str(form.get("remark") or "")
  271. target_raw = form.get("targetUserId")
  272. target_user_id = str(target_raw).strip() if target_raw not in (None, "") else None
  273. upload_files: List[StarletteUploadFile] = []
  274. seen_ids: Set[int] = set()
  275. for key in ("files", "file"):
  276. for item in form.getlist(key):
  277. if not isinstance(item, StarletteUploadFile):
  278. continue
  279. obj_id = id(item)
  280. if obj_id in seen_ids:
  281. continue
  282. seen_ids.add(obj_id)
  283. upload_files.append(item)
  284. if not upload_files:
  285. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择文件")
  286. file_payload = []
  287. for uf in upload_files:
  288. if not uf.filename:
  289. continue
  290. data = await uf.read()
  291. content_type = (
  292. uf.content_type
  293. or mimetypes.guess_type(uf.filename or "")[0]
  294. or "application/octet-stream"
  295. )
  296. file_payload.append((data, uf.filename or "unknown", content_type))
  297. if not file_payload:
  298. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择文件")
  299. try:
  300. invoice = InvoiceService.create(
  301. db,
  302. user=user,
  303. amount=amount,
  304. files=file_payload,
  305. remark=remark,
  306. target_user_id=target_user_id,
  307. )
  308. db.commit()
  309. db.refresh(invoice)
  310. except HTTPException:
  311. db.rollback()
  312. raise
  313. except Exception as exc:
  314. db.rollback()
  315. raise HTTPException(
  316. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="上传发票失败"
  317. ) from exc
  318. tenant_no = InvoiceService._tenant_no(db, user)
  319. out = InvoiceService.out_rows_with_attachments(db, tenant_no, [invoice])[0]
  320. return ApiResponse(data=InvoiceOut.model_validate(out))
  321. @router.delete("/invoices/{invoice_id}", response_model=ApiResponse[bool])
  322. def delete_invoice(
  323. invoice_id: str,
  324. db: Session = Depends(get_db),
  325. user: BxUserContext = Depends(get_bx_current_user),
  326. ):
  327. try:
  328. success = InvoiceService.delete(db, user=user, invoice_id=invoice_id)
  329. db.commit()
  330. except HTTPException:
  331. db.rollback()
  332. raise
  333. return ApiResponse(data=success)
  334. @router.get("/invoice-ledgers", response_model=ApiResponse[List[LedgerOut]])
  335. def list_invoice_ledgers(
  336. request: Request,
  337. userId: Optional[str] = None,
  338. keyword: str = "",
  339. db: Session = Depends(get_db),
  340. user: BxUserContext = Depends(get_bx_current_user),
  341. ):
  342. params = _merge_query(request, userId=userId, keyword=keyword or None)
  343. extra_filters = _ledger_filters_from_params(params)
  344. sf, so = parse_sort_params(params)
  345. rows = InvoiceService.list_ledgers(
  346. db,
  347. user=user,
  348. owner_user_id=userId,
  349. extra_filters=extra_filters or None,
  350. order_by=order_ledger(sf, so),
  351. )
  352. return ApiResponse(data=[LedgerOut.model_validate(r) for r in rows])