Browse Source

feat: 实现手工批量发放额度完整功能(后端3接口+前端批次管理页面)

- 新增 IssueBatchModel(pay_expense_issue_batch) 数据表
- 后端: issuebatch.create/cancel/records/query + batch列表接口
- 前端: 新增手工发放标签页(新建批次/作废/发放明细)
- 新建 IssueBatchForm.vue、IssueBatchDetail.vue 组件
- 字段映射严格对齐支付宝文档
alphah 2 weeks ago
parent
commit
aa6d19c5ae

+ 76 - 0
backend/app/plugin/module_payment/expense/quota/controller.py

@@ -14,6 +14,13 @@ from .schema import (
     ExpenseQuotaDeleteSchema,
     ExpenseQuotaModifySchema,
     ExpenseQuotaQuerySchema,
+    IssueBatchCancelOutSchema,
+    IssueBatchCancelSchema,
+    IssueBatchCreateOutSchema,
+    IssueBatchCreateSchema,
+    IssueBatchListOutSchema,
+    IssueBatchRecordsQueryOutSchema,
+    IssueBatchRecordsQuerySchema,
     QuotaCreateSchema,
     QuotaListOutSchema,
     QuotaOperationOutSchema,
@@ -133,6 +140,75 @@ async def list_quota_controller(
     return SuccessResponse(data=result, msg="查询额度列表成功")
 
 
+# ========================
+# 手工批量发放额度
+# ========================
+
+@QuotaRouter.get(
+    "/issuebatch/list",
+    summary="查询手工发放批次列表",
+    description="分页查询手工发放批次列表",
+)
+async def list_issue_batch_controller(
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:expense:quota:issuebatch:list"]))],
+    page_no: Annotated[int, Query(description="页码")] = 1,
+    page_size: Annotated[int, Query(description="每页数量")] = 20,
+    institution_id: Annotated[str | None, Query(description="制度ID")] = None,
+) -> JSONResponse:
+    search = {}
+    if institution_id:
+        search["institution_id"] = institution_id
+    result = await QuotaService.list_batch_service(
+        auth=auth, page_no=page_no, page_size=page_size, search=search
+    )
+    return SuccessResponse(data=result, msg="查询批次列表成功")
+
+
+@QuotaRouter.post(
+    "/issuebatch/create",
+    summary="手工批量发放额度",
+    description="批量对企业下的员工进行额度发放 (alipay.ebpp.invoice.expensecontrol.issuebatch.create)",
+    response_model=ResponseSchema[IssueBatchCreateOutSchema],
+)
+async def issue_batch_create_controller(
+    data: IssueBatchCreateSchema,
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:expense:quota:issuebatch:create"]))],
+) -> JSONResponse:
+    result = await QuotaService.issue_batch_create_service(auth=auth, data=data)
+    log.info(f"手工批量发放额度成功: batch_no={data.batch_no}, issue_batch_id={result.issue_batch_id}")
+    return SuccessResponse(data=result, msg="手工批量发放额度成功")
+
+
+@QuotaRouter.post(
+    "/issuebatch/cancel",
+    summary="作废手工发放批次",
+    description="作废当前批次下发放的额度 (alipay.ebpp.invoice.expensecontrol.issuebatch.cancel)",
+    response_model=ResponseSchema[IssueBatchCancelOutSchema],
+)
+async def issue_batch_cancel_controller(
+    data: IssueBatchCancelSchema,
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:expense:quota:issuebatch:cancel"]))],
+) -> JSONResponse:
+    result = await QuotaService.issue_batch_cancel_service(auth=auth, data=data)
+    log.info(f"作废手工发放批次成功: issue_batch_id={data.issue_batch_id}")
+    return SuccessResponse(data=result, msg="作废手工发放批次成功")
+
+
+@QuotaRouter.post(
+    "/issuebatch/records",
+    summary="查询手工发放发放明细",
+    description="根据批次号分页查询手工发放的发放明细 (alipay.ebpp.invoice.issuebatch.issuerecords.batchquery)",
+    response_model=ResponseSchema[IssueBatchRecordsQueryOutSchema],
+)
+async def issue_batch_records_query_controller(
+    data: IssueBatchRecordsQuerySchema,
+    auth: Annotated[AuthSchema, Depends(AuthPermission(["module_payment:expense:quota:issuebatch:records"]))],
+) -> JSONResponse:
+    result = await QuotaService.issue_batch_records_query_service(auth=auth, data=data)
+    log.info(f"查询手工发放发放明细成功: issue_batch_id={data.issue_batch_id}")
+    return SuccessResponse(data=result, msg="查询手工发放发放明细成功")
+
+
 @QuotaRouter.post(
     "/outsource/notify",
     summary="外部消费额度同步",

+ 21 - 1
backend/app/plugin/module_payment/expense/quota/crud.py

@@ -1,7 +1,9 @@
+from typing import Any
+
 from app.api.v1.module_system.auth.schema import AuthSchema
 from app.core.base_crud import CRUDBase
 
-from .model import QuotaModel
+from .model import IssueBatchModel, QuotaModel
 from .schema import QuotaCreateSchema, QuotaUpdateSchema
 
 
@@ -21,3 +23,21 @@ class QuotaCRUD(CRUDBase[QuotaModel, QuotaCreateSchema, QuotaUpdateSchema]):
         self, employee_id: str
     ) -> QuotaModel | None:
         return await self.get(employee_id=employee_id)
+
+
+class IssueBatchCRUD(CRUDBase[IssueBatchModel, dict[str, Any], dict[str, Any]]):
+    """手工发放批次 CRUD 操作"""
+
+    def __init__(self, auth: AuthSchema) -> None:
+        self.auth = auth
+        super().__init__(model=IssueBatchModel, auth=auth)
+
+    async def get_by_batch_no(
+        self, batch_no: str
+    ) -> IssueBatchModel | None:
+        return await self.get(batch_no=batch_no)
+
+    async def get_by_issue_batch_id(
+        self, issue_batch_id: str
+    ) -> IssueBatchModel | None:
+        return await self.get(issue_batch_id=issue_batch_id)

+ 46 - 1
backend/app/plugin/module_payment/expense/quota/model.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from decimal import Decimal
 
-from sqlalchemy import DateTime, Numeric, String, Text
+from sqlalchemy import DateTime, Integer, Numeric, String, Text
 from sqlalchemy.orm import Mapped, mapped_column
 
 from app.common.enums import PermissionFilterStrategy
@@ -46,3 +46,48 @@ class QuotaModel(PaymentModelMixin, TenantMixin, EnterpriseMixin):
         default=QuotaStatusEnum.QUOTA_ACTIVE.value,
         comment="状态: QUOTA_ACTIVE/QUOTA_FROZEN/QUOTA_EXHAUSTED/QUOTA_EXPIRED"
     )
+
+
+class IssueBatchModel(PaymentModelMixin, TenantMixin, EnterpriseMixin):
+    """手工发放批次模型"""
+
+    __tablename__ = "pay_expense_issue_batch"
+    __table_args__ = {"comment": "手工发放批次表"}
+    __permission_strategy__ = PermissionFilterStrategy.ENTERPRISE_BASED
+
+    issue_batch_id: Mapped[str | None] = mapped_column(
+        String(64), unique=True, index=True, comment="发放批次ID(支付宝返回)"
+    )
+    batch_no: Mapped[str] = mapped_column(
+        String(64), unique=True, index=True, comment="发放批次号(幂等)"
+    )
+    institution_id: Mapped[str] = mapped_column(
+        String(64), index=True, comment="制度ID"
+    )
+    issue_name: Mapped[str] = mapped_column(
+        String(64), comment="发放名称"
+    )
+    quota_type: Mapped[str] = mapped_column(
+        String(32), default="COUPON", comment="额度类型"
+    )
+    share_mode: Mapped[str] = mapped_column(
+        String(8), default="0", comment="是否可转赠: 0/1"
+    )
+    total_count: Mapped[int] = mapped_column(
+        Integer, default=0, comment="发放总人数"
+    )
+    total_amount: Mapped[Decimal | None] = mapped_column(
+        Numeric(12, 2), default=0, comment="发放总金额"
+    )
+    status: Mapped[str] = mapped_column(
+        String(32), default="ACTIVE", comment="状态: ACTIVE/CANCELLED"
+    )
+    effective_start_date: Mapped[datetime | None] = mapped_column(
+        DateTime, comment="额度有效起始时间"
+    )
+    effective_end_date: Mapped[datetime | None] = mapped_column(
+        DateTime, comment="额度有效结束时间"
+    )
+    issue_desc: Mapped[str | None] = mapped_column(
+        Text, comment="发放说明"
+    )

+ 119 - 0
backend/app/plugin/module_payment/expense/quota/schema.py

@@ -181,3 +181,122 @@ class QuotaOperationOutSchema(BaseModel):
     out_biz_no: Optional[str] = Field(default=None, description="外部业务编号")
     quota_id: Optional[str] = Field(default=None, description="额度ID")
     result: Optional[bool] = Field(default=None, description="操作结果")
+
+
+# ========================
+# 手工批量发放额度 (issuebatch)
+# ========================
+
+class IssueTargetInfoSchema(BaseModel):
+    """手工发放 - 员工发放信息"""
+
+    issue_quota: str = Field(description="发放额度(单位: 元)")
+    owner_open_id: Optional[str] = Field(default=None, description="员工开放ID")
+    owner_id: Optional[str] = Field(default=None, description="员工ID")
+    user_name: Optional[str] = Field(default=None, description="员工姓名")
+    owner_type: Optional[str] = Field(default=None, description="owner类型: PHONE/EMPLOYEE/ENTERPRISE_PAY_UID")
+
+
+class IssueBatchCreateSchema(BaseModel):
+    """手工批量发放额度请求 (alipay.ebpp.invoice.expensecontrol.issuebatch.create)"""
+
+    enterprise_id: str = Field(description="企业ID")
+    issue_name: str = Field(max_length=20, description="发放名称")
+    quota_type: str = Field(description="额度类型: 只能填写COUPON")
+    effective_start_date: str = Field(description="额度有效起始时间 (yyyy-MM-dd HH:mm:ss)")
+    effective_end_date: str = Field(description="额度有效结束时间")
+    institution_id: str = Field(description="制度ID")
+    batch_no: str = Field(description="发放批次号(用于幂等校验, 必须是数字)")
+    share_mode: str = Field(default="0", description="是否可转赠: 0(不可转赠)/1(可转赠)")
+    issue_desc: Optional[str] = Field(default=None, max_length=200, description="发放说明")
+    issue_target_info_list: Optional[list[IssueTargetInfoSchema]] = Field(
+        default=None, description="员工发放信息列表(最多1000条)"
+    )
+
+
+class IssueQuotaCheckFailedItem(BaseModel):
+    """批量发放校验失败项"""
+
+    user_name: Optional[str] = Field(default=None, description="员工姓名")
+    owner_type: Optional[str] = Field(default=None, description="owner类型")
+    owner_id: Optional[str] = Field(default=None, description="员工ID")
+    owner_open_id: Optional[str] = Field(default=None, description="员工开放ID")
+    issue_quota: Optional[str] = Field(default=None, description="发放额度")
+    message: Optional[str] = Field(default=None, description="失败原因")
+    result: Optional[bool] = Field(default=None, description="是否成功")
+
+
+class IssueBatchCreateOutSchema(BaseModel):
+    """手工批量发放额度响应"""
+
+    issue_batch_id: Optional[str] = Field(default=None, description="发放批次ID")
+    issue_quota_check_failed_list: Optional[list[IssueQuotaCheckFailedItem]] = Field(
+        default=None, description="校验失败的数据"
+    )
+
+
+class IssueBatchCancelSchema(BaseModel):
+    """手工批量发放作废请求 (alipay.ebpp.invoice.expensecontrol.issuebatch.cancel)"""
+
+    enterprise_id: str = Field(description="企业ID")
+    institution_id: str = Field(description="制度ID")
+    issue_batch_id: str = Field(description="发放批次ID")
+
+
+class IssueBatchCancelOutSchema(BaseModel):
+    """手工批量发放作废响应"""
+
+    result: bool = Field(description="是否成功")
+
+
+class IssueBatchRecordsQuerySchema(BaseModel):
+    """手工发放发放明细分页查询请求 (alipay.ebpp.invoice.issuebatch.issuerecords.batchquery)"""
+
+    enterprise_id: str = Field(description="企业ID")
+    institution_id: str = Field(description="制度ID")
+    issue_batch_id: str = Field(description="发放批次ID")
+    page_size: int = Field(default=20, description="页大小(最大100)")
+    page_num: int = Field(default=1, description="页码")
+
+
+class IssueRecordInfoItem(BaseModel):
+    """发放记录信息"""
+
+    quota_id: Optional[str] = Field(default=None, description="额度ID")
+    issue_quota: Optional[str] = Field(default=None, description="发放额度")
+    issue_status: Optional[int] = Field(default=None, description="发放状态")
+    owner_type: Optional[str] = Field(default=None, description="owner类型")
+    owner_id: Optional[str] = Field(default=None, description="员工ID")
+    owner_open_id: Optional[str] = Field(default=None, description="员工开放ID")
+    user_name: Optional[str] = Field(default=None, description="员工姓名")
+    currency: Optional[str] = Field(default=None, description="币种")
+
+
+class IssueBatchRecordsQueryOutSchema(BaseModel):
+    """手工发放发放明细分页查询响应"""
+
+    page_num: int = Field(description="页码")
+    page_size: int = Field(description="页大小")
+    total_page_count: str = Field(default="0", description="总页数")
+    issue_record_info_list: Optional[list[IssueRecordInfoItem]] = Field(
+        default=None, description="发放记录信息列表"
+    )
+
+
+class IssueBatchListOutSchema(BaseModel):
+    """手工发放批次列表响应"""
+
+    model_config = ConfigDict(from_attributes=True)
+
+    id: int = Field(description="主键ID")
+    issue_batch_id: Optional[str] = Field(default=None, description="发放批次ID")
+    batch_no: str = Field(description="发放批次号")
+    institution_id: str = Field(description="制度ID")
+    issue_name: str = Field(description="发放名称")
+    quota_type: str = Field(description="额度类型")
+    share_mode: str = Field(description="是否可转赠")
+    total_count: int = Field(description="发放总人数")
+    total_amount: Optional[float] = Field(default=None, description="发放总金额")
+    status: str = Field(description="状态")
+    created_time: Optional[datetime] = Field(default=None, description="创建时间")
+    updated_time: Optional[datetime] = Field(default=None, description="更新时间")

+ 274 - 1
backend/app/plugin/module_payment/expense/quota/service.py

@@ -1,3 +1,6 @@
+from datetime import datetime
+from decimal import Decimal
+
 from app.api.v1.module_system.auth.schema import AuthSchema
 from app.core.alipay import AlipayClient
 from app.core.exceptions import CustomException
@@ -11,6 +14,15 @@ from .schema import (
     ExpenseQuotaModifySchema,
     ExpenseQuotaQueryOutSchema,
     ExpenseQuotaQuerySchema,
+    IssueBatchCancelOutSchema,
+    IssueBatchCancelSchema,
+    IssueBatchCreateOutSchema,
+    IssueBatchCreateSchema,
+    IssueBatchListOutSchema,
+    IssueBatchRecordsQueryOutSchema,
+    IssueBatchRecordsQuerySchema,
+    IssueQuotaCheckFailedItem,
+    IssueRecordInfoItem,
     QuotaCreateSchema,
     QuotaDetailInfoSchema,
     QuotaListOutSchema,
@@ -18,7 +30,8 @@ from .schema import (
     QuotaOutSchema,
     QuotaUpdateSchema,
 )
-from .crud import QuotaCRUD
+from .crud import IssueBatchCRUD, QuotaCRUD
+from .model import IssueBatchModel
 
 
 class QuotaService:
@@ -307,6 +320,266 @@ class QuotaService:
 
         return QuotaOperationOutSchema(out_biz_no=out_biz_no)
 
+    # ========================
+    # 手工批量发放额度
+    # ========================
+
+    @classmethod
+    async def issue_batch_create_service(
+        cls, auth: AuthSchema, data: IssueBatchCreateSchema
+    ) -> IssueBatchCreateOutSchema:
+        """
+        手工批量发放额度
+        调用: alipay.ebpp.invoice.expensecontrol.issuebatch.create
+        """
+        try:
+            from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolIssuebatchCreateRequest import (
+                AlipayEbppInvoiceExpensecontrolIssuebatchCreateRequest,
+            )
+            from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolIssuebatchCreateModel import (
+                AlipayEbppInvoiceExpensecontrolIssuebatchCreateModel,
+            )
+            from alipay.aop.api.domain.IssueTargetInfoContent import (
+                IssueTargetInfoContent,
+            )
+            from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolIssuebatchCreateResponse import (
+                AlipayEbppInvoiceExpensecontrolIssuebatchCreateResponse,
+            )
+        except ImportError:
+            raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
+
+        model = AlipayEbppInvoiceExpensecontrolIssuebatchCreateModel()
+        model.enterprise_id = data.enterprise_id
+        model.issue_name = data.issue_name
+        model.quota_type = data.quota_type
+        model.effective_start_date = data.effective_start_date
+        model.effective_end_date = data.effective_end_date
+        model.institution_id = data.institution_id
+        model.batch_no = data.batch_no
+        model.share_mode = data.share_mode
+        if data.issue_desc:
+            model.issue_desc = data.issue_desc
+        if data.issue_target_info_list:
+            target_list = []
+            for item in data.issue_target_info_list:
+                target = IssueTargetInfoContent()
+                target.issue_quota = item.issue_quota
+                if item.owner_open_id:
+                    target.owner_open_id = item.owner_open_id
+                if item.owner_id:
+                    target.owner_id = item.owner_id
+                if item.user_name:
+                    target.user_name = item.user_name
+                if item.owner_type:
+                    target.owner_type = item.owner_type
+                target_list.append(target)
+            model.issue_target_info_list = target_list
+
+        request = AlipayEbppInvoiceExpensecontrolIssuebatchCreateRequest()
+        request.biz_model = model
+
+        client = AlipayClient.get_client()
+        response = client.execute(request)
+
+        if not response:
+            raise CustomException(msg="手工批量发放额度失败: 无响应")
+
+        result = AlipayEbppInvoiceExpensecontrolIssuebatchCreateResponse()
+        result.parse_response_content(response)
+
+        if not result.is_success():
+            log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
+            raise CustomException(msg=f"手工批量发放额度失败: {result.msg}")
+
+        # 保存批次记录到本地
+        try:
+            issue_batch_crud = IssueBatchCRUD(auth)
+            total_amount = Decimal("0")
+            if data.issue_target_info_list:
+                for item in data.issue_target_info_list:
+                    try:
+                        total_amount += Decimal(item.issue_quota)
+                    except Exception:
+                        pass
+            batch_data = {
+                "issue_batch_id": result.issue_batch_id,
+                "batch_no": data.batch_no,
+                "institution_id": data.institution_id,
+                "issue_name": data.issue_name,
+                "quota_type": data.quota_type,
+                "share_mode": data.share_mode,
+                "total_count": len(data.issue_target_info_list or []),
+                "total_amount": total_amount,
+                "status": "ACTIVE",
+                "effective_start_date": datetime.strptime(data.effective_start_date, "%Y-%m-%d %H:%M:%S"),
+                "effective_end_date": datetime.strptime(data.effective_end_date, "%Y-%m-%d %H:%M:%S"),
+                "issue_desc": data.issue_desc,
+            }
+            await issue_batch_crud.create(batch_data)
+        except Exception as e:
+            log.warning(f"保存发放批次记录失败(不影响发放): {e}")
+
+        # 组装校验失败列表
+        failed_list = None
+        if hasattr(result, 'issue_quota_check_failed_list') and result.issue_quota_check_failed_list:
+            failed_list = []
+            for f in result.issue_quota_check_failed_list:
+                failed_list.append(IssueQuotaCheckFailedItem(
+                    user_name=getattr(f, 'user_name', None),
+                    owner_type=getattr(f, 'owner_type', None),
+                    owner_id=getattr(f, 'owner_id', None),
+                    owner_open_id=getattr(f, 'owner_open_id', None),
+                    issue_quota=getattr(f, 'issue_quota', None),
+                    message=getattr(f, 'message', None),
+                    result=getattr(f, 'result', None),
+                ))
+
+        return IssueBatchCreateOutSchema(
+            issue_batch_id=result.issue_batch_id,
+            issue_quota_check_failed_list=failed_list,
+        )
+
+    @classmethod
+    async def issue_batch_cancel_service(
+        cls, auth: AuthSchema, data: IssueBatchCancelSchema
+    ) -> IssueBatchCancelOutSchema:
+        """
+        作废手工发放批次
+        调用: alipay.ebpp.invoice.expensecontrol.issuebatch.cancel
+        """
+        try:
+            from alipay.aop.api.request.AlipayEbppInvoiceExpensecontrolIssuebatchCancelRequest import (
+                AlipayEbppInvoiceExpensecontrolIssuebatchCancelRequest,
+            )
+            from alipay.aop.api.domain.AlipayEbppInvoiceExpensecontrolIssuebatchCancelModel import (
+                AlipayEbppInvoiceExpensecontrolIssuebatchCancelModel,
+            )
+            from alipay.aop.api.response.AlipayEbppInvoiceExpensecontrolIssuebatchCancelResponse import (
+                AlipayEbppInvoiceExpensecontrolIssuebatchCancelResponse,
+            )
+        except ImportError:
+            raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
+
+        model = AlipayEbppInvoiceExpensecontrolIssuebatchCancelModel()
+        model.enterprise_id = data.enterprise_id
+        model.institution_id = data.institution_id
+        model.issue_batch_id = data.issue_batch_id
+
+        request = AlipayEbppInvoiceExpensecontrolIssuebatchCancelRequest()
+        request.biz_model = model
+
+        client = AlipayClient.get_client()
+        response = client.execute(request)
+
+        if not response:
+            raise CustomException(msg="作废手工发放批次失败: 无响应")
+
+        result = AlipayEbppInvoiceExpensecontrolIssuebatchCancelResponse()
+        result.parse_response_content(response)
+
+        if not result.is_success():
+            log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
+            raise CustomException(msg=f"作废手工发放批次失败: {result.msg}")
+
+        # 更新本地批次记录状态
+        try:
+            issue_batch_crud = IssueBatchCRUD(auth)
+            batch = await issue_batch_crud.get_by_issue_batch_id(data.issue_batch_id)
+            if batch:
+                setattr(batch, 'status', 'CANCELLED')
+                await issue_batch_crud.update(batch.id, {"status": "CANCELLED"})
+        except Exception as e:
+            log.warning(f"更新批次本地状态失败(不影响作废): {e}")
+
+        return IssueBatchCancelOutSchema(
+            result=getattr(result, 'result', False),
+        )
+
+    @classmethod
+    async def issue_batch_records_query_service(
+        cls, auth: AuthSchema, data: IssueBatchRecordsQuerySchema
+    ) -> IssueBatchRecordsQueryOutSchema:
+        """
+        查询手工发放发放明细
+        调用: alipay.ebpp.invoice.issuebatch.issuerecords.batchquery
+        """
+        try:
+            from alipay.aop.api.request.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest import (
+                AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest,
+            )
+            from alipay.aop.api.domain.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel import (
+                AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel,
+            )
+            from alipay.aop.api.response.AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse import (
+                AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse,
+            )
+        except ImportError:
+            raise CustomException(msg="支付宝SDK未正确安装,请检查alipay-sdk-python依赖")
+
+        model = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryModel()
+        model.enterprise_id = data.enterprise_id
+        model.institution_id = data.institution_id
+        model.issue_batch_id = data.issue_batch_id
+        model.page_size = data.page_size
+        model.page_num = data.page_num
+
+        request = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryRequest()
+        request.biz_model = model
+
+        client = AlipayClient.get_client()
+        response = client.execute(request)
+
+        if not response:
+            raise CustomException(msg="查询手工发放明细失败: 无响应")
+
+        result = AlipayEbppInvoiceIssuebatchIssuerecordsBatchqueryResponse()
+        result.parse_response_content(response)
+
+        if not result.is_success():
+            log.error(f"支付宝接口调用失败: {result.code} - {result.msg}")
+            raise CustomException(msg=f"查询手工发放明细失败: {result.msg}")
+
+        record_list = None
+        if hasattr(result, 'issue_record_info_list') and result.issue_record_info_list:
+            record_list = []
+            for r in result.issue_record_info_list:
+                record_list.append(IssueRecordInfoItem(
+                    quota_id=getattr(r, 'quota_id', None),
+                    issue_quota=getattr(r, 'issue_quota', None),
+                    issue_status=getattr(r, 'issue_status', None),
+                    owner_type=getattr(r, 'owner_type', None),
+                    owner_id=getattr(r, 'owner_id', None),
+                    owner_open_id=getattr(r, 'owner_open_id', None),
+                    user_name=getattr(r, 'user_name', None),
+                    currency=getattr(r, 'currency', None),
+                ))
+
+        return IssueBatchRecordsQueryOutSchema(
+            page_num=getattr(result, 'page_num', data.page_num),
+            page_size=getattr(result, 'page_size', data.page_size),
+            total_page_count=getattr(result, 'total_page_count', "0"),
+            issue_record_info_list=record_list,
+        )
+
+    @classmethod
+    async def list_batch_service(
+        cls,
+        auth: AuthSchema,
+        page_no: int = 1,
+        page_size: int = 20,
+        search: dict | None = None,
+    ) -> dict:
+        """分页查询手工发放批次列表(本地DB)"""
+        crud = IssueBatchCRUD(auth)
+        offset = (page_no - 1) * page_size
+        return await crud.page(
+            offset=offset,
+            limit=page_size,
+            order_by=[{"id": "desc"}],
+            search=search or {},
+            out_schema=IssueBatchListOutSchema,
+        )
+
     @classmethod
     async def list_service(
         cls,

BIN
frontend/dist.zip


+ 155 - 0
frontend/src/api/module_payment/quota.ts

@@ -3,6 +3,7 @@ import request from "@/utils/request";
 const API_PATH = "/payment/quota";
 
 const QuotaAPI = {
+  // ========= 原有方法 =========
   listQuota(query?: QuotaPageQuery) {
     return request<ApiResponse<QuotaPageResp>>({
       url: `${API_PATH}`,
@@ -48,10 +49,50 @@ const QuotaAPI = {
       params: query,
     });
   },
+
+  // ========= 手工批量发放额度 =========
+
+  /** 手工批量发放额度 */
+  issueBatchCreate(body: IssueBatchCreateForm) {
+    return request<ApiResponse<IssueBatchCreateResp>>({
+      url: `${API_PATH}/issuebatch/create`,
+      method: "post",
+      data: body,
+    });
+  },
+
+  /** 作废手工发放批次 */
+  issueBatchCancel(body: IssueBatchCancelForm) {
+    return request<ApiResponse<IssueBatchCancelResp>>({
+      url: `${API_PATH}/issuebatch/cancel`,
+      method: "post",
+      data: body,
+    });
+  },
+
+  /** 查询手工发放发放明细 */
+  issueBatchRecords(body: IssueBatchRecordsQuery) {
+    return request<ApiResponse<IssueBatchRecordsResp>>({
+      url: `${API_PATH}/issuebatch/records`,
+      method: "post",
+      data: body,
+    });
+  },
+
+  /** 查询手工发放批次列表 */
+  issueBatchList(query?: IssueBatchListQuery) {
+    return request<ApiResponse<IssueBatchListResp>>({
+      url: `${API_PATH}/issuebatch/list`,
+      method: "get",
+      params: query,
+    });
+  },
 };
 
 export default QuotaAPI;
 
+// ========= 原有类型 =========
+
 export interface QuotaPageQuery {
   page_no?: number;
   page_size?: number;
@@ -116,6 +157,82 @@ export interface QuotaOperation {
   result?: boolean;
 }
 
+// ========= 手工批量发放类型 =========
+
+export interface IssueTargetInfo {
+  issue_quota: string;
+  owner_open_id?: string;
+  owner_id?: string;
+  user_name?: string;
+  owner_type?: string;
+}
+
+export interface IssueBatchCreateForm {
+  enterprise_id: string;
+  issue_name: string;
+  quota_type: string;
+  effective_start_date: string;
+  effective_end_date: string;
+  institution_id: string;
+  batch_no: string;
+  share_mode: string;
+  issue_desc?: string;
+  issue_target_info_list?: IssueTargetInfo[];
+}
+
+export interface IssueQuotaCheckFailedItem {
+  user_name?: string;
+  owner_type?: string;
+  owner_id?: string;
+  owner_open_id?: string;
+  issue_quota?: string;
+  message?: string;
+  result?: boolean;
+}
+
+export interface IssueBatchCreateResp {
+  issue_batch_id?: string;
+  issue_quota_check_failed_list?: IssueQuotaCheckFailedItem[];
+}
+
+export interface IssueBatchCancelForm {
+  enterprise_id: string;
+  institution_id: string;
+  issue_batch_id: string;
+}
+
+export interface IssueBatchCancelResp {
+  result: boolean;
+}
+
+export interface IssueBatchRecordsQuery {
+  enterprise_id: string;
+  institution_id: string;
+  issue_batch_id: string;
+  page_size: number;
+  page_num: number;
+}
+
+export interface IssueRecordInfoItem {
+  quota_id?: string;
+  issue_quota?: string;
+  issue_status?: number;
+  owner_type?: string;
+  owner_id?: string;
+  owner_open_id?: string;
+  user_name?: string;
+  currency?: string;
+}
+
+export interface IssueBatchRecordsResp {
+  page_num: number;
+  page_size: number;
+  total_page_count: string;
+  issue_record_info_list?: IssueRecordInfoItem[];
+}
+
+// ========= 常量 =========
+
 export const TARGET_TYPE_OPTIONS = [
   { label: "制度维度", value: "INSTITUTION" },
   { label: "费用类型维度", value: "EXPENSE_TYPE" },
@@ -153,4 +270,42 @@ export const STATUS_LABEL: Record<string, string> = {
   QUOTA_EXHAUSTED: "已用完",
   QUOTA_EXPIRED: "已过期",
   QUOTA_PENDING: "待发放",
+};
+
+export interface IssueBatchListQuery {
+  page_no?: number;
+  page_size?: number;
+  institution_id?: string;
+}
+
+export interface IssueBatchItem {
+  id: number;
+  issue_batch_id?: string;
+  batch_no: string;
+  institution_id: string;
+  issue_name: string;
+  quota_type: string;
+  share_mode: string;
+  total_count: number;
+  total_amount?: number;
+  status: string;
+  created_time: string;
+  updated_time?: string;
+}
+
+export interface IssueBatchListResp {
+  items: IssueBatchItem[];
+  total: number;
+  page_no: number;
+  page_size: number;
+}
+
+export const ISSUE_BATCH_STATUS_TAG: Record<string, string> = {
+  ACTIVE: "success",
+  CANCELLED: "danger",
+};
+
+export const ISSUE_BATCH_STATUS_LABEL: Record<string, string> = {
+  ACTIVE: "有效",
+  CANCELLED: "已作废",
 };

+ 110 - 0
frontend/src/views/module_payment/quota/components/IssueBatchDetail.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <div v-if="loading" v-loading="loading" style="min-height: 200px" />
+    <template v-else>
+      <el-descriptions :column="2" border size="small" style="margin-bottom: 16px">
+        <el-descriptions-item label="批次ID">{{ batchInfo.issue_batch_id }}</el-descriptions-item>
+        <el-descriptions-item label="批次号">{{ batchInfo.batch_no }}</el-descriptions-item>
+        <el-descriptions-item label="发放名称">{{ batchInfo.issue_name }}</el-descriptions-item>
+        <el-descriptions-item label="制度ID">{{ batchInfo.institution_id }}</el-descriptions-item>
+      </el-descriptions>
+
+      <el-table :data="records" border max-height="400" size="small">
+        <el-table-column type="index" label="序号" width="60" />
+        <el-table-column prop="quota_id" label="额度ID" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="user_name" label="员工姓名" min-width="120" />
+        <el-table-column prop="owner_id" label="员工ID" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="issue_quota" label="发放额度" min-width="100" align="right">
+          <template #default="scope">
+            {{ scope.row.issue_quota ? `¥${scope.row.issue_quota}` : "-" }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="issue_status" label="状态" min-width="80" align="center">
+          <template #default="scope">
+            <el-tag :type="scope.row.issue_status === 1 ? 'success' : 'info'" size="small">
+              {{ scope.row.issue_status === 1 ? "成功" : "失败" }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="currency" label="币种" min-width="60" />
+      </el-table>
+
+      <div v-if="totalPage > 1" style="margin-top: 16px; text-align: right">
+        <el-pagination
+          v-model:current-page="currentPage"
+          :page-size="pageSize"
+          :total="totalCount"
+          layout="prev, pager, next"
+          small
+          @current-change="loadRecords"
+        />
+      </div>
+
+      <div v-if="records.length === 0 && !loading" style="text-align: center; padding: 40px; color: #999">
+        暂无发放记录
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from "vue";
+import QuotaAPI from "@/api/module_payment/quota";
+import { useEnterpriseStore } from "@/store/modules/enterprise.store";
+
+interface Props {
+  issueBatchId: string;
+  institutionId: string;
+  batchInfo?: {
+    issue_batch_id?: string;
+    batch_no?: string;
+    issue_name?: string;
+    institution_id?: string;
+  };
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  batchInfo: () => ({}),
+});
+
+const loading = ref(false);
+const records = ref<any[]>([]);
+const currentPage = ref(1);
+const pageSize = ref(20);
+const totalPage = ref(0);
+const totalCount = ref(0);
+
+const batchInfo = reactive({
+  issue_batch_id: props.batchInfo?.issue_batch_id || props.issueBatchId,
+  batch_no: props.batchInfo?.batch_no || "-",
+  issue_name: props.batchInfo?.issue_name || "-",
+  institution_id: props.batchInfo?.institution_id || props.institutionId,
+});
+
+async function loadRecords() {
+  loading.value = true;
+  try {
+    const store = useEnterpriseStore();
+    const eid = store.getCurrentEnterprise?.enterprise_id;
+    if (!eid) return;
+
+    const res = await QuotaAPI.issueBatchRecords({
+      enterprise_id: eid,
+      institution_id: props.institutionId,
+      issue_batch_id: props.issueBatchId,
+      page_size: pageSize.value,
+      page_num: currentPage.value,
+    });
+    const data = res.data.data;
+    records.value = data?.issue_record_info_list || [];
+    totalPage.value = parseInt(data?.total_page_count || "0");
+    totalCount.value = (totalPage.value || 0) * pageSize.value;
+  } catch (e) {
+    console.error("加载批次记录失败", e);
+  } finally {
+    loading.value = false;
+  }
+}
+
+loadRecords();
+</script>

+ 292 - 0
frontend/src/views/module_payment/quota/components/IssueBatchForm.vue

@@ -0,0 +1,292 @@
+<template>
+  <el-form ref="formRef" :model="formData" :rules="rules" label-width="140px" label-suffix=":">
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-form-item label="发放名称" prop="issue_name">
+          <el-input v-model="formData.issue_name" placeholder="最多20字" :maxlength="20" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="制度ID" prop="institution_id">
+          <el-input v-model="formData.institution_id" placeholder="制度ID" :disabled="!!props.institutionId" />
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-form-item label="额度类型" prop="quota_type">
+          <el-select v-model="formData.quota_type" disabled style="width: 100%">
+            <el-option label="点券" value="COUPON" />
+          </el-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="是否可转赠" prop="share_mode">
+          <el-radio-group v-model="formData.share_mode">
+            <el-radio value="0">不可转赠</el-radio>
+            <el-radio value="1">可转赠</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20">
+      <el-col :span="12">
+        <el-form-item label="有效起始时间" prop="effective_start_date">
+          <el-date-picker
+            v-model="formData.effective_start_date"
+            type="datetime"
+            placeholder="选择日期时间"
+            format="YYYY-MM-DD HH:mm:ss"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            style="width: 100%"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="有效结束时间" prop="effective_end_date">
+          <el-date-picker
+            v-model="formData.effective_end_date"
+            type="datetime"
+            placeholder="选择日期时间"
+            format="YYYY-MM-DD HH:mm:ss"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            style="width: 100%"
+          />
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="20">
+      <el-col :span="24">
+        <el-form-item label="发放说明" prop="issue_desc">
+          <el-input v-model="formData.issue_desc" type="textarea" :rows="2" placeholder="可选,最多200字" :maxlength="200" />
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <el-divider content-position="left">发放员工列表</el-divider>
+
+    <el-row :gutter="20">
+      <el-col :span="24">
+        <el-form-item label="员工选择" prop="issue_target_info_list">
+          <div>
+            <el-button type="primary" plain @click="showEmployeeSelector = true">
+              选择员工并设置额度
+            </el-button>
+            <el-tag v-if="formData.issue_target_info_list.length > 0" type="info" style="margin-left: 8px">
+              已选 {{ formData.issue_target_info_list.length }} 人
+            </el-tag>
+          </div>
+        </el-form-item>
+      </el-col>
+    </el-row>
+
+    <el-row v-if="formData.issue_target_info_list.length > 0" :gutter="20">
+      <el-col :span="24">
+        <el-table :data="formData.issue_target_info_list" border max-height="300" size="small">
+          <el-table-column type="index" label="序号" width="60" />
+          <el-table-column prop="user_name" label="员工姓名" min-width="120" />
+          <el-table-column prop="owner_type" label="识别方式" min-width="120">
+            <template #default="scope">
+              {{ OWNER_TYPE_LABEL[scope.row.owner_type] || scope.row.owner_type }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="owner_id" label="识别ID" min-width="160" show-overflow-tooltip />
+          <el-table-column prop="issue_quota" label="发放额度(元)" min-width="120" align="right" />
+          <el-table-column label="操作" width="80" align="center">
+            <template #default="scope">
+              <el-button type="danger" size="small" link @click="removeTarget(scope.$index)">移除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </el-col>
+    </el-row>
+  </el-form>
+
+  <!-- 员工选择弹窗 -->
+  <el-dialog v-model="showEmployeeSelector" title="选择发放员工" width="700px" destroy-on-close>
+    <div>
+      <el-table
+        ref="employeeTableRef"
+        :data="employeeList"
+        border
+        max-height="400"
+        @selection-change="handleEmployeeSelectionChange"
+      >
+        <el-table-column type="selection" width="55" />
+        <el-table-column prop="employee_name" label="员工姓名" min-width="120" />
+        <el-table-column prop="employee_id" label="员工ID" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="department_name" label="部门" min-width="120" />
+        <el-table-column label="发放额度(元)" width="160">
+          <template #default="scope">
+            <el-input-number
+              v-model="issueQuotaMap[scope.row.employee_id]"
+              :min="0.01"
+              :precision="2"
+              size="small"
+              controls-position="right"
+              style="width: 130px"
+            />
+          </template>
+        </el-table-column>
+      </el-table>
+      <div style="margin-top: 12px; text-align: right">
+        <el-button type="primary" @click="confirmEmployeeSelect">
+          确认选择 (已选 {{ selectedEmployeeIds.length }} 人)
+        </el-button>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, onMounted } from "vue";
+import { ElMessage } from "element-plus";
+import type { IssueTargetInfo } from "@/api/module_payment/quota";
+import EmployeeAPI from "@/api/module_payment/employee";
+import { useEnterpriseStore } from "@/store/modules/enterprise.store";
+
+const OWNER_TYPE_LABEL: Record<string, string> = {
+  EMPLOYEE: "支付宝ID",
+  PHONE: "手机号",
+  ENTERPRISE_PAY_UID: "企业码ID",
+};
+
+interface Props {
+  enterpriseId?: string;
+  institutionId?: string;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<{ (e: "success"): void }>();
+
+const formRef = ref();
+const employeeTableRef = ref();
+const showEmployeeSelector = ref(false);
+const employeeList = ref<any[]>([]);
+const selectedEmployeeIds = ref<string[]>([]);
+const issueQuotaMap = reactive<Record<string, number>>({});
+
+const formData = reactive({
+  issue_name: "",
+  quota_type: "COUPON",
+  effective_start_date: "",
+  effective_end_date: "",
+  institution_id: props.institutionId || "",
+  batch_no: "",
+  share_mode: "0",
+  issue_desc: "",
+  issue_target_info_list: [] as (IssueTargetInfo & { issue_quota: string; user_name?: string })[],
+});
+
+function generateBatchNo(): string {
+  const now = new Date();
+  const ts = now.getFullYear().toString()
+    + String(now.getMonth() + 1).padStart(2, "0")
+    + String(now.getDate()).padStart(2, "0")
+    + String(now.getHours()).padStart(2, "0")
+    + String(now.getMinutes()).padStart(2, "0")
+    + String(now.getSeconds()).padStart(2, "0")
+    + String(now.getMilliseconds()).padStart(3, "0");
+  return ts;
+}
+
+onMounted(() => {
+  formData.batch_no = generateBatchNo();
+  loadEmployees();
+});
+
+async function loadEmployees() {
+  try {
+    let eid = props.enterpriseId;
+    if (!eid) {
+      const store = useEnterpriseStore();
+      eid = store.getCurrentEnterprise?.enterprise_id;
+    }
+    if (!eid) return;
+    const res = await EmployeeAPI.listEmployee({ enterprise_id: eid, page_no: 1, page_size: 500 });
+    employeeList.value = res?.data?.data?.items || [];
+  } catch (e) {
+    console.error("加载员工列表失败", e);
+  }
+}
+
+function handleEmployeeSelectionChange(rows: any[]) {
+  selectedEmployeeIds.value = rows.map((r) => r.employee_id);
+}
+
+function confirmEmployeeSelect() {
+  const targets: (IssueTargetInfo & { issue_quota: string; user_name?: string })[] = [];
+  for (const employee of employeeList.value) {
+    if (!selectedEmployeeIds.value.includes(employee.employee_id)) continue;
+    const quota = issueQuotaMap[employee.employee_id];
+    if (!quota || quota <= 0) continue;
+    targets.push({
+      issue_quota: String(quota),
+      owner_type: "ENTERPRISE_PAY_UID",
+      owner_id: employee.employee_id,
+      user_name: employee.employee_name || employee.name,
+    });
+  }
+  if (targets.length === 0) {
+    ElMessage.warning("请至少选择一个员工并填写有效额度");
+    return;
+  }
+  formData.issue_target_info_list = targets;
+  showEmployeeSelector.value = false;
+}
+
+function removeTarget(index: number) {
+  formData.issue_target_info_list.splice(index, 1);
+}
+
+const rules = reactive({
+  issue_name: [{ required: true, message: "请输入发放名称", trigger: "blur" }],
+  institution_id: [{ required: true, message: "请输入制度ID", trigger: "blur" }],
+  effective_start_date: [{ required: true, message: "请选择有效起始时间", trigger: "change" }],
+  effective_end_date: [{ required: true, message: "请选择有效结束时间", trigger: "change" }],
+});
+
+function getFormData() {
+  const eid = props.enterpriseId || useEnterpriseStore().getCurrentEnterprise?.enterprise_id;
+  return {
+    enterprise_id: eid,
+    issue_name: formData.issue_name,
+    quota_type: formData.quota_type,
+    effective_start_date: formData.effective_start_date,
+    effective_end_date: formData.effective_end_date,
+    institution_id: formData.institution_id,
+    batch_no: formData.batch_no,
+    share_mode: formData.share_mode,
+    issue_desc: formData.issue_desc || undefined,
+    issue_target_info_list: formData.issue_target_info_list.length > 0 ? formData.issue_target_info_list : undefined,
+  };
+}
+
+async function submitForm() {
+  const valid = await formRef.value?.validate().catch(() => false);
+  if (!valid) return false;
+
+  if (formData.issue_target_info_list.length === 0) {
+    ElMessage.warning("请至少选择一个员工并填写额度");
+    return false;
+  }
+  return true;
+}
+
+function resetForm() {
+  formData.issue_name = "";
+  formData.quota_type = "COUPON";
+  formData.effective_start_date = "";
+  formData.effective_end_date = "";
+  formData.institution_id = props.institutionId || "";
+  formData.batch_no = generateBatchNo();
+  formData.share_mode = "0";
+  formData.issue_desc = "";
+  formData.issue_target_info_list = [];
+}
+
+defineExpose({ submitForm, resetForm, getFormData });
+</script>

+ 378 - 176
frontend/src/views/module_payment/quota/index.vue

@@ -1,187 +1,279 @@
 <template>
   <div v-loading="pageLoading" class="app-container" :element-loading-text="loadingText">
-    <PageSearch
-      ref="searchRef"
-      :search-config="searchConfig"
-      @query-click="handleQueryClick"
-      @reset-click="handleResetClick"
-    />
-
-    <PageContent ref="contentRef" :content-config="contentConfig">
-      <template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
-        <CrudToolbarLeft
-          :remove-ids="removeIds"
-          :perm-create="['module_payment:quota:create']"
-          @add="handleOpenDialog('create')"
-        />
-        <div class="data-table__toolbar--right">
-          <CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
-        </div>
-      </template>
+    <div class="category-tabs" style="margin-bottom: 16px; padding: 10px 16px; background: #fff; border-radius: 8px;">
+      <el-button
+        v-for="tab in categoryTabs"
+        :key="tab.key"
+        :type="activeCategory === tab.key ? 'primary' : 'default'"
+        @click="handleCategoryChange(tab.key)"
+      >
+        {{ tab.label }}
+      </el-button>
+    </div>
 
-      <template #table="{ data, loading, tableRef, onSelectionChange }">
-        <div class="data-table__content">
-          <el-table
-            :ref="tableRef as any"
-            v-loading="loading"
-            :data="data"
-            height="100%"
-            border
-            @selection-change="onSelectionChange"
-          >
-            <template #empty>
-              <el-empty :image-size="80" description="暂无数据" />
-            </template>
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'selection')?.show"
-              type="selection"
-              min-width="55"
-              align="center"
-            />
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'quota_id')?.show"
-              key="quota_id"
-              label="额度ID"
-              prop="quota_id"
-              min-width="150"
-              show-overflow-tooltip
-            />
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'employee_id')?.show"
-              key="employee_id"
-              label="员工ID"
-              prop="employee_id"
-              min-width="150"
-              show-overflow-tooltip
-            />
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'institution_id')?.show"
-              key="institution_id"
-              label="制度ID"
-              prop="institution_id"
-              min-width="150"
-              show-overflow-tooltip
-            />
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'quota_type')?.show"
-              key="quota_type"
-              label="额度类型"
-              prop="quota_type"
-              min-width="100"
-            >
-              <template #default="scope">
-                {{ formatQuotaType(scope.row.quota_type) }}
-              </template>
-            </el-table-column>
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'total_amount')?.show"
-              key="total_amount"
-              label="总金额"
-              prop="total_amount"
-              min-width="100"
-              align="right"
-            >
-              <template #default="scope">
-                {{ scope.row.total_amount ? `¥${scope.row.total_amount.toFixed(2)}` : "-" }}
-              </template>
-            </el-table-column>
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'available_amount')?.show"
-              key="available_amount"
-              label="可用金额"
-              prop="available_amount"
-              min-width="100"
-              align="right"
-            >
-              <template #default="scope">
-                {{ scope.row.available_amount ? `¥${scope.row.available_amount.toFixed(2)}` : "-" }}
-              </template>
-            </el-table-column>
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'status')?.show"
-              key="status"
-              label="状态"
-              prop="status"
-              min-width="100"
-            >
-              <template #default="scope">
-                <el-tag :type="STATUS_TAG_TYPE[scope.row.status]">
-                  {{ STATUS_LABEL[scope.row.status] || scope.row.status }}
-                </el-tag>
-              </template>
-            </el-table-column>
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
-              key="created_time"
-              label="创建时间"
-              prop="created_time"
-              min-width="160"
-              sortable
-            />
-            <el-table-column
-              v-if="contentCols.find((col) => col.prop === 'operation')?.show"
-              fixed="right"
-              label="操作"
-              align="center"
-              min-width="160"
+    <!-- 额度管理列表 -->
+    <template v-if="activeCategory === 'quota'">
+      <PageSearch
+        ref="searchRef"
+        :search-config="searchConfig"
+        @query-click="handleQueryClick"
+        @reset-click="handleResetClick"
+      />
+
+      <PageContent ref="contentRef" :content-config="contentConfig">
+        <template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
+          <CrudToolbarLeft
+            :remove-ids="removeIds"
+            :perm-create="['module_payment:quota:create']"
+            @add="handleOpenDialog('create')"
+          />
+          <div class="data-table__toolbar--right">
+            <CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
+          </div>
+        </template>
+
+        <template #table="{ data, loading, tableRef, onSelectionChange }">
+          <div class="data-table__content">
+            <el-table
+              :ref="tableRef as any"
+              v-loading="loading"
+              :data="data"
+              height="100%"
+              border
+              @selection-change="onSelectionChange"
             >
-              <template #default="scope">
-                <el-button
-                  v-hasPerm="['module_payment:quota:detail']"
-                  type="info"
-                  size="small"
-                  link
-                  icon="View"
-                  @click="handleOpenDialog('detail', scope.row.quota_id)"
-                >
-                  详情
-                </el-button>
-                <el-button
-                  v-hasPerm="['module_payment:quota:update']"
-                  type="primary"
-                  size="small"
-                  link
-                  icon="edit"
-                  @click="handleOpenDialog('update', scope.row.quota_id)"
-                >
-                  编辑
-                </el-button>
+              <template #empty>
+                <el-empty :image-size="80" description="暂无数据" />
               </template>
-            </el-table-column>
-          </el-table>
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'selection')?.show"
+                type="selection"
+                min-width="55"
+                align="center"
+              />
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'quota_id')?.show"
+                key="quota_id"
+                label="额度ID"
+                prop="quota_id"
+                min-width="150"
+                show-overflow-tooltip
+              />
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'employee_id')?.show"
+                key="employee_id"
+                label="员工ID"
+                prop="employee_id"
+                min-width="150"
+                show-overflow-tooltip
+              />
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'institution_id')?.show"
+                key="institution_id"
+                label="制度ID"
+                prop="institution_id"
+                min-width="150"
+                show-overflow-tooltip
+              />
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'quota_type')?.show"
+                key="quota_type"
+                label="额度类型"
+                prop="quota_type"
+                min-width="100"
+              >
+                <template #default="scope">
+                  {{ formatQuotaType(scope.row.quota_type) }}
+                </template>
+              </el-table-column>
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'total_amount')?.show"
+                key="total_amount"
+                label="总金额"
+                prop="total_amount"
+                min-width="100"
+                align="right"
+              >
+                <template #default="scope">
+                  {{ scope.row.total_amount ? `¥${scope.row.total_amount.toFixed(2)}` : "-" }}
+                </template>
+              </el-table-column>
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'available_amount')?.show"
+                key="available_amount"
+                label="可用金额"
+                prop="available_amount"
+                min-width="100"
+                align="right"
+              >
+                <template #default="scope">
+                  {{ scope.row.available_amount ? `¥${scope.row.available_amount.toFixed(2)}` : "-" }}
+                </template>
+              </el-table-column>
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'status')?.show"
+                key="status"
+                label="状态"
+                prop="status"
+                min-width="100"
+              >
+                <template #default="scope">
+                  <el-tag :type="STATUS_TAG_TYPE[scope.row.status]">
+                    {{ STATUS_LABEL[scope.row.status] || scope.row.status }}
+                  </el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
+                key="created_time"
+                label="创建时间"
+                prop="created_time"
+                min-width="160"
+                sortable
+              />
+              <el-table-column
+                v-if="contentCols.find((col) => col.prop === 'operation')?.show"
+                fixed="right"
+                label="操作"
+                align="center"
+                min-width="160"
+              >
+                <template #default="scope">
+                  <el-button
+                    v-hasPerm="['module_payment:quota:detail']"
+                    type="info"
+                    size="small"
+                    link
+                    icon="View"
+                    @click="handleOpenDialog('detail', scope.row.quota_id)"
+                  >
+                    详情
+                  </el-button>
+                  <el-button
+                    v-hasPerm="['module_payment:quota:update']"
+                    type="primary"
+                    size="small"
+                    link
+                    icon="edit"
+                    @click="handleOpenDialog('update', scope.row.quota_id)"
+                  >
+                    编辑
+                  </el-button>
+                </template>
+              </el-table-column>
+            </el-table>
+          </div>
+        </template>
+      </PageContent>
+
+      <EnhancedDialog
+        v-model="dialogVisible.visible"
+        :title="dialogVisible.title"
+        @close="handleCloseDialog"
+      >
+        <template v-if="dialogVisible.type === 'detail'">
+          <QuotaDetail :quota-id="currentQuotaId" />
+        </template>
+        <template v-else>
+          <QuotaForm
+            ref="formRef"
+            :type="dialogVisible.type"
+            :quota-id="currentQuotaId"
+            :employee-id="employeeIdFromUrl"
+            :institution-id="institutionIdFromUrl"
+            @success="handleFormSuccess"
+          />
+        </template>
+
+        <template #footer>
+          <div class="dialog-footer">
+            <el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
+              确定
+            </el-button>
+            <el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
+            <el-button @click="handleCloseDialog">取消</el-button>
+          </div>
+        </template>
+      </EnhancedDialog>
+    </template>
+
+    <!-- 手工发放批次管理 -->
+    <template v-if="activeCategory === 'batch'">
+      <div class="batch-section">
+        <div style="margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center;">
+          <h3 style="margin: 0">手工发放批次</h3>
+          <el-button type="primary" @click="handleCreateBatch">
+            <el-icon><Plus /></el-icon> 新建发放
+          </el-button>
         </div>
+
+        <el-table :data="batchList" border max-height="calc(100vh - 300px)">
+          <template #empty>
+            <el-empty :image-size="80" description="暂无发放批次" />
+          </template>
+          <el-table-column type="index" label="序号" width="60" />
+          <el-table-column prop="issue_batch_id" label="批次ID" min-width="180" show-overflow-tooltip />
+          <el-table-column prop="batch_no" label="批次号" min-width="140" />
+          <el-table-column prop="issue_name" label="发放名称" min-width="120" />
+          <el-table-column prop="institution_id" label="制度ID" min-width="160" show-overflow-tooltip />
+          <el-table-column prop="total_count" label="发放人数" width="80" align="center" />
+          <el-table-column prop="total_amount" label="总金额" width="120" align="right">
+            <template #default="scope">
+              {{ scope.row.total_amount ? `¥${Number(scope.row.total_amount).toFixed(2)}` : "-" }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="status" label="状态" width="80" align="center">
+            <template #default="scope">
+              <el-tag :type="ISSUE_BATCH_STATUS_TAG[scope.row.status] || 'info'" size="small">
+                {{ ISSUE_BATCH_STATUS_LABEL[scope.row.status] || scope.row.status }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="created_time" label="创建时间" width="160" />
+          <el-table-column label="操作" width="180" align="center" fixed="right">
+            <template #default="scope">
+              <el-button type="primary" size="small" link @click="handleViewBatchRecords(scope.row)">
+                发放明细
+              </el-button>
+              <el-button
+                v-if="scope.row.status === 'ACTIVE'"
+                type="danger"
+                size="small"
+                link
+                @click="handleCancelBatch(scope.row)"
+              >
+                作废
+              </el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </template>
+
+    <!-- 新建批次弹窗 -->
+    <el-dialog v-model="batchDialogVisible" title="新建手工发放" width="800px" destroy-on-close @close="handleBatchDialogClose">
+      <IssueBatchForm
+        ref="batchFormRef"
+        :enterprise-id="enterpriseIdFromUrl"
+        @success="handleBatchCreateSuccess"
+      />
+      <template #footer>
+        <el-button type="primary" @click="handleBatchSubmit">确认发放</el-button>
+        <el-button @click="batchDialogVisible = false">取消</el-button>
       </template>
-    </PageContent>
-
-    <EnhancedDialog
-      v-model="dialogVisible.visible"
-      :title="dialogVisible.title"
-      @close="handleCloseDialog"
-    >
-      <template v-if="dialogVisible.type === 'detail'">
-        <QuotaDetail :quota-id="currentQuotaId" />
-      </template>
-      <template v-else>
-        <QuotaForm
-          ref="formRef"
-          :type="dialogVisible.type"
-          :quota-id="currentQuotaId"
-          :employee-id="employeeIdFromUrl"
-          :institution-id="institutionIdFromUrl"
-          @success="handleFormSuccess"
-        />
-      </template>
+    </el-dialog>
 
+    <!-- 发放记录弹窗 -->
+    <el-dialog v-model="recordsDialogVisible" title="发放明细" width="900px" destroy-on-close>
+      <IssueBatchDetail
+        :issue-batch-id="currentBatchId"
+        :institution-id="currentBatchInstitutionId"
+        :batch-info="currentBatchInfo"
+      />
       <template #footer>
-        <div class="dialog-footer">
-          <el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">
-            确定
-          </el-button>
-          <el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
-          <el-button @click="handleCloseDialog">取消</el-button>
-        </div>
+        <el-button type="primary" @click="recordsDialogVisible = false">关闭</el-button>
       </template>
-    </EnhancedDialog>
+    </el-dialog>
   </div>
 </template>
 
@@ -191,11 +283,14 @@ defineOptions({
   inheritAttrs: false,
 });
 
+import { Plus } from "@element-plus/icons-vue";
 import QuotaAPI, {
   QuotaPageQuery,
   QUOTA_TYPE_OPTIONS,
   STATUS_TAG_TYPE,
   STATUS_LABEL,
+  ISSUE_BATCH_STATUS_TAG,
+  ISSUE_BATCH_STATUS_LABEL,
 } from "@/api/module_payment/quota";
 import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
 import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
@@ -204,12 +299,15 @@ import PageContent from "@/components/CURD/PageContent.vue";
 import EnhancedDialog from "@/components/CURD/EnhancedDialog.vue";
 import QuotaForm from "./components/QuotaForm.vue";
 import QuotaDetail from "./components/QuotaDetail.vue";
+import IssueBatchForm from "./components/IssueBatchForm.vue";
+import IssueBatchDetail from "./components/IssueBatchDetail.vue";
 import type { ISearchConfig, IContentConfig } from "@/components/CURD/types";
 import { useCrudList } from "@/components/CURD/useCrudList";
 import { useLoadingAction } from "@/composables/useLoadingAction";
 import { useRoute } from "vue-router";
-import { ElMessage } from "element-plus";
-import { ref, reactive, computed } from "vue";
+import { ElMessage, ElMessageBox } from "element-plus";
+import { ref, reactive, computed, onMounted } from "vue";
+import { useEnterpriseStore } from "@/store/modules/enterprise.store";
 
 const route = useRoute();
 
@@ -221,7 +319,20 @@ const { pageLoading, loadingText, execute: loadingExecute } = useLoadingAction()
 
 const employeeIdFromUrl = computed(() => route.query.employee_id as string | undefined);
 const institutionIdFromUrl = computed(() => route.query.institution_id as string | undefined);
+const enterpriseIdFromUrl = computed(() => route.query.enterprise_id as string | undefined);
+
+const categoryTabs = [
+  { key: "quota", label: "额度管理" },
+  { key: "batch", label: "手工发放" },
+];
+const activeCategory = ref("quota");
 
+function handleCategoryChange(key: string) {
+  activeCategory.value = key;
+  if (key === "batch") loadBatchList();
+}
+
+// ===== 额度管理 =====
 const searchConfig = reactive<ISearchConfig>({
   permPrefix: "module_payment:quota",
   colon: true,
@@ -359,4 +470,95 @@ function formatQuotaType(type?: string) {
   const option = QUOTA_TYPE_OPTIONS.find((item) => item.value === type);
   return option ? option.label : type;
 }
+
+// ===== 手工发放批次管理 =====
+const batchFormRef = ref();
+const batchDialogVisible = ref(false);
+const recordsDialogVisible = ref(false);
+const batchList = ref<any[]>([]);
+const currentBatchId = ref("");
+const currentBatchInstitutionId = ref("");
+const currentBatchInfo = ref<any>({});
+
+async function loadBatchList() {
+  try {
+    const store = useEnterpriseStore();
+    const eid = store.getCurrentEnterprise?.enterprise_id;
+    const res = await QuotaAPI.issueBatchList({ page_no: 1, page_size: 200, institution_id: institutionIdFromUrl.value });
+    batchList.value = res?.data?.data?.items || [];
+  } catch (e) {
+    console.error("加载批次列表失败", e);
+  }
+}
+
+function handleCreateBatch() {
+  batchFormRef.value?.resetForm();
+  batchDialogVisible.value = true;
+}
+
+async function handleBatchSubmit() {
+  const formValid = await batchFormRef.value?.submitForm();
+  if (!formValid) return;
+
+  const formData = batchFormRef.value?.getFormData();
+  if (!formData) return;
+
+  await loadingExecute({
+    loadingText: "正在发放...",
+    action: () => QuotaAPI.issueBatchCreate(formData),
+    onSuccess: (res: any) => {
+      const data = res?.data?.data || res;
+      ElMessage.success("发放成功");
+      batchDialogVisible.value = false;
+      if (data?.issue_quota_check_failed_list?.length > 0) {
+        ElMessage.warning(`有 ${data.issue_quota_check_failed_list.length} 条校验失败`);
+      }
+      loadBatchList();
+    },
+  });
+}
+
+function handleBatchDialogClose() {
+  batchDialogVisible.value = false;
+}
+
+function handleViewBatchRecords(row: any) {
+  currentBatchId.value = row.issue_batch_id;
+  currentBatchInstitutionId.value = row.institution_id;
+  currentBatchInfo.value = {
+    issue_batch_id: row.issue_batch_id,
+    batch_no: row.batch_no,
+    issue_name: row.issue_name,
+    institution_id: row.institution_id,
+  };
+  recordsDialogVisible.value = true;
+}
+
+function handleCancelBatch(row: any) {
+  ElMessageBox.confirm(`确认作废发放批次 "${row.issue_name}"?作废后已发放的额度将无法使用。`, "警告", {
+    confirmButtonText: "确认作废",
+    cancelButtonText: "取消",
+    type: "warning",
+  }).then(async () => {
+    const store = useEnterpriseStore();
+    const eid = store.getCurrentEnterprise?.enterprise_id;
+    if (!eid) {
+      ElMessage.error("企业ID不存在");
+      return;
+    }
+    await loadingExecute({
+      loadingText: "正在作废...",
+      action: () =>
+        QuotaAPI.issueBatchCancel({
+          enterprise_id: eid,
+          institution_id: row.institution_id,
+          issue_batch_id: row.issue_batch_id,
+        }),
+      onSuccess: () => {
+        ElMessage.success("作废成功");
+        loadBatchList();
+      },
+    });
+  }).catch(() => {});
+}
 </script>