财务台账:同步上一晚店铺余额与平台提现 #523

Merged
shanshi merged 2 commits from feature/shanshi-ledger-shop-balance-withdrawals into main 2026-10-24 20:37:30 +08:00
12 changed files with 908 additions and 12 deletions

View File

@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
use Throwable;
class SyncFinanceLedgerShopData extends Command
{
protected $signature = 'finance-ledger:sync-shop-data
{--from= : 提现业务日期起,默认从台账期初日}
{--to= : 提现业务日期止,最晚只同步到前一晚}
{--days= : 只同步最近 N 天}
{--no-recalculate : 只同步数据,不重算流水}';
protected $description = '同步上一晚店铺余额,并把平台账单中的提现对应到财务账户收入';
public function handle(FinanceLedgerShopDataSynchronizer $synchronizer): int
{
$days = (int) ($this->option('days') ?: 0);
$from = $this->option('from') ?: null;
if ($days > 0) {
$from = now()->subDays(max(1, $days) - 1)->toDateString();
}
try {
$result = $synchronizer->sync(
$from,
$this->option('to') ?: null,
! $this->option('no-recalculate'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['店铺余额日期', $result['balance']['date'] ?: '-'],
['余额源记录', $result['balance']['source']],
['补充确认店铺', $result['balance']['added']],
['已更新店铺余额', $result['balance']['matched']],
['店铺余额合计', number_format((float) $result['balance']['amount'], 2)],
['提现处理笔数', $result['withdrawals']['processed']],
['提现新增或更新', $result['withdrawals']['written']],
['上游已存在提现', $result['withdrawals']['upstream_duplicates']],
['店铺未匹配', $result['withdrawals']['unmatched']],
['账户缺失', $result['withdrawals']['account_missing']],
['提现收入合计', number_format((float) $result['withdrawals']['income'], 2)],
['提现冲回支出', number_format((float) $result['withdrawals']['expense'], 2)],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['PHP 峰值内存', number_format(memory_get_peak_usage(true) / 1048576, 1) . ' MiB'],
]);
$this->info('财务台账店铺余额与提现同步完成。');
return self::SUCCESS;
}
}

View File

@ -17,10 +17,12 @@ use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk;
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk;
use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter;
use Modules\EcommerceFinance\View\FinanceLedgerPageData;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class FinanceLedger extends Controller
{
@ -34,6 +36,45 @@ class FinanceLedger extends Controller
return $template->download();
}
public function syncShopData(FinanceLedgerShopDataSynchronizer $synchronizer)
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以同步店铺余额和提现。');
$target = route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'ledger',
]) . '?tab=shops';
try {
$result = $synchronizer->sync(now()->subDays(30)->toDateString());
} catch (Throwable $exception) {
report($exception);
return redirect()->to($target)->with('error', '店铺数据同步失败:' . $exception->getMessage());
}
$message = sprintf(
'已补充确认店铺 %s 个,同步上一晚店铺余额 %s 个,合计 %s 元;新增或更新提现 %s 笔,收入 %s 元%s。',
number_format((int) $result['balance']['added']),
number_format((int) $result['balance']['matched']),
number_format((float) $result['balance']['amount'], 2),
number_format((int) $result['withdrawals']['written']),
number_format((float) $result['withdrawals']['income'], 2),
$result['withdrawals']['upstream_duplicates'] > 0
? ',跳过已有记录 ' . number_format((int) $result['withdrawals']['upstream_duplicates']) . ' 笔'
: '',
);
if ($result['withdrawals']['unmatched'] > 0 || $result['withdrawals']['account_missing'] > 0) {
$message .= sprintf(
';店铺未匹配 %s 笔,账户缺失 %s 笔',
number_format((int) $result['withdrawals']['unmatched']),
number_format((int) $result['withdrawals']['account_missing']),
);
}
return redirect()->to($target)->with('success', $message);
}
public function unavailableBalanceTasks(Request $request)
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_shops') || $schema->hasColumn('fin_shops', 'shop_balance_date')) {
return;
}
$schema->table('fin_shops', function (Blueprint $table): void {
$table->date('shop_balance_date')->nullable()->after('shop_balance');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_shops') || ! $schema->hasColumn('fin_shops', 'shop_balance_date')) {
return;
}
$schema->table('fin_shops', function (Blueprint $table): void {
$table->dropColumn('shop_balance_date');
});
}
};

View File

@ -223,8 +223,8 @@ CRON_TZ=Asia/Shanghai
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 同步 AI 库动账、上一晚店铺余额和平台提现,并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --days=7 --quiet' >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -227,8 +227,8 @@ CRON_TZ=Asia/Shanghai
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 同步 AI 库动账、上一晚店铺余额和平台提现,并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-shop-data --days=7 --quiet' >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -75,7 +75,7 @@ class JushuitanShopBalanceRepository
foreach ($stores as $store) {
$names = array_values(array_unique(array_filter([
self::normalizeStoreName((string) ($store->shop_name ?? '')),
self::normalizeStoreName((string) ($store->erp_store_name ?? '')),
self::normalizeStoreName((string) ($store->erp_store_name ?? $store->shop_name ?? '')),
])));
foreach ($balanceDates as $balanceDate) {
@ -88,7 +88,7 @@ class JushuitanShopBalanceRepository
continue;
}
$targetPlatform = self::platformKey((string) ($store->store_platform ?? ''));
$targetPlatform = self::platformKey((string) ($store->store_platform ?? $store->platform ?? ''));
if ($targetPlatform !== null) {
$matches = $matches->filter(
static function (object $row) use ($targetPlatform): bool {

View File

@ -0,0 +1,458 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\View\BillingDetailsPageData;
class FinanceLedgerShopDataSynchronizer
{
public const WITHDRAWAL_SOURCE = FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE;
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly JushuitanShopBalanceRepository $shopBalances,
private readonly BillingDetailsPageData $billingDetails,
) {
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{
* balance:array{date:?string,source:int,added:int,matched:int,amount:float},
* withdrawals:array{from:string,to:string,processed:int,written:int,upstream_duplicates:int,unmatched:int,account_missing:int,income:float,expense:float},
* recalculated:?array
* }
*/
public function sync(?string $from = null, ?string $to = null, bool $recalculate = true): array
{
$previousNight = now()->startOfDay()->subDay()->toDateString();
$to = $this->validDate($to) ?? $previousNight;
if ($to > $previousNight) {
$to = $previousNight;
}
$from = $this->validDate($from)
?? (string) config('finance_ledger.opening_date', '2026-10-17');
if ($from > $to) {
$from = $to;
}
$balance = $this->syncShopBalances($to);
$withdrawals = $this->syncWithdrawals($from, $to);
$recalculated = $recalculate && ($balance['matched'] > 0 || $withdrawals['written'] > 0)
? app(FinanceLedgerCalculator::class)->recalculate()
: null;
return [
'balance' => $balance,
'withdrawals' => $withdrawals,
'recalculated' => $recalculated,
];
}
/**
* @return array{date:?string,source:int,added:int,matched:int,amount:float}
*/
public function syncShopBalances(?string $through = null): array
{
$through = $this->validDate($through) ?? now()->startOfDay()->subDay()->toDateString();
$balanceDate = $this->shopBalances->latestBalanceDate(CarbonImmutable::parse($through));
if ($balanceDate === null) {
return ['date' => null, 'source' => 0, 'added' => 0, 'matched' => 0, 'amount' => 0.0];
}
$shops = $this->connection->table('fin_shops')
->orderBy('id')
->get(['id', 'shop_name', 'platform', 'account_short']);
$added = $this->addConfirmedMissingShops($balanceDate, $shops);
if ($added > 0) {
$shops = $this->connection->table('fin_shops')
->orderBy('id')
->get(['id', 'shop_name', 'platform', 'account_short']);
}
$matches = $this->shopBalances->matchesForStores($shops, $balanceDate);
$updates = [];
foreach ($matches as $id => $match) {
if (($match['status'] ?? '') === 'matched' && $match['balance'] !== null) {
$updates[(int) $id] = round((float) $match['balance'], 2);
}
}
if ($updates !== []) {
$hasBalanceDate = $this->connection->getSchemaBuilder()->hasColumn('fin_shops', 'shop_balance_date');
foreach (array_chunk($updates, 400, true) as $chunk) {
$case = [];
foreach ($chunk as $id => $amount) {
$case[] = 'WHEN ' . $id . ' THEN ' . number_format($amount, 2, '.', '');
}
$values = [
'shop_balance' => $this->connection->raw(
'CASE `id` ' . implode(' ', $case) . ' ELSE `shop_balance` END',
),
'updated_at' => now(),
];
if ($hasBalanceDate) {
$values['shop_balance_date'] = $balanceDate;
}
$this->connection->table('fin_shops')
->whereIn('id', array_keys($chunk))
->update($values);
}
}
return [
'date' => $balanceDate,
'source' => $this->shopBalances->countForDate($balanceDate),
'added' => $added,
'matched' => count($updates),
'amount' => round(array_sum($updates), 2),
];
}
private function addConfirmedMissingShops(string $balanceDate, $existingShops): int
{
$sourceRows = $this->connection->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', $balanceDate)
->get(['source_platform', 'store_name']);
$existingExact = [];
$existingNormalized = [];
foreach ($existingShops as $shop) {
$name = trim((string) ($shop->shop_name ?? ''));
if ($name === '') {
continue;
}
$existingExact[$name] = true;
$existingNormalized[JushuitanShopBalanceRepository::normalizeStoreName($name)] = true;
}
$receivingByName = [];
foreach (StoreReceivingAccount::withoutGlobalScopes()->get() as $account) {
$name = trim((string) ($account->erp_store_name ?? ''));
if ($name === '') {
continue;
}
$receivingByName[JushuitanShopBalanceRepository::normalizeStoreName($name)][] = $account;
}
$seen = [];
$insert = [];
$now = now();
foreach ($sourceRows as $source) {
$name = trim((string) ($source->store_name ?? ''));
if ($name === '' || isset($seen[$name]) || isset($existingExact[$name])) {
continue;
}
$seen[$name] = true;
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($name);
if (isset($existingNormalized[$normalized])) {
continue;
}
$receivingMatches = $receivingByName[$normalized] ?? [];
if (count($receivingMatches) !== 1) {
continue;
}
$receiving = $receivingMatches[0];
$bankLastFour = trim((string) ($receiving->bank_card_last_four ?? ''));
$payout = trim((string) ($receiving->withdrawal_account_name ?? ''));
if ($bankLastFour !== '') {
$payout = trim($payout . ' ' . $bankLastFour);
}
$insert[] = [
'shop_name' => mb_substr($name, 0, 200),
'platform' => $this->financialPlatform((string) ($source->source_platform ?? ''), $name),
'company_name' => trim((string) ($receiving->store_entity_name ?? '')) ?: null,
'account_short' => null,
'status' => trim((string) ($receiving->store_status ?? '')) ?: '在营',
'payout_account' => $payout !== '' ? mb_substr($payout, 0, 100) : null,
'shop_balance' => 0,
'shop_balance_date' => null,
'remark' => '由店铺收款账户和余额快照补充,待确认回款账户',
'created_at' => $now,
'updated_at' => $now,
];
}
if ($insert === []) {
return 0;
}
$inserted = 0;
foreach (array_chunk($insert, 200) as $chunk) {
$inserted += $this->connection->table('fin_shops')->insertOrIgnore($chunk);
}
return $inserted;
}
/**
* @return array{from:string,to:string,processed:int,written:int,upstream_duplicates:int,unmatched:int,account_missing:int,income:float,expense:float}
*/
private function syncWithdrawals(string $from, string $to): array
{
$shops = $this->connection->table('fin_shops')
->orderBy('id')
->get(['id', 'shop_name', 'platform', 'account_short']);
[$shopByName, $shopsByNormalized] = $this->shopIndexes($shops);
$existingKeys = $this->existingWithdrawalKeys($from, $to, $shopByName);
$filters = $this->billingDetails->exportFilters([
'start' => $from,
'end' => $to,
'remark' => ['提现'],
]);
$columns = $this->billingDetails->exportColumns([
'record_code',
'store_name',
'finance_remark',
'bill_item',
'occurred_at',
'income',
'expense',
]);
$rows = [];
$processed = $writtenIncome = $writtenExpense = 0;
$duplicates = $unmatched = $accountMissing = 0;
$now = now();
foreach ($this->billingDetails->exportRows($filters, $columns) as $row) {
$processed++;
$date = substr(trim((string) ($row['occurred_at'] ?? '')), 0, 10);
$recordCode = trim((string) ($row['record_code'] ?? ''));
if ($date === '' || $recordCode === '') {
continue;
}
$sourcePlatform = trim((string) ($row['platform'] ?? ''));
$sourceStore = trim((string) ($row['store_name'] ?? ''));
$shop = $this->matchShop($sourceStore, $sourcePlatform, $shopByName, $shopsByNormalized);
if ($shop === null) {
$unmatched++;
continue;
}
$accountShort = trim((string) ($shop->account_short ?? ''));
if ($accountShort === '' || $accountShort === '/') {
$accountMissing++;
continue;
}
$incomeCents = (int) round((float) ($row['income'] ?? 0) * 100);
$expenseCents = (int) round((float) ($row['expense'] ?? 0) * 100);
$netCents = $incomeCents - $expenseCents;
if ($netCents === 0) {
continue;
}
// 平台提现是从平台账户流出、银行账户流入;平台退回则反向记账。
$targetIncomeCents = $netCents < 0 ? abs($netCents) : 0;
$targetExpenseCents = $netCents > 0 ? $netCents : 0;
$duplicateKey = implode('|', [
$date,
$accountShort,
$targetIncomeCents,
$targetExpenseCents,
]);
if (isset($existingKeys[$duplicateKey])) {
$duplicates++;
continue;
}
$detail = implode(' · ', array_values(array_filter([
$sourcePlatform !== '' ? $sourcePlatform . '账单提现' : '店铺账单提现',
trim((string) $shop->shop_name),
trim((string) ($row['bill_item'] ?? '')),
])));
$rowKey = hash('sha256', implode("\0", [
self::WITHDRAWAL_SOURCE,
$sourcePlatform,
$recordCode,
(string) $shop->shop_name,
]));
$rows[] = [
'txn_date' => $date,
'account_short' => $accountShort,
'detail' => mb_substr($detail, 0, 4000),
'income' => number_format($targetIncomeCents / 100, 2, '.', ''),
'expense' => number_format($targetExpenseCents / 100, 2, '.', ''),
'approval_no' => mb_substr(($sourcePlatform !== '' ? $sourcePlatform . ':' : '') . $recordCode, 0, 100),
'source' => self::WITHDRAWAL_SOURCE,
'source_value' => mb_substr((string) $shop->shop_name, 0, 200),
'process_type' => '店铺提现',
'row_key' => $rowKey,
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$writtenIncome += $targetIncomeCents;
$writtenExpense += $targetExpenseCents;
}
foreach (array_chunk($rows, 400) as $chunk) {
$this->connection->table('fin_transactions')->upsert(
$chunk,
['row_key'],
[
'txn_date', 'account_short', 'detail', 'income', 'expense', 'approval_no',
'source', 'source_value', 'process_type', 'synced_at', 'updated_at',
],
);
}
return [
'from' => $from,
'to' => $to,
'processed' => $processed,
'written' => count($rows),
'upstream_duplicates' => $duplicates,
'unmatched' => $unmatched,
'account_missing' => $accountMissing,
'income' => round($writtenIncome / 100, 2),
'expense' => round($writtenExpense / 100, 2),
];
}
/**
* @return array<string,string>
*/
private function existingWithdrawalKeys(string $from, string $to, array $shopByName): array
{
$rows = $this->connection->table('fin_transactions')
->whereBetween('txn_date', [$from, $to])
->where('source', '<>', self::WITHDRAWAL_SOURCE)
->where(function ($query): void {
$query->where('source', 'shop')->orWhere('process_type', '店铺提现');
})
->get(['txn_date', 'account_short', 'source_value', 'income', 'expense']);
$keys = [];
foreach ($rows as $row) {
$shop = $shopByName[trim((string) ($row->source_value ?? ''))]
?? $shopByName[trim((string) ($row->account_short ?? ''))]
?? null;
$account = trim((string) ($shop->account_short ?? $row->account_short ?? ''));
if ($account === '') {
continue;
}
$key = implode('|', [
substr((string) $row->txn_date, 0, 10),
$account,
(int) round((float) $row->income * 100),
(int) round((float) $row->expense * 100),
]);
$keys[$key] = true;
}
return $keys;
}
/**
* @return array{0:array<string,object>,1:array<string,list<object>>}
*/
private function shopIndexes($shops): array
{
$byName = [];
$byNormalized = [];
foreach ($shops as $shop) {
$name = trim((string) ($shop->shop_name ?? ''));
if ($name === '') {
continue;
}
$byName[$name] = $shop;
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($name);
$byNormalized[$normalized][] = $shop;
}
return [$byName, $byNormalized];
}
private function matchShop(string $storeName, string $platform, array $byName, array $byNormalized): ?object
{
if (isset($byName[$storeName])) {
return $byName[$storeName];
}
$normalized = JushuitanShopBalanceRepository::normalizeStoreName($storeName);
$candidates = $byNormalized[$normalized] ?? [];
if (count($candidates) === 1) {
return $candidates[0];
}
$matches = array_values(array_filter(
$candidates,
fn (object $shop): bool => $this->platformCompatible($platform, (string) ($shop->platform ?? '')),
));
return count($matches) === 1 ? $matches[0] : null;
}
private function platformCompatible(string $source, string $target): bool
{
$source = mb_strtolower(trim($source));
$target = mb_strtolower(trim($target));
if ($source === '' || $target === '' || str_contains($source, $target) || str_contains($target, $source)) {
return true;
}
foreach ([
['京东', '京东自营', '京东供销'],
['京喜', '京喜企业店'],
['淘系支付宝', '天猫', '淘宝', '阿里巴巴', '淘工厂'],
['淘系微信', '天猫', '淘宝'],
['抖音', '视频号小店'],
['唯品会', '唯品会MP'],
] as $aliases) {
$sourceHit = array_filter($aliases, static fn (string $alias): bool => str_contains($source, mb_strtolower($alias)));
$targetHit = array_filter($aliases, static fn (string $alias): bool => str_contains($target, mb_strtolower($alias)));
if ($sourceHit !== [] && $targetHit !== []) {
return true;
}
}
return false;
}
private function financialPlatform(string $sourcePlatform, string $storeName): string
{
$sourcePlatform = trim($sourcePlatform);
$storeName = trim($storeName);
foreach ([
'天猫' => '天猫',
'淘宝' => '淘宝',
'1688' => '阿里巴巴',
'阿里巴巴' => '阿里巴巴',
'淘工厂' => '淘工厂',
'京东' => '京东',
'京喜' => '京喜企业店',
'拼多多' => '拼多多',
'抖音' => '抖音小店',
'头条放心购' => '抖音小店',
'唯品会MP' => '唯品会MP',
'得物' => '得物',
'小红书' => '小红书',
'快手' => '快手小店',
] as $needle => $platform) {
if (str_contains($storeName, $needle) || str_contains($sourcePlatform, $needle)) {
return $platform;
}
}
return $sourcePlatform !== '' ? $sourcePlatform : '未填写';
}
private function validDate(?string $value): ?string
{
$value = trim((string) $value);
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value ? $value : null;
}
}

View File

@ -15,6 +15,8 @@ class FinanceLedgerTransactionWriter
{
public const MANUAL_SOURCE = 'manual';
public const SHOP_WITHDRAWAL_SOURCE = 'shop_bill';
public function replace(array $transactions, bool $recalculate = true): array
{
return $this->persist($transactions, $recalculate, true);
@ -90,7 +92,7 @@ class FinanceLedgerTransactionWriter
$connection->transaction(function () use ($connection, $rows, $now, $replaceExisting): void {
if ($replaceExisting) {
$connection->table('fin_transactions')
->where('source', '<>', self::MANUAL_SOURCE)
->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE])
->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
@ -127,7 +129,7 @@ class FinanceLedgerTransactionWriter
{
$stats = $connection->table('fin_transactions')
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
->where('source', '<>', self::MANUAL_SOURCE)
->whereNotIn('source', [self::MANUAL_SOURCE, self::SHOP_WITHDRAWAL_SOURCE])
->whereNotNull('source_value')
->where('source_value', '<>', '')
->groupBy('source', 'source_value')

View File

@ -9,6 +9,8 @@ use App\Utilities\FinanceLedgerAccess;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
/**
* 财务台账页面数据。
@ -49,6 +51,7 @@ class FinanceLedgerPageData
'ledgerAccountSummary' => $accounts['summary'],
'ledgerShops' => $shops['rows'],
'ledgerShopSummary' => $shops['summary'],
'ledgerShopBalanceDate' => $this->latestShopBalanceDate(),
'ledgerFlows' => $flows['rows'],
'ledgerFlowSummary' => $flows['summary'],
'ledgerTransactions' => $transactions['rows'],
@ -480,6 +483,19 @@ class FinanceLedgerPageData
];
}
private function latestShopBalanceDate(): ?string
{
if (! Schema::connection($this->db()->getName())->hasTable(JushuitanShopBalanceRepository::TABLE)) {
return null;
}
$latest = $this->db()->table(JushuitanShopBalanceRepository::TABLE)
->where('balance_date', '<=', now()->startOfDay()->subDay()->toDateString())
->max('balance_date');
return $latest === null ? null : substr((string) $latest, 0, 10);
}
private function accountOptions(): array
{
return $this->db()->table('fin_accounts')

View File

@ -54,7 +54,8 @@
$date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : '';
$ledgerPageUrl = route('business.pages.show', ['section' => 'financial-operations', 'page' => 'ledger']);
$tabUrl = static fn (string $tab): string => $ledgerPageUrl . '?' . http_build_query(['tab' => $tab]);
$sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入'];
$sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入'];
$shopBalanceDate = $ledgerShopBalanceDate ?? null;
$manualTransactionAccountOptions = $ledgerManualTransactionAccountOptions ?? [];
$transactionAccountHeaderOptions = $ledgerTransactionAccountHeaderOptions ?? [];
$transactionDateHeaderOptions = $ledgerTransactionDateHeaderOptions ?? [];
@ -189,6 +190,7 @@
.ledger-filter-combobox__empty,
.ledger-filter-combobox__more { margin:.25rem 0 0; padding:.45rem .5rem; border-top:1px solid #e8e3da; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.4; text-align:center; }
.ledger-actions { display:flex; gap:.45rem; padding-bottom:.05rem; }
.ledger-store-sync { display:flex; align-items:center; gap:.55rem; margin:-.2rem 0 .65rem; color:var(--vip-content-muted, #595959); font-size:.7rem; }
.ledger-edit-switch { position:relative; display:inline-flex; align-items:center; align-self:flex-end; gap:.45rem; height:2.15rem; padding:0 .65rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.45rem; background:#fff; color:var(--vip-content-muted, #595959); font-size:.72rem; font-weight:600; cursor:pointer; user-select:none; white-space:nowrap; }
.ledger-edit-switch input { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
.ledger-edit-switch__track { position:relative; width:2.15rem; height:1.2rem; border-radius:999px; background:#d6d0c5; transition:background .16s ease; }
@ -541,7 +543,17 @@
<div class="ledger-field"><label for="shops-company">所属公司</label><input id="shops-company" name="shops_company" value="{{ request('shops_company') }}" placeholder="公司名称"></div>
<div class="ledger-actions"><button class="ledger-btn primary" type="submit"><span class="material-icons-outlined">search</span>查询</button><a class="ledger-btn" href="{{ $tabUrl('shops') }}"><span class="material-icons-outlined">refresh</span>重置</a></div>
</form>
<div class="ledger-summary"><div class="ledger-summary__item"><span>店铺数</span><b>{{ number_format((int) ($ledgerShopSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>店铺余额合计</span><b>{{ $money($ledgerShopSummary['balance'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>余额口径</span><b>所属公司账户合计(待确认)</b></div></div>
@if ($canEditAccountStatus)
<form class="ledger-store-sync" method="POST" action="{{ route('business.financial-ledger.shop-data.sync') }}">
@csrf
<button class="ledger-btn" type="submit">
<span class="material-icons-outlined" aria-hidden="true">sync</span>
同步上一晚余额与提现
</button>
<span>{{ $shopBalanceDate ? '店铺余额日期:' . $shopBalanceDate : '尚未同步店铺余额' }}</span>
</form>
@endif
<div class="ledger-summary"><div class="ledger-summary__item"><span>店铺数</span><b>{{ number_format((int) ($ledgerShopSummary['count'] ?? 0)) }}</b></div><div class="ledger-summary__item"><span>店铺余额合计</span><b>{{ $money($ledgerShopSummary['balance'] ?? 0) }}</b></div><div class="ledger-summary__item"><span>余额口径</span><b>{{ $shopBalanceDate ? '上一晚店铺余额(' . $shopBalanceDate . ')' : '尚未同步' }}</b></div></div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:56rem"><thead><tr>
@foreach ($ledgerTableHeaders['shops'] as $header)
@ -557,7 +569,7 @@
])
@endforeach
</tr></thead>
<tbody>@forelse ($ledgerShops as $row)<tr><td>{{ $row->shop_name }}</td><td>{{ $row->platform }}</td><td>{{ $row->company_name }}</td><td>{{ $row->account_short }}</td><td><span class="{{ $row->status === '在营' ? 'success' : 'muted' }}">{{ $row->status }}</span></td><td>{{ $row->payout_account }}</td><td class="num">{{ $money($row->shop_balance) }}</td><td>{{ $row->remark }}</td></tr>@empty<tr><td colspan="8"><div class="ledger-empty">没有匹配的店铺数据。</div></td></tr>@endforelse</tbody>
<tbody>@forelse ($ledgerShops as $row)<tr><td>{{ $row->shop_name }}</td><td>{{ $row->platform }}</td><td>{{ $row->company_name }}</td><td>{{ $row->account_short }}</td><td><span class="{{ $row->status === '在营' ? 'success' : 'muted' }}">{{ $row->status }}</span></td><td>{{ $row->payout_account }}</td><td class="num" @if ($row->shop_balance_date ?? null) title="余额日期:{{ $row->shop_balance_date }}" @endif>{{ $money($row->shop_balance) }}</td><td>{{ $row->remark }}</td></tr>@empty<tr><td colspan="8"><div class="ledger-empty">没有匹配的店铺数据。</div></td></tr>@endforelse</tbody>
</table>
@include('business.pages.financial-ledger.partials.pagination', [
'rows' => $ledgerShops,
@ -777,7 +789,8 @@
<div class="ledger-note" style="line-height:1.8">
<b>取数边界:</b>动账明细只读 AI 库 <span class="ledger-mono">JSReportReplica.rpt</span> 的视图,不读 base,不直连 JianSun;账户、店铺、公司、期初由财务系统单独导入。当前本地数据为 rpt 实时同步结果,期初日 {{ $ledgerOpeningDate }}。<br>
<b>流水口径:</b>按账户逐日滚动,期末 = 期初 + 当日入账 − 当日出账;期初日无交易也保留一行。<br>
<b>店铺余额:</b>当前按“所属公司名下所有账户最新期末余额合计”计算,待财务确认是否改为回款账户口径。
<b>店铺余额:</b>取聚水潭快照中每个店铺上一晚的最新余额;未匹配店铺保留原值并等待核对。<br>
<b>平台提现:</b>按“平台 + 店铺名称”匹配店铺台账的账户简称,提现金额作为对应银行账户收入入账;已由其他来源同步的同日同店同金额记录会自动跳过。
</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:55rem"><thead><tr>

View File

@ -696,6 +696,10 @@ Route::post('workspace/financial-ledger/transactions/manual', [\App\Http\Control
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.transactions.manual');
Route::post('workspace/financial-ledger/shop-data/sync', [\App\Http\Controllers\Business\FinanceLedger::class, 'syncShopData'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.shop-data.sync');
Route::post('workspace/financial-ledger/transactions/{id}/account', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateTransactionAccount'])
->whereNumber('id')
->middleware('business.page:financial-operations,ledger')

View File

@ -0,0 +1,266 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\StoreReceivingAccount;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Mockery;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
use Modules\EcommerceFinance\Services\FinanceLedgerTransactionWriter;
use Modules\EcommerceFinance\View\BillingDetailsPageData;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerShopDataSynchronizerTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_shop_balance_uses_latest_balance_on_or_before_previous_night(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '京东-余额测试店',
'platform' => '京东',
'account_short' => '余额测试账户',
'shop_balance' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
$this->insertBalance($connection, '2026-10-22', '京东-余额测试店', 222.22);
$this->insertBalance($connection, '2026-10-23', '京东-余额测试店', 333.33);
$this->insertBalance($connection, '2026-10-24', '京东-余额测试店', 444.44);
$result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23');
$this->assertSame('2026-10-23', $result['date']);
$this->assertSame(1, $result['matched']);
$shop = $connection->table('fin_shops')->where('shop_name', '京东-余额测试店')->first();
$this->assertSame(333.33, (float) $shop->shop_balance);
$this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10));
}
public function test_balance_sync_adds_confirmed_store_that_is_missing_from_shop_ledger(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
StoreReceivingAccount::withoutGlobalScopes()->create([
'company_id' => $this->company->id,
'store_platform' => '抖音',
'store_name' => '抖音-待补充店铺',
'store_entity_name' => '补充主体有限公司',
'receiving_method' => '银行卡',
]);
$this->insertBalance(
DB::connection('business_mirror'),
'2026-10-23',
'抖音-待补充店铺',
765.43,
'头条放心购',
);
$result = app(FinanceLedgerShopDataSynchronizer::class)->syncShopBalances('2026-10-23');
$this->assertSame(1, $result['added']);
$shop = DB::connection('business_mirror')->table('fin_shops')
->where('shop_name', '抖音-待补充店铺')
->first();
$this->assertNotNull($shop);
$this->assertSame('抖音小店', $shop->platform);
$this->assertSame('补充主体有限公司', $shop->company_name);
$this->assertNull($shop->account_short);
$this->assertSame(765.43, (float) $shop->shop_balance);
$this->assertSame('2026-10-23', substr((string) $shop->shop_balance_date, 0, 10));
}
public function test_withdrawal_rows_are_mapped_to_shop_account_income_and_are_idempotent(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '京东-提现测试店',
'platform' => '京东',
'account_short' => '提现测试账户',
'created_at' => now(),
'updated_at' => now(),
]);
$this->bindWithdrawalRows([
[
'record_code' => 'JD-WITHDRAW-1',
'store_name' => '京东-提现测试店',
'platform' => '京东',
'bill_item' => '提现',
'finance_remark' => '提现',
'occurred_at' => '2026-10-23 20:00:00',
'income' => '0.00',
'expense' => '123.45',
],
], 2);
$synchronizer = app(FinanceLedgerShopDataSynchronizer::class);
$first = $synchronizer->sync('2026-10-23', '2026-10-23', false);
$second = $synchronizer->sync('2026-10-23', '2026-10-23', false);
$this->assertSame(1, $first['withdrawals']['written']);
$this->assertSame(1, $second['withdrawals']['written']);
$this->assertDatabaseCount('fin_transactions', 1, 'business_mirror');
$transaction = $connection->table('fin_transactions')->first();
$this->assertSame('shop_bill', $transaction->source);
$this->assertSame('提现测试账户', $transaction->account_short);
$this->assertSame('店铺提现', $transaction->process_type);
$this->assertSame(123.45, (float) $transaction->income);
$this->assertSame(0.0, (float) $transaction->expense);
}
public function test_withdrawal_is_skipped_when_same_cashflow_was_already_synced_from_upstream(): void
{
Carbon::setTestNow('2026-10-24 09:00:00');
$connection = DB::connection('business_mirror');
$connection->table('fin_shops')->insert([
'shop_name' => '京东-重复提现店',
'platform' => '京东',
'account_short' => '重复提现账户',
'created_at' => now(),
'updated_at' => now(),
]);
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-23',
'account_short' => '重复提现账户',
'detail' => '上游已同步提现',
'income' => 88.00,
'expense' => 0,
'approval_no' => 'UPSTREAM-1',
'source' => 'shop',
'source_value' => '京东-重复提现店',
'process_type' => '店铺提现',
'row_key' => hash('sha256', 'upstream-duplicate'),
'created_at' => now(),
'updated_at' => now(),
]);
$this->bindWithdrawalRows([
[
'record_code' => 'JD-WITHDRAW-2',
'store_name' => '京东-重复提现店',
'platform' => '京东',
'bill_item' => '提现',
'finance_remark' => '提现',
'occurred_at' => '2026-10-23 21:00:00',
'income' => '0.00',
'expense' => '88.00',
],
]);
$result = app(FinanceLedgerShopDataSynchronizer::class)->sync('2026-10-23', '2026-10-23', false);
$this->assertSame(0, $result['withdrawals']['written']);
$this->assertSame(1, $result['withdrawals']['upstream_duplicates']);
$this->assertSame(0, $connection->table('fin_transactions')->where('source', 'shop_bill')->count());
}
public function test_full_upstream_replacement_preserves_generated_shop_withdrawals(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_transactions')->insert([
'txn_date' => '2026-10-23',
'account_short' => '保留提现账户',
'detail' => '页面生成提现',
'income' => 50,
'expense' => 0,
'approval_no' => 'JD:KEEP-1',
'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE,
'source_value' => '京东-保留提现店',
'process_type' => '店铺提现',
'row_key' => hash('sha256', 'keep-shop-withdrawal'),
'created_at' => now(),
'updated_at' => now(),
]);
app(FinanceLedgerTransactionWriter::class)->replace([
[
'txn_date' => '2026-10-23',
'account_short' => '上游账户',
'detail' => '上游动账',
'income' => 10,
'expense' => 0,
'approval_no' => 'UPSTREAM-2',
'source' => 'dingtalk',
'source_value' => '上游来源',
'process_type' => '费用',
],
], false);
$this->assertDatabaseHas('fin_transactions', [
'source' => FinanceLedgerTransactionWriter::SHOP_WITHDRAWAL_SOURCE,
'approval_no' => 'JD:KEEP-1',
], 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'source' => 'dingtalk',
'approval_no' => 'UPSTREAM-2',
], 'business_mirror');
}
private function insertBalance(
$connection,
string $date,
string $storeName,
float $balance,
string $platform = '京东',
): void
{
$connection->table(JushuitanShopBalanceRepository::TABLE)->insert([
'balance_date' => $date,
'source_scope' => 'all',
'source_platform' => $platform,
'store_name' => $storeName,
'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName($storeName),
'platform_account_id' => null,
'balance' => $balance,
'source_row_key' => hash('sha256', $date . '|' . $storeName . '|' . $balance),
'source_file_name' => 'fixture.xlsx',
'source_sha256' => hash('sha256', 'fixture'),
'imported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
private function bindWithdrawalRows(array $rows, int $calls = 1): void
{
$billing = Mockery::mock(BillingDetailsPageData::class);
$billing->shouldReceive('exportFilters')
->times($calls)
->andReturnUsing(static fn (array $input): array => $input + ['remark' => ['提现']]);
$billing->shouldReceive('exportColumns')
->times($calls)
->andReturn([
'record_code' => ['记录编码', 'text'],
'store_name' => ['店铺', 'text'],
'finance_remark' => ['财务备注', 'text'],
'bill_item' => ['账单项目', 'tag'],
'occurred_at' => ['发生时间', 'text'],
'income' => ['收入金额', 'num'],
'expense' => ['支出金额', 'num'],
]);
$billing->shouldReceive('exportRows')
->times($calls)
->andReturnUsing(static function () use ($rows): \Generator {
yield from $rows;
});
app()->instance(BillingDetailsPageData::class, $billing);
}
}