Prechádzať zdrojové kódy

feat: 增加回单下载

gatsby 3 týždňov pred
rodič
commit
5852f54e22

+ 94 - 19
backend/app/plugin/module_payment/account/controller.py

@@ -3,6 +3,7 @@ from typing import Annotated, Any, Optional, Dict
 
 from fastapi import APIRouter, Body, Depends, Path, Query
 from fastapi.responses import JSONResponse, StreamingResponse
+from redis.asyncio import Redis
 
 from app.api.v1.module_system.auth.schema import AuthSchema
 from app.common.response import ResponseSchema, SuccessResponse
@@ -10,6 +11,7 @@ from app.core.dependencies import AuthPermission
 from app.core.logger import log
 from app.core.router_class import OperationLogRoute
 from app.core.base_params import PaginationQueryParam
+from app.core.dependencies import redis_getter
 
 from .schema import (
     AccountAuthorizeApplySchema,
@@ -21,6 +23,9 @@ from .schema import (
     AccountQuerySchema,
     AccountTransferSchema,
     AccountWithdrawSchema,
+    ReceiptApplySchema,
+    ReceiptApplyOutSchema,
+    ReceiptQueryOutSchema,
     TransferListOutSchema,
     TransferOutSchema,
     TransferTaskOutSchema,
@@ -277,25 +282,95 @@ async def transfer_export_controller(
 #     return SuccessResponse(data=result, msg="申请资金回单成功")
 
 
-# @AccountRouter.get(
-#     "/receipt/{file_id}",
-#     summary="查询资金回单",
-#     description="查询资金业务回单状态",
-#     response_model=ResponseSchema[ReceiptQueryOutSchema],
-# )
-# async def receipt_query_controller(
-#     file_id: Annotated[str, Path(description="文件申请号")],
-#     auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:account:receipt"]))],
-#     enterprise_id: Annotated[str, Query(description="企业ID")] = None,
-# ) -> JSONResponse:
-#     """查询资金回单"""
-#     if not enterprise_id:
-#         enterprise_id = auth.enterprise_id or ""
-#     result = await AccountService.receipt_query_service(
-#         auth=auth, enterprise_id=enterprise_id, file_id=file_id
-#     )
-#     log.info(f"查询资金回单成功: {file_id}")
-#     return SuccessResponse(data=result, msg="查询资金回单成功")
+@AccountRouter.post(
+    "/receipt/apply",
+    summary="申请转账业务回单",
+    description="申请指定转账单号的业务回单,返回 file_id 用于查询状态",
+    response_model=ResponseSchema[ReceiptApplyOutSchema],
+)
+async def receipt_apply_controller(
+    data: ReceiptApplySchema,
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:account:receipt"]))],
+    redis: Annotated[Redis, Depends(redis_getter)],
+) -> JSONResponse:
+    """
+    申请转账业务回单
+    
+    调用: alipay.commerce.ec.trans.receipt.apply
+    
+    参数:
+    - enterprise_id: 企业ID
+    - order_no: 支付宝转账单号
+    """
+    file_id = await AccountService.apply_receipt_service(
+        auth=auth,
+        redis=redis,
+        data=data,
+    )
+    log.info(f"申请转账业务回单成功: order_no={data.order_no}, file_id={file_id}")
+    return SuccessResponse(data={"file_id": file_id}, msg="申请成功")
+
+
+@AccountRouter.get(
+    "/receipt/{enterprise_id}/{file_id}",
+    summary="查询回单状态",
+    description="查询转账回单的处理状态和下载链接",
+    response_model=ResponseSchema[ReceiptQueryOutSchema],
+)
+async def receipt_query_controller(
+    enterprise_id: Annotated[str, Path(description="企业ID")],
+    file_id: Annotated[str, Path(description="文件申请号")],
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:account:receipt"]))],
+) -> JSONResponse:
+    """
+    查询回单状态
+    
+    调用: alipay.commerce.ec.trans.receipt.query
+    
+    参数:
+    - file_id: 文件申请号(有效期2天)
+    """
+    result = await AccountService.query_receipt_service(
+        enterprise_id=enterprise_id,
+        file_id=file_id,
+    )
+    log.info(f"查询回单状态成功: file_id={file_id}, status={result['status']}")
+    return SuccessResponse(data=result, msg="查询成功")
+
+
+@AccountRouter.get(
+    "/receipt/download",
+    summary="获取回单下载链接",
+    description="获取回单下载链接(封装申请+查询,PROCESS状态直接返回,由前端轮询)",
+    response_model=ResponseSchema[ReceiptQueryOutSchema],
+)
+async def receipt_download_controller(
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:account:receipt"]))],
+    redis: Annotated[Redis, Depends(redis_getter)],
+    enterprise_id: Annotated[str, Query(description="企业ID")],
+    order_no: Annotated[str, Query(description="支付宝转账单号")],
+) -> JSONResponse:
+    """
+    获取回单下载链接
+    
+    封装完整流程:
+    1. 申请/获取 file_id(自动缓存)
+    2. 查询回单状态
+    3. 直接返回状态(PROCESS状态由前端主动查询)
+    """
+    file_id = await AccountService.apply_receipt_service(
+        auth=auth,
+        redis=redis,
+        data=ReceiptApplySchema(enterprise_id=enterprise_id, order_no=order_no),
+    )
+    
+    result = await AccountService.query_receipt_service(
+        enterprise_id=enterprise_id,
+        file_id=file_id,
+    )
+
+    log.info(f"获取回单下载链接: file_id={file_id}, status={result['status']}")
+    return SuccessResponse(data=result, msg="操作成功")
 
 
 # @AccountRouter.post(

+ 7 - 7
backend/app/plugin/module_payment/account/schema.py

@@ -152,13 +152,6 @@ class AccountWithdrawSchema(BaseModel):
     amount: Decimal = Field(max_digits=10, decimal_places=2, gt=0, description="提现金额")
 
 
-class ReceiptApplySchema(BaseModel):
-    """申请资金回单请求"""
-
-    enterprise_id: str = Field(description="企业ID")
-    order_no: str = Field(description="支付宝转账单号")
-
-
 class AccountQuerySchema(BaseModel):
     """资金专户查询请求"""
 
@@ -414,6 +407,13 @@ class WithdrawOutSchema(BaseModel):
     created_time: datetime = Field(description="创建时间")
 
 
+class ReceiptApplySchema(BaseModel):
+    """申请回单请求"""
+
+    enterprise_id: str = Field(description="企业ID")
+    order_no: str = Field(description="支付宝转账单号")
+
+
 class ReceiptApplyOutSchema(BaseModel):
     """申请回单响应"""
 

+ 124 - 0
backend/app/plugin/module_payment/account/service.py

@@ -27,6 +27,7 @@ from .schema import (
     AccountTransferSchema,
     AccountTransferOutSchema,
     AccountWithdrawSchema,
+    ReceiptApplySchema,
     TransferListOutSchema,
     TransferOutSchema,
     TenantTransferCreate,
@@ -654,6 +655,129 @@ class AccountService:
         return ExcelUtil.export_list2excel(list_data, mapping_dict)
 
 
+    @classmethod
+    async def apply_receipt_service(
+        cls,
+        auth: AuthSchema,
+        redis: Redis,
+        data: ReceiptApplySchema,
+    ) -> str:
+        """
+        申请转账业务回单
+        
+        调用: alipay.commerce.ec.trans.receipt.apply
+        
+        参数:
+        - enterprise_id: 企业ID
+        - order_no: 支付宝转账单号
+        
+        返回: file_id
+        """
+        from app.core.redis_crud import RedisCURD
+        
+        redis_crud = RedisCURD(redis)
+        
+        cache_key = f"receipt:{data.enterprise_id}:{data.order_no}"
+        cached_file_id = await redis_crud.get(cache_key)
+        
+        if cached_file_id:
+            log.info(f"使用缓存的 file_id: {cached_file_id}")
+            return cached_file_id
+        
+        crud = EnterpriseCRUD(auth)
+        enterprise = await crud.get_by_enterprise_id(data.enterprise_id)
+        if not enterprise:
+            raise CustomException(msg="企业不存在")
+        
+        from alipay.aop.api.request.AlipayCommerceEcTransReceiptApplyRequest import (
+            AlipayCommerceEcTransReceiptApplyRequest,
+        )
+        from alipay.aop.api.domain.AlipayCommerceEcTransReceiptApplyModel import (
+            AlipayCommerceEcTransReceiptApplyModel,
+        )
+        from alipay.aop.api.response.AlipayCommerceEcTransReceiptApplyResponse import (
+            AlipayCommerceEcTransReceiptApplyResponse,
+        )
+        
+        model = AlipayCommerceEcTransReceiptApplyModel()
+        model.enterprise_id = data.enterprise_id
+        model.order_no = data.order_no
+        
+        request = AlipayCommerceEcTransReceiptApplyRequest()
+        request.biz_model = model
+        
+        client = AlipayClient.get_client()
+        response = client.execute(request)
+        
+        if not response:
+            raise CustomException(msg="申请回单失败: 无响应")
+        
+        result = AlipayCommerceEcTransReceiptApplyResponse()
+        result.parse_response_content(response)
+        
+        if not result.is_success():
+            # 清除缓存
+            await redis_crud.delete(cache_key)
+            raise CustomException(msg=f"申请回单失败: {result.msg}")
+        
+        file_id = str(result.file_id)
+        await redis_crud.set(cache_key, file_id, expire=172800)
+        
+        log.info(f"申请回单成功: order_no={data.order_no}, file_id={file_id}")
+        return file_id
+
+
+    @classmethod
+    async def query_receipt_service(cls, enterprise_id: str, file_id: str) -> dict:
+        """
+        查询回单状态
+        
+        调用: alipay.commerce.ec.trans.receipt.query
+        
+        参数:
+        - file_id: 文件申请号
+        
+        返回: {file_id, status, download_url, error_message}
+        """   
+        from alipay.aop.api.request.AlipayCommerceEcTransReceiptQueryRequest import (
+            AlipayCommerceEcTransReceiptQueryRequest,
+        )
+        from alipay.aop.api.response.AlipayCommerceEcTransReceiptQueryResponse import (
+            AlipayCommerceEcTransReceiptQueryResponse,
+        )
+        from alipay.aop.api.domain.AlipayCommerceEcTransReceiptQueryModel import (
+            AlipayCommerceEcTransReceiptQueryModel,
+        )
+        
+        model = AlipayCommerceEcTransReceiptQueryModel()
+        model.enterprise_id = enterprise_id
+        model.file_id = file_id
+        
+        request = AlipayCommerceEcTransReceiptQueryRequest()
+        request.biz_model = model
+
+        client = AlipayClient.get_client()
+        response = client.execute(request)
+        
+        if not response:
+            raise CustomException(msg="查询回单失败: 无响应")
+        
+        result = AlipayCommerceEcTransReceiptQueryResponse()
+        result.parse_response_content(response)
+        
+        if not result.is_success():
+            raise CustomException(msg=f"查询回单失败: {result.msg}")
+        
+        data = {
+            "file_id": file_id,
+            "status": result.status,
+            "download_url": result.download_url,
+            "error_message": result.error_message,
+        }
+        
+        return data
+
+
     @classmethod
     async def update_transfer_status_service(
         cls,

+ 28 - 0
frontend/src/api/module_payment/account.ts

@@ -122,8 +122,36 @@ export const AccountAPI = {
       responseType: "blob",
     });
   },
+
+  downloadReceipt(params: {
+    enterprise_id: string;
+    order_no: string;
+  }) {
+    return request<ApiResponse<ReceiptQueryOutSchema>>({
+      url: `${API_PATH}/receipt/download`,
+      method: "get",
+      params,
+    });
+  },
+
+  queryReceipt(params: {
+    enterprise_id: string;
+    file_id: string;
+  }) {
+    return request<ApiResponse<ReceiptQueryOutSchema>>({
+      url: `${API_PATH}/receipt/${params.enterprise_id}/${params.file_id}`,
+      method: "get",
+    });
+  },
 };
 
+export interface ReceiptQueryOutSchema {
+  file_id: string;
+  status: string;
+  download_url?: string;
+  error_message?: string;
+}
+
 export interface TransferOutSchema {
   out_biz_no?: string;
   enterprise_id?: string;

+ 10 - 3
frontend/src/layouts/components/NavBar/components/EnterpriseSwitcher.vue

@@ -4,7 +4,7 @@
       <div class="enterprise-switcher__trigger">
         <el-icon><OfficeBuilding /></el-icon>
         <span class="enterprise-switcher__name">
-          {{ currentEnterprise?.name }}
+          {{ currentEnterpriseName || "-" }}
         </span>
         <el-icon class="enterprise-switcher__arrow"><ArrowDown /></el-icon>
       </div>
@@ -17,7 +17,10 @@
             :class="{ 'is-active': ent.enterprise_id === currentEnterprise?.enterprise_id }"
           >
             <div class="enterprise-item">
-              <span class="enterprise-item__name">{{ ent.name }}</span>
+              <div class="enterprise-item__row">
+                <!-- <span class="enterprise-item__label">名称:</span> -->
+                <span class="enterprise-item__value">{{ ent.name }} ({{ ent.enterprise_id }})</span>
+              </div>
               <el-icon v-if="ent.enterprise_id === currentEnterprise?.enterprise_id" class="enterprise-item__check">
                 <Check />
               </el-icon>
@@ -43,6 +46,10 @@ const currentEnterprise = computed(() => enterpriseStore.getCurrentEnterprise);
 const hasEnterprise = computed(() => enterpriseStore.hasEnterprise);
 const enterpriseList = computed(() => enterpriseStore.getEnterpriseList);
 
+const currentEnterpriseName = computed(() => {
+  if (!currentEnterprise.value) return "-";
+  return `${currentEnterprise.value.name} (${currentEnterprise.value.enterprise_id.slice(-4)})`;
+});
 
 // 在组件挂载或依赖变化时处理
 onMounted(() => {
@@ -101,7 +108,7 @@ function handleSwitch(enterpriseId: string) {
   &__name {
     margin-left: 8px;
     margin-right: 4px;
-    max-width: 120px;
+    max-width: 220px;
     overflow: hidden;
     text-overflow: ellipsis;
     white-space: nowrap;

+ 7 - 1
frontend/src/views/module_payment/account/components/AccountOverview.vue

@@ -146,7 +146,7 @@
           <div class="stat-item">
             <div class="stat-label">当前企业</div>
             <div class="stat-value text-truncate">
-              {{ currentEnterprise?.name || "-" }}
+              {{ currentEnterpriseName || "-" }}
             </div>
           </div>
         </el-card>
@@ -263,6 +263,12 @@ const currentEnterpriseId = computed(
 
 const currentEnterprise = computed(() => enterpriseStore.getCurrentEnterprise);
 
+// 展示企业名 + 企业ID后四位
+const currentEnterpriseName = computed(() => {
+  if (!currentEnterprise.value) return "-";
+  return `${currentEnterprise.value.name} (${currentEnterprise.value.enterprise_id.slice(-4)})`;
+});
+
 const authorizeStatus = computed(() =>
   accountData.value.account_book_id ? "AUTHORIZED" : "PENDING"
 );

+ 82 - 1
frontend/src/views/module_payment/account/index.vue

@@ -322,11 +322,21 @@
               </el-table-column>
               <el-table-column prop="order_title" label="转账标题" min-width="150" />
               <el-table-column prop="created_time" label="创建时间" min-width="160" />
-              <el-table-column label="操作" width="100" fixed="right">
+              <el-table-column label="操作" width="160" fixed="right">
                 <template #default="{ row }">
                   <el-button type="primary" link @click="handleViewTransferDetail(row.out_biz_no)">
                     详情
                   </el-button>
+                  <el-button
+                    type="primary"
+                    link
+                    icon="Download"
+                    @click="handleDownloadReceipt(row)"
+                    :disabled="row.status !== 'SUCCESS'"
+                    :title="row.status !== 'SUCCESS' ? '仅成功的转账可下载回单' : '下载回单'"
+                  >
+                    下载回单
+                  </el-button>
                 </template>
               </el-table-column>
             </el-table>
@@ -1267,6 +1277,77 @@ function handleViewTransferDetail(outBizNo: string) {
   transferDetailVisible.value = true;
 }
 
+function downloadFile(url: string) {
+  window.open(url, "_blank");
+}
+
+async function handleDownloadReceipt(row: any) {
+  const loadingText = ref("获取回单中...");
+  const loadingInstance = ElMessage({
+    message: loadingText.value,
+    type: "loading",
+    duration: 0,
+  });
+
+  try {
+    const res = await AccountAPI.downloadReceipt({
+      enterprise_id: currentEnterpriseId.value!,
+      order_no: row.order_no,
+    });
+
+    const { file_id, status, download_url, error_message } = res.data.data;
+
+    if (status === "SUCCESS" && download_url) {
+      downloadFile(download_url);
+      ElMessage.success("回单下载成功");
+    } else if (status === "FAIL") {
+      ElMessage.error(`回单生成失败: ${error_message || "未知错误"}`);
+    } else if (status === "PROCESS" || status === "INIT") {
+      await pollForReceipt(file_id);
+    } else {
+      ElMessage.info("回单状态未知,请稍后尝试");
+    }
+  } catch (error: any) {
+    ElMessage.error(error.message || "获取回单失败");
+  } finally {
+    loadingInstance.close();
+  }
+}
+
+async function pollForReceipt(file_id: string) {
+  const maxRetries = 3;
+  const retryInterval = 3000;
+  let retryCount = 0;
+
+  while (retryCount < maxRetries) {
+    await new Promise(resolve => setTimeout(resolve, retryInterval));
+    retryCount++;
+
+    try {
+      const res = await AccountAPI.queryReceipt({
+        enterprise_id: currentEnterpriseId.value!,
+        file_id,
+      });
+
+      const { status, download_url, error_message } = res.data.data;
+
+      if (status === "SUCCESS" && download_url) {
+        downloadFile(download_url);
+        ElMessage.success("回单下载成功");
+        return;
+      } else if (status === "FAIL") {
+        ElMessage.error(`回单生成失败: ${error_message || "未知错误"}`);
+        return;
+      }
+    } catch (error: any) {
+      ElMessage.error(`轮询失败: ${error.message}`);
+      return;
+    }
+  }
+
+  ElMessage.info("回单正在生成中,请稍后手动刷新页面重试");
+}
+
 function handleTransferDetailRefresh() {
   fetchTransferList();
 }