Files
zkxw/docs/superpowers/plans/2026-05-24-channel-service-order.md
T
2026-05-24 23:04:33 +08:00

56 KiB
Raw Blame History

渠道服务购买订单 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现渠道服务购买订单功能,包括充值/购买两种订单类型、渠道账户余额管理、产品份数库存管理、财务确认流程。

Architecture: 扩展现有 biz_channel_service_order 表和 pilot_service.py 模块,新增 2 张表(渠道账户 + 产品份数库存)。后端遵循现有 _row_to_dict + CRUD + API 三层模式,前端新增 2 个页面 + 1 个渠道页扩展。

Tech Stack: FastAPI + SQLAlchemy + Pydantic v2 (后端) · Vue 3 + Element Plus + Tailwind CSS 4 (前端) · MySQL 5.7

Design Spec: docs/superpowers/specs/2026-05-24-channel-service-order-design.md


File Map

Action File Responsibility
CREATE docs/sql/channel-service-order-migration.sql 数据库迁移脚本
MODIFY backend/app/models/models.py 新增 BizChannelAccount, BizChannelProductQuota 模型
MODIFY backend/app/crud/pilot_service.py 新增订单 CRUD + 状态流转 + 账户/份数辅助函数
MODIFY backend/app/api/pilot_service.py 新增订单 API 端点 + Pydantic Body
MODIFY web-admin/src/api/admin.ts 新增前端 API 函数
MODIFY web-admin/src/utils/status-tag-registry.ts 新增状态标签
CREATE web-admin/src/views/pilot-service/channel-service-order/index.vue 订单管理页
CREATE web-admin/src/views/pilot-service/channel-service-order-finance/index.vue 财务确认页
MODIFY web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue 账户信息 Tab

Task 1: 数据库迁移脚本

Files:

  • Create: docs/sql/channel-service-order-migration.sql

  • Step 1: 编写迁移 SQL

-- =============================================
-- 渠道服务购买订单 - 数据库迁移
-- 日期: 2026-05-24
-- =============================================

-- 1. 扩展 biz_channel_service_order 表
ALTER TABLE biz_channel_service_order
  ADD COLUMN channel_agreement_id VARCHAR(64) DEFAULT NULL COMMENT '渠道协议ID(购买订单必填)',
  ADD COLUMN balance_before DECIMAL(18,2) DEFAULT NULL COMMENT '操作前余额快照',
  ADD COLUMN balance_after DECIMAL(18,2) DEFAULT NULL COMMENT '操作后余额快照',
  ADD COLUMN finance_confirm_time DATETIME DEFAULT NULL COMMENT '财务确认时间',
  ADD COLUMN finance_confirm_attachments VARCHAR(4000) DEFAULT NULL COMMENT '财务确认附件(逗号分隔URL)',
  ADD COLUMN finance_remark VARCHAR(512) DEFAULT NULL COMMENT '财务确认备注',
  ADD COLUMN refund_apply_by VARCHAR(64) DEFAULT NULL COMMENT '退费申请人ID',
  ADD COLUMN refund_apply_time DATETIME DEFAULT NULL COMMENT '退费申请时间',
  ADD COLUMN refund_amount DECIMAL(18,2) DEFAULT NULL COMMENT '退费金额',
  ADD COLUMN refund_remark VARCHAR(512) DEFAULT NULL COMMENT '退费备注',
  ADD COLUMN refund_attachments VARCHAR(4000) DEFAULT NULL COMMENT '退费附件(逗号分隔URL)',
  ADD COLUMN refund_finance_confirm_time DATETIME DEFAULT NULL COMMENT '退费财务确认时间',
  ADD COLUMN refund_finance_confirm_attachments VARCHAR(4000) DEFAULT NULL COMMENT '退费财务确认附件(逗号分隔URL)',
  ADD COLUMN refund_finance_remark VARCHAR(512) DEFAULT NULL COMMENT '退费财务确认备注';

-- 2. 新增 渠道账户余额 表
CREATE TABLE IF NOT EXISTS biz_channel_account (
  id VARCHAR(64) NOT NULL COMMENT '主键ID',
  channel_id VARCHAR(64) NOT NULL COMMENT '渠道ID',
  balance_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '当前余额(元)',
  tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  order_num INT DEFAULT NULL COMMENT '排序号',
  remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
  create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
  create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  del_flag TINYINT(4) NOT NULL DEFAULT 0 COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (id),
  UNIQUE KEY uk_account_channel (channel_id, del_flag),
  KEY idx_ca_tenant (tenant_no, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='渠道账户余额';

-- 3. 新增 渠道产品份数库存 表
CREATE TABLE IF NOT EXISTS biz_channel_product_quota (
  id VARCHAR(64) NOT NULL COMMENT '主键ID',
  channel_id VARCHAR(64) NOT NULL COMMENT '渠道ID',
  project_code VARCHAR(64) DEFAULT NULL COMMENT '项目编码',
  project_name VARCHAR(200) DEFAULT NULL COMMENT '项目名称',
  cell_type_code VARCHAR(64) DEFAULT NULL COMMENT '细胞类型编码',
  cell_type_name VARCHAR(200) DEFAULT NULL COMMENT '细胞类型名称',
  spec_text VARCHAR(200) DEFAULT NULL COMMENT '规格',
  total_qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总购买份数',
  used_qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已使用份数(中试服务扣减)',
  remaining_qty DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '剩余份数 = total_qty - used_qty',
  tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
  order_num INT DEFAULT NULL COMMENT '排序号',
  remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
  create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id',
  create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
  create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id',
  update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  del_flag TINYINT(4) NOT NULL DEFAULT 0 COMMENT '删除标记:0-正常 1-已删除',
  PRIMARY KEY (id),
  UNIQUE KEY uk_quota_channel_product (channel_id, project_code, cell_type_code, spec_text, del_flag),
  KEY idx_cpq_tenant (tenant_no, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='渠道产品份数库存';

-- 4. 菜单数据(在中试服务管理 parent_id 下新增)
-- 注意: parent_id 需要替换为实际的「中试服务管理」菜单 ID
-- 先查询: SELECT id FROM sys_menu WHERE menu_name='中试服务管理' AND parent_id IN (SELECT id FROM sys_menu WHERE menu_name='web端菜单');
-- 以下用占位 PARENT_ID 表示,执行时替换

SET @pilot_parent_id = (SELECT id FROM sys_menu WHERE menu_name='中试服务管理' AND parent_id IN (SELECT id FROM sys_menu WHERE menu_name='web端菜单') LIMIT 1);

INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
VALUES (REPLACE(UUID(),'-',''), '渠道服务购买订单', @pilot_parent_id, 60, 'channel-service-order', 'pilot-service/channel-service-order/index', 1, '1', '1', '', '', NOW(), NOW());

INSERT INTO sys_menu (id, menu_name, parent_id, order_num, path, component, menu_type, visible, status, perms, icon, create_time, update_time)
VALUES (REPLACE(UUID(),'-',''), '渠道服务购买订单(财务确认)', @pilot_parent_id, 61, 'channel-service-order-finance', 'pilot-service/channel-service-order-finance/index', 1, '1', '1', '', '', NOW(), NOW());
  • Step 2: 在目标数据库执行迁移

Run: 在 MySQL 客户端中执行此 SQL 文件。

  • Step 3: Commit
git add docs/sql/channel-service-order-migration.sql
git commit -m "feat(channel-order): add database migration for channel service order"

Task 2: 后端 SQLAlchemy 模型

Files:

  • Modify: backend/app/models/models.py (在 BizChannelMailAddress 类之后,约 L1714 后追加)

  • Step 1: 新增 BizChannelAccount 和 BizChannelProductQuota 模型

在 BizChannelMailAddress 类之后、BizCheckupInstitution 类之前追加以下代码:

class BizChannelAccount(Base):
    """渠道账户余额 biz_channel_account"""

    __tablename__ = "biz_channel_account"

    id = Column(String(64), primary_key=True, comment="主键ID")
    channel_id = Column(String(64), nullable=False, comment="渠道ID")
    balance_amount = Column(Numeric(18, 2), nullable=False, default=0, comment="当前余额(元)")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")


class BizChannelProductQuota(Base):
    """渠道产品份数库存 biz_channel_product_quota"""

    __tablename__ = "biz_channel_product_quota"

    id = Column(String(64), primary_key=True, comment="主键ID")
    channel_id = Column(String(64), nullable=False, comment="渠道ID")
    project_code = Column(String(64), nullable=True, comment="项目编码")
    project_name = Column(String(200), nullable=True, comment="项目名称")
    cell_type_code = Column(String(64), nullable=True, comment="细胞类型编码")
    cell_type_name = Column(String(200), nullable=True, comment="细胞类型名称")
    spec_text = Column(String(200), nullable=True, comment="规格")
    total_qty = Column(Numeric(18, 4), nullable=False, default=0, comment="总购买份数")
    used_qty = Column(Numeric(18, 4), nullable=False, default=0, comment="已使用份数")
    remaining_qty = Column(Numeric(18, 4), nullable=False, default=0, comment="剩余份数")
    tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
    order_num = Column(Integer, nullable=True, comment="排序号")
    remark = Column(String(512), nullable=True, comment="备注")
    create_by = Column(String(64), nullable=True, comment="创建人id")
    create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
    create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
    update_by = Column(String(64), nullable=True, comment="更新人id")
    update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
    del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
  • Step 2: 确认 BizChannelServiceOrder 和 BizChannelServiceOrderProduct 模型

检查 models.py 中是否已存在 BizChannelServiceOrder 和 BizChannelServiceOrderProduct 模型。如果存在,需要添加新字段(channel_agreement_id, balance_before, balance_after, finance_confirm_time 等)。如果不存在,需要新建。参照 BizChannelAgreement 的字段模式。

新增字段追加到 BizChannelServiceOrder 模型中(在 order_status 字段之后、tenant_no 之前):

channel_agreement_id = Column(String(64), nullable=True, comment="渠道协议ID(购买订单必填)")
balance_before = Column(Numeric(18, 2), nullable=True, comment="操作前余额快照")
balance_after = Column(Numeric(18, 2), nullable=True, comment="操作后余额快照")
finance_confirm_time = Column(DateTime, nullable=True, comment="财务确认时间")
finance_confirm_attachments = Column(String(4000), nullable=True, comment="财务确认附件(逗号分隔URL)")
finance_remark = Column(String(512), nullable=True, comment="财务确认备注")
refund_apply_by = Column(String(64), nullable=True, comment="退费申请人ID")
refund_apply_time = Column(DateTime, nullable=True, comment="退费申请时间")
refund_amount = Column(Numeric(18, 2), nullable=True, comment="退费金额")
refund_remark = Column(String(512), nullable=True, comment="退费备注")
refund_attachments = Column(String(4000), nullable=True, comment="退费附件(逗号分隔URL)")
refund_finance_confirm_time = Column(DateTime, nullable=True, comment="退费财务确认时间")
refund_finance_confirm_attachments = Column(String(4000), nullable=True, comment="退费财务确认附件")
refund_finance_remark = Column(String(512), nullable=True, comment="退费财务确认备注")
  • Step 3: 验证后端启动

Run: cd backend && source venv/bin/activate && python -c "from app.models.models import BizChannelAccount, BizChannelProductQuota, BizChannelServiceOrder, BizChannelServiceOrderProduct; print('Models OK')"

  • Step 4: Commit
git add backend/app/models/models.py
git commit -m "feat(channel-order): add SQLAlchemy models for channel account, product quota, and service order fields"

Task 3: 后端 CRUD — 账户 & 份数辅助函数

Files:

  • Modify: backend/app/crud/pilot_service.py (文件末尾追加)

先实现底层辅助函数,因为后续订单 CRUD 依赖它们。

  • Step 1: 在 pilot_service.py 的 import 区添加新模型引用

在文件头部的 from app.models.models import ... 中追加:BizChannelAccount, BizChannelProductQuota, BizChannelServiceOrder, BizChannelServiceOrderProduct。

  • Step 2: 在文件末尾追加账户辅助函数
# ────────────────────────────────────────────
# Channel Account 渠道账户
# ────────────────────────────────────────────

def _get_or_create_channel_account(db, channel_id, tenant_no="zkxw", user_id=None):
    """获取渠道账户,不存在则创建(余额为 0)"""
    account = (
        db.query(BizChannelAccount)
        .filter(
            and_(
                BizChannelAccount.channel_id == channel_id,
                BizChannelAccount.del_flag == 0,
            )
        )
        .with_for_update()
        .first()
    )
    if not account:
        account = BizChannelAccount(
            id=generate_uuid(),
            channel_id=channel_id,
            balance_amount=0,
            tenant_no=tenant_no,
            create_by=user_id,
            create_dept_id="",
            create_time=datetime.now(),
            update_by=user_id,
            update_time=datetime.now(),
            del_flag=0,
        )
        db.add(account)
        db.flush()
    return account


def get_channel_account(db, channel_id, tenant_no=None):
    """获取渠道账户(只读,不加锁)"""
    q = db.query(BizChannelAccount).filter(
        and_(BizChannelAccount.channel_id == channel_id, BizChannelAccount.del_flag == 0)
    )
    if tenant_no:
        q = q.filter(BizChannelAccount.tenant_no == tenant_no)
    return q.first()


def _row_to_dict_channel_account(row) -> Dict[str, Any]:
    return {
        "id": row.id,
        "channelId": row.channel_id or "",
        "balanceAmount": float(row.balance_amount) if row.balance_amount else 0.0,
        "tenantNo": row.tenant_no or "",
        "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
        "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
    }
  • Step 3: 追加份数库存辅助函数
# ────────────────────────────────────────────
# Channel Product Quota 渠道产品份数库存
# ────────────────────────────────────────────

def _upsert_channel_product_quota(db, channel_id, project_code, project_name,
                                   cell_type_code, cell_type_name, spec_text,
                                   add_qty, tenant_no="zkxw", user_id=None):
    """新增或更新渠道产品份数(购买时调用,add_qty > 0)"""
    quota = (
        db.query(BizChannelProductQuota)
        .filter(
            and_(
                BizChannelProductQuota.channel_id == channel_id,
                BizChannelProductQuota.project_code == project_code,
                BizChannelProductQuota.cell_type_code == cell_type_code,
                BizChannelProductQuota.spec_text == spec_text,
                BizChannelProductQuota.del_flag == 0,
            )
        )
        .first()
    )
    if quota:
        quota.total_qty = (quota.total_qty or 0) + add_qty
        quota.remaining_qty = (quota.remaining_qty or 0) + add_qty
        quota.update_by = user_id
        quota.update_time = datetime.now()
        db.add(quota)
    else:
        quota = BizChannelProductQuota(
            id=generate_uuid(),
            channel_id=channel_id,
            project_code=project_code,
            project_name=project_name,
            cell_type_code=cell_type_code,
            cell_type_name=cell_type_name,
            spec_text=spec_text,
            total_qty=add_qty,
            used_qty=0,
            remaining_qty=add_qty,
            tenant_no=tenant_no,
            create_by=user_id,
            create_dept_id="",
            create_time=datetime.now(),
            update_by=user_id,
            update_time=datetime.now(),
            del_flag=0,
        )
        db.add(quota)
    db.flush()
    return quota


def _reduce_channel_product_quota(db, channel_id, project_code, cell_type_code,
                                   spec_text, reduce_qty, user_id=None):
    """扣减渠道产品份数(作废时调用,reduce_qty > 0)"""
    quota = (
        db.query(BizChannelProductQuota)
        .filter(
            and_(
                BizChannelProductQuota.channel_id == channel_id,
                BizChannelProductQuota.project_code == project_code,
                BizChannelProductQuota.cell_type_code == cell_type_code,
                BizChannelProductQuota.spec_text == spec_text,
                BizChannelProductQuota.del_flag == 0,
            )
        )
        .first()
    )
    if not quota:
        raise ValueError(f"渠道产品份数不存在: {project_code}/{cell_type_code}/{spec_text}")
    if (quota.remaining_qty or 0) < reduce_qty:
        raise ValueError(
            f"产品 {quota.project_name}({quota.spec_text}) 已使用份数,无法完全作废。"
            f"剩余={quota.remaining_qty}, 需退回={reduce_qty}"
        )
    quota.total_qty = (quota.total_qty or 0) - reduce_qty
    quota.remaining_qty = (quota.remaining_qty or 0) - reduce_qty
    quota.update_by = user_id
    quota.update_time = datetime.now()
    db.add(quota)
    db.flush()
    return quota


def list_channel_product_quotas(db, channel_id, tenant_no=None):
    """获取渠道产品份数列表"""
    q = db.query(BizChannelProductQuota).filter(
        and_(
            BizChannelProductQuota.channel_id == channel_id,
            BizChannelProductQuota.del_flag == 0,
        )
    )
    if tenant_no:
        q = q.filter(BizChannelProductQuota.tenant_no == tenant_no)
    rows = q.order_by(BizChannelProductQuota.create_time.desc()).all()
    return [_row_to_dict_channel_product_quota(x) for x in rows]


def _row_to_dict_channel_product_quota(row) -> Dict[str, Any]:
    return {
        "id": row.id,
        "channelId": row.channel_id or "",
        "projectCode": row.project_code or "",
        "projectName": row.project_name or "",
        "cellTypeCode": row.cell_type_code or "",
        "cellTypeName": row.cell_type_name or "",
        "specText": row.spec_text or "",
        "totalQty": float(row.total_qty) if row.total_qty else 0,
        "usedQty": float(row.used_qty) if row.used_qty else 0,
        "remainingQty": float(row.remaining_qty) if row.remaining_qty else 0,
        "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
        "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
    }
  • Step 4: Commit
git add backend/app/crud/pilot_service.py
git commit -m "feat(channel-order): add CRUD helpers for channel account and product quota"

Task 4: 后端 CRUD — 订单基础操作 (list/get/create/update/delete)

Files:

  • Modify: backend/app/crud/pilot_service.py (继续在末尾追加)

  • Step 1: 追加 _row_to_dict 和 list/get 函数

# ────────────────────────────────────────────
# Channel Service Order 渠道服务购买订单
# ────────────────────────────────────────────

def _row_to_dict_service_order(row, db=None, tenant_no=None) -> Dict[str, Any]:
    tn = tenant_no or row.tenant_no
    from app.utils.minio import urls_to_attachment_list
    result = {
        "id": row.id,
        "channelId": row.channel_id or "",
        "channelName": row.channel_name or "",
        "deptName": row.dept_name or "",
        "orderType": row.order_type or "",
        "prepayAmount": float(row.prepay_amount) if row.prepay_amount else None,
        "purchaseTotalAmount": float(row.purchase_total_amount) if row.purchase_total_amount else None,
        "orderStatus": row.order_status or "",
        "channelAgreementId": row.channel_agreement_id or "",
        "balanceBefore": float(row.balance_before) if row.balance_before else None,
        "balanceAfter": float(row.balance_after) if row.balance_after else None,
        "financeConfirmTime": row.finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.finance_confirm_time else None,
        "financeConfirmAttachments": row.finance_confirm_attachments or "",
        "financeConfirmAttachmentFiles": urls_to_attachment_list(row.finance_confirm_attachments, db, tn) if db and row.finance_confirm_attachments else [],
        "financeRemark": row.finance_remark or "",
        "refundApplyBy": row.refund_apply_by or "",
        "refundApplyTime": row.refund_apply_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_apply_time else None,
        "refundAmount": float(row.refund_amount) if row.refund_amount else None,
        "refundRemark": row.refund_remark or "",
        "refundAttachments": row.refund_attachments or "",
        "refundAttachmentFiles": urls_to_attachment_list(row.refund_attachments, db, tn) if db and row.refund_attachments else [],
        "refundFinanceConfirmTime": row.refund_finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_finance_confirm_time else None,
        "refundFinanceConfirmAttachments": row.refund_finance_confirm_attachments or "",
        "refundFinanceConfirmAttachmentFiles": urls_to_attachment_list(row.refund_finance_confirm_attachments, db, tn) if db and row.refund_finance_confirm_attachments else [],
        "refundFinanceRemark": row.refund_finance_remark or "",
        "tenantNo": row.tenant_no or "",
        "remark": row.remark or "",
        "createBy": row.create_by or "",
        "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
        "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
    }
    return result


def _row_to_dict_service_order_product(row) -> Dict[str, Any]:
    return {
        "id": row.id,
        "serviceOrderId": row.service_order_id or "",
        "projectCode": row.project_code or "",
        "projectName": row.project_name or "",
        "cellType": row.cell_type or "",
        "specText": row.spec_text or "",
        "agreementUnitPrice": float(row.agreement_unit_price) if row.agreement_unit_price else 0,
        "buyQty": float(row.buy_qty) if row.buy_qty else 0,
        "buyAmount": float(row.buy_amount) if row.buy_amount else 0,
    }


def list_channel_service_orders(db, *, page=1, page_size=10, order_type=None,
                                  order_status=None, channel_id=None,
                                  keyword=None, tenant_no=None, user_info=None):
    def _filtered():
        q0 = db.query(BizChannelServiceOrder).filter(BizChannelServiceOrder.del_flag == 0)
        if tenant_no:
            q0 = q0.filter(BizChannelServiceOrder.tenant_no == tenant_no)
        if order_type:
            q0 = q0.filter(BizChannelServiceOrder.order_type == order_type)
        if order_status:
            # 支持逗号分隔的多个状态
            statuses = [s.strip() for s in order_status.split(",") if s.strip()]
            if statuses:
                q0 = q0.filter(BizChannelServiceOrder.order_status.in_(statuses))
        if channel_id:
            q0 = q0.filter(BizChannelServiceOrder.channel_id == channel_id)
        kw = (keyword or "").strip()
        if kw:
            like = f"%{kw}%"
            q0 = q0.filter(BizChannel.channel_name.like(like))
        if user_info:
            from app.core.data_scope import apply_data_scope
            q0 = apply_data_scope(q0, BizChannelServiceOrder, user_info, db, tenant_no)
        return q0

    total = _filtered().order_by(None).with_entities(func.count(BizChannelServiceOrder.id)).scalar() or 0
    page = max(1, page)
    page_size = min(max(1, page_size), 100)
    rows = _filtered().order_by(BizChannelServiceOrder.create_time.desc()).offset((page - 1) * page_size).limit(page_size).all()
    return [_row_to_dict_service_order(x, db, tenant_no) for x in rows], int(total)


def get_service_order(db, order_id, tenant_no=None):
    q = db.query(BizChannelServiceOrder).filter(
        and_(BizChannelServiceOrder.id == order_id, BizChannelServiceOrder.del_flag == 0)
    )
    if tenant_no:
        q = q.filter(BizChannelServiceOrder.tenant_no == tenant_no)
    return q.first()


def get_service_order_products(db, service_order_id, tenant_no=None):
    q = db.query(BizChannelServiceOrderProduct).filter(
        and_(BizChannelServiceOrderProduct.service_order_id == service_order_id,
             BizChannelServiceOrderProduct.del_flag == 0)
    )
    if tenant_no:
        q = q.filter(BizChannelServiceOrderProduct.tenant_no == tenant_no)
    return q.all()
  • Step 2: 追加 create 函数
def create_channel_service_order(
    db,
    *,
    channel_id,
    order_type,
    channel_name=None,
    dept_name=None,
    prepay_amount=None,
    channel_agreement_id=None,
    products=None,  # list of dicts: {project_code, project_name, cell_type, spec_text, agreement_unit_price, buy_qty, buy_amount}
    tenant_no="zkxw",
    remark=None,
    user_id=None,
):
    """创建渠道服务购买订单"""
    # 1. 校验渠道存在
    channel = get_channel(db, channel_id, tenant_no)
    if not channel:
        raise ValueError("渠道不存在")
    channel_name = channel.channel_name

    # 2. 确定 order_status
    if order_type == "yfk":
        if not prepay_amount or float(prepay_amount) <= 0:
            raise ValueError("充值金额必须大于 0")
        order_status = "dqr"
        purchase_total_amount = None
    elif order_type == "gmcp":
        if not products:
            raise ValueError("购买产品订单必须包含产品明细")
        order_status = "sxz"
        # 校验协议
        if not channel_agreement_id:
            raise ValueError("购买订单必须关联渠道协议")
        agr = db.query(BizChannelAgreement).filter(
            and_(BizChannelAgreement.id == channel_agreement_id,
                 BizChannelAgreement.del_flag == 0)
        ).first()
        if not agr or agr.agreement_status != "sxz":
            raise ValueError("渠道协议不存在或未生效")
        prepay_amount = None
        purchase_total_amount = sum(float(p["buy_amount"]) for p in products)
        # 校验余额
        account = _get_or_create_channel_account(db, channel_id, tenant_no, user_id)
        if float(account.balance_amount) < purchase_total_amount:
            raise ValueError(f"渠道余额不足。当前余额: {account.balance_amount}, 需要: {purchase_total_amount}")
    else:
        raise ValueError(f"不支持的订单类型: {order_type}")

    balance_before = None
    balance_after = None

    # 3. 扣减余额(仅购买订单)
    if order_type == "gmcp":
        account = _get_or_create_channel_account(db, channel_id, tenant_no, user_id)
        balance_before = float(account.balance_amount)
        account.balance_amount = float(account.balance_amount) - purchase_total_amount
        account.update_by = user_id
        account.update_time = datetime.now()
        db.add(account)
        db.flush()
        balance_after = float(account.balance_amount)

    # 4. 创建订单主表
    order = BizChannelServiceOrder(
        id=generate_uuid(),
        channel_id=channel_id,
        channel_name=channel_name,
        dept_name=dept_name or "",
        order_type=order_type,
        prepay_amount=prepay_amount,
        purchase_total_amount=purchase_total_amount,
        order_status=order_status,
        channel_agreement_id=channel_agreement_id,
        balance_before=balance_before,
        balance_after=balance_after,
        tenant_no=tenant_no,
        remark=remark or "",
        create_by=user_id,
        create_dept_id="",
        create_time=datetime.now(),
        update_by=user_id,
        update_time=datetime.now(),
        del_flag=0,
    )
    db.add(order)
    db.flush()

    # 5. 创建产品明细 + 更新份数库存(仅购买订单)
    if order_type == "gmcp" and products:
        for p in products:
            product_row = BizChannelServiceOrderProduct(
                id=generate_uuid(),
                service_order_id=order.id,
                project_code=p.get("project_code", ""),
                project_name=p.get("project_name", ""),
                cell_type=p.get("cell_type", ""),
                spec_text=p.get("spec_text", ""),
                agreement_unit_price=p.get("agreement_unit_price"),
                buy_qty=p.get("buy_qty"),
                buy_amount=p.get("buy_amount"),
                tenant_no=tenant_no,
                create_by=user_id,
                create_dept_id="",
                create_time=datetime.now(),
                update_by=user_id,
                update_time=datetime.now(),
                del_flag=0,
            )
            db.add(product_row)
            # 更新份数库存
            # 从协议产品价格表获取 cell_type_code 和 cell_type_name
            agr_price = db.query(BizChannelAgreementProductPrice).filter(
                and_(
                    BizChannelAgreementProductPrice.channel_agreement_id == channel_agreement_id,
                    BizChannelAgreementProductPrice.project_code == p.get("project_code"),
                    BizChannelAgreementProductPrice.spec_text == p.get("spec_text"),
                    BizChannelAgreementProductPrice.del_flag == 0,
                )
            ).first()
            cell_type_code = agr_price.cell_type_code if agr_price else ""
            cell_type_name = agr_price.cell_type_name if agr_price else p.get("cell_type", "")
            _upsert_channel_product_quota(
                db, channel_id,
                project_code=p.get("project_code", ""),
                project_name=p.get("project_name", ""),
                cell_type_code=cell_type_code,
                cell_type_name=cell_type_name,
                spec_text=p.get("spec_text", ""),
                add_qty=float(p.get("buy_qty", 0)),
                tenant_no=tenant_no,
                user_id=user_id,
            )

    db.commit()
    db.refresh(order)
    return order
  • Step 3: 追加 update 和 soft_delete 函数
def update_channel_service_order(db, order_id, *, tenant_no=None, user_id=None, **kwargs):
    """修改订单(仅 dqr/sxz 状态可改)"""
    order = get_service_order(db, order_id, tenant_no)
    if not order:
        raise ValueError("订单不存在")
    if order.order_status not in ("dqr", "sxz"):
        raise ValueError(f"当前状态 {order.order_status} 不允许修改")
    # 只允许修改 dept_name, remark
    for field in ("dept_name", "remark"):
        if field in kwargs and kwargs[field] is not None:
            setattr(order, field, kwargs[field])
    order.update_by = user_id
    order.update_time = datetime.now()
    db.add(order)
    db.commit()
    db.refresh(order)
    return order


def soft_delete_service_order(db, order_id, tenant_no=None):
    """软删除订单(仅 dqr 状态可删)"""
    order = get_service_order(db, order_id, tenant_no)
    if not order:
        raise ValueError("订单不存在")
    if order.order_status != "dqr":
        raise ValueError("只有待确认状态的订单可以删除")
    order.del_flag = 1
    order.update_time = datetime.now()
    db.add(order)
    db.commit()
  • Step 4: Commit
git add backend/app/crud/pilot_service.py
git commit -m "feat(channel-order): add service order CRUD - list, get, create, update, delete"

Task 5: 后端 CRUD — 状态流转 (confirm/refund/void)

Files:

  • Modify: backend/app/crud/pilot_service.py (继续追加)

  • Step 1: 追加状态流转函数

def confirm_receipt(db, order_id, *, finance_remark=None, finance_confirm_attachments=None,
                     tenant_no=None, user_id=None):
    """财务确认充值 (dqr → yqr, 余额+)"""
    order = get_service_order(db, order_id, tenant_no)
    if not order:
        raise ValueError("订单不存在")
    if order.order_type != "yfk":
        raise ValueError("只有充值订单可以进行收款确认")
    if order.order_status != "dqr":
        raise ValueError(f"当前状态 {order.order_status} 不允许确认收款")

    account = _get_or_create_channel_account(db, order.channel_id, tenant_no, user_id)
    balance_before = float(account.balance_amount)
    account.balance_amount = float(account.balance_amount) + float(order.prepay_amount)
    account.update_by = user_id
    account.update_time = datetime.now()
    db.add(account)
    db.flush()
    balance_after = float(account.balance_amount)

    order.order_status = "yqr"
    order.balance_before = balance_before
    order.balance_after = balance_after
    order.finance_confirm_time = datetime.now()
    order.finance_confirm_attachments = finance_confirm_attachments or ""
    order.finance_remark = finance_remark or ""
    order.update_by = user_id
    order.update_time = datetime.now()
    db.add(order)
    db.commit()
    db.refresh(order)
    return order


def submit_refund(db, order_id, *, refund_amount=None, refund_remark=None,
                   refund_attachments=None, tenant_no=None, user_id=None):
    """发起退费 (yqr → tfqrz)"""
    order = get_service_order(db, order_id, tenant_no)
    if not order:
        raise ValueError("订单不存在")
    if order.order_type != "yfk":
        raise ValueError("只有充值订单可以发起退费")
    if order.order_status != "yqr":
        raise ValueError(f"当前状态 {order.order_status} 不允许发起退费")

    actual_refund = float(refund_amount) if refund_amount else float(order.prepay_amount)
    if actual_refund > float(order.prepay_amount):
        raise ValueError(f"退费金额不能超过充值金额 {order.prepay_amount}")

    order.order_status = "tfqrz"
    order.refund_apply_by = user_id
    order.refund_apply_time = datetime.now()
    order.refund_amount = actual_refund
    order.refund_remark = refund_remark or ""
    order.refund_attachments = refund_attachments or ""
    order.update_by = user_id
    order.update_time = datetime.now()
    db.add(order)
    db.commit()
    db.refresh(order)
    return order


def confirm_refund(db, order_id, *, refund_finance_remark=None,
                    refund_finance_confirm_attachments=None, tenant_no=None, user_id=None):
    """财务确认退费 (tfqrz → ytf, 余额-)"""
    order = get_service_order(db, order_id, tenant_no)
    if not order:
        raise ValueError("订单不存在")
    if order.order_status != "tfqrz":
        raise ValueError(f"当前状态 {order.order_status} 不允许确认退费")

    account = _get_or_create_channel_account(db, order.channel_id, tenant_no, user_id)
    balance_before = float(account.balance_amount)
    account.balance_amount = float(account.balance_amount) - float(order.refund_amount)
    account.update_by = user_id
    account.update_time = datetime.now()
    db.add(account)
    db.flush()
    balance_after = float(account.balance_amount)

    order.order_status = "ytf"
    order.balance_before = balance_before
    order.balance_after = balance_after
    order.refund_finance_confirm_time = datetime.now()
    order.refund_finance_confirm_attachments = refund_finance_confirm_attachments or ""
    order.refund_finance_remark = refund_finance_remark or ""
    order.update_by = user_id
    order.update_time = datetime.now()
    db.add(order)
    db.commit()
    db.refresh(order)
    return order


def void_service_order(db, order_id, *, tenant_no=None, user_id=None):
    """作废购买订单 (sxz → yzf, 余额+, 份数-)"""
    order = get_service_order(db, order_id, tenant_no)
    if not order:
        raise ValueError("订单不存在")
    if order.order_type != "gmcp":
        raise ValueError("只有购买订单可以作废")
    if order.order_status != "sxz":
        raise ValueError(f"当前状态 {order.order_status} 不允许作废")

    # 退回余额
    account = _get_or_create_channel_account(db, order.channel_id, tenant_no, user_id)
    balance_before = float(account.balance_amount)
    account.balance_amount = float(account.balance_amount) + float(order.purchase_total_amount)
    account.update_by = user_id
    account.update_time = datetime.now()
    db.add(account)
    db.flush()
    balance_after = float(account.balance_amount)

    # 退回份数
    products = get_service_order_products(db, order.id, tenant_no)
    for p in products:
        # 获取 cell_type_code 从协议价格表
        agr_price = db.query(BizChannelAgreementProductPrice).filter(
            and_(
                BizChannelAgreementProductPrice.channel_agreement_id == order.channel_agreement_id,
                BizChannelAgreementProductPrice.project_code == p.project_code,
                BizChannelAgreementProductPrice.spec_text == p.spec_text,
                BizChannelAgreementProductPrice.del_flag == 0,
            )
        ).first()
        cell_type_code = agr_price.cell_type_code if agr_price else ""
        _reduce_channel_product_quota(
            db, order.channel_id,
            project_code=p.project_code,
            cell_type_code=cell_type_code,
            spec_text=p.spec_text,
            reduce_qty=float(p.buy_qty),
            user_id=user_id,
        )

    order.order_status = "yzf"
    order.balance_before = balance_before
    order.balance_after = balance_after
    order.update_by = user_id
    order.update_time = datetime.now()
    db.add(order)
    db.commit()
    db.refresh(order)
    return order
  • Step 2: Commit
git add backend/app/crud/pilot_service.py
git commit -m "feat(channel-order): add status transitions - confirm receipt, refund, void"

Task 6: 后端 API 路由

Files:

  • Modify: backend/app/api/pilot_service.py

  • Step 1: 在文件末尾追加 Pydantic Body 类

# --- 渠道服务购买订单 ---


class ServiceOrderProductItem(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    project_code: Optional[str] = None
    project_name: Optional[str] = None
    cell_type: Optional[str] = None
    spec_text: Optional[str] = None
    agreement_unit_price: Optional[float] = None
    buy_qty: Optional[float] = None
    buy_amount: Optional[float] = None


class ServiceOrderCreateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    channel_id: str
    order_type: str  # yfk / gmcp
    dept_name: Optional[str] = None
    prepay_amount: Optional[float] = None
    channel_agreement_id: Optional[str] = None
    products: Optional[List[ServiceOrderProductItem]] = None
    remark: Optional[str] = None


class ServiceOrderUpdateBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    dept_name: Optional[str] = None
    remark: Optional[str] = None


class FinanceConfirmBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    finance_remark: Optional[str] = None
    finance_confirm_attachments: Optional[str] = None


class RefundSubmitBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    refund_amount: Optional[float] = None
    refund_remark: Optional[str] = None
    refund_attachments: Optional[str] = None


class RefundConfirmBody(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    refund_finance_remark: Optional[str] = None
    refund_finance_confirm_attachments: Optional[str] = None
  • Step 2: 追加 API 端点
@router.get("/channel-service-orders")
async def list_service_orders(
    page: int = Query(1, ge=1),
    pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
    orderType: Optional[str] = Query(None, alias="orderType"),
    orderStatus: Optional[str] = Query(None, alias="orderStatus"),
    channelId: Optional[str] = Query(None, alias="channelId"),
    keyword: Optional[str] = None,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    items, total = ps_svc.list_channel_service_orders(
        db, page=page, page_size=pageSize, order_type=orderType,
        order_status=orderStatus, channel_id=channelId,
        keyword=keyword, tenant_no=tenant_no, user_info=user,
    )
    tp = ps_svc.total_pages(total, pageSize)
    return success_response(
        {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}
    )


@router.get("/channel-service-orders/{order_id}")
async def get_service_order_detail(
    order_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    order = ps_svc.get_service_order(db, order_id, tenant_no)
    if not order:
        return error_response(404, "订单不存在")
    result = ps_svc._row_to_dict_service_order(order, db, tenant_no)
    # 加载产品明细
    products = ps_svc.get_service_order_products(db, order_id, tenant_no)
    result["products"] = [ps_svc._row_to_dict_service_order_product(p) for p in products]
    return success_response(result)


@router.post("/channel-service-orders")
async def create_service_order(
    body: ServiceOrderCreateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        products_data = None
        if body.products:
            products_data = [p.model_dump() for p in body.products]
        obj = ps_svc.create_channel_service_order(
            db,
            channel_id=body.channel_id,
            order_type=body.order_type,
            dept_name=body.dept_name,
            prepay_amount=body.prepay_amount,
            channel_agreement_id=body.channel_agreement_id,
            products=products_data,
            tenant_no=tenant_no,
            remark=body.remark,
            user_id=user.get("user_id"),
        )
        result = ps_svc._row_to_dict_service_order(obj, db, tenant_no)
        prods = ps_svc.get_service_order_products(db, obj.id, tenant_no)
        result["products"] = [ps_svc._row_to_dict_service_order_product(p) for p in prods]
        return success_response(result, message="创建成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/channel-service-orders/{order_id}")
async def update_service_order(
    order_id: str,
    body: ServiceOrderUpdateBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        payload = body.model_dump(exclude_unset=True, by_alias=False)
        obj = ps_svc.update_channel_service_order(
            db, order_id, tenant_no=tenant_no, user_id=user.get("user_id"), **payload,
        )
        return success_response(
            ps_svc._row_to_dict_service_order(obj, db, tenant_no),
            message="更新成功",
        )
    except ValueError as e:
        return error_response(400, str(e))


@router.delete("/channel-service-orders/{order_id}")
async def delete_service_order(
    order_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        ps_svc.soft_delete_service_order(db, order_id, tenant_no=tenant_no)
        return success_response(None, message="删除成功")
    except ValueError as e:
        return error_response(400, str(e))


@router.post("/channel-service-orders/{order_id}/confirm-receipt")
async def confirm_receipt_api(
    order_id: str,
    body: FinanceConfirmBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        obj = ps_svc.confirm_receipt(
            db, order_id,
            finance_remark=body.finance_remark,
            finance_confirm_attachments=body.finance_confirm_attachments,
            tenant_no=tenant_no,
            user_id=user.get("user_id"),
        )
        return success_response(
            ps_svc._row_to_dict_service_order(obj, db, tenant_no),
            message="确认收款成功",
        )
    except ValueError as e:
        return error_response(400, str(e))


@router.post("/channel-service-orders/{order_id}/submit-refund")
async def submit_refund_api(
    order_id: str,
    body: RefundSubmitBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        obj = ps_svc.submit_refund(
            db, order_id,
            refund_amount=body.refund_amount,
            refund_remark=body.refund_remark,
            refund_attachments=body.refund_attachments,
            tenant_no=tenant_no,
            user_id=user.get("user_id"),
        )
        return success_response(
            ps_svc._row_to_dict_service_order(obj, db, tenant_no),
            message="退费申请已提交",
        )
    except ValueError as e:
        return error_response(400, str(e))


@router.post("/channel-service-orders/{order_id}/confirm-refund")
async def confirm_refund_api(
    order_id: str,
    body: RefundConfirmBody,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        obj = ps_svc.confirm_refund(
            db, order_id,
            refund_finance_remark=body.refund_finance_remark,
            refund_finance_confirm_attachments=body.refund_finance_confirm_attachments,
            tenant_no=tenant_no,
            user_id=user.get("user_id"),
        )
        return success_response(
            ps_svc._row_to_dict_service_order(obj, db, tenant_no),
            message="退费确认成功",
        )
    except ValueError as e:
        return error_response(400, str(e))


@router.put("/channel-service-orders/{order_id}/void")
async def void_service_order_api(
    order_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        obj = ps_svc.void_service_order(
            db, order_id,
            tenant_no=tenant_no,
            user_id=user.get("user_id"),
        )
        return success_response(
            ps_svc._row_to_dict_service_order(obj, db, tenant_no),
            message="订单已作废",
        )
    except ValueError as e:
        return error_response(400, str(e))


@router.get("/channels/{channel_id}/account")
async def get_channel_account_api(
    channel_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    account = ps_svc.get_channel_account(db, channel_id, tenant_no)
    if not account:
        return success_response({"channelId": channel_id, "balanceAmount": 0.0})
    return success_response(ps_svc._row_to_dict_channel_account(account))


@router.get("/channels/{channel_id}/product-quotas")
async def list_product_quotas_api(
    channel_id: str,
    user: dict = Depends(require_admin_or_super_admin),
    db: Session = Depends(get_db),
):
    tenant_no = _get_tenant_no(db, user)
    items = ps_svc.list_channel_product_quotas(db, channel_id, tenant_no)
    return success_response(items)
  • Step 3: 验证后端启动无报错

Run: cd backend && source venv/bin/activate && python -c "from app.api.pilot_service import router; print('API routes OK')"

  • Step 4: Commit
git add backend/app/api/pilot_service.py
git commit -m "feat(channel-order): add API endpoints for service orders, finance confirm, account, quotas"

Task 7: 前端 API 封装 & 状态标签

Files:

  • Modify: web-admin/src/api/admin.ts

  • Modify: web-admin/src/utils/status-tag-registry.ts

  • Step 1: 在 admin.ts 末尾追加 API 函数

// ========== 渠道服务购买订单 ==========

export function fetchChannelServiceOrderList(params: any) {
  return request({ url: '/admin/channel-service-orders', method: 'get', params })
}

export function fetchChannelServiceOrderDetail(id: string) {
  return request({ url: `/admin/channel-service-orders/${id}`, method: 'get' })
}

export function fetchCreateChannelServiceOrder(data: any) {
  return request({ url: '/admin/channel-service-orders', method: 'post', data })
}

export function fetchUpdateChannelServiceOrder(id: string, data: any) {
  return request({ url: `/admin/channel-service-orders/${id}`, method: 'put', data })
}

export function fetchDeleteChannelServiceOrder(id: string) {
  return request({ url: `/admin/channel-service-orders/${id}`, method: 'delete' })
}

export function fetchConfirmReceipt(id: string, data: any) {
  return request({ url: `/admin/channel-service-orders/${id}/confirm-receipt`, method: 'post', data })
}

export function fetchSubmitRefund(id: string, data: any) {
  return request({ url: `/admin/channel-service-orders/${id}/submit-refund`, method: 'post', data })
}

export function fetchConfirmRefund(id: string, data: any) {
  return request({ url: `/admin/channel-service-orders/${id}/confirm-refund`, method: 'post', data })
}

export function fetchVoidChannelServiceOrder(id: string) {
  return request({ url: `/admin/channel-service-orders/${id}/void`, method: 'put' })
}

export function fetchChannelAccount(channelId: string) {
  return request({ url: `/admin/channels/${channelId}/account`, method: 'get' })
}

export function fetchChannelProductQuotas(channelId: string) {
  return request({ url: `/admin/channels/${channelId}/product-quotas`, method: 'get' })
}
  • Step 2: 在 status-tag-registry.ts 中追加状态定义

在 agreementStatus 之后追加:

serviceOrderType: {
  yfk: { label: '充值', tagType: 'info' },
  gmcp: { label: '购买产品', tagType: 'primary' },
},

serviceOrderStatus: {
  dqr: { label: '待确认', tagType: 'info' },
  yqr: { label: '已确认', tagType: 'success' },
  tfqrz: { label: '退费确认中', tagType: 'warning' },
  ytf: { label: '已退费', tagType: 'danger' },
  sxz: { label: '生效中', tagType: 'success' },
  yzf: { label: '已作废', tagType: 'danger' },
},

同时在 statusMap 导出对象中注册这两个新映射。

  • Step 3: Commit
git add web-admin/src/api/admin.ts web-admin/src/utils/status-tag-registry.ts
git commit -m "feat(channel-order): add frontend API functions and status tag registry"

Task 8: 前端 — 渠道服务购买订单管理页

Files:

  • Create: web-admin/src/views/pilot-service/channel-service-order/index.vue

  • Step 1: 创建订单列表页

参考 system/order/index.vue 复杂列表模式。核心功能:

  • 搜索栏:订单类型下拉、订单状态下拉、渠道名称搜索、创建时间范围
  • 列表:el-table 展示订单编号、渠道名称、订单类型(Tag)、金额、状态(Tag)、创建时间、操作
  • 操作按钮按状态动态显示
  • 新增/编辑弹窗:选择渠道 → 选择订单类型 → 充值/购买模式切换
  • 购买模式:选择协议 → 添加产品 → 修改单价 → 计算金额 → 余额校验
  • 查看弹窗:展示订单详情 + 产品明细 + 财务确认信息
  • 退费弹窗:填写退费金额/备注/附件
  • 作废确认弹窗

参考模板: src/views/system/order/index.vue 的列表+弹窗结构。 参考渠道协议选择: src/views/pilot-service/channel/index.vue 中协议管理的模式。 状态 Tag 使用: <el-tag :type="getStatusTag('serviceOrderStatus', row.orderStatus)?.tagType" size="small">{{ getStatusTag('serviceOrderStatus', row.orderStatus)?.label }}</el-tag>

由于此页面逻辑较复杂(~1500 行),实现时遵循以下关键约束:

  • 使用 label-position="top" 表单布局

  • 弹窗 .dialog-form-layout 样式

  • 表格操作列 fixed="right" min-width ≥ 260px

  • 表格列使用 min-width 不使用 width

  • 状态/类型字段使用 <el-tag> 展示

  • Fresh Mint 色彩规范(--primary #10b981)

  • Step 2: Commit

git add web-admin/src/views/pilot-service/channel-service-order/
git commit -m "feat(channel-order): add channel service order management page"

Task 9: 前端 — 财务确认页

Files:

  • Create: web-admin/src/views/pilot-service/channel-service-order-finance/index.vue

  • Step 1: 创建财务确认页

参考 cell-storage/payment-confirm/index.vue 的双 Tab 模式。

  • Tab 1:待确认 — 列表显示 orderStatus IN ('dqr', 'tfqrz') 的订单
    • dqr 订单 → 「确认收款」按钮 → 弹窗(只读订单信息 + 财务备注 textarea + 附件上传卡片)
    • tfqrz 订单 → 「确认退费」按钮 → 弹窗(只读订单+退费信息 + 退费确认备注 + 退费确认附件)
  • Tab 2:已确认 — 列表显示 orderStatus IN ('yqr', 'ytf') 的订单
    • 查看详情弹窗

参考模板: src/views/cell-storage/payment-confirm/index.vue 附件上传: 使用卡片化上传模式(140×96px dashed border card),参考 ProtocolFormDialog.vue 的附件上传。

  • Step 2: Commit
git add web-admin/src/views/pilot-service/channel-service-order-finance/
git commit -m "feat(channel-order): add financial confirmation page"

Task 10: 前端 — 渠道信息页扩展(账户 Tab)

Files:

  • Modify: web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue

  • Step 1: 在查看弹窗中新增「账户信息」Tab

在 ChannelViewDialog.vue 的 Tab 列表中追加「账户信息」Tab:

  • 展示当前余额(大字数字展示)

  • 展示产品份数库存列表(el-table: 项目名称、细胞类型、规格、总份数、已使用、剩余份数)

  • 数据通过 fetchChannelAccount(channelId) 和 fetchChannelProductQuotas(channelId) 获取

  • Step 2: Commit

git add web-admin/src/views/pilot-service/channel/ChannelViewDialog.vue
git commit -m "feat(channel-order): add account info tab to channel view dialog"

Task 11: 集成验证

  • Step 1: 启动后端服务

Run: cd backend && source venv/bin/activate && python -m uvicorn app.main:app --reload --port 8000

  • Step 2: 访问 Swagger 文档验证 API

打开 http://127.0.0.1:8000/docs,检查以下端点是否出现:

  • GET /api/web/admin/channel-service-orders

  • POST /api/web/admin/channel-service-orders

  • POST /api/web/admin/channel-service-orders/{id}/confirm-receipt

  • POST /api/web/admin/channel-service-orders/{id}/submit-refund

  • POST /api/web/admin/channel-service-orders/{id}/confirm-refund

  • PUT /api/web/admin/channel-service-orders/{id}/void

  • GET /api/web/admin/channels/{channelId}/account

  • GET /api/web/admin/channels/{channelId}/product-quotas

  • Step 3: 启动前端开发服务器

Run: cd web-admin && pnpm dev

  • Step 4: 访问页面验证功能

打开浏览器:

  • 渠道服务购买订单列表页
  • 财务确认页
  • 渠道详情页的账户信息 Tab

测试场景:

  1. 创建充值订单 → 财务确认 → 余额增加
  2. 创建购买订单 → 选择协议 → 添加产品 → 扣余额 → 查看份数库存
  3. 作废购买订单 → 余额退回 → 份数退回
  4. 充值订单发起退费 → 财务确认退费 → 余额减少
  • Step 5: Final Commit
git add -A
git commit -m "feat(channel-order): complete channel service order feature with balance and quota management"