feat: 接入快递运费对账业务镜像 (#118)
This commit is contained in:
parent
e94d556125
commit
9ad1b1c6bb
@ -26,6 +26,7 @@ use Illuminate\Database\QueryException;
|
||||
use Modules\EcommerceFinance\Exceptions\UpstreamSnapshotPendingException;
|
||||
use Modules\EcommerceFinance\Repositories\AlibabaBillRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\EdgeBandingRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InventoryRepository;
|
||||
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
|
||||
@ -73,6 +74,7 @@ class Pages extends Controller
|
||||
private readonly ProductCatalogMirrorRepository $productCatalog,
|
||||
private readonly ProductPriceMirrorRepository $productPrices,
|
||||
private readonly CourierInformationMirrorRepository $courierInformation,
|
||||
private readonly CourierFreightReconciliationMirrorRepository $courierFreightReconciliations,
|
||||
private readonly RawBoardPayablesRepository $rawBoardPayables,
|
||||
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
|
||||
private readonly SupplierPayableReportRepository $supplierPayableReports,
|
||||
@ -505,6 +507,7 @@ class Pages extends Controller
|
||||
'normal-products' => $this->contentForNormalProducts(),
|
||||
'product-prices' => $this->contentForProductPrices(),
|
||||
'courier-information' => $this->contentForCourierInformation(),
|
||||
'freight-reconciliation' => $this->contentForFreightReconciliation(),
|
||||
'board-deductions' => $this->contentForBoardDeductions(),
|
||||
'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
|
||||
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
|
||||
@ -544,6 +547,71 @@ class Pages extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
protected function contentForFreightReconciliation(): array
|
||||
{
|
||||
$request = request();
|
||||
$date = static function (mixed $value): string {
|
||||
$value = trim((string) $value);
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1 ? $value : '';
|
||||
};
|
||||
$filters = [
|
||||
'express_no' => mb_substr(trim((string) $request->query('express_no', '')), 0, 80),
|
||||
'express_name' => mb_substr(trim((string) $request->query('express_name', '')), 0, 80),
|
||||
'month' => mb_substr(trim((string) $request->query('month', '')), 0, 20),
|
||||
'status' => mb_substr(trim((string) $request->query('status', '')), 0, 40),
|
||||
'modified_from' => $date($request->query('modified_from', '')),
|
||||
'modified_to' => $date($request->query('modified_to', '')),
|
||||
];
|
||||
$page = max((int) $request->query('page', 1), 1);
|
||||
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
|
||||
$rows = new LengthAwarePaginator([], 0, $limit, $page, [
|
||||
'path' => $request->url(),
|
||||
'query' => $request->except('page'),
|
||||
]);
|
||||
$state = null;
|
||||
$sync = ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
$summary = ['count' => 0, 'payable_amount' => 0.0, 'actual_payment' => 0.0, 'confirmed_amount' => 0.0, 'reconciliation_difference' => 0.0];
|
||||
$error = null;
|
||||
$pending = false;
|
||||
|
||||
try {
|
||||
if (! $this->courierFreightReconciliations->available()) {
|
||||
$error = '共享业务镜像尚未建立,运费对账暂时无法读取。';
|
||||
} elseif (! $this->courierFreightReconciliations->ready()) {
|
||||
$state = $this->courierFreightReconciliations->state();
|
||||
$sync = $this->courierFreightReconciliations->syncStatus($state);
|
||||
$pending = true;
|
||||
$error = '运费对账镜像正在完成首次同步,完成前不会把未同步数据误判为无数据。';
|
||||
} else {
|
||||
$result = $this->courierFreightReconciliations->paginate($filters, $page, $limit);
|
||||
$rows = new LengthAwarePaginator(
|
||||
Collection::make($result['data'] ?? []),
|
||||
(int) ($result['total'] ?? 0),
|
||||
$limit,
|
||||
$page,
|
||||
['path' => $request->url(), 'query' => $request->except('page')],
|
||||
);
|
||||
$state = $result['state'] ?? null;
|
||||
$sync = $result['sync'] ?? $sync;
|
||||
$summary = $result['summary'] ?? $summary;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$error = '运费对账数据暂时无法读取,请稍后重试。';
|
||||
}
|
||||
|
||||
return [
|
||||
'view' => 'business.pages.courier-payables.freight-reconciliation',
|
||||
'freightReconciliations' => $rows,
|
||||
'freightReconciliationFilters' => $filters,
|
||||
'freightReconciliationSummary' => $summary,
|
||||
'freightReconciliationState' => $state,
|
||||
'freightReconciliationSync' => $sync,
|
||||
'freightReconciliationError' => $error,
|
||||
'freightReconciliationPending' => $pending,
|
||||
];
|
||||
}
|
||||
|
||||
protected function contentForDingPayableWorkflows(): array
|
||||
{
|
||||
$normalizeDate = static function (mixed $value): string {
|
||||
|
||||
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/** 快递应付运费对账的共享业务镜像只读访问。 */
|
||||
class CourierFreightReconciliationMirrorRepository
|
||||
{
|
||||
public const DATASET = 'jiansun.dbo.fys_express_courier_settlement';
|
||||
|
||||
/** Only columns present in js_express_courier_settlement may be exposed. */
|
||||
private const COLUMNS = [
|
||||
'Id', 'month', 'status', 'courier_company_name', 'expresscode', 'expressname',
|
||||
'replenish_courier', 'opening_balance', 'courier_provided_amount',
|
||||
'reconciliation_difference', 'confirmed_amount', 'waybill', 'advance_payment',
|
||||
'payable_amount', 'actual_payment', 'reconciliation_person', 'file_status',
|
||||
'create_time', 'modify_time', 'is_delete', 'taskid', 'guid', 'creator',
|
||||
'compensation_amount', 'other_replenishment', 'corporate_payment_amount',
|
||||
'personal_payment_amount',
|
||||
];
|
||||
|
||||
private readonly ConnectionInterface $connection;
|
||||
|
||||
public function __construct(
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
) {
|
||||
$this->connection = $mirror->connection();
|
||||
}
|
||||
|
||||
public function available(): bool
|
||||
{
|
||||
try {
|
||||
$schema = Schema::connection($this->connection->getName());
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
|
||||
return $schema->hasTable('business_mirror_table_states')
|
||||
&& $schema->hasTable($dataset->targetTable());
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function ready(): bool
|
||||
{
|
||||
try {
|
||||
return $this->mirror->ready($this->datasets->get(self::DATASET));
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function state(): ?array
|
||||
{
|
||||
try {
|
||||
return $this->mirror->state($this->datasets->get(self::DATASET));
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{code:string,last_succeeded_at:?string,lag_minutes:?int} */
|
||||
public function syncStatus(?array $state = null): array
|
||||
{
|
||||
$state ??= $this->state();
|
||||
if (! is_array($state)) {
|
||||
return ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
}
|
||||
|
||||
$lastSucceeded = $state['last_succeeded_at'] ?? null;
|
||||
$lagMinutes = null;
|
||||
if (is_string($lastSucceeded) && $lastSucceeded !== '') {
|
||||
try {
|
||||
$lagMinutes = (int) ceil(max(0, now()->getTimestamp() - Carbon::parse($lastSucceeded)->getTimestamp()) / 60);
|
||||
} catch (Throwable) {
|
||||
$lagMinutes = null;
|
||||
}
|
||||
}
|
||||
|
||||
$status = (string) ($state['status'] ?? 'pending');
|
||||
if (($state['initial_completed_at'] ?? null) === null) {
|
||||
$code = $status === 'failed' ? 'initial_failed' : ($status === 'running' ? 'initializing' : 'pending');
|
||||
} elseif ($status === 'failed') {
|
||||
$code = 'failed';
|
||||
} elseif ($status === 'running') {
|
||||
$code = 'syncing';
|
||||
} elseif ($lagMinutes === null || $lagMinutes > max((int) config('upstream_mirrors.courier_information.stale_after_minutes', 60), 1)) {
|
||||
$code = 'stale';
|
||||
} else {
|
||||
$code = 'fresh';
|
||||
}
|
||||
|
||||
return ['code' => $code, 'last_succeeded_at' => $lastSucceeded, 'lag_minutes' => $lagMinutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $filters
|
||||
* @return array{data:list<array<string,mixed>>,total:int,summary:array<string,float|int>,state:array<string,mixed>|null,sync:array<string,mixed>}
|
||||
*/
|
||||
public function paginate(array $filters, int $page, int $perPage): array
|
||||
{
|
||||
$dataset = $this->datasets->get(self::DATASET);
|
||||
if (! $this->mirror->ready($dataset)) {
|
||||
throw new RuntimeException('courier_freight_reconciliation_mirror_not_ready');
|
||||
}
|
||||
|
||||
$page = max($page, 1);
|
||||
$perPage = min(max($perPage, 1), 100);
|
||||
$query = $this->filteredQuery($dataset->targetTable(), $filters);
|
||||
$total = (clone $query)->count();
|
||||
$summary = (clone $query)->selectRaw(
|
||||
'COUNT(*) AS aggregate_count, '
|
||||
. 'COALESCE(SUM(payable_amount), 0) AS payable_amount, '
|
||||
. 'COALESCE(SUM(actual_payment), 0) AS actual_payment, '
|
||||
. 'COALESCE(SUM(confirmed_amount), 0) AS confirmed_amount, '
|
||||
. 'COALESCE(SUM(reconciliation_difference), 0) AS reconciliation_difference'
|
||||
)->first();
|
||||
$rows = (clone $query)->select(self::COLUMNS)
|
||||
->orderByDesc('Id')
|
||||
->forPage($page, $perPage)
|
||||
->get()
|
||||
->map(fn (object $row): array => $this->mapRow($row))
|
||||
->all();
|
||||
$state = $this->mirror->state($dataset);
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'total' => $total,
|
||||
'summary' => [
|
||||
'count' => (int) ($summary->aggregate_count ?? 0),
|
||||
'payable_amount' => (float) ($summary->payable_amount ?? 0),
|
||||
'actual_payment' => (float) ($summary->actual_payment ?? 0),
|
||||
'confirmed_amount' => (float) ($summary->confirmed_amount ?? 0),
|
||||
'reconciliation_difference' => (float) ($summary->reconciliation_difference ?? 0),
|
||||
],
|
||||
'state' => $state,
|
||||
'sync' => $this->syncStatus($state),
|
||||
];
|
||||
}
|
||||
|
||||
private function filteredQuery(string $table, array $filters): Builder
|
||||
{
|
||||
$query = $this->connection->table($table)
|
||||
->where(static function (Builder $query): void {
|
||||
$query->whereNull('is_delete')->orWhere('is_delete', '=', 0);
|
||||
});
|
||||
$this->containsFilter($query, 'waybill', $filters['express_no'] ?? '');
|
||||
$this->containsFilter($query, 'expressname', $filters['express_name'] ?? '');
|
||||
|
||||
if (($filters['month'] ?? '') !== '') {
|
||||
$query->where('month', '=', $filters['month']);
|
||||
}
|
||||
if (($filters['status'] ?? '') !== '') {
|
||||
$query->where('status', '=', $filters['status']);
|
||||
}
|
||||
if (($filters['modified_from'] ?? '') !== '') {
|
||||
$query->where('modify_time', '>=', $filters['modified_from'] . ' 00:00:00');
|
||||
}
|
||||
if (($filters['modified_to'] ?? '') !== '') {
|
||||
$query->where('modify_time', '<', date('Y-m-d 00:00:00', strtotime($filters['modified_to'] . ' +1 day')));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function containsFilter(Builder $query, string $column, mixed $value): void
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$escaped = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $value);
|
||||
$query->whereRaw($column . " LIKE ? ESCAPE '!'", ['%' . $escaped . '%']);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function mapRow(object $row): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach (self::COLUMNS as $column) {
|
||||
$mapped[$column] = $row->{$column} ?? null;
|
||||
}
|
||||
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $amount) {
|
||||
$mapped[$amount] = $mapped[$amount] === null ? null : (float) $mapped[$amount];
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
}
|
||||
@ -1,311 +1,87 @@
|
||||
<!--
|
||||
快递应付 - 运费对账
|
||||
表头设计来源: 快递应付-运费对账功能页面的表头.xls (45列)
|
||||
-->
|
||||
@php
|
||||
$filters = $freightReconciliationFilters ?? [];
|
||||
$summary = $freightReconciliationSummary ?? [];
|
||||
$sync = $freightReconciliationSync ?? ['code' => 'unavailable', 'last_succeeded_at' => null, 'lag_minutes' => null];
|
||||
$statusLabels = ['fresh' => '数据新鲜', 'stale' => '数据可能滞后', 'syncing' => '同步中', 'initializing' => '首次同步中', 'pending' => '等待同步', 'failed' => '同步失败', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像不可用'];
|
||||
$money = static fn (mixed $value): string => number_format((float) ($value ?? 0), 2);
|
||||
@endphp
|
||||
|
||||
<div class="courier-reconciliation-page">
|
||||
<style>
|
||||
#main-body > .container {
|
||||
max-width: none;
|
||||
width: auto;
|
||||
margin-left: 1.5rem;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
.courier-reconciliation-page { color: #29251f; }
|
||||
.courier-reconciliation-page .recon-control { border: 1px solid #d9d0c2; border-radius: .375rem; padding: .5rem .7rem; font-size: .8125rem; background: #fff; outline: none; }
|
||||
.courier-reconciliation-page .recon-control:focus { border-color: #b18a4a; box-shadow: 0 0 0 2px rgba(177,138,74,.15); }
|
||||
.courier-reconciliation-page .recon-button { border-radius: .375rem; padding: .5rem 1rem; font-size: .8125rem; }
|
||||
.courier-reconciliation-page .recon-button--primary { background: #5d492c; color: #fff; }
|
||||
.courier-reconciliation-page .recon-button--primary:hover { background: #49391f; }
|
||||
.courier-reconciliation-page .recon-button--secondary { border: 1px solid #d9d0c2; color: #655b4e; background: #fff; }
|
||||
.courier-reconciliation-page .recon-button--secondary:hover { background: #f7f3ed; }
|
||||
.courier-reconciliation-page .recon-table th { background: #f5f1ea; color: #6f675c; font-weight: 600; }
|
||||
.courier-reconciliation-page .recon-table td, .courier-reconciliation-page .recon-table th { border-bottom: 1px solid #eee8de; padding: .65rem .75rem; white-space: nowrap; }
|
||||
.courier-reconciliation-page .recon-table tbody tr:hover { background: #fcfaf7; }
|
||||
</style>
|
||||
<div class="w-full">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '运费对账' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 mb-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-800">{{ $page['title'] ?? '运费对账' }}</h1>
|
||||
<p class="mt-1 text-xs text-gray-500">数据来源:JianSun · dbo.fys_express_courier_settlement · 共享业务镜像</p>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<form method="GET" class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col min-w-[140px]">
|
||||
<label class="text-xs text-gray-500 mb-1">快递单号</label>
|
||||
<input type="text" name="express_no" value="{{ request('express_no') }}" placeholder="搜索快递单号"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[120px]">
|
||||
<label class="text-xs text-gray-500 mb-1">条形码</label>
|
||||
<input type="text" name="barcode" value="{{ request('barcode') }}" placeholder="搜索条形码"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[120px]">
|
||||
<label class="text-xs text-gray-500 mb-1">快递名称</label>
|
||||
<input type="text" name="express_name" value="{{ request('express_name') }}" placeholder="快递公司"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[120px]">
|
||||
<label class="text-xs text-gray-500 mb-1">平台</label>
|
||||
<select name="platform"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none bg-white">
|
||||
<option value="">全部</option>
|
||||
<option value="淘宝天猫" {{ request('platform') === '淘宝天猫' ? 'selected' : '' }}>淘宝天猫</option>
|
||||
<option value="京东商城" {{ request('platform') === '京东商城' ? 'selected' : '' }}>京东商城</option>
|
||||
<option value="拼多多" {{ request('platform') === '拼多多' ? 'selected' : '' }}>拼多多</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[110px]">
|
||||
<label class="text-xs text-gray-500 mb-1">供应商</label>
|
||||
<input type="text" name="supplier" value="{{ request('supplier') }}" placeholder="供应商"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[130px]">
|
||||
<label class="text-xs text-gray-500 mb-1">发货时间 从</label>
|
||||
<input type="date" name="ship_from" value="{{ request('ship_from') }}"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex flex-col min-w-[130px]">
|
||||
<label class="text-xs text-gray-500 mb-1">发货时间 至</label>
|
||||
<input type="date" name="ship_to" value="{{ request('ship_to') }}"
|
||||
class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:ring-1 focus:ring-blue-400 focus:border-blue-400 outline-none">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit"
|
||||
class="bg-blue-500 hover:bg-blue-600 text-white text-sm px-4 py-1.5 rounded transition">
|
||||
查询
|
||||
</button>
|
||||
<a href="{{ url()->current() }}"
|
||||
class="border border-gray-300 text-gray-600 text-sm px-4 py-1.5 rounded hover:bg-gray-50 transition">
|
||||
重置
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Data Table - 45 columns with horizontal scroll -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs whitespace-nowrap">
|
||||
<thead>
|
||||
<!-- Column Group Headers -->
|
||||
<tr class="bg-gray-100 border-b border-gray-300 text-gray-500 font-medium">
|
||||
<th class="sticky left-0 bg-gray-100 z-10 px-2 py-1.5 text-center border-r border-gray-200" style="min-width:40px">#</th>
|
||||
<!-- 物流信息 (10 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="10" style="min-width:800px">物流信息</th>
|
||||
<!-- 订单信息 (8 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="8" style="min-width:700px">订单信息</th>
|
||||
<!-- 收件信息 (7 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="7" style="min-width:650px">收件信息</th>
|
||||
<!-- 商品规格 (5 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="5" style="min-width:380px">商品规格</th>
|
||||
<!-- 平台/店铺 (3 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="3" style="min-width:300px">平台/店铺</th>
|
||||
<!-- 供应商/仓库 (5 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="5" style="min-width:480px">供应商/仓库</th>
|
||||
<!-- 时间 (3 cols) -->
|
||||
<th class="px-2 py-1 text-center border-r border-gray-200" colspan="3" style="min-width:400px">时间</th>
|
||||
<!-- 备注 (2 cols) -->
|
||||
<th class="px-2 py-1 text-center" colspan="2" style="min-width:220px">备注</th>
|
||||
</tr>
|
||||
<!-- Column Sub-Headers -->
|
||||
<tr class="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
|
||||
<th class="sticky left-0 bg-gray-50 z-10 px-2 py-1.5 text-center border-r border-gray-200" style="min-width:40px">#</th>
|
||||
{{-- 物流信息 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">快递单号</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">快递名称1</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">快递名称2</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">运费</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">费率</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">结算重量</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">结算体积</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">对账重量</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">对账体积</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">换单前单号</th>
|
||||
{{-- 订单信息 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">交易单号</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">商品ID</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">条形码</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:160px">商品名称</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:50px">数量</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:70px">金额</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">货号</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">配货单号</th>
|
||||
{{-- 收件信息 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:50px">省</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:70px">市</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:70px">区</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:200px">收货详细地址</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:70px">收货姓名</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">手机号码</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:90px">座机号码</th>
|
||||
{{-- 商品规格 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:60px">长(cm)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:60px">宽(cm)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:60px">高(cm)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">体积(cm³)</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200 text-right" style="min-width:80px">重量(kg)</th>
|
||||
{{-- 平台/店铺 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:90px">平台</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:130px">店铺名称</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">买家昵称</th>
|
||||
{{-- 供应商/仓库 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">供应商</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">仓库</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:100px">成品条码</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">成品名称</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">成品供应商</th>
|
||||
{{-- 时间 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:140px">发货时间</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">下载时间</th>
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:120px">付款时间</th>
|
||||
{{-- 备注 --}}
|
||||
<th class="px-2 py-1.5 border-r border-gray-200" style="min-width:130px">买家留言</th>
|
||||
<th class="px-2 py-1.5" style="min-width:130px">卖家备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($freightReconciliations ?? [] as $index => $item)
|
||||
<tr class="border-b border-gray-100 hover:bg-blue-50/40 transition">
|
||||
<td class="sticky left-0 bg-white z-10 px-2 py-2 text-center text-gray-400 border-r border-gray-100">
|
||||
{{ ($freightReconciliations->currentPage() - 1) * $freightReconciliations->perPage() + $index + 1 }}
|
||||
</td>
|
||||
{{-- 物流信息 --}}
|
||||
<td class="px-2 py-2 mono text-blue-600 border-r border-gray-100">{{ $item['express_no'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['express_name1'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['express_name2'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-right font-medium border-r border-gray-100 {{ ($item['freight'] ?? 0) > 0 ? 'text-gray-800' : 'text-gray-300' }}">
|
||||
{{ isset($item['freight']) ? number_format((float) $item['freight'], 2) : '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ $item['rate'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['settle_weight']) ? number_format((float) $item['settle_weight'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['settle_volume']) ? number_format((float) $item['settle_volume'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['recon_weight']) ? number_format((float) $item['recon_weight'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['recon_volume']) ? number_format((float) $item['recon_volume'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 border-r border-gray-100 text-xs">{{ $item['prev_express_no'] ?? '-' }}</td>
|
||||
{{-- 订单信息 --}}
|
||||
<td class="px-2 py-2 mono text-xs text-gray-500 border-r border-gray-100">{{ $item['trade_no'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs text-gray-500 border-r border-gray-100">{{ $item['product_id'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-gray-700 border-r border-gray-100">{{ $item['barcode'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100 max-w-[160px] truncate" title="{{ $item['product_name'] ?? '' }}">
|
||||
{{ $item['product_name'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ $item['qty'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['amount']) ? number_format((float) $item['amount'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100">{{ $item['item_no'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100">{{ $item['pick_no'] ?? '-' }}</td>
|
||||
{{-- 收件信息 --}}
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['province'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['city'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['district'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100 max-w-[200px] truncate" title="{{ $item['address'] ?? '' }}">
|
||||
{{ $item['address'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['receiver_name'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs border-r border-gray-100">{{ $item['receiver_phone'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs text-gray-400 border-r border-gray-100">{{ $item['receiver_tel'] ?? '-' }}</td>
|
||||
{{-- 商品规格 --}}
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['length']) ? number_format((float) $item['length'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['width']) ? number_format((float) $item['width'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['height']) ? number_format((float) $item['height'], 2) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['volume']) ? number_format((float) $item['volume'], 4) : '-' }}</td>
|
||||
<td class="px-2 py-2 text-right border-r border-gray-100">{{ isset($item['weight']) ? number_format((float) $item['weight'], 4) : '-' }}</td>
|
||||
{{-- 平台/店铺 --}}
|
||||
<td class="px-2 py-2 border-r border-gray-100">
|
||||
<span class="platform-tag {{ str_contains($item['platform'] ?? '', '天猫') || str_contains($item['platform'] ?? '', '淘宝') ? 'bg-orange-100 text-orange-700' : (str_contains($item['platform'] ?? '', '京东') ? 'bg-red-100 text-red-700' : (str_contains($item['platform'] ?? '', '拼多多') ? 'bg-pink-100 text-pink-700' : 'bg-gray-100 text-gray-600')) }} text-xs px-1.5 py-0.5 rounded">
|
||||
{{ $item['platform'] ?? '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-500 border-r border-gray-100 max-w-[130px] truncate" title="{{ $item['shop_name'] ?? '' }}">
|
||||
{{ $item['shop_name'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-gray-400 text-xs border-r border-gray-100">{{ $item['buyer_nick'] ?? '-' }}</td>
|
||||
{{-- 供应商/仓库 --}}
|
||||
<td class="px-2 py-2 border-r border-gray-100">{{ $item['supplier'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-500 border-r border-gray-100">{{ $item['warehouse'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 mono text-xs text-gray-400 border-r border-gray-100">{{ $item['finished_barcode'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['finished_name'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['finished_supplier'] ?? '-' }}</td>
|
||||
{{-- 时间 --}}
|
||||
<td class="px-2 py-2 text-xs border-r border-gray-100">{{ $item['ship_time'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['download_time'] ?? '-' }}</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100">{{ $item['pay_time'] ?? '-' }}</td>
|
||||
{{-- 备注 --}}
|
||||
<td class="px-2 py-2 text-xs text-gray-400 border-r border-gray-100 max-w-[130px] truncate" title="{{ $item['buyer_msg'] ?? '' }}">
|
||||
{{ $item['buyer_msg'] ?? '-' }}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-xs text-gray-400 max-w-[130px] truncate" title="{{ $item['seller_memo'] ?? '' }}">
|
||||
{{ $item['seller_memo'] ?? '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="46" class="text-center py-16 text-gray-400">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span class="text-sm">暂无运费对账数据</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if (isset($freightReconciliations) && $freightReconciliations->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-500">
|
||||
共 <span class="font-medium text-gray-700">{{ $freightReconciliations->total() }}</span> 条记录
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@if ($freightReconciliations->onFirstPage())
|
||||
<span class="px-3 py-1 text-sm text-gray-300 border border-gray-200 rounded cursor-not-allowed">上一页</span>
|
||||
@else
|
||||
<a href="{{ $freightReconciliations->previousPageUrl() }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">上一页</a>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$current = $freightReconciliations->currentPage();
|
||||
$last = $freightReconciliations->lastPage();
|
||||
$start = max(1, $current - 2);
|
||||
$end = min($last, $current + 2);
|
||||
@endphp
|
||||
|
||||
@if ($start > 1)
|
||||
<a href="{{ $freightReconciliations->url(1) }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">1</a>
|
||||
@if ($start > 2)
|
||||
<span class="px-1 text-gray-400">...</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@for ($p = $start; $p <= $end; $p++)
|
||||
@if ($p === $current)
|
||||
<span class="px-3 py-1 text-sm text-white bg-blue-500 border border-blue-500 rounded">{{ $p }}</span>
|
||||
@else
|
||||
<a href="{{ $freightReconciliations->url($p) }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">{{ $p }}</a>
|
||||
@endif
|
||||
@endfor
|
||||
|
||||
@if ($end < $last)
|
||||
@if ($end < $last - 1)
|
||||
<span class="px-1 text-gray-400">...</span>
|
||||
@endif
|
||||
<a href="{{ $freightReconciliations->url($last) }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">{{ $last }}</a>
|
||||
@endif
|
||||
|
||||
@if ($freightReconciliations->hasMorePages())
|
||||
<a href="{{ $freightReconciliations->nextPageUrl() }}" class="px-3 py-1 text-sm text-gray-600 border border-gray-200 rounded hover:bg-gray-50 transition">下一页</a>
|
||||
@else
|
||||
<span class="px-3 py-1 text-sm text-gray-300 border border-gray-200 rounded cursor-not-allowed">下一页</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="rounded-full border border-[#d9d0c2] bg-white px-3 py-1.5 text-[#6f675c]">{{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }}</span>
|
||||
@if (!empty($sync['last_succeeded_at'])) <span class="text-gray-500">最近同步 {{ $sync['last_succeeded_at'] }}</span> @endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; }
|
||||
.platform-tag { white-space: nowrap; }
|
||||
/* Sticky first column on horizontal scroll */
|
||||
@media (max-width: 768px) {
|
||||
.overflow-x-auto table { font-size: 10px; }
|
||||
}
|
||||
</style>
|
||||
@if (!empty($freightReconciliationError))
|
||||
<div class="mb-4 rounded border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">{{ $freightReconciliationError }}</div>
|
||||
@endif
|
||||
|
||||
<div class="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||
@foreach ([
|
||||
['label' => '记录数', 'value' => number_format((int) ($summary['count'] ?? 0))],
|
||||
['label' => '应付金额', 'value' => '¥ ' . $money($summary['payable_amount'] ?? 0)],
|
||||
['label' => '已确认金额', 'value' => '¥ ' . $money($summary['confirmed_amount'] ?? 0)],
|
||||
['label' => '实际付款', 'value' => '¥ ' . $money($summary['actual_payment'] ?? 0)],
|
||||
['label' => '对账差额', 'value' => '¥ ' . $money($summary['reconciliation_difference'] ?? 0)],
|
||||
] as $stat)
|
||||
<div class="rounded-lg border border-[#e7e0d5] bg-white px-4 py-3 shadow-sm"><div class="text-xs text-gray-500">{{ $stat['label'] }}</div><div class="mt-1 text-lg font-semibold text-[#4b3a24]">{{ $stat['value'] }}</div></div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mb-5 rounded-lg border border-[#e7e0d5] bg-white p-4 shadow-sm">
|
||||
<form method="GET" class="flex flex-wrap items-end gap-3" aria-label="运费对账筛选">
|
||||
@foreach ([
|
||||
['name' => 'express_no', 'label' => '快递单号', 'placeholder' => '包含匹配'],
|
||||
['name' => 'express_name', 'label' => '快递名称', 'placeholder' => '包含匹配'],
|
||||
['name' => 'month', 'label' => '月份', 'placeholder' => '如 2026-08'],
|
||||
['name' => 'status', 'label' => '状态', 'placeholder' => '精确匹配'],
|
||||
] as $field)
|
||||
<label class="flex min-w-[150px] flex-col gap-1 text-xs text-gray-500"><span>{{ $field['label'] }}</span><input class="recon-control" type="text" name="{{ $field['name'] }}" value="{{ $filters[$field['name']] ?? '' }}" placeholder="{{ $field['placeholder'] }}"></label>
|
||||
@endforeach
|
||||
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>修改时间起</span><input class="recon-control" type="date" name="modified_from" value="{{ $filters['modified_from'] ?? '' }}"></label>
|
||||
<label class="flex min-w-[145px] flex-col gap-1 text-xs text-gray-500"><span>修改时间止</span><input class="recon-control" type="date" name="modified_to" value="{{ $filters['modified_to'] ?? '' }}"></label>
|
||||
<div class="flex gap-2"><button class="recon-button recon-button--primary" type="submit">查询</button><a class="recon-button recon-button--secondary" href="{{ url()->current() }}">重置</a></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-[#e7e0d5] bg-white shadow-sm">
|
||||
<div class="overflow-x-auto"><table class="recon-table min-w-full text-sm"><thead><tr>
|
||||
<th class="text-left">快递单号</th><th class="text-left">快递名称</th><th class="text-left">月份</th><th class="text-left">状态</th>
|
||||
<th class="text-right">对账差额</th><th class="text-right">已确认金额</th><th class="text-right">应付金额</th><th class="text-right">实际付款</th><th class="text-right">预付款</th><th class="text-right">赔偿金额</th><th class="text-right">其他补款</th><th class="text-right">公司付款</th><th class="text-right">个人付款</th><th class="text-left">创建时间</th><th class="text-left">修改时间</th>
|
||||
</tr></thead><tbody>
|
||||
@forelse ($freightReconciliations ?? [] as $row)
|
||||
<tr><td class="font-mono text-xs text-[#4b3a24]">{{ $row['waybill'] ?? '-' }}</td><td>{{ $row['expressname'] ?: ($row['courier_company_name'] ?? '-') }}</td><td>{{ $row['month'] ?? '-' }}</td><td>{{ $row['status'] ?? '-' }}</td>
|
||||
@foreach (['reconciliation_difference','confirmed_amount','payable_amount','actual_payment','advance_payment','compensation_amount','other_replenishment','corporate_payment_amount','personal_payment_amount'] as $amount)
|
||||
<td class="text-right tabular-nums">{{ $row[$amount] === null ? '-' : $money($row[$amount]) }}</td>
|
||||
@endforeach
|
||||
<td>{{ $row['create_time'] ?? '-' }}</td><td>{{ $row['modify_time'] ?? '-' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="15" class="px-4 py-16 text-center text-sm text-gray-500">@if ($freightReconciliationPending ?? false) 正在等待镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif</td></tr>
|
||||
@endforelse
|
||||
</tbody></table></div>
|
||||
@if (($freightReconciliations ?? null)?->hasPages())
|
||||
<div class="flex items-center justify-between border-t border-[#eee8de] px-4 py-3 text-sm text-gray-500"><span>共 {{ $freightReconciliations->total() }} 条记录</span><div>{{ $freightReconciliations->onEachSide(1)->links() }}</div></div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,12 +4,67 @@ namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class PagesTest extends FeatureTestCase
|
||||
{
|
||||
public function testFreightReconciliationPageRendersSharedMirrorRowsAndSummary(): void
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
|
||||
$repository = Mockery::mock(CourierFreightReconciliationMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('paginate')->once()->with(
|
||||
Mockery::on(static fn (array $filters): bool => $filters['express_no'] === 'FIXTURE'),
|
||||
1,
|
||||
Mockery::type('int'),
|
||||
)->andReturn([
|
||||
'data' => [[
|
||||
'waybill' => 'FIXTURE-WAYBILL',
|
||||
'expressname' => '测试快递',
|
||||
'courier_company_name' => '测试快递',
|
||||
'month' => '2026-08',
|
||||
'status' => '已确认',
|
||||
'reconciliation_difference' => 1.25,
|
||||
'confirmed_amount' => 20.0,
|
||||
'payable_amount' => 20.0,
|
||||
'actual_payment' => 18.75,
|
||||
'advance_payment' => 0.0,
|
||||
'compensation_amount' => 0.0,
|
||||
'other_replenishment' => 0.0,
|
||||
'corporate_payment_amount' => 18.75,
|
||||
'personal_payment_amount' => 0.0,
|
||||
'create_time' => '2026-08-01 09:00:00',
|
||||
'modify_time' => '2026-08-02 09:00:00',
|
||||
]],
|
||||
'total' => 1,
|
||||
'summary' => ['count' => 1, 'payable_amount' => 20.0, 'actual_payment' => 18.75, 'confirmed_amount' => 20.0, 'reconciliation_difference' => 1.25],
|
||||
'state' => ['status' => 'succeeded'],
|
||||
'sync' => ['code' => 'fresh', 'last_succeeded_at' => '2026-08-02 09:00:00', 'lag_minutes' => 1],
|
||||
]);
|
||||
$this->app->instance(CourierFreightReconciliationMirrorRepository::class, $repository);
|
||||
|
||||
$this->loginAs()
|
||||
->get(route('business.pages.show', [
|
||||
'section' => 'courier-payables',
|
||||
'page' => 'freight-reconciliation',
|
||||
'express_no' => 'FIXTURE',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('FIXTURE-WAYBILL')
|
||||
->assertSeeText('应付金额')
|
||||
->assertSeeText('20.00')
|
||||
->assertSeeText('数据新鲜')
|
||||
->assertSeeText('共享业务镜像')
|
||||
->assertDontSee('class="business-blank-page"', false);
|
||||
}
|
||||
|
||||
public function testTaobaoAlipayBillsEndpointIsAvailableToWorkspaceUsers()
|
||||
{
|
||||
$this->user->attachPermission(
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\CourierFreightReconciliationMirrorRepository;
|
||||
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CourierFreightReconciliationMirrorRepositoryTest extends TestCase
|
||||
{
|
||||
private CourierFreightReconciliationMirrorRepository $repository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config([
|
||||
'database.connections.business_mirror' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
],
|
||||
'upstream_mirrors.connection' => 'business_mirror',
|
||||
'upstream_mirrors.datasets' => [
|
||||
CourierFreightReconciliationMirrorRepository::DATASET => [
|
||||
'source_key' => 'jiansun',
|
||||
'schema' => 'dbo',
|
||||
'object' => 'fys_express_courier_settlement',
|
||||
'target' => 'js_express_courier_settlement',
|
||||
'keys' => ['Id'],
|
||||
'watermark' => 'modify_time',
|
||||
],
|
||||
],
|
||||
]);
|
||||
DB::purge('business_mirror');
|
||||
|
||||
Schema::connection('business_mirror')->create('business_mirror_table_states', function (Blueprint $table): void {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
$table->string('status')->nullable();
|
||||
$table->dateTime('last_succeeded_at')->nullable();
|
||||
$table->string('run_mode')->nullable();
|
||||
});
|
||||
Schema::connection('business_mirror')->create('js_express_courier_settlement', function (Blueprint $table): void {
|
||||
$table->integer('Id');
|
||||
foreach (['month', 'status', 'courier_company_name', 'expresscode', 'expressname', 'replenish_courier', 'reconciliation_person', 'file_status', 'taskid', 'guid', 'creator'] as $column) {
|
||||
$table->string($column)->nullable();
|
||||
}
|
||||
foreach (['opening_balance', 'courier_provided_amount', 'reconciliation_difference', 'confirmed_amount', 'advance_payment', 'payable_amount', 'actual_payment', 'compensation_amount', 'other_replenishment', 'corporate_payment_amount', 'personal_payment_amount'] as $column) {
|
||||
$table->decimal($column, 20, 6)->nullable();
|
||||
}
|
||||
$table->string('waybill')->nullable();
|
||||
$table->dateTime('create_time')->nullable();
|
||||
$table->dateTime('modify_time')->nullable();
|
||||
$table->boolean('is_delete')->nullable();
|
||||
});
|
||||
|
||||
$this->repository = new CourierFreightReconciliationMirrorRepository(
|
||||
new BusinessMirrorDatasetRegistry(),
|
||||
new GenericBusinessMirrorRepository(app('db')),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_unready_mirror(): void
|
||||
{
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('courier_freight_reconciliation_mirror_not_ready');
|
||||
|
||||
$this->repository->paginate([], 1, 25);
|
||||
}
|
||||
|
||||
public function test_it_filters_sorts_and_summarizes_only_approved_mirror_fields(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
'dataset_key' => CourierFreightReconciliationMirrorRepository::DATASET,
|
||||
'initial_completed_at' => '2026-09-01 09:00:00',
|
||||
'status' => 'succeeded',
|
||||
'last_succeeded_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
DB::connection('business_mirror')->table('js_express_courier_settlement')->insert([
|
||||
$this->row(1, 'YT-001', '圆通速递', 100, 80, 20, '2026-08'),
|
||||
$this->row(2, 'YT-002', '圆通速递', 50, 50, 0, '2026-08'),
|
||||
$this->row(3, 'ZT-003', '中通礼品单', 30, 25, 5, '2026-09'),
|
||||
]);
|
||||
|
||||
$result = $this->repository->paginate([
|
||||
'express_no' => 'YT',
|
||||
'express_name' => '圆通',
|
||||
'month' => '2026-08',
|
||||
], 1, 1);
|
||||
|
||||
$this->assertSame(2, $result['total']);
|
||||
$this->assertSame('YT-002', $result['data'][0]['waybill']);
|
||||
$this->assertSame(150.0, $result['summary']['payable_amount']);
|
||||
$this->assertSame(130.0, $result['summary']['actual_payment']);
|
||||
$this->assertSame(20.0, $result['summary']['reconciliation_difference']);
|
||||
$this->assertSame('fresh', $result['sync']['code']);
|
||||
$this->assertArrayNotHasKey('password', $result['data'][0]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function row(int $id, string $waybill, string $express, float $payable, float $actual, float $difference, string $month): array
|
||||
{
|
||||
return [
|
||||
'Id' => $id,
|
||||
'waybill' => $waybill,
|
||||
'expressname' => $express,
|
||||
'courier_company_name' => $express,
|
||||
'month' => $month,
|
||||
'status' => '已确认',
|
||||
'payable_amount' => $payable,
|
||||
'actual_payment' => $actual,
|
||||
'confirmed_amount' => $payable,
|
||||
'reconciliation_difference' => $difference,
|
||||
'create_time' => '2026-09-07 09:00:00',
|
||||
'modify_time' => '2026-09-07 10:00:00',
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user