diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 5bbde4e4..ece814a0 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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 { diff --git a/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php new file mode 100644 index 00000000..75e8018c --- /dev/null +++ b/modules/EcommerceFinance/Repositories/CourierFreightReconciliationMirrorRepository.php @@ -0,0 +1,198 @@ +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|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 $filters + * @return array{data:list>,total:int,summary:array,state:array|null,sync:array} + */ + 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 */ + 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; + } +} diff --git a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php index 9a5b066b..e7082813 100644 --- a/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php +++ b/resources/views/business/pages/courier-payables/freight-reconciliation.blade.php @@ -1,311 +1,87 @@ - +@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 + +
-
- -
-
-

{{ $page['title'] ?? '运费对账' }}

-
+ +
+
+

{{ $page['title'] ?? '运费对账' }}

+

数据来源:JianSun · dbo.fys_express_courier_settlement · 共享业务镜像

- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - - 重置 - -
-
-
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - {{-- 物流信息 --}} - - - - - - - - - - - {{-- 订单信息 --}} - - - - - - - - - {{-- 收件信息 --}} - - - - - - - - {{-- 商品规格 --}} - - - - - - {{-- 平台/店铺 --}} - - - - {{-- 供应商/仓库 --}} - - - - - - {{-- 时间 --}} - - - - {{-- 备注 --}} - - - - - - @forelse ($freightReconciliations ?? [] as $index => $item) - - - {{-- 物流信息 --}} - - - - - - - - - - - {{-- 订单信息 --}} - - - - - - - - - {{-- 收件信息 --}} - - - - - - - - {{-- 商品规格 --}} - - - - - - {{-- 平台/店铺 --}} - - - - {{-- 供应商/仓库 --}} - - - - - - {{-- 时间 --}} - - - - {{-- 备注 --}} - - - - @empty - - - - @endforelse - -
#物流信息订单信息收件信息商品规格平台/店铺供应商/仓库时间备注
#快递单号快递名称1快递名称2运费费率结算重量结算体积对账重量对账体积换单前单号交易单号商品ID条形码商品名称数量金额货号配货单号省市区收货详细地址收货姓名手机号码座机号码长(cm)宽(cm)高(cm)体积(cm³)重量(kg)平台店铺名称买家昵称供应商仓库成品条码成品名称成品供应商发货时间下载时间付款时间买家留言卖家备注
- {{ ($freightReconciliations->currentPage() - 1) * $freightReconciliations->perPage() + $index + 1 }} - {{ $item['express_no'] ?? '-' }}{{ $item['express_name1'] ?? '-' }}{{ $item['express_name2'] ?? '-' }} - {{ isset($item['freight']) ? number_format((float) $item['freight'], 2) : '-' }} - {{ $item['rate'] ?? '-' }}{{ isset($item['settle_weight']) ? number_format((float) $item['settle_weight'], 4) : '-' }}{{ isset($item['settle_volume']) ? number_format((float) $item['settle_volume'], 4) : '-' }}{{ isset($item['recon_weight']) ? number_format((float) $item['recon_weight'], 4) : '-' }}{{ isset($item['recon_volume']) ? number_format((float) $item['recon_volume'], 4) : '-' }}{{ $item['prev_express_no'] ?? '-' }}{{ $item['trade_no'] ?? '-' }}{{ $item['product_id'] ?? '-' }}{{ $item['barcode'] ?? '-' }} - {{ $item['product_name'] ?? '-' }} - {{ $item['qty'] ?? '-' }}{{ isset($item['amount']) ? number_format((float) $item['amount'], 2) : '-' }}{{ $item['item_no'] ?? '-' }}{{ $item['pick_no'] ?? '-' }}{{ $item['province'] ?? '-' }}{{ $item['city'] ?? '-' }}{{ $item['district'] ?? '-' }} - {{ $item['address'] ?? '-' }} - {{ $item['receiver_name'] ?? '-' }}{{ $item['receiver_phone'] ?? '-' }}{{ $item['receiver_tel'] ?? '-' }}{{ isset($item['length']) ? number_format((float) $item['length'], 2) : '-' }}{{ isset($item['width']) ? number_format((float) $item['width'], 2) : '-' }}{{ isset($item['height']) ? number_format((float) $item['height'], 2) : '-' }}{{ isset($item['volume']) ? number_format((float) $item['volume'], 4) : '-' }}{{ isset($item['weight']) ? number_format((float) $item['weight'], 4) : '-' }} - - {{ $item['platform'] ?? '-' }} - - - {{ $item['shop_name'] ?? '-' }} - {{ $item['buyer_nick'] ?? '-' }}{{ $item['supplier'] ?? '-' }}{{ $item['warehouse'] ?? '-' }}{{ $item['finished_barcode'] ?? '-' }}{{ $item['finished_name'] ?? '-' }}{{ $item['finished_supplier'] ?? '-' }}{{ $item['ship_time'] ?? '-' }}{{ $item['download_time'] ?? '-' }}{{ $item['pay_time'] ?? '-' }} - {{ $item['buyer_msg'] ?? '-' }} - - {{ $item['seller_memo'] ?? '-' }} -
-
- - - - - 暂无运费对账数据 -
-
-
- - - @if (isset($freightReconciliations) && $freightReconciliations->hasPages()) -
-
- 共 {{ $freightReconciliations->total() }} 条记录 -
-
- @if ($freightReconciliations->onFirstPage()) - 上一页 - @else - 上一页 - @endif - - @php - $current = $freightReconciliations->currentPage(); - $last = $freightReconciliations->lastPage(); - $start = max(1, $current - 2); - $end = min($last, $current + 2); - @endphp - - @if ($start > 1) - 1 - @if ($start > 2) - ... - @endif - @endif - - @for ($p = $start; $p <= $end; $p++) - @if ($p === $current) - {{ $p }} - @else - {{ $p }} - @endif - @endfor - - @if ($end < $last) - @if ($end < $last - 1) - ... - @endif - {{ $last }} - @endif - - @if ($freightReconciliations->hasMorePages()) - 下一页 - @else - 下一页 - @endif -
-
- @endif +
+ {{ $statusLabels[$sync['code'] ?? 'unavailable'] ?? '镜像状态未知' }} + @if (!empty($sync['last_succeeded_at'])) 最近同步 {{ $sync['last_succeeded_at'] }} @endif
- + @if (!empty($freightReconciliationError)) +
{{ $freightReconciliationError }}
+ @endif + +
+ @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) +
{{ $stat['label'] }}
{{ $stat['value'] }}
+ @endforeach +
+ +
+
+ @foreach ([ + ['name' => 'express_no', 'label' => '快递单号', 'placeholder' => '包含匹配'], + ['name' => 'express_name', 'label' => '快递名称', 'placeholder' => '包含匹配'], + ['name' => 'month', 'label' => '月份', 'placeholder' => '如 2026-08'], + ['name' => 'status', 'label' => '状态', 'placeholder' => '精确匹配'], + ] as $field) + + @endforeach + + +
重置
+
+
+ +
+
+ + + + @forelse ($freightReconciliations ?? [] as $row) + + @foreach (['reconciliation_difference','confirmed_amount','payable_amount','actual_payment','advance_payment','compensation_amount','other_replenishment','corporate_payment_amount','personal_payment_amount'] as $amount) + + @endforeach + + + @empty + + @endforelse +
快递单号快递名称月份状态对账差额已确认金额应付金额实际付款预付款赔偿金额其他补款公司付款个人付款创建时间修改时间
{{ $row['waybill'] ?? '-' }}{{ $row['expressname'] ?: ($row['courier_company_name'] ?? '-') }}{{ $row['month'] ?? '-' }}{{ $row['status'] ?? '-' }}{{ $row[$amount] === null ? '-' : $money($row[$amount]) }}{{ $row['create_time'] ?? '-' }}{{ $row['modify_time'] ?? '-' }}
@if ($freightReconciliationPending ?? false) 正在等待镜像首次同步完成… @else 暂无符合条件的运费对账数据 @endif
+ @if (($freightReconciliations ?? null)?->hasPages()) +
共 {{ $freightReconciliations->total() }} 条记录
{{ $freightReconciliations->onEachSide(1)->links() }}
+ @endif +
+
diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 97422752..fdcc8065 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -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( diff --git a/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php new file mode 100644 index 00000000..67bb15ef --- /dev/null +++ b/tests/Unit/EcommerceFinance/CourierFreightReconciliationMirrorRepositoryTest.php @@ -0,0 +1,124 @@ + [ + '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 */ + 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', + ]; + } +}