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