feat: 京东钱包按账期计算期初和期末余额 #901

Merged
qiuxiang merged 1 commits from feature/qiuxiang-jd-wallet-period-balances into main 2026-11-05 19:34:03 +08:00
7 changed files with 299 additions and 21 deletions

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private const TABLE = 'jd_wallet_bill_rows';
private const INDEX = 'jd_wallet_opening_balance_index';
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable(self::TABLE) || $schema->hasIndex(self::TABLE, self::INDEX)) {
return;
}
$schema->table(self::TABLE, static function (Blueprint $table): void {
$table->index(
['generation', 'store_name', 'account_name', 'occurred_at', 'record_code'],
self::INDEX,
);
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable(self::TABLE) || ! $schema->hasIndex(self::TABLE, self::INDEX)) {
return;
}
$schema->table(
self::TABLE,
static fn (Blueprint $table) => $table->dropIndex(self::INDEX),
);
}
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
};

View File

@ -12,6 +12,6 @@ php artisan business-mirror:import-jd-wallet <UTF-8-CSV路径> <YYYY-MM> --count
导入前备份受影响镜像与月份状态;首次建表记录原表不存在。原件及无损 CSV 仅放私有目录,编号保存为文本。按批写入待启用版本,验证完整月份、笔数、收支、币种、重复编号和日期后原子启用,保留上一版本供回滚。失败不会启用部分数据。
每条“账户余额”保持原始值,可为负数。汇总“账户余额合计(最新)”取当前筛选结果中每个“店铺 + 账户名称”的最新流水,按费用结算时间、数值记录编码消歧后求和;不把流水余额逐笔相加,也不把全表最后一笔当所有账户余额。此口径不补齐筛选范围内没有流水的账户。
每条“账户余额”保持原始值,可为负数。页面“期初余额”取当前账期开始前,聚水潭京东钱包每个“店铺 + 账户名称”最后一笔流水余额之和;同一费用结算时间按数值记录编码消歧。首个导入月份没有上月流水时,按当月首笔余额减首笔收入加首笔支出倒推期初。“期末余额”不再取筛选结果的最新快照,统一按“期初余额 + 收入合计 - 支出合计”计算。切换月份时,期初截止日随所选账期变化。
回滚时使用同一锁恢复旧月份状态的 `active_generation`,原镜像行保留。首次接入可撤销该月份启用状态;不删除上游或其他业务数据。上线核对实际页面、首页/次页/末页、店铺与日期筛选、收支及全量导出。

View File

@ -523,7 +523,15 @@ class AccountBills extends Controller
'current_page' => 1,
'last_page' => 1,
'per_page' => $perPage,
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
'summary' => [
'income' => 0,
'expense' => 0,
'opening_balance' => null,
'opening_balance_date' => null,
'opening_balance_source' => null,
'balance' => null,
'count' => 0,
],
'columns' => AccountBillColumns::for($billType),
], 503);
}

View File

@ -2,13 +2,17 @@
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use RuntimeException;
class StoreReceivingJdWalletBillRepository
{
private const OPENING_BALANCE_CACHE_SECONDS = 300;
public function __construct(private readonly DatabaseManager $databases) {}
public function paginate(array $filters, string $sort, string $direction, int $page, int $perPage): array
@ -27,12 +31,21 @@ class StoreReceivingJdWalletBillRepository
->whereIn('record_code', $group->pluck('record_code')->all())->get(['record_code', 'row_data']);
foreach ($records as $record) $rows[$generation . ':' . $record->record_code] = json_decode($record->row_data, true, 512, JSON_THROW_ON_ERROR);
}
$balance = $this->latestBalanceTotal($filters, $periods);
$opening = $this->openingBalance($filters, $periods);
$income = round((float) $summary->income, 2);
$expense = round((float) $summary->expense, 2);
return [
'data' => $keys->map(fn ($key) => $rows[$key->generation . ':' . $key->record_code])->all(),
'total' => $total, 'current_page' => $page, 'last_page' => max(1, (int) ceil($total / $perPage)), 'per_page' => $perPage,
'summary' => ['count' => $total, 'income' => round((float) $summary->income, 2), 'expense' => round((float) $summary->expense, 2),
'balance' => $balance],
'summary' => [
'count' => $total,
'income' => $income,
'expense' => $expense,
'opening_balance' => $opening['balance'],
'opening_balance_date' => $opening['date'],
'opening_balance_source' => $opening['source'],
'balance' => round($opening['balance'] + $income - $expense, 2),
],
'sync' => ['status' => 'imported', 'source' => '胜算原始导出',
'last_succeeded_at' => $periods->max('imported_at'),
'periods' => $periods->map(fn ($p) => substr($p->period_start, 0, 7))->values()->all()],
@ -90,12 +103,127 @@ class StoreReceivingJdWalletBillRepository
return $q->orderBy($sort, $direction)->orderBy('record_code', $direction);
}
private function latestBalanceTotal(array $filters, \Illuminate\Support\Collection $periods): float
/**
* 当前账期开始前,每个店铺账户最后一笔余额之和。
*
* 首个导入月份没有上月流水时,用当月首笔余额倒推期初,
* 避免把没有上月数据误报成零余额。
*
* @return array{balance:float,date:?string,source:string}
*/
private function openingBalance(array $filters, \Illuminate\Support\Collection $periods): array
{
// Balances are snapshots: sum only the latest row per shop/account within the selected results.
$ranked = $this->query($filters, $periods)->select('balance')
->selectRaw('ROW_NUMBER() OVER (PARTITION BY store_name, account_name ORDER BY occurred_at DESC, LENGTH(record_code) DESC, record_code DESC) AS balance_rank');
return round((float) $this->db()->query()->fromSub($ranked, 'latest')->where('balance_rank', 1)->sum('balance'), 2);
$start = $this->date($filters['start'] ?? null) ?? $this->date($filters['end'] ?? null);
if ($start === null) {
return ['balance' => 0.0, 'date' => null, 'source' => 'none'];
}
$monthStart = $start->modify('first day of this month')->startOfDay();
$nextMonthStart = $monthStart->modify('+1 month');
$cutoff = $monthStart->format('Y-m-d H:i:s');
$periodEnd = $nextMonthStart->format('Y-m-d H:i:s');
$store = trim((string) ($filters['store_name'] ?? ''));
$generations = $periods->pluck('active_generation')->filter()->sort()->values()->all();
$cacheKey = 'jd-wallet-opening-balance:v1:' . hash('sha256', implode('|', [
$cutoff,
$store,
implode(',', $generations),
]));
$opening = Cache::remember(
$cacheKey,
self::OPENING_BALANCE_CACHE_SECONDS,
function () use ($generations, $cutoff, $periodEnd, $store): array {
$latestRows = $this->rankedBalances(
$generations,
$store,
'0000-01-01 00:00:00',
$cutoff,
'desc',
);
if ($latestRows->isNotEmpty()) {
return [
'balance' => round((float) $latestRows->sum('balance'), 2),
'source' => 'previous_period',
];
}
$firstRows = $this->rankedBalances(
$generations,
$store,
$cutoff,
$periodEnd,
'asc',
);
if ($firstRows->isEmpty()) {
return ['balance' => 0.0, 'source' => 'none'];
}
return [
'balance' => round((float) $firstRows->sum(
static fn (object $row): float => (float) $row->balance
- (float) $row->income
+ (float) $row->expense,
), 2),
'source' => 'first_row_estimate',
];
},
);
return [
'balance' => (float) $opening['balance'],
'date' => $monthStart->modify('-1 day')->format('Y-m-d'),
'source' => (string) $opening['source'],
];
}
/**
* @param list<string> $generations
* @return \Illuminate\Support\Collection<int,object>
*/
private function rankedBalances(
array $generations,
string $store,
string $from,
?string $before,
string $direction,
): \Illuminate\Support\Collection {
$query = $this->db()->table('jd_wallet_bill_rows')
->whereIn('generation', $generations)
->whereNotNull('balance')
->where('occurred_at', '>=', $from)
->when($before !== null, fn (Builder $query) => $query->where('occurred_at', '<', $before))
->when($store !== '', fn (Builder $query) => $query->where('store_name', $store))
->select(['store_name', 'account_name', 'occurred_at', 'income', 'expense', 'balance', 'record_code']);
$ranked = $query->selectRaw(
'ROW_NUMBER() OVER (PARTITION BY store_name, account_name ORDER BY occurred_at '
. $direction . ', LENGTH(record_code) ' . $direction . ', record_code ' . $direction
. ') AS balance_rank',
);
return $this->db()->query()
->fromSub($ranked, 'ranked_balances')
->where('balance_rank', 1)
->get();
}
private function date(mixed $value): ?CarbonImmutable
{
$date = trim((string) $value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
return null;
}
try {
$parsed = CarbonImmutable::createFromFormat('!Y-m-d', $date);
} catch (\Throwable) {
return null;
}
return $parsed instanceof CarbonImmutable && $parsed->format('Y-m-d') === $date
? $parsed
: null;
}
private function db(): \Illuminate\Database\Connection

File diff suppressed because one or more lines are too long

View File

@ -20,6 +20,7 @@ class JdWalletBillTest extends TestCase
config(['database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]);
DB::purge('business_mirror');
(require database_path('migrations/2026_09_18_160000_create_jd_wallet_bill_mirror_tables.php'))->up();
(require database_path('migrations/2026_11_05_100000_index_jd_wallet_opening_balances.php'))->up();
}
protected function tearDown(): void
@ -39,7 +40,9 @@ class JdWalletBillTest extends TestCase
$this->assertSame(2, $first['total']);
$this->assertSame(40.12, $first['summary']['income']);
$this->assertSame(2.03, $first['summary']['expense']);
$this->assertSame(-5.03, $first['summary']['balance']);
$this->assertSame(-45.15, $first['summary']['opening_balance']);
$this->assertSame('first_row_estimate', $first['summary']['opening_balance_source']);
$this->assertSame(-7.06, $first['summary']['balance']);
$this->assertSame('2000000000000000001', $first['data'][0]['record_code']);
$this->assertSame('10100126080000000000000001', $first['data'][0]['payload']['serial_no']);
$this->assertSame('2026-07-31 10:00:00', $first['data'][0]['payload']['bill_date']);
@ -120,7 +123,61 @@ class JdWalletBillTest extends TestCase
app(StoreReceivingJdWalletBillRepository::class)->paginate([], 'occurred_at', 'asc', 1, 10);
}
public function test_balances_use_latest_row_per_shop_and_account_and_keep_negative_values(): void
public function test_july_opening_uses_june_last_balance_and_closing_uses_the_period_formula(): void
{
$this->import([
$this->row('1', '0.00', '0.00', [
'费用结算时间' => '2026-06-15 12:00:00',
'账户余额' => '90.00',
]),
$this->row('2', '0.00', '0.00', [
'费用结算时间' => '2026-06-30 23:59:59',
'账户余额' => '100.00',
]),
$this->row('3', '0.00', '0.00', [
'费用结算时间' => '2026-06-30 23:59:59',
'账户余额' => '101.00',
]),
$this->row('4', '0.00', '0.00', [
'费用结算时间' => '2026-06-30 18:00:00',
'账户名称' => '另一账户',
'账户余额' => '20.00',
]),
$this->row('5', '0.00', '0.00', [
'店铺' => '另一店',
'费用结算时间' => '2026-06-30 17:00:00',
'账户余额' => '30.00',
]),
], 5, '0.00', '0.00', null, '2026-06');
$this->import([
$this->row('7', '15.00', '3.00', [
'费用结算时间' => '2026-07-15 12:00:00',
'账户余额' => '999.00',
]),
], 1, '15.00', '3.00', null, '2026-07');
$repo = app(StoreReceivingJdWalletBillRepository::class);
$allStores = $repo->paginate([
'start' => '2026-07-01',
'end' => '2026-07-31',
], 'occurred_at', 'desc', 1, 10);
$this->assertSame(151.0, $allStores['summary']['opening_balance']);
$this->assertSame('2026-06-30', $allStores['summary']['opening_balance_date']);
$this->assertSame('previous_period', $allStores['summary']['opening_balance_source']);
$this->assertSame(15.0, $allStores['summary']['income']);
$this->assertSame(3.0, $allStores['summary']['expense']);
$this->assertSame(163.0, $allStores['summary']['balance']);
$oneStore = $repo->paginate([
'store_name' => '测试京东店',
'start' => '2026-07-01',
'end' => '2026-07-31',
], 'occurred_at', 'desc', 1, 10);
$this->assertSame(121.0, $oneStore['summary']['opening_balance']);
$this->assertSame(133.0, $oneStore['summary']['balance']);
}
public function test_equations_keep_negative_balances_and_do_not_add_snapshot_rows(): void
{
$rows = [
array_replace($this->row('9', '10.00'), ['账户余额' => '100.00']),
@ -130,12 +187,19 @@ class JdWalletBillTest extends TestCase
];
$this->import($rows, 4, '30.00', '2.00');
$repo = app(StoreReceivingJdWalletBillRepository::class);
$this->assertSame(21.97, $repo->paginate([], 'income', 'asc', 1, 1)['summary']['balance']);
$this->assertSame(1.97, $repo->paginate(['store_name' => '测试京东店'], 'income', 'desc', 1, 10)['summary']['balance']);
$this->assertSame(0.0, $repo->paginate(['start' => '2026-09-01'], 'occurred_at', 'desc', 1, 10)['summary']['balance']);
$this->assertSame(28.0, $repo->paginate([], 'income', 'asc', 1, 1)['summary']['balance']);
$this->assertSame(30.0, $repo->paginate(['store_name' => '测试京东店'], 'income', 'desc', 1, 10)['summary']['balance']);
$this->assertSame(21.97, $repo->paginate(['start' => '2026-09-01'], 'occurred_at', 'desc', 1, 10)['summary']['balance']);
}
private function import(array $rows, int $count, string $income, string $expense, ?string $through = null): array
private function import(
array $rows,
int $count,
string $income,
string $expense,
?string $through = null,
string $month = '2026-08',
): array
{
$path = tempnam(sys_get_temp_dir(), 'jd-daily-');
$this->files[] = $path;
@ -143,15 +207,15 @@ class JdWalletBillTest extends TestCase
fputcsv($file, array_keys($rows[0]));
foreach ($rows as $row) fputcsv($file, array_values($row));
fclose($file);
return app(JdWalletBillImporter::class)->import($path, '2026-08', $count, $income, $expense, $through);
return app(JdWalletBillImporter::class)->import($path, $month, $count, $income, $expense, $through);
}
private function row(string $code, string $income, string $expense = '0.00'): array
private function row(string $code, string $income, string $expense = '0.00', array $overrides = []): array
{
return array_replace(array_fill_keys(array_values(JdWalletBillImporter::PAYLOAD_FIELDS), ''), [
'记录编码' => $code, '店铺' => '测试京东店', '账单项目' => '货款', '交易订单号' => '3606404000000001',
'流水号' => '10100126080000000000000001', '费用结算时间' => '2026-08-31 23:59:59',
'账单日期' => '2026-07-31 10:00:00', '收入金额' => $income, '支出金额' => $expense, '币种' => 'CNY', '账户余额' => '-5.03', '账户名称' => '测试账户',
]);
], $overrides);
}
}

View File

@ -98,6 +98,23 @@ class AccountBillsTableTest extends TestCase
$this->assertStringContainsString('.tw-stats--taobao-wechat{grid-template-columns:repeat(5,minmax(0,1fr))}', $source);
}
public function test_jd_wallet_places_opening_balance_before_income_and_uses_period_formula(): void
{
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');
$this->assertStringContainsString("'tw-stats--jd-wallet': billType === 'jd-wallet'", $source);
$this->assertStringContainsString("if(this.billType==='jd-wallet')", $source);
$this->assertStringContainsString("label:'期末余额'", $source);
$this->assertStringContainsString("note:'期初余额 + 收入合计 - 支出合计'", $source);
$this->assertStringContainsString('聚水潭京东钱包最后一笔', $source);
$this->assertStringNotContainsString("this.billType==='jd-wallet'?'账户余额合计(最新)'", $source);
$this->assertLessThan(
strpos($source, "label:'收入合计'"),
strpos($source, "label:'期初余额'"),
);
$this->assertStringContainsString('.tw-stats--jd-wallet{grid-template-columns:repeat(5,minmax(0,1fr))}', $source);
}
public function test_promotion_marketing_card_opens_the_store_detail_modal(): void
{
$source = file_get_contents(__DIR__ . '/../../../resources/assets/js/components/business/AccountBills.vue');