CaiWuXiTong/modules/EcommerceFinance/Services/DewuSettlementBillImporter.php
yueyang af10f3c972 接入聚胜算得物完整账单 (#245)
保留得物、退货退款订单和扣减其他费用明细三类渠道;增加受控幂等导入、原子替换与页面权威表查询。
2026-09-22 19:07:11 +08:00

732 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use DateTimeImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use XMLReader;
use ZipArchive;
class DewuSettlementBillImporter
{
private const IMPORT_TABLE = 'dewu_settlement_bill_imports';
private const STAGING_TABLE = 'dewu_settlement_bill_staging';
private const ROW_TABLE = 'dewu_settlement_bill_rows';
private const HEADER_ALIASES = [
'record_code' => ['记录编码'],
'order_no' => ['订单号'],
'online_order_no' => ['线上单号'],
'custom_remark' => ['自定义备注'],
'bill_item' => ['账单项目'],
'store_name' => ['店铺'],
'order_type' => ['订单类型'],
'goods_name' => ['商品名称'],
'goods_no' => ['商品货号'],
'quantity' => ['数量'],
'channel' => ['渠道'],
'business_type' => ['业务类型'],
'income_amount' => ['收入金额'],
'expense_amount' => ['支出金额'],
'online_real_store' => ['线上单号对应实际店铺'],
'order_created_at' => ['订单创建时间'],
'paid_refunded_at' => ['支付/退款时间'],
'ship_return_at' => ['发货/退货时间'],
'settle_status' => ['结算状态'],
'pay_period' => ['收款账期'],
'pulled_at' => ['拉取时间'],
'source_updated_at' => ['修改时间'],
'source_modified_by' => ['修改人'],
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{status:string,row_count:int,excluded_rows:int,store_count:int,bill_item_count:int,channel_counts:array<string,int>,income_total:string,expense_total:string,net_total:string,period_start:string,period_end:string,source_sha256:string}
*/
public function import(
string $path,
string $fromDate,
string $toDate,
int $expectedCount,
string $expectedIncome,
string $expectedExpense,
int $batchSize = 1000,
): array {
$path = $this->validatedPath($path);
[$from, $to] = $this->period($fromDate, $toDate);
$this->assertSchemaReady();
if ($expectedCount <= 0) {
throw new RuntimeException('权威笔数控制数必须大于零。');
}
$expectedIncome = $this->controlAmount($expectedIncome, '收入');
$expectedExpense = $this->controlAmount($expectedExpense, '支出');
$batchSize = min(max($batchSize, 100), 5000);
$hash = hash_file('sha256', $path);
if (! is_string($hash)) {
throw new RuntimeException('无法计算得物完整账单文件摘要。');
}
$existing = $this->connection->table(self::IMPORT_TABLE)
->where('source_sha256', $hash)
->first();
if ($existing !== null && $existing->status === 'succeeded') {
$this->assertSameImportRequest(
$existing,
$from,
$to,
$expectedCount,
$expectedIncome,
$expectedExpense,
);
if ($this->activeRowCount($hash, $from, $to) === (int) $existing->row_count) {
return $this->result((array) $existing, 'unchanged');
}
}
$generation = $existing !== null ? (string) $existing->generation : (string) Str::uuid();
$now = now();
$import = [
'generation' => $generation,
'source_filename' => mb_substr(basename($path), 0, 255),
'source_sha256' => $hash,
'period_start' => $from->format('Y-m-d'),
'period_end' => $to->format('Y-m-d'),
'status' => 'running',
'expected_row_count' => $expectedCount,
'expected_income_total' => $expectedIncome,
'expected_expense_total' => $expectedExpense,
'row_count' => 0,
'excluded_rows' => 0,
'store_count' => 0,
'bill_item_count' => 0,
'channel_counts' => null,
'income_total' => '0.00',
'expense_total' => '0.00',
'net_total' => '0.00',
'completed_at' => null,
'last_error_code' => null,
'updated_at' => $now,
];
if ($existing === null) {
$this->connection->table(self::IMPORT_TABLE)->insert($import + ['created_at' => $now]);
} else {
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($import);
}
$stats = null;
try {
$stats = $this->stage($path, $generation, $from, $to, $batchSize);
$this->assertControls($stats, $expectedCount, $expectedIncome, $expectedExpense);
$this->activate($generation, $hash, $from, $to, $stats, $batchSize);
return $this->result([
'row_count' => $stats['row_count'],
'excluded_rows' => $stats['excluded_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'channel_counts' => $stats['channel_counts'],
'income_total' => $stats['income_total'],
'expense_total' => $stats['expense_total'],
'net_total' => $stats['net_total'],
'period_start' => $from->format('Y-m-d'),
'period_end' => $to->format('Y-m-d'),
'source_sha256' => $hash,
], 'succeeded');
} catch (Throwable $exception) {
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
$failure = [
'status' => 'failed',
'last_error_code' => mb_substr(class_basename($exception), 0, 120),
'updated_at' => now(),
];
if (is_array($stats)) {
$failure += [
'row_count' => $stats['row_count'],
'excluded_rows' => $stats['excluded_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'channel_counts' => json_encode($stats['channel_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'income_total' => $stats['income_total'],
'expense_total' => $stats['expense_total'],
'net_total' => $stats['net_total'],
];
}
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($failure);
throw $exception;
}
}
private function validatedPath(string $path): string
{
$realPath = realpath($path);
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
throw new RuntimeException('得物完整账单文件不存在或不可读。');
}
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
throw new RuntimeException('聚胜算完整账单导入只支持 XLSX 文件。');
}
return $realPath;
}
/** @return array{0:DateTimeImmutable,1:DateTimeImmutable} */
private function period(string $fromDate, string $toDate): array
{
$from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromDate);
$to = DateTimeImmutable::createFromFormat('!Y-m-d', $toDate);
if ($from === false || $to === false
|| $from->format('Y-m-d') !== $fromDate
|| $to->format('Y-m-d') !== $toDate
|| $from > $to) {
throw new RuntimeException('得物账单导入账期无效。');
}
return [$from, $to];
}
private function assertSchemaReady(): void
{
$schema = Schema::connection($this->connection->getName());
foreach ([self::IMPORT_TABLE, self::STAGING_TABLE, self::ROW_TABLE] as $table) {
if (! $schema->hasTable($table)) {
throw new RuntimeException('得物完整账单表尚未完成迁移:' . $table);
}
}
}
/**
* @return array{row_count:int,excluded_rows:int,store_count:int,bill_item_count:int,channel_counts:array<string,int>,income_total:string,expense_total:string,net_total:string}
*/
private function stage(
string $path,
string $generation,
DateTimeImmutable $from,
DateTimeImmutable $to,
int $batchSize,
): array {
$archive = new ZipArchive();
if ($archive->open($path) !== true) {
throw new RuntimeException('无法打开得物完整账单 XLSX 文件。');
}
try {
$worksheet = $this->firstWorksheet($archive);
$sharedStrings = $this->sharedStrings($path, $archive);
} finally {
$archive->close();
}
$reader = new XMLReader();
if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) {
throw new RuntimeException('无法读取得物完整账单工作表。');
}
$headerMap = null;
$batch = [];
$rowCount = 0;
$incomeTotal = '0.00';
$expenseTotal = '0.00';
$stores = [];
$billItems = [];
$channelCounts = [];
try {
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
continue;
}
$rowNumber = (int) ($reader->getAttribute('r') ?: 0);
$cells = $this->rowCells($reader->readOuterXml(), $sharedStrings);
if ($headerMap === null) {
$headerMap = $this->headerMap($cells);
continue;
}
$values = [];
foreach ($headerMap as $header => $column) {
$values[$header] = trim((string) ($cells[$column] ?? ''));
}
if (implode('', $values) === '') {
continue;
}
$recordCode = $this->required($values, 'record_code', $rowNumber, '记录编码');
$channel = $this->required($values, 'channel', $rowNumber, '渠道');
$storeName = $this->nullable($values['store_name']);
$billItem = $this->nullable($values['bill_item']);
$paidRefundedAt = $this->optionalDateTime($values['paid_refunded_at'], $rowNumber, '支付/退款时间');
$shipReturnAt = $this->optionalDateTime($values['ship_return_at'], $rowNumber, '发货/退货时间');
$billDate = $shipReturnAt ?? $paidRefundedAt;
if ($billDate === null) {
throw new RuntimeException("第 {$rowNumber} 行缺少可用于账期核验的发货/退货时间或支付/退款时间。");
}
if ($billDate < $from->format('Y-m-d') . ' 00:00:00'
|| $billDate >= $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') {
throw new RuntimeException("第 {$rowNumber} 行账单时间超出指定账期。");
}
$income = $this->money($values['income_amount'], $rowNumber, '收入金额', false);
$expense = $this->money($values['expense_amount'], $rowNumber, '支出金额', true);
$signed = bcsub($income, $expense, 2);
$batch[] = [
'generation' => $generation,
'record_code' => mb_substr($recordCode, 0, 191),
'order_no' => $this->nullable($values['order_no'], 191),
'online_order_no' => $this->nullable($values['online_order_no'], 191),
'custom_remark' => $this->nullable($values['custom_remark']),
'bill_item' => $billItem,
'store_name' => $storeName,
'order_type' => $this->nullable($values['order_type']),
'goods_name' => $this->nullable($values['goods_name'], 1000),
'goods_no' => $this->nullable($values['goods_no'], 191),
'quantity' => $this->nullable($values['quantity'], 64),
'channel' => mb_substr($channel, 0, 255),
'business_type' => $this->nullable($values['business_type']),
'income_amount' => $income,
'expense_amount' => $expense,
'signed_amount' => $signed,
'online_real_store' => $this->nullable($values['online_real_store']),
'order_created_at' => $this->optionalDateTime($values['order_created_at'], $rowNumber, '订单创建时间'),
'paid_refunded_at' => $paidRefundedAt,
'ship_return_at' => $shipReturnAt,
'bill_date' => $billDate,
'settle_status' => $this->nullable($values['settle_status']),
'pay_period' => $this->nullable($values['pay_period']),
'pulled_at' => $this->optionalDateTime($values['pulled_at'], $rowNumber, '拉取时间'),
'source_updated_at' => $this->optionalDateTime($values['source_updated_at'], $rowNumber, '修改时间'),
'source_modified_by' => $this->nullable($values['source_modified_by']),
'source_row_number' => $rowNumber,
];
$rowCount++;
if ($storeName !== null) {
$stores[$storeName] = true;
}
if ($billItem !== null) {
$billItems[$billItem] = true;
}
$channelCounts[$channel] = ($channelCounts[$channel] ?? 0) + 1;
$incomeTotal = bcadd($incomeTotal, $income, 2);
$expenseTotal = bcadd($expenseTotal, $expense, 2);
if (count($batch) >= $batchSize) {
$this->connection->table(self::STAGING_TABLE)->insert($batch);
$batch = [];
}
}
} finally {
$reader->close();
}
if ($headerMap === null) {
throw new RuntimeException('未找到聚胜算得物账单标准表头。');
}
if ($batch !== []) {
$this->connection->table(self::STAGING_TABLE)->insert($batch);
}
if ($rowCount === 0) {
throw new RuntimeException('文件中没有得物完整账单数据。');
}
return [
'row_count' => $rowCount,
'excluded_rows' => 0,
'store_count' => count($stores),
'bill_item_count' => count($billItems),
'channel_counts' => $channelCounts,
'income_total' => $incomeTotal,
'expense_total' => $expenseTotal,
'net_total' => bcsub($incomeTotal, $expenseTotal, 2),
];
}
private function assertControls(array $stats, int $count, string $income, string $expense): void
{
if ($stats['row_count'] !== $count
|| bccomp($stats['income_total'], $income, 2) !== 0
|| bccomp($stats['expense_total'], $expense, 2) !== 0) {
throw new RuntimeException(sprintf(
'权威控制数不一致:实际 %d 笔,收入 %s,支出 %s;期望 %d 笔,收入 %s,支出 %s。',
$stats['row_count'],
$stats['income_total'],
$stats['expense_total'],
$count,
$income,
$expense,
));
}
}
private function assertSameImportRequest(
object $existing,
DateTimeImmutable $from,
DateTimeImmutable $to,
int $expectedCount,
string $expectedIncome,
string $expectedExpense,
): void {
if ((string) $existing->period_start !== $from->format('Y-m-d')
|| (string) $existing->period_end !== $to->format('Y-m-d')
|| (int) $existing->expected_row_count !== $expectedCount
|| bccomp((string) $existing->expected_income_total, $expectedIncome, 2) !== 0
|| bccomp((string) $existing->expected_expense_total, $expectedExpense, 2) !== 0) {
throw new RuntimeException('该文件已有成功导入记录,但本次账期或权威控制数与历史记录不一致。');
}
}
private function activeRowCount(
string $hash,
DateTimeImmutable $from,
DateTimeImmutable $to,
): int {
return $this->connection->table(self::ROW_TABLE)
->where('source_sha256', $hash)
->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00')
->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
->count();
}
private function activate(
string $generation,
string $hash,
DateTimeImmutable $from,
DateTimeImmutable $to,
array $stats,
int $batchSize,
): void {
$now = now();
$columns = [
'order_no', 'online_order_no', 'custom_remark', 'bill_item', 'store_name', 'order_type',
'goods_name', 'goods_no', 'quantity', 'channel', 'business_type', 'income_amount',
'expense_amount', 'signed_amount', 'online_real_store', 'order_created_at',
'paid_refunded_at', 'ship_return_at', 'bill_date', 'settle_status', 'pay_period',
'pulled_at', 'source_updated_at', 'source_modified_by',
'source_row_number', 'source_generation', 'source_sha256', 'imported_at',
];
$this->connection->transaction(function () use (
$generation, $hash, $from, $to, $stats, $batchSize, $columns, $now
): void {
$this->connection->table(self::ROW_TABLE)
->where('bill_date', '>=', $from->format('Y-m-d') . ' 00:00:00')
->where('bill_date', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
->delete();
$this->connection->table(self::STAGING_TABLE)
->where('generation', $generation)
->orderBy('id')
->chunkById($batchSize, function ($rows) use ($generation, $hash, $columns, $now): void {
$payload = [];
foreach ($rows as $row) {
$payload[] = [
'record_code' => $row->record_code,
'order_no' => $row->order_no,
'online_order_no' => $row->online_order_no,
'custom_remark' => $row->custom_remark,
'bill_item' => $row->bill_item,
'store_name' => $row->store_name,
'order_type' => $row->order_type,
'goods_name' => $row->goods_name,
'goods_no' => $row->goods_no,
'quantity' => $row->quantity,
'channel' => $row->channel,
'business_type' => $row->business_type,
'income_amount' => $row->income_amount,
'expense_amount' => $row->expense_amount,
'signed_amount' => $row->signed_amount,
'online_real_store' => $row->online_real_store,
'order_created_at' => $row->order_created_at,
'paid_refunded_at' => $row->paid_refunded_at,
'ship_return_at' => $row->ship_return_at,
'bill_date' => $row->bill_date,
'settle_status' => $row->settle_status,
'pay_period' => $row->pay_period,
'pulled_at' => $row->pulled_at,
'source_updated_at' => $row->source_updated_at,
'source_modified_by' => $row->source_modified_by,
'source_row_number' => $row->source_row_number,
'source_generation' => $generation,
'source_sha256' => $hash,
'imported_at' => $now,
];
}
$this->connection->table(self::ROW_TABLE)->upsert($payload, ['record_code'], $columns);
});
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update([
'status' => 'succeeded',
'row_count' => $stats['row_count'],
'excluded_rows' => $stats['excluded_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'channel_counts' => json_encode($stats['channel_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'income_total' => $stats['income_total'],
'expense_total' => $stats['expense_total'],
'net_total' => $stats['net_total'],
'completed_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
});
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
}
/** @return array<int,string> */
private function rowCells(string $xml, array $sharedStrings): array
{
$row = simplexml_load_string($xml);
if (! $row instanceof SimpleXMLElement) {
return [];
}
$cells = [];
foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) {
$reference = (string) ($cell['r'] ?? '');
if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) {
continue;
}
$type = (string) ($cell['t'] ?? '');
if ($type === 'inlineStr') {
$nodes = $cell->xpath('.//*[local-name()="t"]') ?: [];
$value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes));
} else {
$nodes = $cell->xpath('./*[local-name()="v"]') ?: [];
$raw = isset($nodes[0]) ? (string) $nodes[0] : '';
$value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw;
}
$cells[$this->columnIndex($matches[1])] = $value;
}
return $cells;
}
/** @param array<int,string> $cells @return array<string,int> */
private function headerMap(array $cells): ?array
{
$lookup = [];
foreach ($cells as $column => $value) {
$lookup[trim($value)] = $column;
}
$mapping = [];
foreach (self::HEADER_ALIASES as $field => $aliases) {
foreach ($aliases as $alias) {
if (array_key_exists($alias, $lookup)) {
$mapping[$field] = $lookup[$alias];
break;
}
}
if (! array_key_exists($field, $mapping)) {
return null;
}
}
return $mapping;
}
private function required(array $values, string $key, int $row, ?string $label = null): string
{
$value = trim((string) ($values[$key] ?? ''));
if ($value === '') {
throw new RuntimeException("第 {$row} 行缺少" . ($label ?? $key) . '。');
}
return $value;
}
private function money(
string $value,
int $row,
string $field,
bool $absolute = false,
bool $allowNegative = false,
): string {
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
$value = $value === '' ? '0' : $value;
if (! is_numeric($value) || (! $allowNegative && ! $absolute && bccomp($value, '0', 6) < 0)) {
throw new RuntimeException("第 {$row} 行{$field}不是有效金额。");
}
if ($absolute && bccomp($value, '0', 6) < 0) {
$value = bcmul($value, '-1', 6);
}
return bcadd($value, '0', 2);
}
private function controlAmount(string $value, string $label): string
{
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
if (! is_numeric($value) || bccomp($value, '0', 6) < 0) {
throw new RuntimeException("权威{$label}控制数无效。");
}
return bcadd($value, '0', 2);
}
private function dateTime(string $value, int $row, string $field): string
{
$parsed = $this->parseDateTime($value);
if ($parsed === null) {
throw new RuntimeException("第 {$row} 行{$field}不是有效日期时间。");
}
return $parsed;
}
private function optionalDateTime(string $value, int $row, string $field): ?string
{
if (trim($value) === '') {
return null;
}
return $this->dateTime($value, $row, $field);
}
private function parseDateTime(string $value): ?string
{
$value = trim($value);
try {
if (is_numeric($value)) {
$seconds = (int) round((((float) $value) - 25569) * 86400);
return Carbon::createFromTimestampUTC($seconds)->format('Y-m-d H:i:s');
}
return Carbon::parse($value)->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function nullable(string $value, int $max = 255): ?string
{
$value = trim($value);
return $value === '' ? null : mb_substr($value, 0, $max);
}
private function columnIndex(string $letters): int
{
$index = 0;
foreach (str_split(strtoupper($letters)) as $letter) {
$index = ($index * 26) + ord($letter) - 64;
}
return $index - 1;
}
/** @return list<string> */
private function sharedStrings(string $path, ZipArchive $archive): array
{
if ($archive->locateName('xl/sharedStrings.xml') === false) {
return [];
}
$reader = new XMLReader();
if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) {
throw new RuntimeException('无法读取 XLSX 共享文本。');
}
$strings = [];
try {
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') {
continue;
}
$item = simplexml_load_string($reader->readOuterXml());
$nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : [];
$strings[] = implode('', array_map(
static fn (SimpleXMLElement $node): string => (string) $node,
$nodes,
));
}
} finally {
$reader->close();
}
return $strings;
}
private function firstWorksheet(ZipArchive $archive): string
{
$worksheets = [];
for ($index = 0; $index < $archive->numFiles; $index++) {
$name = $archive->getNameIndex($index);
if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) {
$worksheets[] = $name;
}
}
sort($worksheets, SORT_NATURAL);
if ($worksheets === []) {
throw new RuntimeException('XLSX 文件中没有工作表。');
}
return $worksheets[0];
}
private function zipUri(string $path, string $entry): string
{
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
}
private function result(array $row, string $status): array
{
return [
'status' => $status,
'row_count' => (int) ($row['row_count'] ?? 0),
'excluded_rows' => (int) ($row['excluded_rows'] ?? 0),
'store_count' => (int) ($row['store_count'] ?? 0),
'bill_item_count' => (int) ($row['bill_item_count'] ?? 0),
'channel_counts' => $this->channelCounts($row['channel_counts'] ?? []),
'income_total' => bcadd((string) ($row['income_total'] ?? '0'), '0', 2),
'expense_total' => bcadd((string) ($row['expense_total'] ?? '0'), '0', 2),
'net_total' => bcadd((string) ($row['net_total'] ?? '0'), '0', 2),
'period_start' => (string) ($row['period_start'] ?? ''),
'period_end' => (string) ($row['period_end'] ?? ''),
'source_sha256' => (string) ($row['source_sha256'] ?? ''),
];
}
/** @return array<string,int> */
private function channelCounts(mixed $value): array
{
if (is_string($value)) {
try {
$value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
}
if (! is_array($value)) {
return [];
}
$counts = [];
foreach ($value as $channel => $count) {
if (is_string($channel) && $channel !== '' && is_numeric($count)) {
$counts[$channel] = (int) $count;
}
}
return $counts;
}
}