Explorar el Código

feat: 增加回单下载

gatsby hace 3 semanas
padre
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 import APIRouter, Body, Depends, Path, Query
 from fastapi.responses import JSONResponse, StreamingResponse
 from fastapi.responses import JSONResponse, StreamingResponse
+from redis.asyncio import Redis
 
 
 from app.api.v1.module_system.auth.schema import AuthSchema
 from app.api.v1.module_system.auth.schema import AuthSchema
 from app.common.response import ResponseSchema, SuccessResponse
 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.logger import log
 from app.core.router_class import OperationLogRoute
 from app.core.router_class import OperationLogRoute
 from app.core.base_params import PaginationQueryParam
 from app.core.base_params import PaginationQueryParam
+from app.core.dependencies import redis_getter
 
 
 from .schema import (
 from .schema import (
     AccountAuthorizeApplySchema,
     AccountAuthorizeApplySchema,
@@ -21,6 +23,9 @@ from .schema import (
     AccountQuerySchema,
     AccountQuerySchema,
     AccountTransferSchema,
     AccountTransferSchema,
     AccountWithdrawSchema,
     AccountWithdrawSchema,
+    ReceiptApplySchema,
+    ReceiptApplyOutSchema,
+    ReceiptQueryOutSchema,
     TransferListOutSchema,
     TransferListOutSchema,
     TransferOutSchema,
     TransferOutSchema,
     TransferTaskOutSchema,
     TransferTaskOutSchema,
@@ -277,25 +282,95 @@ async def transfer_export_controller(
 #     return SuccessResponse(data=result, msg="申请资金回单成功")
 #     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(
 # @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="提现金额")
     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):
 class AccountQuerySchema(BaseModel):
     """资金专户查询请求"""
     """资金专户查询请求"""
 
 
@@ -414,6 +407,13 @@ class WithdrawOutSchema(BaseModel):
     created_time: datetime = Field(description="创建时间")
     created_time: datetime = Field(description="创建时间")
 
 
 
 
+class ReceiptApplySchema(BaseModel):
+    """申请回单请求"""
+
+    enterprise_id: str = Field(description="企业ID")
+    order_no: str = Field(description="支付宝转账单号")
+
+
 class ReceiptApplyOutSchema(BaseModel):
 class ReceiptApplyOutSchema(BaseModel):
     """申请回单响应"""
     """申请回单响应"""
 
 

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

@@ -27,6 +27,7 @@ from .schema import (
     AccountTransferSchema,
     AccountTransferSchema,
     AccountTransferOutSchema,
     AccountTransferOutSchema,
     AccountWithdrawSchema,
     AccountWithdrawSchema,
+    ReceiptApplySchema,
     TransferListOutSchema,
     TransferListOutSchema,
     TransferOutSchema,
     TransferOutSchema,
     TenantTransferCreate,
     TenantTransferCreate,
@@ -654,6 +655,129 @@ class AccountService:
         return ExcelUtil.export_list2excel(list_data, mapping_dict)
         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
     @classmethod
     async def update_transfer_status_service(
     async def update_transfer_status_service(
         cls,
         cls,

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

@@ -122,8 +122,36 @@ export const AccountAPI = {
       responseType: "blob",
       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 {
 export interface TransferOutSchema {
   out_biz_no?: string;
   out_biz_no?: string;
   enterprise_id?: string;
   enterprise_id?: string;

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

@@ -4,7 +4,7 @@
       <div class="enterprise-switcher__trigger">
       <div class="enterprise-switcher__trigger">
         <el-icon><OfficeBuilding /></el-icon>
         <el-icon><OfficeBuilding /></el-icon>
         <span class="enterprise-switcher__name">
         <span class="enterprise-switcher__name">
-          {{ currentEnterprise?.name }}
+          {{ currentEnterpriseName || "-" }}
         </span>
         </span>
         <el-icon class="enterprise-switcher__arrow"><ArrowDown /></el-icon>
         <el-icon class="enterprise-switcher__arrow"><ArrowDown /></el-icon>
       </div>
       </div>
@@ -17,7 +17,10 @@
             :class="{ 'is-active': ent.enterprise_id === currentEnterprise?.enterprise_id }"
             :class="{ 'is-active': ent.enterprise_id === currentEnterprise?.enterprise_id }"
           >
           >
             <div class="enterprise-item">
             <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">
               <el-icon v-if="ent.enterprise_id === currentEnterprise?.enterprise_id" class="enterprise-item__check">
                 <Check />
                 <Check />
               </el-icon>
               </el-icon>
@@ -43,6 +46,10 @@ const currentEnterprise = computed(() => enterpriseStore.getCurrentEnterprise);
 const hasEnterprise = computed(() => enterpriseStore.hasEnterprise);
 const hasEnterprise = computed(() => enterpriseStore.hasEnterprise);
 const enterpriseList = computed(() => enterpriseStore.getEnterpriseList);
 const enterpriseList = computed(() => enterpriseStore.getEnterpriseList);
 
 
+const currentEnterpriseName = computed(() => {
+  if (!currentEnterprise.value) return "-";
+  return `${currentEnterprise.value.name} (${currentEnterprise.value.enterprise_id.slice(-4)})`;
+});
 
 
 // 在组件挂载或依赖变化时处理
 // 在组件挂载或依赖变化时处理
 onMounted(() => {
 onMounted(() => {
@@ -101,7 +108,7 @@ function handleSwitch(enterpriseId: string) {
   &__name {
   &__name {
     margin-left: 8px;
     margin-left: 8px;
     margin-right: 4px;
     margin-right: 4px;
-    max-width: 120px;
+    max-width: 220px;
     overflow: hidden;
     overflow: hidden;
     text-overflow: ellipsis;
     text-overflow: ellipsis;
     white-space: nowrap;
     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-item">
             <div class="stat-label">当前企业</div>
             <div class="stat-label">当前企业</div>
             <div class="stat-value text-truncate">
             <div class="stat-value text-truncate">
-              {{ currentEnterprise?.name || "-" }}
+              {{ currentEnterpriseName || "-" }}
             </div>
             </div>
           </div>
           </div>
         </el-card>
         </el-card>
@@ -263,6 +263,12 @@ const currentEnterpriseId = computed(
 
 
 const currentEnterprise = computed(() => enterpriseStore.getCurrentEnterprise);
 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(() =>
 const authorizeStatus = computed(() =>
   accountData.value.account_book_id ? "AUTHORIZED" : "PENDING"
   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>
               <el-table-column prop="order_title" label="转账标题" min-width="150" />
               <el-table-column prop="order_title" label="转账标题" min-width="150" />
               <el-table-column prop="created_time" label="创建时间" min-width="160" />
               <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 }">
                 <template #default="{ row }">
                   <el-button type="primary" link @click="handleViewTransferDetail(row.out_biz_no)">
                   <el-button type="primary" link @click="handleViewTransferDetail(row.out_biz_no)">
                     详情
                     详情
                   </el-button>
                   </el-button>
+                  <el-button
+                    type="primary"
+                    link
+                    icon="Download"
+                    @click="handleDownloadReceipt(row)"
+                    :disabled="row.status !== 'SUCCESS'"
+                    :title="row.status !== 'SUCCESS' ? '仅成功的转账可下载回单' : '下载回单'"
+                  >
+                    下载回单
+                  </el-button>
                 </template>
                 </template>
               </el-table-column>
               </el-table-column>
             </el-table>
             </el-table>
@@ -1267,6 +1277,77 @@ function handleViewTransferDetail(outBizNo: string) {
   transferDetailVisible.value = true;
   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() {
 function handleTransferDetailRefresh() {
   fetchTransferList();
   fetchTransferList();
 }
 }