CaiWuXiTong/modules/EcommerceFinance/Repositories/PinduoduoSpreadBillRepository.php

421 lines
15 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.pdd_spread_bills
* 字段映射(上游表 -> 表1 列):
* shopName -> 店铺名称
* amount -> 金额(元)
* balance -> 余额(元)
* summary -> 摘要
* flowType(1=收入, 2=支出) -> 收支类型
* toAdvertiserFundType -> 资金类型(1=现金, 3=红包/券)
* createTime -> 费用时间
*
* 提供两个能力:
* - paginate():分页查询流水,返回表1 7 列结构
* - summary():当前筛选条件下的汇总(收入合计、支出合计、净额、笔数)
*/
class PinduoduoSpreadBillRepository
{
private const TABLE = '[dbo].[pdd_spread_bills]';
private const SOURCE = 'jiansun';
/** flowType -> 中文收支类型 */
private const FLOW_TYPE_MAP = [
1 => '收入',
2 => '支出',
];
/** toAdvertiserFundType -> 中文资金类型 */
private const FUND_TYPE_MAP = [
1 => '现金',
3 => '红包/券',
];
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::PINDUODUO) ?? false;
}
/**
* 店铺下拉选项(全部去重)
*
* @return string[]
*/
public function storeOptions(): array
{
if ($this->usesBusinessMirror()) {
return $this->mirror->pinduoduoStoreOptions();
}
$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 7 列结构。
*
* 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
?string $toDate = null, // Y-m-d
?string $flowType = null,
int $page = 1,
int $pageSize = 25
): array {
$page = max(1, $page);
$pageSize = max(1, min(200, $pageSize));
if ($this->usesBusinessMirror()) {
$result = $this->mirror->paginatePinduoduo(
$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];
}
// 分页(按 createTime DESC, id DESC 排序,与列表展示方向一致)
$offsetStart = ($page - 1) * $pageSize + 1;
$offsetEnd = $page * $pageSize;
$pagedSql = 'SELECT * FROM ('
. 'SELECT src.id, src.shopName, src.amount, src.balance, src.summary, '
. 'src.flowType, src.toAdvertiserFundType, src.createTime, '
. 'ROW_NUMBER() OVER (ORDER BY src.createTime 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(500, $batchSize));
$page = 1;
$exported = 0;
do {
$result = $this->mirror->paginatePinduoduo(
$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=支出
* @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->summarizePinduoduo($storeName, $fromDate, $toDate, $flowType);
} else {
[$whereSql, $params] = $this->buildWhere($storeName, $fromDate, $toDate, $flowType);
$row = $this->query->first(
'SELECT '
. 'COALESCE(SUM(CASE WHEN flowType = 1 THEN amount ELSE 0 END), 0) AS income_total, '
. 'COALESCE(SUM(CASE WHEN flowType = 2 THEN amount ELSE 0 END), 0) AS expense_total, '
. 'COUNT(*) AS cnt '
. 'FROM ' . self::TABLE . $whereSql,
$params
);
}
$income = (float) ($row['income_total'] ?? 0);
$expense = (float) ($row['expense_total'] ?? 0);
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->paginatePinduoduoMonthlyBalances($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, createTime), 0)';
[$whereSql, $params] = $this->buildMonthlyWhere($storeName, $month);
$total = (int) $this->query->scalar(
'SELECT COUNT(*) FROM ('
. 'SELECT shopName, ' . $monthExpression . ' AS month_start '
. 'FROM ' . self::TABLE . ' ' . $whereSql . ' '
. '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 flowType = 1 THEN amount ELSE 0 END), 0) AS income_total, '
. 'COALESCE(SUM(CASE WHEN flowType = 2 THEN amount ELSE 0 END), 0) AS expense_total '
. 'FROM ' . self::TABLE . ' ' . $whereSql . ' '
. 'GROUP BY shopName, ' . $monthExpression
. '), numbered AS ('
. 'SELECT m.shopName, m.month_start, m.income_total, m.expense_total, '
. 'opening_entry.balance AS opening_balance, closing_entry.balance AS system_closing_balance, '
. 'ROW_NUMBER() OVER (ORDER BY m.month_start DESC, m.shopName ASC) AS rn '
. 'FROM monthly m '
. 'OUTER APPLY (SELECT TOP 1 p.balance FROM ' . self::TABLE . ' p '
. 'WHERE p.shopName = m.shopName AND p.createTime < m.month_start '
. 'ORDER BY p.createTime DESC, p.id DESC) opening_entry '
. 'OUTER APPLY (SELECT TOP 1 p.balance FROM ' . self::TABLE . ' p '
. 'WHERE p.shopName = m.shopName AND p.createTime < DATEADD(MONTH, 1, m.month_start) '
. 'ORDER BY p.createTime DESC, p.id DESC) closing_entry'
. ') SELECT shopName, CONVERT(varchar(10), EOMONTH(month_start), 120) AS balance_date, '
. 'opening_balance, income_total, expense_total, system_closing_balance, 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',
'createTime IS NOT NULL',
'createTime < 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[] = 'createTime >= ? AND createTime < DATEADD(MONTH, 1, CAST(? AS date))';
$params[] = $monthStart;
$params[] = $monthStart;
}
return ['WHERE ' . implode(' AND ', $conditions), $params];
}
/**
* 构造 WHERE 子句(不带前导空格时给前面加空格,避免拼到 FROM 后粘连)。
*
* @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;
}
// 日期按 Y-m-d 校验;SQL Server 用字符串比较 datetime 即可
if ($fromDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $fromDate) === 1) {
$conditions[] = 'createTime >= ?';
$params[] = $fromDate . ' 00:00:00';
}
if ($toDate !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $toDate) === 1) {
$conditions[] = 'createTime <= ?';
$params[] = $toDate . ' 23:59:59';
}
if ($flowType === 'income') {
$conditions[] = 'flowType = 1';
} elseif ($flowType === 'expense') {
$conditions[] = 'flowType = 2';
}
$whereSql = $conditions === [] ? '' : ' WHERE ' . implode(' AND ', $conditions);
return [$whereSql, $params];
}
/**
* 把上游行归一化为表1 7 列结构(前端可直接消费)。
*/
private function normalize(array $row): array
{
$flowType = isset($row['flowType']) ? (int) $row['flowType'] : null;
$fundType = isset($row['toAdvertiserFundType']) ? (int) $row['toAdvertiserFundType'] : null;
return [
'store_name' => (string) ($row['shopName'] ?? ''),
'amount' => (float) ($row['amount'] ?? 0),
'balance' => (float) ($row['balance'] ?? 0),
'summary' => (string) ($row['summary'] ?? ''),
'income_expense_type' => self::FLOW_TYPE_MAP[$flowType] ?? '',
'fund_type' => self::FUND_TYPE_MAP[$fundType] ?? '',
'cost_time' => (string) ($row['createTime'] ?? ''),
// 原始字段保留,前端可选展示
'flow_type_raw' => $flowType,
'fund_type_raw' => $fundType,
];
}
/** @return array<string,mixed> */
private function normalizeMonthlyBalance(array $row): array
{
$opening = isset($row['opening_balance']) ? round((float) $row['opening_balance'], 2) : null;
$income = round((float) ($row['income_total'] ?? 0), 2);
$expense = round((float) ($row['expense_total'] ?? 0), 2);
$systemClosing = isset($row['system_closing_balance'])
? round((float) $row['system_closing_balance'], 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' => '',
];
}
}