CaiWuXiTong/modules/EcommerceFinance/Repositories/TmallTaobaoSpreadBillRepository.php
chenxi 8bfd82aa29 店铺营销账户账单各平台导出 (#77)
为淘系、阿里巴巴和京东专用账单补齐按当前筛选条件导出,并保留拼多多及通用平台既有导出能力。导出统一优先读取共享业务镜像。
2026-08-24 14:29:46 +08:00

447 lines
17 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\Repositories;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* 淘系(天猫+淘宝)支付宝账户流水 - 子功能页面数据仓库
*
* 数据来源:上游 JianSun.dbo.tmall_spread_bills
* 表1列:店铺名称 | 记账日期 | 交易日期 | 金额 | 余额 | 备注 | finType | 收支类型 | 交易类型
*
* 字段映射(上游表 -> 表1 列):
* shopName -> 店铺名称
* uniTransTime -> 记账日期(datetime)
* transTime -> 交易日期(date)
* amount(分) -> 金额(元)—— 上游以「分」存储,返回时除以 100
* balance(分) -> 余额(元)—— 上游以「分」存储,返回时除以 100
* comment -> 备注
* finType -> finType(CREDIT/DEBIT 原值)
* finType 派生 -> 收支类型(CREDIT=支出, DEBIT=收入)
* bizType/tradeType -> 交易类型(CASH_DEDUCT=扣款, INTEL_CHARGE_POST=充值 等)
*
* 金额单位换算:上游 amount/balance 均以「分」存储,前端期望「元」,
* 因此 normalize() 中统一除以 100,summary() 也基于「元」单位汇总。
*/
class TmallTaobaoSpreadBillRepository
{
private const TABLE = '[dbo].[tmall_spread_bills]';
private const SOURCE = 'jiansun';
/** finType -> 中文收支类型(CREDIT=支出, DEBIT=收入,与天猫平台口径一致)*/
private const FIN_TYPE_MAP = [
'CREDIT' => '支出',
'DEBIT' => '收入',
];
/** bizType -> 中文交易类型(天猫平台 bizType 枚举)*/
private const BIZ_TYPE_MAP = [
'CASH_DEDUCT' => '扣款',
'INTEL_CHARGE_POST' => '充值',
'TRANSFER_IN' => '转入',
'TRANSFER_OUT' => '转出',
'REFUND' => '退款',
'WITHDRAW' => '提现',
];
/** tradeType 兜底映射 */
private const TRADE_TYPE_MAP = [
'DEDUCT' => '扣款',
'CHARGE' => '充值',
'TRANSFER_IN' => '转入',
'TRANSFER_OUT' => '转出',
'REFUND' => '退款',
'WITHDRAW' => '提现',
];
private UpstreamReadOnlyQuery $query;
public function __construct(
UpstreamReadOnlyQueryFactory $factory,
private readonly ?MarketingAccountBillMirrorRepository $mirror = null,
) {
$this->query = $factory->forSource(self::SOURCE);
}
public function usesBusinessMirror(): bool
{
return $this->mirror?->ready(MarketingAccountBillMirrorRepository::TAOBAO) ?? false;
}
/**
* 店铺下拉选项(全部去重)
*
* @return string[]
*/
public function storeOptions(): array
{
if ($this->usesBusinessMirror()) {
return $this->mirror->taobaoStoreOptions();
}
$rows = $this->query->select(
'SELECT DISTINCT shopName FROM ' . self::TABLE . ' WHERE shopName IS NOT NULL ORDER BY shopName'
);
return array_map(static fn (array $r) => (string) $r['shopName'], $rows);
}
/**
* 分页查询淘系支付宝账户流水,返回表1 9 列结构。
*
* SQL Server 2012 兼容:用 ROW_NUMBER() OVER 做分页,避免 OFFSET/FETCH。
*
* @param string|null $flowType 收支类型筛选:income=收入,expense=支出
* @return array{rows: array<int,array<string,mixed>>, total: int}
*/
public function paginate(
?string $storeName = null,
?string $fromDate = null, // Y-m-d(按交易日期 transTime 过滤)
?string $toDate = null, // Y-m-d
?string $flowType = null, // 'income' | 'expense' | null
int $page = 1,
int $pageSize = 25
): array {
$page = max(1, $page);
$pageSize = max(1, min(200, $pageSize));
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginateTaobao(
$storeName,
$fromDate,
$toDate,
$flowType,
$page,
$pageSize,
);
return [
'rows' => array_map(fn (array $row): array => $this->normalize($row), $result['rows']),
'total' => $result['total'],
];
}
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
// 总数
$total = (int) $this->query->scalar(
'SELECT COUNT(*) FROM ' . self::TABLE . $whereSql,
$params
);
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
// 分页(按记账日期 uniTransTime DESC, id DESC 排序,与列表展示方向一致)
$offsetStart = ($page - 1) * $pageSize + 1;
$offsetEnd = $page * $pageSize;
$pagedSql = 'SELECT * FROM ('
. 'SELECT src.id, src.shopName, src.uniTransTime, src.transTime, '
. 'src.amount, src.balance, src.comment, src.finType, '
. 'src.bizType, src.tradeType, '
. 'ROW_NUMBER() OVER (ORDER BY src.uniTransTime DESC, src.id DESC) AS rn '
. 'FROM ' . self::TABLE . ' src' . $whereSql
. ') AS t WHERE t.rn BETWEEN ? AND ? ORDER BY t.rn';
$rawRows = $this->query->select(
$pagedSql,
array_merge($params, [$offsetStart, $offsetEnd])
);
return [
'rows' => array_map(fn (array $r) => $this->normalize($r), $rawRows),
'total' => $total,
];
}
/** @return \Generator<int,array<string,mixed>> */
public function exportRows(
?string $storeName = null,
?string $fromDate = null,
?string $toDate = null,
?string $flowType = null,
int $batchSize = 200,
): \Generator {
if (! $this->usesBusinessMirror()) {
throw new \RuntimeException('淘系账单共享业务镜像尚未完成同步。');
}
$batchSize = max(1, min(200, $batchSize));
$page = 1;
$exported = 0;
do {
$result = $this->mirror->paginateTaobao(
$storeName,
$fromDate,
$toDate,
$flowType,
$page,
$batchSize,
);
$rows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
$total = max(0, (int) ($result['total'] ?? 0));
foreach ($rows as $row) {
yield $this->normalize($row);
}
$count = count($rows);
$exported += $count;
$page++;
} while ($count > 0 && $exported < $total);
}
/**
* 当前筛选条件下的汇总(金额单位:元)。
*
* @param string|null $flowType 'income'/'expense'/null
* @return array{income_total: float, expense_total: float, net: float, count: int}
*/
public function summary(
?string $storeName = null,
?string $fromDate = null,
?string $toDate = null,
?string $flowType = null
): array {
if ($this->usesBusinessMirror()) {
$row = $this->mirror->summarizeTaobao($storeName, $fromDate, $toDate, $flowType);
} else {
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
// amount 单位为「分」,汇总时除以 100 转为「元」
$row = $this->query->first(
'SELECT '
. 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, '
. 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw, '
. 'COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . $whereSql,
$params
);
}
// 分 -> 元
$income = ((float) ($row['income_total_raw'] ?? 0)) / 100;
$expense = ((float) ($row['expense_total_raw'] ?? 0)) / 100;
return [
'income_total' => $income,
'expense_total' => $expense,
'net' => $income - $expense,
'count' => (int) ($row['cnt'] ?? 0),
];
}
/** @return array{rows: array<int,array<string,mixed>>, total: int} */
public function paginateMonthlyBalances(
?string $storeName = null,
?string $month = null,
int $page = 1,
int $pageSize = 25
): array {
$page = max(1, $page);
$pageSize = max(1, min(5000, $pageSize));
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginateTaobaoMonthlyBalances($storeName, $month, $page, $pageSize);
return [
'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $result['rows']),
'total' => $result['total'],
];
}
$monthExpression = 'DATEADD(MONTH, DATEDIFF(MONTH, 0, transTime), 0)';
[$where, $params] = $this->buildMonthlyWhere($storeName, $month);
$total = (int) $this->query->scalar(
'SELECT COUNT(*) FROM ('
. 'SELECT shopName, ' . $monthExpression . ' AS month_start '
. 'FROM ' . self::TABLE . ' ' . $where . ' '
. 'GROUP BY shopName, ' . $monthExpression
. ') AS monthly_count',
$params
);
if ($total === 0) {
return ['rows' => [], 'total' => 0];
}
$offsetStart = (($page - 1) * $pageSize) + 1;
$offsetEnd = $page * $pageSize;
$sql = 'WITH monthly AS ('
. 'SELECT shopName, ' . $monthExpression . ' AS month_start, '
. 'COALESCE(SUM(CASE WHEN finType = \'DEBIT\' THEN amount ELSE 0 END), 0) AS income_total_raw, '
. 'COALESCE(SUM(CASE WHEN finType = \'CREDIT\' THEN amount ELSE 0 END), 0) AS expense_total_raw '
. 'FROM ' . self::TABLE . ' ' . $where . ' '
. 'GROUP BY shopName, ' . $monthExpression
. '), numbered AS ('
. 'SELECT m.shopName, m.month_start, m.income_total_raw, m.expense_total_raw, '
. 'opening_entry.balance AS opening_balance_raw, closing_entry.balance AS system_closing_balance_raw, '
. 'ROW_NUMBER() OVER (ORDER BY m.month_start DESC, m.shopName ASC) AS rn '
. 'FROM monthly m '
. 'OUTER APPLY (SELECT TOP 1 t.balance FROM ' . self::TABLE . ' t '
. 'WHERE t.shopName = m.shopName AND t.transTime < m.month_start '
. 'ORDER BY t.transTime DESC, t.id DESC) opening_entry '
. 'OUTER APPLY (SELECT TOP 1 t.balance FROM ' . self::TABLE . ' t '
. 'WHERE t.shopName = m.shopName AND t.transTime < DATEADD(MONTH, 1, m.month_start) '
. 'ORDER BY t.transTime DESC, t.id DESC) closing_entry'
. ') SELECT shopName, CONVERT(varchar(10), EOMONTH(month_start), 120) AS balance_date, '
. 'opening_balance_raw, income_total_raw, expense_total_raw, system_closing_balance_raw, rn '
. 'FROM numbered WHERE rn BETWEEN ? AND ? ORDER BY rn';
$rows = $this->query->select($sql, array_merge($params, [$offsetStart, $offsetEnd]));
return [
'rows' => array_map(fn (array $row): array => $this->normalizeMonthlyBalance($row), $rows),
'total' => $total,
];
}
/** @return array{0:string,1:array<int,mixed>} */
private function buildMonthlyWhere(?string $storeName, ?string $month): array
{
$conditions = [
'shopName IS NOT NULL',
'transTime IS NOT NULL',
'transTime < DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()), 0)',
];
$params = [];
if ($storeName !== null && $storeName !== '') {
$conditions[] = 'shopName = ?';
$params[] = $storeName;
}
if ($month !== null && preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
$monthStart = $month . '-01';
$conditions[] = 'transTime >= ? AND transTime < DATEADD(MONTH, 1, CAST(? AS date))';
$params[] = $monthStart;
$params[] = $monthStart;
}
return ['WHERE ' . implode(' AND ', $conditions), $params];
}
/**
* 构造 WHERE 子句。
*
* @param string|null $flowType 'income'/'expense'/null
* @return array{0:string,1:array<int,mixed>}
*/
private function buildWhere(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType = null
): array {
$conditions = [];
$params = [];
if ($storeName !== null && $storeName !== '') {
$conditions[] = 'shopName = ?';
$params[] = $storeName;
}
// 日期按交易日期 transTime 过滤;Y-m-d 校验后字符串比较 date 即可
if ($fromDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fromDate) === 1) {
$conditions[] = 'transTime >= ?';
$params[] = $fromDate;
}
if ($toDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $toDate) === 1) {
$conditions[] = 'transTime <= ?';
$params[] = $toDate;
}
// 收支类型筛选:income -> finType='DEBIT'(收入),expense -> finType='CREDIT'(支出)
if ($flowType === 'income') {
$conditions[] = 'finType = \'DEBIT\'';
} elseif ($flowType === 'expense') {
$conditions[] = 'finType = \'CREDIT\'';
}
$whereSql = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
return [$whereSql, $params];
}
/**
* 把上游行归一化为表1 9 列结构(前端可直接消费)。
* 金额单位从「分」转为「元」。
*/
private function normalize(array $row): array
{
$finType = (string) ($row['finType'] ?? '');
$bizType = (string) ($row['bizType'] ?? '');
$tradeType = (string) ($row['tradeType'] ?? '');
// 交易类型:优先 bizType,其次 tradeType,最后返回原始 bizType
$transactionType = self::BIZ_TYPE_MAP[$bizType]
?? self::TRADE_TYPE_MAP[$tradeType]
?? $bizType;
return [
// 表1 9 列
'store_name' => (string) ($row['shopName'] ?? ''),
'bookkeeping_time' => (string) ($row['uniTransTime'] ?? ''),
'transaction_date' => (string) ($row['transTime'] ?? ''),
'amount' => ((float) ($row['amount'] ?? 0)) / 100,
'balance' => ((float) ($row['balance'] ?? 0)) / 100,
'comment' => (string) ($row['comment'] ?? ''),
'fin_type' => $finType,
'income_expense_type' => self::FIN_TYPE_MAP[$finType] ?? '',
'transaction_type' => $transactionType,
// 原始字段保留
'biz_type_raw' => $bizType,
'trade_type_raw' => $tradeType,
];
}
/** @return array<string,mixed> */
private function normalizeMonthlyBalance(array $row): array
{
$opening = isset($row['opening_balance_raw'])
? round(((float) $row['opening_balance_raw']) / 100, 2)
: null;
$income = round(((float) ($row['income_total_raw'] ?? 0)) / 100, 2);
$expense = round(((float) ($row['expense_total_raw'] ?? 0)) / 100, 2);
$systemClosing = isset($row['system_closing_balance_raw'])
? round(((float) $row['system_closing_balance_raw']) / 100, 2)
: null;
$calculatedClosing = $opening === null ? null : round($opening + $income - $expense, 2);
$difference = $systemClosing === null || $calculatedClosing === null
? null
: round($systemClosing - $calculatedClosing, 2);
if ($opening === null && $systemClosing === null) {
$reason = '缺少期初余额和系统期末余额';
} elseif ($opening === null) {
$reason = '缺少上月期末余额';
} elseif ($systemClosing === null) {
$reason = '缺少本月系统期末余额';
} elseif (abs($difference) < 0.005) {
$reason = '';
} else {
$reason = '系统期末余额与计算期末余额相差 ' . number_format($difference, 2) . ' 元';
}
return [
'date' => (string) ($row['balance_date'] ?? ''),
'store_name' => (string) ($row['shopName'] ?? ''),
'opening_balance' => $opening,
'income_total' => $income,
'expense_total' => $expense,
'closing_balance' => $calculatedClosing,
'system_closing_balance' => $systemClosing,
'balance_difference' => $difference,
'difference_reason' => $reason,
'verification_status' => $difference !== null && abs($difference) < 0.005 ? '已核对' : '待核对',
'notes' => '',
];
}
}