From df14f627f3ea1f4a4e97edd72759f5f0278625e5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 12 Sep 2026 14:43:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8F=8A=E6=AF=8F=E6=97=A5=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportJushuitanShopBalances.php | 44 +++ app/Http/Controllers/Business/Pages.php | 110 ++++++ config/business_navigation.php | 1 + ...reate_jushuitan_shop_balance_snapshots.php | 52 +++ docs/上游业务镜像清单.md | 1 + .../JushuitanShopBalanceRepository.php | 179 +++++++++ .../Services/JushuitanShopBalanceImporter.php | 340 ++++++++++++++++++ .../withdrawal-management.blade.php | 131 +++++++ tests/Feature/Business/ManagementCrudTest.php | 106 ++++++ .../JushuitanShopBalanceRepositoryTest.php | 220 ++++++++++++ 10 files changed, 1184 insertions(+) create mode 100644 app/Console/Commands/ImportJushuitanShopBalances.php create mode 100644 database/migrations/2026_09_12_100000_create_jushuitan_shop_balance_snapshots.php create mode 100644 modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php create mode 100644 modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php create mode 100644 resources/views/business/pages/fund-management/withdrawal-management.blade.php create mode 100644 tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php diff --git a/app/Console/Commands/ImportJushuitanShopBalances.php b/app/Console/Commands/ImportJushuitanShopBalances.php new file mode 100644 index 00000000..f8292e56 --- /dev/null +++ b/app/Console/Commands/ImportJushuitanShopBalances.php @@ -0,0 +1,44 @@ +import( + (string) $this->argument('path'), + $this->option('balance-date') === null ? null : (string) $this->option('balance-date'), + (string) $this->option('scope'), + (bool) $this->option('dry-run'), + ); + } catch (Throwable $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $this->info(sprintf( + '%s:%s 共 %d 条,替换原有 %d 条。', + $result['dry_run'] ? '校验完成' : '导入完成', + $result['balance_date'], + $result['total'], + $result['replaced'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 9ad3bf8f..4f45004f 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -13,6 +13,7 @@ use App\Models\Business\CourierQuoteSignature; use App\Models\Business\CourierQuoteVersion; use App\Models\Business\CourierSigningSubject; use App\Models\Business\InstallationDetail; +use App\Models\Business\StoreReceivingAccount; use App\Business\CourierSurchargeQuoteRepository; use App\Business\CourierSurchargeTypeRepository; use App\Models\StoreReceivables\StoreZhongcaiReconciliation; @@ -35,6 +36,7 @@ use Modules\EcommerceFinance\Repositories\EdgeBandingRepository; use Modules\EcommerceFinance\Repositories\InventoryRepository; use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; use Modules\EcommerceFinance\Repositories\JdWalletBillRepository; +use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository; use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository; use Modules\EcommerceFinance\Repositories\PinduoduoSpreadBillRepository; use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository; @@ -89,6 +91,7 @@ class Pages extends Controller private readonly UpstreamQueryCacheFactory $upstreamCaches, private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes, private readonly CourierSurchargeTypeRepository $courierSurchargeTypes, + private readonly JushuitanShopBalanceRepository $jushuitanShopBalances, ) { $this->middleware('auth'); $this->middleware('company.identify'); @@ -627,11 +630,118 @@ class Pages extends Controller 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'courier-surcharge' => $this->contentForCourierSurcharge(), + 'withdrawal-management' => $this->contentForWithdrawalManagement(), 'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(), default => null, }; } + /** + * Consolidated, non-destructive view of the withdrawal configuration kept + * with each store receiving account. Updates remain in the source page so + * a withdrawal action cannot delete an entire store-account record. + */ + protected function contentForWithdrawalManagement(): array + { + $clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length); + $platform = $clean(request()->query('platform')); + $withdrawalPath = $clean(request()->query('withdrawal_path')); + $keyword = $clean(request()->query('keyword'), 191); + $balanceStatus = $clean(request()->query('balance_status')); + if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) { + $balanceStatus = ''; + } + + $platforms = StoreReceivingAccount::query() + ->whereNotNull('store_platform') + ->distinct() + ->orderBy('store_platform') + ->pluck('store_platform') + ->filter() + ->values() + ->all(); + $withdrawalPaths = StoreReceivingAccount::query() + ->whereNotNull('receiving_method') + ->distinct() + ->orderBy('receiving_method') + ->pluck('receiving_method') + ->filter() + ->values() + ->all(); + + $query = StoreReceivingAccount::query(); + + if ($platform !== '') { + $query->where('store_platform', $platform); + } + + if ($withdrawalPath !== '') { + $query->where('receiving_method', $withdrawalPath); + } + + if ($keyword !== '') { + $query->where('store_name', 'like', '%' . addcslashes($keyword, '%_\\') . '%'); + } + + $today = now()->startOfDay(); + $balanceDate = $today->copy()->subDay(); + $withdrawalRows = $query->orderByDesc('id')->get(); + $withdrawalBalances = $this->jushuitanShopBalances->matchesForStores( + $withdrawalRows, + $balanceDate->toDateString(), + ); + $balanceCounts = collect($withdrawalBalances)->countBy( + static fn (array $match): string => $match['status'], + ); + + if ($balanceStatus !== '') { + $withdrawalRows = $withdrawalRows->filter( + static fn (StoreReceivingAccount $row): bool => ($withdrawalBalances[(int) $row->id]['status'] ?? 'not_ready') === $balanceStatus, + ); + } + + $statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3]; + $withdrawalRows = $withdrawalRows->sort( + static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($withdrawalBalances, $statusOrder): int { + $leftStatus = $withdrawalBalances[(int) $left->id]['status'] ?? 'not_ready'; + $rightStatus = $withdrawalBalances[(int) $right->id]['status'] ?? 'not_ready'; + $statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99); + + return $statusComparison !== 0 + ? $statusComparison + : ((int) $right->id <=> (int) $left->id); + }, + )->values(); + + $page = max((int) request()->query('page', 1), 1); + $perPage = 50; + $withdrawalRows = new LengthAwarePaginator( + $withdrawalRows->forPage($page, $perPage)->values(), + $withdrawalRows->count(), + $perPage, + $page, + ['path' => request()->url(), 'query' => request()->except('page')], + ); + + return [ + 'view' => 'business.pages.fund-management.withdrawal-management', + 'withdrawalRows' => $withdrawalRows, + 'withdrawalFilters' => compact('platform', 'withdrawalPath', 'keyword', 'balanceStatus'), + 'withdrawalPlatforms' => $platforms, + 'withdrawalPaths' => $withdrawalPaths, + 'withdrawalBalances' => $withdrawalBalances, + 'withdrawalBalanceSummary' => [ + 'source' => $this->jushuitanShopBalances->countForDate($balanceDate->toDateString()), + 'matched' => (int) $balanceCounts->get('matched', 0), + 'ambiguous' => (int) $balanceCounts->get('ambiguous', 0), + 'unmatched' => (int) $balanceCounts->get('unmatched', 0), + 'not_ready' => (int) $balanceCounts->get('not_ready', 0), + ], + 'withdrawalBalanceColumnLabel' => $today->format('n月j日') . '店铺余额', + 'withdrawalBalanceDate' => $balanceDate->toDateString(), + ]; + } + /** * 快递附加费报价维护。 * diff --git a/config/business_navigation.php b/config/business_navigation.php index 4386a3bc..3f2e14c5 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -322,6 +322,7 @@ $navigation = [ ['slug' => 'bank-accounts', 'title' => '银行账户'], ['slug' => 'shared-company-accounts', 'title' => '公司公用账户'], ['slug' => 'fund-forecast', 'title' => '资金预测表'], + ['slug' => 'withdrawal-management', 'title' => '提现管理'], ], ], [ diff --git a/database/migrations/2026_09_12_100000_create_jushuitan_shop_balance_snapshots.php b/database/migrations/2026_09_12_100000_create_jushuitan_shop_balance_snapshots.php new file mode 100644 index 00000000..e13f0e3b --- /dev/null +++ b/database/migrations/2026_09_12_100000_create_jushuitan_shop_balance_snapshots.php @@ -0,0 +1,52 @@ +connection()); + + if ($schema->hasTable('jushuitan_shop_balance_snapshots')) { + return; + } + + $schema->create('jushuitan_shop_balance_snapshots', function (Blueprint $table): void { + $table->id(); + $table->date('balance_date'); + $table->string('source_scope', 64)->default('all'); + $table->string('source_platform', 64)->nullable(); + $table->string('store_name', 255); + $table->string('normalized_store_name', 255); + $table->string('platform_account_id', 191)->nullable(); + $table->decimal('balance', 20, 6); + $table->char('source_row_key', 64); + $table->string('source_file_name', 255); + $table->char('source_sha256', 64); + $table->dateTime('imported_at', 6); + $table->timestamps(6); + + $table->unique( + ['balance_date', 'source_scope', 'source_row_key'], + 'jst_shop_balance_date_scope_row_unique', + ); + $table->index( + ['balance_date', 'normalized_store_name'], + 'jst_shop_balance_date_store_index', + ); + }); + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('jushuitan_shop_balance_snapshots'); + } + + private function connection(): string + { + return (string) config('upstream_mirrors.connection', 'business_mirror'); + } +}; diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 645292ab..27f90cd5 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -25,6 +25,7 @@ | 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 | | 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative --count=<核对笔数> --income=<收入> --expense=<支出>`,只接受完整月份和精确核对值,保留旧版本、文件摘要及原镜像供回滚。生产执行前备份状态与受影响月份镜像,并使用 `/run/caiwu-upstream-sync.lock`。实际财务数据只放私有目录,不进入 Git。 | | 已接入待首次同步 | 店铺收款账单的拼多多账户读取 `jiansun.dbo.jst_originalBillInfo` 中固定 `channel = PinduoduoImport` 的独立切片镜像 `js_jst_pdd_receiving_bill_rows`;不使用慢一天的 `operations_replica` 副本或营销账户 `pdd_spread_bills`。 | +| 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 JSON/XLSX 及多平台“平台资金账户余额”导出,进入 `jushuitan_shop_balance_snapshots`;已导入 2026-09-11 的淘系、拼多多和抖音共 164 条余额。页面按店铺名称精确匹配并强制使用平台消歧,未匹配和多匹配不会显示为零。 | | 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 | ## 经营分析副本库(operations_replica) diff --git a/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php new file mode 100644 index 00000000..4046fcdb --- /dev/null +++ b/modules/EcommerceFinance/Repositories/JushuitanShopBalanceRepository.php @@ -0,0 +1,179 @@ +connection = $database->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** + * @param iterable $stores + * @return array + */ + public function matchesForStores(iterable $stores, string $balanceDate): array + { + $stores = is_array($stores) ? $stores : iterator_to_array($stores); + $result = []; + + foreach ($stores as $store) { + $result[(int) $store->id] = $this->emptyMatch('not_ready'); + } + + if (! $this->hasTable()) { + return $result; + } + + $sourceRows = $this->connection->table(self::TABLE) + ->where('balance_date', $balanceDate) + ->orderBy('id') + ->get([ + 'id', 'source_platform', 'store_name', 'normalized_store_name', + 'platform_account_id', 'balance', + ]); + + if ($sourceRows->isEmpty()) { + return $result; + } + + foreach ($stores as $store) { + $names = array_values(array_unique(array_filter([ + self::normalizeStoreName((string) ($store->shop_name ?? '')), + self::normalizeStoreName((string) ($store->erp_store_name ?? '')), + ]))); + + $matches = $sourceRows->filter( + static fn (object $row): bool => in_array((string) $row->normalized_store_name, $names, true), + )->values(); + + if ($matches->isEmpty()) { + $result[(int) $store->id] = $this->emptyMatch('unmatched'); + continue; + } + + $targetPlatform = self::platformKey((string) ($store->store_platform ?? '')); + if ($targetPlatform !== null) { + $matches = $matches->filter( + static function (object $row) use ($targetPlatform): bool { + $sourcePlatform = self::platformKey((string) ($row->source_platform ?? '')) + ?? self::platformKey((string) self::sourcePlatform((string) ($row->store_name ?? ''))); + + return $sourcePlatform !== null && $sourcePlatform === $targetPlatform; + }, + )->values(); + + if ($matches->isEmpty()) { + $result[(int) $store->id] = $this->emptyMatch('unmatched'); + continue; + } + } + + if ($matches->count() !== 1) { + $result[(int) $store->id] = $this->emptyMatch('ambiguous'); + continue; + } + + $match = $matches->first(); + $result[(int) $store->id] = [ + 'status' => 'matched', + 'balance' => (float) $match->balance, + 'source_store_name' => (string) $match->store_name, + 'source_platform' => $match->source_platform === null ? null : (string) $match->source_platform, + ]; + } + + return $result; + } + + public function countForDate(string $balanceDate): int + { + if (! $this->hasTable()) { + return 0; + } + + return $this->connection->table(self::TABLE) + ->where('balance_date', $balanceDate) + ->count(); + } + + public static function normalizeStoreName(string $value): string + { + $value = trim(str_replace(['-', '—', '–'], '-', $value)); + $value = preg_replace( + '/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-_::]+/u', + '', + $value, + ) ?? $value; + $value = preg_replace('/\s+/u', '', $value) ?? $value; + + return mb_substr(mb_strtolower($value), 0, 255); + } + + public static function sourcePlatform(string $storeName): ?string + { + if (preg_match( + '/^(支付宝|天猫|淘宝|阿里巴巴|1688|京东商城|京东|拼多多|抖音|快手|小红书|唯品会|得物|淘工厂)[\s\-—–_::]+/u', + trim($storeName), + $matches, + ) === 1) { + return $matches[1]; + } + + return str_ends_with(mb_strtolower(trim($storeName)), '1688') ? '1688' : null; + } + + private static function platformKey(string $platform): ?string + { + $platform = mb_strtolower(trim($platform)); + foreach ([ + 'tmall' => ['天猫'], + 'taobao' => ['淘宝'], + '1688' => ['1688', '阿里巴巴'], + 'jd' => ['京东'], + 'jingxi' => ['京喜'], + 'pinduoduo' => ['拼多多'], + 'douyin' => ['抖音', '头条放心购'], + 'kuaishou' => ['快手'], + 'xiaohongshu' => ['小红书'], + 'vipshop' => ['唯品会'], + 'dewu' => ['得物'], + 'taogongchang' => ['淘工厂'], + ] as $key => $aliases) { + foreach ($aliases as $alias) { + if (str_contains($platform, mb_strtolower($alias))) { + return $key; + } + } + } + + return null; + } + + /** @return array{status:string,balance:null,source_store_name:null,source_platform:null} */ + private function emptyMatch(string $status): array + { + return [ + 'status' => $status, + 'balance' => null, + 'source_store_name' => null, + 'source_platform' => null, + ]; + } + + private function hasTable(): bool + { + return Schema::connection($this->connection->getName())->hasTable(self::TABLE); + } +} diff --git a/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php new file mode 100644 index 00000000..a1b442af --- /dev/null +++ b/modules/EcommerceFinance/Services/JushuitanShopBalanceImporter.php @@ -0,0 +1,340 @@ +connection = $database->connection( + (string) config('upstream_mirrors.connection', 'business_mirror'), + ); + } + + /** @return array{balance_date:string,total:int,replaced:int,dry_run:bool} */ + public function import( + string $path, + ?string $balanceDate = null, + string $sourceScope = 'all', + bool $dryRun = false, + ): array { + $realPath = realpath($path); + if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) { + throw new InvalidArgumentException('聚水潭店铺余额文件不存在或不可读取。'); + } + + $sourceScope = trim($sourceScope); + if ($sourceScope === '' || mb_strlen($sourceScope) > 64) { + throw new InvalidArgumentException('来源范围不能为空且不能超过 64 个字符。'); + } + + $requestedDate = $this->date($balanceDate); + [$headers, $dataRows] = match (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION))) { + 'json' => $this->jsonRows($realPath), + 'xlsx', 'xls' => $this->workbookRows($realPath), + default => throw new InvalidArgumentException('店铺余额仅支持聚水潭接口 JSON 或官方 XLSX/XLS 导出文件。'), + }; + [$selectedDate, $storeField, $accountField, $balanceField, $platformField] = $this->fields($headers, $requestedDate); + $rows = $this->normalizeRows( + $dataRows, + $selectedDate, + $storeField, + $accountField, + $balanceField, + $platformField, + ); + + if ($rows === []) { + throw new InvalidArgumentException('聚水潭店铺余额文件中没有可导入的数据行,已保留原有快照。'); + } + if (! Schema::connection($this->connection->getName())->hasTable(JushuitanShopBalanceRepository::TABLE)) { + throw new RuntimeException('聚水潭店铺余额镜像表尚未创建,请先执行数据库迁移。'); + } + + $existing = $this->connection->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', $selectedDate) + ->where('source_scope', $sourceScope) + ->count(); + + if (! $dryRun) { + $now = now(); + $sourceName = mb_substr(basename($realPath), 0, 255); + $sourceSha256 = hash_file('sha256', $realPath); + + $this->connection->transaction(function () use ( + $rows, $selectedDate, $sourceScope, $sourceName, $sourceSha256, $now + ): void { + $this->connection->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', $selectedDate) + ->where('source_scope', $sourceScope) + ->delete(); + + foreach (array_chunk($rows, 500) as $chunk) { + $this->connection->table(JushuitanShopBalanceRepository::TABLE)->insert(array_map( + static fn (array $row): array => $row + [ + 'balance_date' => $selectedDate, + 'source_scope' => $sourceScope, + 'source_file_name' => $sourceName, + 'source_sha256' => $sourceSha256, + 'imported_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + $chunk, + )); + } + }); + } + + return [ + 'balance_date' => $selectedDate, + 'total' => count($rows), + 'replaced' => $existing, + 'dry_run' => $dryRun, + ]; + } + + /** @return array{0:list>,1:list>} */ + private function jsonRows(string $path): array + { + $payload = json_decode((string) file_get_contents($path), true); + if (! is_array($payload)) { + throw new InvalidArgumentException('聚水潭接口 JSON 格式无效。'); + } + + $table = $this->findTablePayload($payload); + if ($table === null) { + throw new InvalidArgumentException('接口响应中缺少 fldHeads 或 dataList。'); + } + if (array_key_exists('isSuccess', $table) && ! $table['isSuccess']) { + throw new InvalidArgumentException('聚水潭接口返回失败:' . trim((string) ($table['msg'] ?? $table['message'] ?? '未知错误'))); + } + + return [array_values($table['fldHeads']), array_values($table['dataList'])]; + } + + /** @return array|null */ + private function findTablePayload(array $payload): ?array + { + if (isset($payload['fldHeads'], $payload['dataList']) + && is_array($payload['fldHeads']) && is_array($payload['dataList'])) { + return $payload; + } + + foreach ($payload as $value) { + if (is_array($value) && ($found = $this->findTablePayload($value)) !== null) { + return $found; + } + } + + return null; + } + + /** @return array{0:list,1:list>} */ + private function workbookRows(string $path): array + { + $spreadsheet = IOFactory::load($path); + $worksheet = $spreadsheet->getActiveSheet(); + $matrix = $worksheet->toArray(null, true, true, true); + $spreadsheet->disconnectWorksheets(); + + $headerNumber = null; + foreach (array_slice($matrix, 0, 20, true) as $rowNumber => $row) { + $values = array_map(static fn (mixed $value): string => trim((string) $value), $row); + $hasBalanceColumn = in_array('平台实际期末余额', $values, true) + || count(array_filter($values, fn (string $value): bool => $this->dateFromHeader($value, null) !== null)) > 0; + if (in_array('店铺', $values, true) && $hasBalanceColumn) { + $headerNumber = $rowNumber; + break; + } + } + if ($headerNumber === null) { + throw new InvalidArgumentException('聚水潭导出文件中未找到“店铺”和日期列。'); + } + + $headers = []; + foreach ($matrix[$headerNumber] as $column => $title) { + if (trim((string) $title) !== '') { + $headers[] = ['title' => trim((string) $title), 'fld' => (string) $column]; + } + } + + return [$headers, array_values(array_filter( + array_slice($matrix, (int) $headerNumber), + static fn (array $row): bool => count(array_filter($row, static fn (mixed $value): bool => trim((string) $value) !== '')) > 0, + ))]; + } + + /** @return array{0:string,1:string,2:?string,3:string,4:?string} */ + private function fields(array $headers, ?string $requestedDate): array + { + $storeField = null; + $accountField = null; + $platformField = null; + $dateFields = []; + $actualBalanceField = null; + + foreach ($headers as $header) { + $title = trim((string) ($header['title'] ?? '')); + $field = trim((string) ($header['fld'] ?? '')); + if ($field === '') { + continue; + } + if (in_array($title, ['店铺', '店铺名称'], true) || $field === 'shop_name') { + $storeField = $field; + } + if (in_array($title, ['支付宝账号', '平台账号'], true) || $field === 'platform_shop_id') { + $accountField = $field; + } + if ($title === '平台' || $field === 'platform') { + $platformField = $field; + } + if ($title === '平台实际期末余额') { + $actualBalanceField = $field; + } + $date = $this->dateFromHeader($title, $requestedDate) + ?? $this->dateFromHeader($field, $requestedDate); + if ($date !== null) { + $dateFields[$field] = $date; + } + } + + if ($storeField === null) { + throw new InvalidArgumentException('聚水潭店铺余额数据缺少“店铺”列。'); + } + + if ($requestedDate !== null) { + $balanceField = array_search($requestedDate, $dateFields, true); + if ($balanceField === false && $actualBalanceField !== null) { + $balanceField = $actualBalanceField; + } + if ($balanceField === false) { + throw new InvalidArgumentException("店铺余额数据中没有 {$requestedDate} 日期列。"); + } + + return [$requestedDate, $storeField, $accountField, (string) $balanceField, $platformField]; + } + + $uniqueDates = array_values(array_unique($dateFields)); + if ($uniqueDates === [] && $actualBalanceField !== null) { + throw new InvalidArgumentException('平台资金账户余额缺少日期,请使用 --balance-date 指定导出日期。'); + } + if (count($uniqueDates) !== 1) { + throw new InvalidArgumentException('店铺余额包含多个日期,请使用 --balance-date 指定要导入的日期。'); + } + + $selectedDate = $uniqueDates[0]; + $balanceField = array_search($selectedDate, $dateFields, true); + + return [$selectedDate, $storeField, $accountField, (string) $balanceField, $platformField]; + } + + /** @return list> */ + private function normalizeRows( + array $dataRows, + string $balanceDate, + string $storeField, + ?string $accountField, + string $balanceField, + ?string $platformField, + ): array { + $rows = []; + + foreach ($dataRows as $row) { + if (! is_array($row)) { + continue; + } + $storeName = trim((string) ($row[$storeField] ?? '')); + if ($storeName === '' || $storeName === '总计') { + continue; + } + if (mb_strlen($storeName) > 255) { + throw new InvalidArgumentException('聚水潭店铺名称超过 255 个字符:' . mb_substr($storeName, 0, 40)); + } + + $balance = $this->amount($row[$balanceField] ?? null, $storeName, $balanceDate); + $accountId = $accountField === null ? '' : trim((string) ($row[$accountField] ?? '')); + $sourcePlatform = $platformField === null ? '' : trim((string) ($row[$platformField] ?? '')); + if ($sourcePlatform === '') { + $sourcePlatform = JushuitanShopBalanceRepository::sourcePlatform($storeName); + } + $normalizedName = JushuitanShopBalanceRepository::normalizeStoreName($storeName); + $rowKey = hash('sha256', implode("\0", [$sourcePlatform, $normalizedName, $accountId])); + + if (isset($rows[$rowKey]) && $rows[$rowKey]['balance'] !== $balance) { + throw new InvalidArgumentException("店铺 {$storeName} 在 {$balanceDate} 存在冲突余额。"); + } + + $rows[$rowKey] = [ + 'source_platform' => $sourcePlatform, + 'store_name' => $storeName, + 'normalized_store_name' => $normalizedName, + 'platform_account_id' => $accountId === '' ? null : mb_substr($accountId, 0, 191), + 'balance' => $balance, + 'source_row_key' => $rowKey, + ]; + } + + return array_values($rows); + } + + private function amount(mixed $value, string $storeName, string $date): float + { + $normalized = str_replace([',', ',', '¥', '¥'], '', trim((string) $value)); + if ($normalized === '' || ! is_numeric($normalized)) { + throw new InvalidArgumentException("店铺 {$storeName} 的 {$date} 余额不是有效金额。"); + } + + return round((float) $normalized, 6); + } + + private function date(?string $value): ?string + { + if ($value === null || trim($value) === '') { + return null; + } + + try { + $date = CarbonImmutable::createFromFormat('!Y-m-d', trim($value)); + } catch (\Throwable) { + throw new InvalidArgumentException('余额日期必须使用 YYYY-MM-DD 格式。'); + } + + if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== trim($value)) { + throw new InvalidArgumentException('余额日期必须使用 YYYY-MM-DD 格式。'); + } + + return $date->format('Y-m-d'); + } + + private function dateFromHeader(string $value, ?string $requestedDate): ?string + { + $value = trim($value); + if (preg_match('/(20\d{2})[-_\/]?(\d{2})[-_\/]?(\d{2})/', $value, $matches) === 1) { + return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]); + } + if (preg_match('/^(\d{1,2})[-\/](\d{1,2})$/', $value, $matches) !== 1) { + return null; + } + + $year = $requestedDate === null ? (int) now()->format('Y') : (int) substr($requestedDate, 0, 4); + $date = CarbonImmutable::create($year, (int) $matches[1], (int) $matches[2])->startOfDay(); + + if ($requestedDate === null && $date->isAfter(now()->addDay()->endOfDay())) { + $date = $date->subYear(); + } + + return $date->format('Y-m-d'); + } +} diff --git a/resources/views/business/pages/fund-management/withdrawal-management.blade.php b/resources/views/business/pages/fund-management/withdrawal-management.blade.php new file mode 100644 index 00000000..f4da90c1 --- /dev/null +++ b/resources/views/business/pages/fund-management/withdrawal-management.blade.php @@ -0,0 +1,131 @@ +
+
+
+ + 提现配置 +
+
+ 余额源 {{ number_format($withdrawalBalanceSummary['source']) }} 条 + 已显示余额 {{ number_format($withdrawalBalanceSummary['matched']) }} 条 + @if ($withdrawalBalanceSummary['ambiguous'] > 0) + 多条匹配 {{ number_format($withdrawalBalanceSummary['ambiguous']) }} 条 + @endif +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + @if (request()->hasAny(['platform', 'withdrawal_path', 'keyword', 'balance_status'])) + 重置 + @endif +
+ +
+ + + + + + + + + + + + + + + + + + @forelse ($withdrawalRows as $row) + @php + $bankCard = filled($row->bank_card_last_four) + ? '**** ' . $row->bank_card_last_four + : '未配置'; + $balanceMatch = $withdrawalBalances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null]; + $balanceText = match ($balanceMatch['status']) { + 'matched' => number_format((float) $balanceMatch['balance'], 2), + 'ambiguous' => '多条匹配', + 'unmatched' => '未匹配', + default => '待同步', + }; + $balanceTitle = $balanceMatch['status'] === 'matched' + ? '聚水潭店铺:' . $balanceMatch['source_store_name'] . ';余额日期:' . $withdrawalBalanceDate + : '余额日期:' . $withdrawalBalanceDate; + @endphp + + + + + + + + + + + + + + @empty + + + + @endforelse + +
店铺平台ERP店铺名称平台店铺ID店铺主体提现路径提现账户名称银行卡号{{ $withdrawalBalanceColumnLabel }}预留标准提现金额是否达标
{{ $row->store_platform ?: '未配置' }}{{ $row->erp_store_name ?: '-' }}{{ $row->store_id ?: '-' }}{{ $row->store_entity_name ?: '-' }}{{ $row->withdrawal_path ?: '未配置' }}{{ $row->withdrawal_account_name ?: '未配置' }}{{ $bankCard }}{{ $balanceText }}待配置待接入待核验
+
+ +
暂无店铺收款账户,请先在店铺收款账户中维护。
+
+
+
+ +
+ 共 {{ $withdrawalRows->total() }} 条 · 第 {{ $withdrawalRows->currentPage() }} / {{ $withdrawalRows->lastPage() }} 页 + + @if ($withdrawalRows->onFirstPage()) + ‹ 上一页 + @else + ‹ 上一页 + @endif + @if ($withdrawalRows->hasMorePages()) + 下一页 › + @else + 下一页 › + @endif + +
+
diff --git a/tests/Feature/Business/ManagementCrudTest.php b/tests/Feature/Business/ManagementCrudTest.php index c7e6c5cc..64d8a68f 100644 --- a/tests/Feature/Business/ManagementCrudTest.php +++ b/tests/Feature/Business/ManagementCrudTest.php @@ -9,8 +9,11 @@ use App\Models\Business\InstallationDetail; use App\Models\Business\StoreReceivingAccount; use App\Utilities\BusinessNavigation; use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Mockery; use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository; +use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository; use Tests\Feature\FeatureTestCase; class ManagementCrudTest extends FeatureTestCase @@ -57,6 +60,109 @@ class ManagementCrudTest extends FeatureTestCase ->assertDontSeeText('安装明细汇总'); } + public function testWithdrawalManagementShowsSafeStoreWithdrawalConfiguration(): void + { + Carbon::setTestNow('2026-09-13 10:00:00'); + $store = StoreReceivingAccount::query()->create([ + 'company_id' => $this->company->id, + 'store_platform' => '天猫', + 'erp_store_name' => '提现测试ERP店', + 'store_id' => 'TM-TEST-1001', + 'shop_name' => '提现测试店铺', + 'store_entity_name' => '提现测试主体', + 'login_password' => 'must-not-be-visible', + 'withdrawal_path' => '支付宝', + 'withdrawal_account_name' => '测试提现账户', + 'bank_card_last_four' => '4321', + ]); + StoreReceivingAccount::query()->create([ + 'company_id' => $this->company->id, + 'store_platform' => '京东', + 'erp_store_name' => '未接入测试ERP店', + 'shop_name' => '未接入测试店铺', + 'store_entity_name' => '未接入测试主体', + 'withdrawal_path' => '银行卡', + ]); + + DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ + 'balance_date' => '2026-09-12', + 'source_scope' => 'all', + 'source_platform' => '天猫', + 'store_name' => '天猫-提现测试店铺', + 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName('天猫-提现测试店铺'), + 'platform_account_id' => 'fixture-account', + 'balance' => 19895.01, + 'source_row_key' => hash('sha256', (string) $store->id), + 'source_file_name' => 'fixture.json', + 'source_sha256' => str_repeat('a', 64), + 'imported_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertContains('fund-management/withdrawal-management', \App\Utilities\SidebarPermissions::allKeys()); + + try { + $response = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'fund-management', + 'page' => 'withdrawal-management', + ])); + $unmatchedResponse = $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'fund-management', + 'page' => 'withdrawal-management', + 'balance_status' => 'unmatched', + ])); + } finally { + Carbon::setTestNow(); + } + + $response + ->assertOk() + ->assertSeeText('提现管理') + ->assertSeeText('提现配置') + ->assertSeeText('提现测试ERP店') + ->assertSeeText('平台店铺ID') + ->assertSeeText('TM-TEST-1001') + ->assertSeeText('测试提现账户') + ->assertSeeText('**** 4321') + ->assertSeeText('9月13日店铺余额') + ->assertSeeText('19,895.01') + ->assertSeeText('余额源 1 条') + ->assertSeeText('已显示余额 1 条') + ->assertSeeText('余额状态') + ->assertSeeText('预留标准') + ->assertSeeText('提现金额') + ->assertSeeText('是否达标') + ->assertSeeText('待核验') + ->assertSee('data-finance-table-name="withdrawal-management-v3"', false) + ->assertSeeInOrder([ + '店铺平台', + 'ERP店铺名称', + '平台店铺ID', + '店铺主体', + '提现路径', + '提现账户名称', + '银行卡号', + '9月13日店铺余额', + '预留标准', + '提现金额', + '是否达标', + ], false) + ->assertSee('aria-label="提现管理筛选"', false) + ->assertSee('title="聚水潭店铺:天猫-提现测试店铺;余额日期:2026-09-12"', false) + ->assertSeeInOrder(['提现测试ERP店', '未接入测试ERP店']) + ->assertDontSee('店铺名称', false) + ->assertDontSee('银行卡账号(后四位)', false) + ->assertDontSee('配置状态', false) + ->assertDontSee('操作', false) + ->assertDontSeeText('must-not-be-visible'); + + $unmatchedResponse + ->assertOk() + ->assertSeeText('未接入测试ERP店') + ->assertDontSeeText('提现测试ERP店'); + } + public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): void { $mirror = Mockery::mock(InstallationDetailMirrorRepository::class); diff --git a/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php new file mode 100644 index 00000000..1feb3bf7 --- /dev/null +++ b/tests/Unit/EcommerceFinance/JushuitanShopBalanceRepositoryTest.php @@ -0,0 +1,220 @@ + */ + private array $temporaryFiles = []; + + protected function setUp(): void + { + parent::setUp(); + + DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->delete(); + } + + protected function tearDown(): void + { + foreach ($this->temporaryFiles as $path) { + if (is_file($path)) { + unlink($path); + } + } + + parent::tearDown(); + } + + public function testItImportsTheRequestedJsonDateAndUsesPlatformToDisambiguateStores(): void + { + $path = $this->jsonFile([ + 'data' => [ + 'isSuccess' => true, + 'fldHeads' => [ + ['title' => '店铺', 'fld' => 'shop_name'], + ['title' => '支付宝账号', 'fld' => 'platform_shop_id'], + ['title' => '09-11', 'fld' => 'balance_20260911'], + ['title' => '09-10', 'fld' => 'balance_20260910'], + ], + 'dataList' => [ + ['shop_name' => '天猫-同名店', 'platform_shop_id' => 'TM-1', 'balance_20260911' => '19,895.01', 'balance_20260910' => 1], + ['shop_name' => '淘宝-同名店', 'platform_shop_id' => 'TB-1', 'balance_20260911' => 425, 'balance_20260910' => 2], + ['shop_name' => '支付宝-唯一店', 'platform_shop_id' => 'ALI-1', 'balance_20260911' => 10334.09, 'balance_20260910' => 3], + ['shop_name' => '拼多多-跨平台店', 'platform_shop_id' => 'PDD-1', 'balance_20260911' => 999, 'balance_20260910' => 4], + ], + ], + ]); + + $result = app(JushuitanShopBalanceImporter::class)->import($path, '2026-09-11'); + $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ + (object) ['id' => 1, 'store_platform' => '天猫', 'shop_name' => '同名店', 'erp_store_name' => 'ERP同名店'], + (object) ['id' => 2, 'store_platform' => '淘宝', 'shop_name' => '同名店', 'erp_store_name' => 'ERP同名店'], + (object) ['id' => 3, 'store_platform' => '京东', 'shop_name' => '唯一店', 'erp_store_name' => 'ERP唯一店'], + (object) ['id' => 4, 'store_platform' => '拼多多', 'shop_name' => '不存在', 'erp_store_name' => 'ERP不存在'], + (object) ['id' => 5, 'store_platform' => '京东', 'shop_name' => '跨平台店', 'erp_store_name' => 'ERP跨平台店'], + ], '2026-09-11'); + + $this->assertSame('2026-09-11', $result['balance_date']); + $this->assertSame(4, $result['total']); + $this->assertSame(4, app(JushuitanShopBalanceRepository::class)->countForDate('2026-09-11')); + $this->assertSame(19895.01, $matches[1]['balance']); + $this->assertSame(425.0, $matches[2]['balance']); + $this->assertSame('unmatched', $matches[3]['status']); + $this->assertSame('unmatched', $matches[4]['status']); + $this->assertSame('unmatched', $matches[5]['status']); + } + + public function testItKeepsMultipleUnresolvedAccountsAmbiguous(): void + { + $path = $this->jsonFile([ + 'isSuccess' => true, + 'fldHeads' => [ + ['title' => '店铺', 'fld' => 'shop_name'], + ['title' => '支付宝账号', 'fld' => 'platform_shop_id'], + ['title' => '09-11', 'fld' => 'balance'], + ], + 'dataList' => [ + ['shop_name' => '天猫-重复店', 'platform_shop_id' => 'A-1', 'balance' => 10], + ['shop_name' => '天猫-重复店', 'platform_shop_id' => 'A-2', 'balance' => 20], + ], + ]); + + app(JushuitanShopBalanceImporter::class)->import($path, '2026-09-11'); + $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ + (object) ['id' => 10, 'store_platform' => '天猫', 'shop_name' => '重复店', 'erp_store_name' => '重复店'], + ], '2026-09-11'); + + $this->assertSame('ambiguous', $matches[10]['status']); + $this->assertNull($matches[10]['balance']); + } + + public function testItInfers1688PlatformWhenTheExportedStoreHasNoPlatformPrefix(): void + { + DB::connection('business_mirror')->table(JushuitanShopBalanceRepository::TABLE)->insert([ + 'balance_date' => '2026-09-11', + 'source_scope' => 'alipay', + 'source_platform' => null, + 'store_name' => '新佰广1688', + 'normalized_store_name' => JushuitanShopBalanceRepository::normalizeStoreName('新佰广1688'), + 'platform_account_id' => '2088180857774924', + 'balance' => 44501.18, + 'source_row_key' => hash('sha256', '新佰广1688'), + 'source_file_name' => 'fixture.xlsx', + 'source_sha256' => str_repeat('b', 64), + 'imported_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ + (object) ['id' => 30, 'store_platform' => '阿里巴巴', 'shop_name' => '新佰广1688', 'erp_store_name' => '新佰广1688'], + ], '2026-09-11'); + + $this->assertSame('matched', $matches[30]['status']); + $this->assertSame(44501.18, $matches[30]['balance']); + $this->assertSame('1688', JushuitanShopBalanceRepository::sourcePlatform('新佰广1688')); + $this->assertSame('淘宝', JushuitanShopBalanceRepository::sourcePlatform('淘宝-1688家具店')); + } + + public function testItReadsThePreviousYearDateFromAnXlsxExportOnNewYearsDay(): void + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray([ + ['店铺', '支付宝账号', '12-31'], + ['天猫-跨年店', 'TM-2026', 88.88], + ]); + $path = sys_get_temp_dir() . '/jushuitan-shop-balance-' . uniqid('', true) . '.xlsx'; + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + $this->temporaryFiles[] = $path; + + $result = app(JushuitanShopBalanceImporter::class)->import($path, '2026-12-31'); + + $this->assertSame('2026-12-31', $result['balance_date']); + $this->assertSame(1, $result['total']); + $this->assertSame(88.88, (float) DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->value('balance')); + } + + public function testItImportsPlatformFundBalancesFromTheActualClosingBalanceColumn(): void + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray([ + ['平台', '来源', '店铺', '期初余额', '本期收入', '本期支出', '期末余额', '平台实际期末余额', '状态'], + ['拼多多', '拼多多货款账户', '拼多多-多平台测试店', 0, 0, 0, 0, 34499.28, '空'], + ]); + $path = sys_get_temp_dir() . '/jushuitan-platform-balance-' . uniqid('', true) . '.xlsx'; + (new Xlsx($spreadsheet))->save($path); + $spreadsheet->disconnectWorksheets(); + $this->temporaryFiles[] = $path; + + $result = app(JushuitanShopBalanceImporter::class)->import( + $path, + '2026-09-11', + 'pinduoduo', + ); + $matches = app(JushuitanShopBalanceRepository::class)->matchesForStores([ + (object) ['id' => 20, 'store_platform' => '拼多多', 'shop_name' => '多平台测试店', 'erp_store_name' => 'ERP测试店'], + ], '2026-09-11'); + + $this->assertSame(1, $result['total']); + $this->assertSame(34499.28, $matches[20]['balance']); + $this->assertSame('拼多多', $matches[20]['source_platform']); + } + + public function testItReplacesTheSameDateAndSourceScopeOnRefresh(): void + { + $firstPath = $this->jsonFile([ + 'isSuccess' => true, + 'fldHeads' => [ + ['title' => '店铺', 'fld' => 'shop_name'], + ['title' => '09-12', 'fld' => 'balance'], + ], + 'dataList' => [ + ['shop_name' => '拼多多-每日刷新店', 'balance' => 100], + ['shop_name' => '拼多多-应被移除店', 'balance' => 200], + ], + ]); + $secondPath = $this->jsonFile([ + 'isSuccess' => true, + 'fldHeads' => [ + ['title' => '店铺', 'fld' => 'shop_name'], + ['title' => '09-12', 'fld' => 'balance'], + ], + 'dataList' => [ + ['shop_name' => '拼多多-每日刷新店', 'balance' => 350.25], + ], + ]); + + app(JushuitanShopBalanceImporter::class)->import($firstPath, '2026-09-12', 'pinduoduo'); + $result = app(JushuitanShopBalanceImporter::class)->import($secondPath, '2026-09-12', 'pinduoduo'); + $rows = DB::connection('business_mirror') + ->table(JushuitanShopBalanceRepository::TABLE) + ->where('balance_date', '2026-09-12') + ->where('source_scope', 'pinduoduo') + ->get(['store_name', 'balance']); + + $this->assertSame(2, $result['replaced']); + $this->assertCount(1, $rows); + $this->assertSame('拼多多-每日刷新店', $rows->first()->store_name); + $this->assertSame(350.25, (float) $rows->first()->balance); + } + + /** @param array $payload */ + private function jsonFile(array $payload): string + { + $path = sys_get_temp_dir() . '/jushuitan-shop-balance-' . uniqid('', true) . '.json'; + file_put_contents($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $this->temporaryFiles[] = $path; + + return $path; + } +}