feat: 接入提现管理及每日店铺余额

This commit is contained in:
Codex 2026-09-12 14:43:20 +08:00 committed by WuYongJie
parent f586ac5b9e
commit df14f627f3
10 changed files with 1184 additions and 0 deletions

View File

@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use Throwable;
class ImportJushuitanShopBalances extends Command
{
protected $signature = 'business:import-jushuitan-shop-balances
{path : 聚水潭店铺余额接口 JSON 或 XLSX/XLS 导出文件}
{--balance-date= : 导入日期,格式 YYYY-MM-DD;多日期导出必须指定}
{--scope=all : 来源范围,同一日期同一范围会被原子替换}
{--dry-run : 只校验和统计,不写入共享业务库}';
protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照';
public function handle(JushuitanShopBalanceImporter $importer): int
{
try {
$result = $importer->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;
}
}

View File

@ -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(),
];
}
/**
* 快递附加费报价维护。
*

View File

@ -322,6 +322,7 @@ $navigation = [
['slug' => 'bank-accounts', 'title' => '银行账户'],
['slug' => 'shared-company-accounts', 'title' => '公司公用账户'],
['slug' => 'fund-forecast', 'title' => '资金预测表'],
['slug' => 'withdrawal-management', 'title' => '提现管理'],
],
],
[

View File

@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@ -25,6 +25,7 @@
| 页面已切换 | 店铺收款账单的淘系支付宝账户读取 `operations_replica.rpt.jst_originalBillInfo`,并关联 `rpt.jst_shopInfo` 展示店铺;分页、筛选、汇总和导出统一读取共享业务镜像。 |
| 抖音按月校准 | 抖音账户对已核对的完整月份读取 `douyin_authoritative_bills` 中胜算原始明细,`douyin_authoritative_periods` 原子选择有效版本;该月份不再叠加聚水潭拆分行。未校准月份继续读取原始账单镜像。导入命令为 `business-mirror:import-douyin-authoritative <CSV路径> <YYYY-MM> --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)

View File

@ -0,0 +1,179 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
class JushuitanShopBalanceRepository
{
public const TABLE = 'jushuitan_shop_balance_snapshots';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param iterable<object> $stores
* @return array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>
*/
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);
}
}

View File

@ -0,0 +1,340 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
class JushuitanShopBalanceImporter
{
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->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<array<string,mixed>>,1:list<array<string,mixed>>} */
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<string,mixed>|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<array{title:string,fld:string}>,1:list<array<string,mixed>>} */
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<array<string,mixed>> */
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');
}
}

View File

@ -0,0 +1,131 @@
<section class="biz-card mt-6" aria-label="提现配置列表">
<div class="biz-card-head">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">account_balance_wallet</span>
<span>提现配置</span>
</div>
<div>
<span class="biz-badge">余额源 {{ number_format($withdrawalBalanceSummary['source']) }} 条</span>
<span class="biz-badge biz-badge-yes">已显示余额 {{ number_format($withdrawalBalanceSummary['matched']) }} 条</span>
@if ($withdrawalBalanceSummary['ambiguous'] > 0)
<span class="biz-badge biz-badge-no">多条匹配 {{ number_format($withdrawalBalanceSummary['ambiguous']) }} 条</span>
@endif
</div>
</div>
<form class="biz-filter-bar" method="GET" action="{{ request()->url() }}" aria-label="提现管理筛选">
<div class="biz-filter-field">
<label for="withdrawal-platform">店铺平台</label>
<select id="withdrawal-platform" class="biz-filter-select" name="platform">
<option value="">全部</option>
@foreach ($withdrawalPlatforms as $option)
<option value="{{ $option }}" @selected($withdrawalFilters['platform'] === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="biz-filter-field">
<label for="withdrawal-path">提现路径</label>
<select id="withdrawal-path" class="biz-filter-select" name="withdrawal_path">
<option value="">全部</option>
@foreach ($withdrawalPaths as $option)
<option value="{{ $option }}" @selected($withdrawalFilters['withdrawalPath'] === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="biz-filter-field">
<label for="withdrawal-keyword">ERP店铺名称</label>
<input id="withdrawal-keyword" class="biz-filter-input" type="search" name="keyword" value="{{ $withdrawalFilters['keyword'] }}" placeholder="输入店铺名称">
</div>
<div class="biz-filter-field">
<label for="withdrawal-balance-status">余额状态</label>
<select id="withdrawal-balance-status" class="biz-filter-select" name="balance_status">
<option value="">全部</option>
<option value="matched" @selected($withdrawalFilters['balanceStatus'] === 'matched')>已接入</option>
<option value="unmatched" @selected($withdrawalFilters['balanceStatus'] === 'unmatched')>未匹配</option>
<option value="ambiguous" @selected($withdrawalFilters['balanceStatus'] === 'ambiguous')>多条匹配</option>
<option value="not_ready" @selected($withdrawalFilters['balanceStatus'] === 'not_ready')>待同步</option>
</select>
</div>
<button type="submit" class="biz-btn biz-btn-primary">筛选</button>
@if (request()->hasAny(['platform', 'withdrawal_path', 'keyword', 'balance_status']))
<a class="biz-btn biz-btn-ghost" href="{{ request()->url() }}">重置</a>
@endif
</form>
<div class="biz-table-wrap">
<table class="biz-table" data-finance-table-name="withdrawal-management-v3">
<thead>
<tr>
<th>店铺平台</th>
<th>ERP店铺名称</th>
<th>平台店铺ID</th>
<th>店铺主体</th>
<th>提现路径</th>
<th>提现账户名称</th>
<th>银行卡号</th>
<th class="num" title="余额数据日期:{{ $withdrawalBalanceDate }}">{{ $withdrawalBalanceColumnLabel }}</th>
<th class="num">预留标准</th>
<th class="num">提现金额</th>
<th>是否达标</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td>{{ $row->store_platform ?: '未配置' }}</td>
<td>{{ $row->erp_store_name ?: '-' }}</td>
<td>{{ $row->store_id ?: '-' }}</td>
<td>{{ $row->store_entity_name ?: '-' }}</td>
<td>{{ $row->withdrawal_path ?: '未配置' }}</td>
<td>{{ $row->withdrawal_account_name ?: '未配置' }}</td>
<td>{{ $bankCard }}</td>
<td class="num" title="{{ $balanceTitle }}">{{ $balanceText }}</td>
<td class="num">待配置</td>
<td class="num">待接入</td>
<td><span class="biz-badge biz-badge-no">待核验</span></td>
</tr>
@empty
<tr>
<td colspan="11">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">inbox</span>
<div>暂无店铺收款账户,请先在店铺收款账户中维护。</div>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="biz-pagination">
<span>共 {{ $withdrawalRows->total() }} 条 · 第 {{ $withdrawalRows->currentPage() }} / {{ $withdrawalRows->lastPage() }} 页</span>
<span>
@if ($withdrawalRows->onFirstPage())
<span>‹ 上一页</span>
@else
<a href="{{ $withdrawalRows->previousPageUrl() }}">‹ 上一页</a>
@endif
@if ($withdrawalRows->hasMorePages())
<a href="{{ $withdrawalRows->nextPageUrl() }}">下一页 ›</a>
@else
<span>下一页 ›</span>
@endif
</span>
</div>
</section>

View File

@ -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([
'<th>店铺平台</th>',
'<th>ERP店铺名称</th>',
'<th>平台店铺ID</th>',
'<th>店铺主体</th>',
'<th>提现路径</th>',
'<th>提现账户名称</th>',
'<th>银行卡号</th>',
'<th class="num" title="余额数据日期:2026-09-12">9月13日店铺余额</th>',
'<th class="num">预留标准</th>',
'<th class="num">提现金额</th>',
'<th>是否达标</th>',
], false)
->assertSee('aria-label="提现管理筛选"', false)
->assertSee('title="聚水潭店铺:天猫-提现测试店铺;余额日期:2026-09-12"', false)
->assertSeeInOrder(['提现测试ERP店', '未接入测试ERP店'])
->assertDontSee('<th>店铺名称</th>', false)
->assertDontSee('<th>银行卡账号(后四位)</th>', false)
->assertDontSee('<th>配置状态</th>', false)
->assertDontSee('<th>操作</th>', false)
->assertDontSeeText('must-not-be-visible');
$unmatchedResponse
->assertOk()
->assertSeeText('未接入测试ERP店')
->assertDontSeeText('提现测试ERP店');
}
public function testInstallationDetailsRendersWorkbookHeadersAndImportedRows(): void
{
$mirror = Mockery::mock(InstallationDetailMirrorRepository::class);

View File

@ -0,0 +1,220 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class JushuitanShopBalanceRepositoryTest extends TestCase
{
/** @var list<string> */
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<string,mixed> $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;
}
}