财务台账:同步上一晚店铺余额与平台提现 #523
59
app/Console/Commands/SyncFinanceLedgerShopData.php
Normal file
59
app/Console/Commands/SyncFinanceLedgerShopData.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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'));
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
266
tests/Feature/Business/FinanceLedgerShopDataSynchronizerTest.php
Normal file
266
tests/Feature/Business/FinanceLedgerShopDataSynchronizerTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user