PAYATHON 2026

如何通过 SoftLayer API 获取上一计费周期的 Billing Item 明细?

支付老李

结论

SoftLayer API 可以查询上一计费周期已经结算的费用明细,但需要从 Billing Invoice 获取,不能直接查询当前的 Billing Item。

查询流程如下:

  1. 使用 SoftLayer_Account::getInvoices 查找上一计费周期的已结算发票。
  2. 使用 SoftLayer_Billing_Invoice::getItems 获取发票中的费用行。
  3. 根据 billingItemId、categoryCode、hostName、domainName 等字段识别设备并汇总费用。
  4. 只有返回结果提供了明确的用量字段,才能直接取得小时数。如果没有,只能在小时单价固定,且不存在折扣、抵扣或最低计费限制时,用收费金额除以小时单价来估算。

Billing Item 表示计费对象,Invoice Item 才是历史账单中的实际费用记录。设备取消后,可能已经不在当前的 Billing Item 列表中,但相关费用仍会保留在历史发票明细里。

查询上一计费周期的发票

可以通过 REST API 查询账户发票:

GET https://api.softlayer.com/rest/v3.1/SoftLayer_Account/getInvoices.json

建议分页读取相关发票,再通过以下字段筛选:

  • typeCode:区分正式发票、贷项或其他账单类型。
  • statusCode:判断发票是否已经关闭或结算。
  • createDate、closedDate:确定发票所属的计费周期。
  • amount:发票总金额。

不能简单地把列表中的第二张发票当作上一周期发票。账户中可能同时存在调整单、贷项单或人工补单,需要结合实际账期和发票日期判断。

获取发票费用明细

确定发票 ID 后,调用:

GET https://api.softlayer.com/rest/v3.1/SoftLayer_Billing_Invoice/{INVOICE_ID}/getItems.json

例如:

GET https://api.softlayer.com/rest/v3.1/SoftLayer_Billing_Invoice/12345678/getItems.json

Invoice Item 通常可能包含以下信息,具体字段取决于账户、产品类型和 API 返回结构:

  • billingItemId
  • categoryCode
  • description
  • hostName
  • domainName
  • recurringFee
  • hourlyRecurringFee
  • oneTimeFee
  • setupFee
  • laborFee
  • createDate

可以用 billingItemId 关联同一个计费对象。对于设备类费用,hostName 和 domainName 通常也能帮助识别具体服务器,但并非所有费用行都会返回这些字段。

Python 示例

下面的示例使用 SoftLayer Python 客户端,先按日期区间查找发票,再分页读取费用明细。账期的起止日期应以账户的实际结算周期为准。

from datetime import datetime, timezone
from decimal import Decimal
import SoftLayer

client = SoftLayer.create_client_from_env()

cycle_start = datetime(2026, 7, 1, tzinfo=timezone.utc)
cycle_end = datetime(2026, 8, 1, tzinfo=timezone.utc)


def parse_datetime(value):
    if not value:
        return None

    value = value.replace("Z", "+00:00")
    parsed = datetime.fromisoformat(value)

    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)

    return parsed


def get_all(service, method, object_id=None, page_size=100):
    result = []
    offset = 0

    while True:
        kwargs = {
            "limit": page_size,
            "offset": offset,
        }

        if object_id is not None:
            kwargs["id"] = object_id

        page = client.call(service, method, **kwargs)
        result.extend(page)

        if len(page) < page_size:
            break

        offset += page_size

    return result


invoices = get_all("Account", "getInvoices")

matching_invoices = []

for invoice in invoices:
    invoice_date = parse_datetime(
        invoice.get("closedDate") or invoice.get("createDate")
    )

    if invoice_date is None:
        continue

    if not (cycle_start <= invoice_date < cycle_end):
        continue

    # 字段值应以账户实际返回为准。
    # 通常只统计正式发票,贷项单应另行处理。
    if invoice.get("typeCode") not in (None, "INVOICE"):
        continue

    matching_invoices.append(invoice)

for invoice in matching_invoices:
    invoice_id = invoice["id"]
    items = get_all(
        "Billing_Invoice",
        "getItems",
        object_id=invoice_id,
    )

    print(
        f"Invoice: {invoice_id}, "
        f"closedDate={invoice.get('closedDate')}, "
        f"amount={invoice.get('amount')}"
    )

    for item in items:
        print({
            "billingItemId": item.get("billingItemId"),
            "categoryCode": item.get("categoryCode"),
            "description": item.get("description"),
            "hostName": item.get("hostName"),
            "domainName": item.get("domainName"),
            "recurringFee": item.get("recurringFee"),
            "hourlyRecurringFee": item.get("hourlyRecurringFee"),
            "oneTimeFee": item.get("oneTimeFee"),
            "setupFee": item.get("setupFee"),
            "laborFee": item.get("laborFee"),
        })

这个示例没有假定上一周期只有一张发票。如果周期内同时存在正式发票、调整单和贷项单,需要分别读取,并按业务规则合并。

统计创建的设备数量

Invoice Item 的行数不能直接当作设备数量。一台设备可能对应多条费用行,例如:

  • 计算资源费用
  • 内存升级费用
  • 存储费用
  • 操作系统许可证
  • 带宽或网络费用
  • 附加软件费用

应先找出代表设备主体的费用行,再按 billingItemId 或关联的资源 ID 去重。

device_ids = set()

for item in items:
    category_code = item.get("categoryCode")
    billing_item_id = item.get("billingItemId")

    if not billing_item_id:
        continue

    # 这里不要直接照搬固定分类列表。
    # 应先检查账户实际返回的 categoryCode,
    # 再确定哪些分类代表设备主体。
    if category_code in DEVICE_CATEGORY_CODES:
        device_ids.add(billing_item_id)

print("Device count:", len(device_ids))

DEVICE_CATEGORY_CODES 需要根据实际返回的数据确定。不同 SoftLayer 产品的分类代码和费用结构可能不同,不能假定裸金属服务器、虚拟服务器和其他设备使用同一个分类代码。

如果还要区分本周期新创建的设备和以前创建但本周期继续计费的设备,需要取得关联 Billing Item 的 createDate,检查它是否落在目标账期内。设备出现在发票中,只能说明它在该周期产生了费用,不能证明它是在该周期创建的。

小时数和收费金额

收费金额

查询历史实际收费时,应优先使用 Invoice Item 中的费用字段,并以发票金额作为最终核对依据。

不能只计算:

hourlyRecurringFee × hours

实际账单还可能包括:

  • 一次性费用
  • 安装费
  • 人工费
  • 税费
  • 折扣
  • 贷项或退款
  • 调账
  • 最低计费单位或取整

因此,费用行的汇总结果未必与发票 amount 完全一致,贷项单和税费尤其可能通过独立记录表示。

使用小时数

计费小时数不等于设备的实际运行时间。即使设备已经关机,只要计费资源仍然存在,也可能继续产生小时费用。

如果 Invoice Item 返回了明确的用量或数量字段,应直接使用该字段。字段是否存在以及具体含义,需要以当前账户的实际响应和对应 API 数据类型说明为准。

如果没有明确的小时数字段,可以在条件允许时按以下公式估算:

estimated_hours = hourly_charge_amount / hourly_rate

Python 示例:

from decimal import Decimal, InvalidOperation


def to_decimal(value):
    if value in (None, ""):
        return None

    try:
        return Decimal(str(value))
    except InvalidOperation:
        return None


hourly_rate = to_decimal(item.get("hourlyRecurringFee"))
charged_amount = to_decimal(item.get("recurringFee"))

estimated_hours = None

if hourly_rate and charged_amount is not None and hourly_rate != 0:
    estimated_hours = charged_amount / hourly_rate

反算结果只能用于估算。如果存在折扣、抵扣、价格调整、分段定价,或者费用字段的含义不同,就不能把这个结果视为官方计费小时数。

注意事项

  • 日期通常包含时区信息。筛选账期时应统一转换时区,避免将周期边界附近的发票归入错误月份。
  • 发票明细可能很多,调用 getInvoices 和 getItems 时需要处理分页。
  • 查询已取消设备时,应检查历史 Invoice Item,不能只依赖当前 Billing Item。
  • 一台设备可能对应多条费用行,统计设备数时必须按主体计费项或资源 ID 去重。
  • 发票中出现某台设备的费用,不代表该设备是在这个周期创建的。统计创建数量时还需检查 Billing Item 的 createDate。
  • 贷项、退款和调账可能以独立发票或独立费用行存在,汇总时不能遗漏。
  • hourlyRecurringFee 通常表示小时费率,未经确认不能将它视为该周期的实际收费总额。
  • 如果需要可用于财务对账的精确小时数,应使用发票返回的明确用量字段,或 SoftLayer/IBM 提供的账单导出数据,不能只依赖反算结果。

备注:内容仅供参考。