feat: 接入提现管理及每日店铺余额
This commit is contained in:
parent
f586ac5b9e
commit
df14f627f3
44
app/Console/Commands/ImportJushuitanShopBalances.php
Normal file
44
app/Console/Commands/ImportJushuitanShopBalances.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递附加费报价维护。
|
||||
*
|
||||
|
||||
@ -322,6 +322,7 @@ $navigation = [
|
||||
['slug' => 'bank-accounts', 'title' => '银行账户'],
|
||||
['slug' => 'shared-company-accounts', 'title' => '公司公用账户'],
|
||||
['slug' => 'fund-forecast', 'title' => '资金预测表'],
|
||||
['slug' => 'withdrawal-management', 'title' => '提现管理'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user