diff --git a/app/Console/Commands/SyncBusinessMirrors.php b/app/Console/Commands/SyncBusinessMirrors.php index e894feff..24445c13 100644 --- a/app/Console/Commands/SyncBusinessMirrors.php +++ b/app/Console/Commands/SyncBusinessMirrors.php @@ -155,9 +155,18 @@ class SyncBusinessMirrors extends Command if (($state['status'] ?? '') === 'failed' && ! empty($state['last_attempted_at']) && now()->diffInSeconds(\Illuminate\Support\Carbon::parse($state['last_attempted_at'])) < 300) continue; + // Only explicitly opted-in small datasets receive periodic full reconciliation. + // Keep an interrupted full run in full mode so its cursor/generation can resume. + $hours = (int) $datasets->definitionValue($key, 'full_reconcile_hours', 0); + $lastFull = $state['last_full_succeeded_at'] ?? null; + $reconcile = $hours > 0 && (empty($lastFull) + || \Illuminate\Support\Carbon::parse($lastFull)->addHours($hours)->lte(now())); + $resumeFull = $hours > 0 && ($state['run_mode'] ?? '') === 'full' + && in_array($state['status'] ?? '', ['running', 'failed'], true); + $scheduled[] = [ 'key' => $key, - 'full' => $initial || (bool) $this->option('full'), + 'full' => $initial || $reconcile || $resumeFull || (bool) $this->option('full'), 'catchup' => $initial || ($state['status'] ?? '') === 'running', 'last_attempted_at' => (string) ($state['last_attempted_at'] ?? ''), ]; diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 730de895..7a1c34de 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -2398,6 +2398,7 @@ class Pages extends Controller 'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(), 'board-deductions' => $this->contentForBoardDeductions(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(), + 'supplier-management' => app(\Modules\EcommerceFinance\View\SupplierManagementPageData::class)->page(), 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'product-costs' => $this->contentForProductCosts(), diff --git a/app/Http/Controllers/Business/SupplierManagement.php b/app/Http/Controllers/Business/SupplierManagement.php new file mode 100644 index 00000000..58f5ab3b --- /dev/null +++ b/app/Http/Controllers/Business/SupplierManagement.php @@ -0,0 +1,36 @@ +ready(), 503, '供应商资料尚未完成首次同步,暂时不能导出。'); + $filters = $page->filters($request->query()); + + return response()->streamDownload(static function () use ($repository, $filters): void { + $csv = fopen('php://output', 'w'); + fputs($csv, "\xEF\xBB\xBF"); + fputcsv($csv, array_values(SupplierManagementMirrorRepository::COLUMNS)); + foreach ($repository->exportRows($filters) as $row) { + $values = []; + foreach (array_keys(SupplierManagementMirrorRepository::COLUMNS) as $column) { + $value = (string) ($row->{$column} ?? ''); + if ($column === 'coopersgn') $value = ['1' => '合作', '2' => '不合作'][$value] ?? '未维护'; + // Excel 不能执行供应商提供的公式文本,且长编码保留为文本。 + if (preg_match('/^[=+\-@\t\r\n]/', ltrim($value)) || preg_match('/^\d{12,}$/', $value)) $value = "'" . $value; + $values[] = $value; + } + fputcsv($csv, $values); + } + fclose($csv); + }, 'supplier_management_' . now()->format('Ymd_His') . '.csv', + ['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store']); + } +} diff --git a/config/business_navigation.php b/config/business_navigation.php index 0ac9d064..bed51c82 100644 --- a/config/business_navigation.php +++ b/config/business_navigation.php @@ -674,6 +674,7 @@ $navigation = [ 'title' => '供应商应付', 'icon' => 'local_shipping', 'pages' => [ + ['slug' => 'supplier-management', 'title' => '供应商管理'], ['slug' => 'product-prices', 'title' => '商品价格'], ['slug' => 'payment-reconciliation', 'title' => '货款对账'], ['slug' => 'return-reconciliation', 'title' => '退货对账'], diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index e5305288..7e1b3ca9 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -203,6 +203,43 @@ return [ ], 'datasets' => [ + // 工作平台供应商管理(简尚版):列表和详情只读共享镜像。 + // 供应商视图含关联资料变化,无可靠视图更新时间,按稳定键追加并周期全量校准。 + 'jiansun.dbo.js_supplier_View' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'js_supplier_View', + 'target' => 'js_supplier_management', 'keys' => ['Id'], + 'full_reconcile_hours' => 1, + 'columns' => ['Id', 'guid', 'sourcefrom', 'coopersgn', 'scmcode', 'scmname', 'scmtype', + 'tradebody', 'company', 'enterpriseType', 'creditcode', 'handlePerson', 'legalPerson', + 'buyer', 'tradebtime', 'tradeetime', 'cooperbtime', 'cooperetime', 'memo', + 'updator', 'local_created', 'local_updated'], + 'indexes' => [['scmcode', 'Id'], ['coopersgn', 'Id']], + ], + 'jiansun.dbo.js_supplier_contract_View' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'js_supplier_contract_View', + 'target' => 'js_supplier_contracts', 'keys' => ['id'], + 'full_reconcile_hours' => 1, + 'columns' => ['id', 'ordernum', 'originname', 'title', 'tradebody', 'company', + 'eSignDocTemplateId', 'type', 'tradebtime', 'tradeetime', 'sendTime', 'completeTime', 'state', 'createUser'], + 'indexes' => [['tradebody(64)', 'company(64)']], + ], + 'jiansun.dbo.js_supplier_linkers' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'js_supplier_linkers', + 'target' => 'js_supplier_contacts', 'keys' => ['Id'], 'watermark' => 'local_updated', + 'full_reconcile_hours' => 24, + 'columns' => ['Id', 'sourcefrom', 'scmname', 'linker', 'linkmobile', 'linkweixin', + 'linkjob', 'memo', 'local_created', 'local_updated'], + 'indexes' => [['scmname(64)', 'Id']], + ], + 'jiansun.dbo.js_supplier_payinfo' => [ + 'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'js_supplier_payinfo', + 'target' => 'js_supplier_payment_accounts', 'keys' => ['Id'], 'watermark' => 'local_updated', + 'full_reconcile_hours' => 24, + 'columns' => ['Id', 'sourcefrom', 'scmname', 'enable', 'accounttype', 'account', + 'accountname', 'accountbank', 'memo', 'invoice', 'accountnature', + 'creator', 'updator', 'local_created', 'local_updated'], + 'indexes' => [['scmname(64)', 'Id']], + ], // 经营分析副本库:组织资料、订单、退款和聚水潭基础数据。 'operations_replica.rpt.fys_companyInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_companyInfo', 'target' => 'ops_fys_company_info', 'keys' => ['Id'], 'watermark' => 'updateTIme'], 'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'], diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 55625e3b..6dcf1f73 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -66,6 +66,7 @@ | 店铺收款账单旧来源(已停用) | `dbo.alipay_Bill_Detail` | | 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` | | 供应商应付 | `dbo.finance_purchaseReturn`、`dbo.finance_invoiceDeduction`、`dbo.fys_supplierPaymentSummary`、`dbo.finance_advanceLoans`、`dbo.pm_unOrderDetails`、`dbo.finance_sampleDeduction`、`dbo.fys_express_courier_settlement` | +| 供应商管理(简尚版) | `jiansun.dbo.js_supplier_View`(`Id` 稳定键,镜像 `js_supplier_management`)、`dbo.js_supplier_contract_View`(`id` 稳定键,镜像 `js_supplier_contracts`)、`dbo.js_supplier_linkers`(镜像 `js_supplier_contacts`)与 `dbo.js_supplier_payinfo`(镜像 `js_supplier_payment_accounts`)。联系人、打款资料按 `Id` / `local_updated` 增量并每 24 小时全量核对;主视图及合同视图无可靠更新时间,按稳定键追加并每小时全量校准。复用现有轮转调度、游标与代次,未完成校准继续全量模式。页面按供应商名称、采购人员及合作状态筛选;合同同时匹配合同主体和签订公司,联系人及账户按供应商名称关联。未完成首次同步时明确提示,不在页面请求中直连上游;操作日志关联未确认时不展示其他模块日志。 | | 快递运费对账明细 | `dbo.jst_order_express_details`、`dbo.Jst_SendData_View_extension`、`dbo.jst_salesOutboundInfo` | | 工作平台退货取件管理 | `dbo.ekf_ReturnPickupManagement`、`dbo.ekf_ReturnPickupManagement_Item`、`dbo.ekf_ReturnPickupManagement_TruthReExpressNumber`、发货仓名称 `dbo.wp_warehouse`、`dbo.wp_warehouseConfig` | | 聚水潭进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(全部仓库、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像。三方仓页面仍固定 `wms_co_id = 15045364`,商品价格管理的进销存报表读取全仓库) | diff --git a/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php b/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php new file mode 100644 index 00000000..1d20b748 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/SupplierManagementMirrorRepository.php @@ -0,0 +1,116 @@ + 'jiansun.dbo.js_supplier_contract_View', + 'contacts' => 'jiansun.dbo.js_supplier_linkers', + 'payments' => 'jiansun.dbo.js_supplier_payinfo', + ]; + + public const COLUMNS = [ + 'sourcefrom' => '数据来源', 'coopersgn' => '是否合作', 'scmcode' => '供应商代码', + 'scmname' => '供应商名称', 'scmtype' => '供应商类型', 'tradebody' => '合同主体', + 'company' => '签订公司', 'enterpriseType' => '企业类型', 'creditcode' => '社会信用代码', + 'handlePerson' => '电子合同经办人', 'buyer' => '采购人员', + ]; + + public function __construct( + private readonly BusinessMirrorDatasetRegistry $datasets, + private readonly GenericBusinessMirrorRepository $mirror, + ) { + } + + public function ready(): bool + { + return $this->mirror->ready($this->datasets->get(self::DATASET)); + } + + public function state(): ?array + { + return $this->mirror->state($this->datasets->get(self::DATASET)); + } + + public function paginate(array $filters, int $page, int $limit): array + { + $query = $this->query($filters); + + return [ + 'total' => (clone $query)->count(), + 'rows' => $query->orderBy('scmname')->orderBy('Id') + ->forPage(max($page, 1), min(max($limit, 1), 100))->get()->all(), + ]; + } + + /** 全部筛选结果,按上游稳定键小批读取,保留编码/账号文本。 */ + public function exportRows(array $filters): \Generator + { + $lastId = 0; + do { + $rows = $this->query($filters)->where('Id', '>', $lastId)->orderBy('Id')->limit(500)->get(); + foreach ($rows as $row) { + $lastId = (int) $row->Id; + yield $row; + } + } while ($rows->count() === 500); + } + + /** 详情以已选供应商关联,不能在未选择时显示全体账户或合同。 */ + public function details(object $supplier, string $tab, int $page, int $limit): array + { + if (! array_key_exists($tab, self::RELATED)) { + return ['ready' => false, 'rows' => [], 'total' => 0, + 'message' => '工作平台操作日志的供应商关联尚未确认,暂不展示。']; + } + $dataset = $this->datasets->get(self::RELATED[$tab]); + if (! $this->mirror->ready($dataset)) { + return ['ready' => false, 'rows' => [], 'total' => 0, 'message' => '该详情资料尚未完成首次同步。']; + } + $query = $this->mirror->connection()->table($dataset->targetTable()); + if ($tab === 'contracts') { + if (trim((string) $supplier->tradebody) === '' || trim((string) $supplier->company) === '') { + return ['ready' => true, 'rows' => [], 'total' => 0, + 'message' => '该供应商未维护合同主体或签订公司,无法准确匹配合同。']; + } + $query->where('tradebody', $supplier->tradebody)->where('company', $supplier->company); + $query->orderByDesc('sendTime')->orderBy('id'); + } else { + if (trim((string) $supplier->scmname) === '') { + return ['ready' => true, 'rows' => [], 'total' => 0, 'message' => '该供应商未维护名称,无法准确匹配详情。']; + } + $query->where('scmname', $supplier->scmname)->orderBy('Id'); + } + + return ['ready' => true, 'total' => (clone $query)->count(), + 'rows' => $query->forPage(max($page, 1), min(max($limit, 1), 100))->get()->all(), + 'message' => null]; + } + + private function query(array $filters): Builder + { + if (! $this->ready()) { + throw new RuntimeException('supplier_management_mirror_not_ready'); + } + $query = $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable()); + foreach (['supplier_name' => 'scmname', 'buyer' => 'buyer'] as $key => $column) { + if (($filters[$key] ?? '') !== '') { + $value = '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $filters[$key]) . '%'; + $query->whereRaw($column . " LIKE ? ESCAPE '!'", [$value]); + } + } + if (in_array($filters['cooperation_status'] ?? '', ['1', '2'], true)) { + $query->where('coopersgn', (int) $filters['cooperation_status']); + } + + return $query; + } +} diff --git a/modules/EcommerceFinance/View/SupplierManagementPageData.php b/modules/EcommerceFinance/View/SupplierManagementPageData.php new file mode 100644 index 00000000..25dcd1e7 --- /dev/null +++ b/modules/EcommerceFinance/View/SupplierManagementPageData.php @@ -0,0 +1,73 @@ + '合同信息', 'contacts' => '联系信息', 'payments' => '打款信息', 'logs' => '操作日志']; + + public function __construct(private readonly SupplierManagementMirrorRepository $repository) + { + } + + public function filters(array $input): array + { + $text = static fn ($key): string => mb_substr(trim(is_scalar($input[$key] ?? '') ? (string) ($input[$key] ?? '') : ''), 0, 191); + return ['supplier_name' => $text('supplier_name'), 'buyer' => $text('buyer'), + 'cooperation_status' => in_array($text('cooperation_status'), ['1', '2'], true) ? $text('cooperation_status') : '']; + } + + public function page(): array + { + $filters = $this->filters(request()->query()); + $page = max((int) request('page', 1), 1); + $limit = min(max((int) request('limit', 20), 1), 100); + $requestedTab = request('detail_tab', 'contracts'); + $tab = is_string($requestedTab) && array_key_exists($requestedTab, self::TABS) ? $requestedTab : 'contracts'; + $result = ['rows' => [], 'total' => 0]; + $detail = ['rows' => [], 'total' => 0, 'message' => '请选择上方供应商查看详情。', 'ready' => false]; + $state = null; + $ready = false; + $error = null; + $selected = null; + try { + $state = $this->repository->state(); + $ready = $this->repository->ready(); + if ($ready) { + $result = $this->repository->paginate($filters, $page, $limit); + if ((int) request('supplier_id', 0) > 0) { + // 选择必须仍在当前列表中;筛选和翻页后清除旧选择。 + $selected = collect($result['rows'])->firstWhere('Id', (int) request('supplier_id')); + if ($selected) { + try { + $detail = $this->repository->details($selected, $tab, max((int) request('detail_page', 1), 1), $limit); + } catch (Throwable $exception) { + report($exception); + $detail['message'] = '该详情资料暂时无法读取,请稍后重试。'; + } + } else { + $detail['message'] = '该供应商不在当前列表,请重新选择。'; + } + } + } + } catch (Throwable $exception) { + report($exception); + $error = '供应商资料暂时无法读取,请稍后重试。'; + } + return [ + 'view' => 'business.pages.supplier-payables.supplier-management', + 'supplierFilters' => $filters, 'supplierState' => $state, 'supplierReady' => $ready, + 'supplierError' => $error, 'supplierColumns' => SupplierManagementMirrorRepository::COLUMNS, + 'suppliers' => new LengthAwarePaginator($result['rows'], $result['total'], $limit, $page, + ['path' => request()->url(), 'query' => request()->except('page', 'supplier_id', 'detail_page')]), + 'selectedSupplier' => $selected, 'supplierDetailTab' => $tab, + 'supplierDetail' => $detail, + 'supplierDetails' => new LengthAwarePaginator($detail['rows'], $detail['total'], $limit, max((int) request('detail_page', 1), 1), + ['path' => request()->url(), 'pageName' => 'detail_page', 'query' => request()->except('detail_page')]), + ]; + } +} diff --git a/resources/views/business/pages/supplier-payables/local-navigation.blade.php b/resources/views/business/pages/supplier-payables/local-navigation.blade.php index 0971904b..42579790 100644 --- a/resources/views/business/pages/supplier-payables/local-navigation.blade.php +++ b/resources/views/business/pages/supplier-payables/local-navigation.blade.php @@ -90,6 +90,13 @@ event.preventDefault(); navigate(link.href); }); + // A nested server-rendered page can change its URL without replacing the workspace. + // Drop the previous URL's node so Back restores its own filters instead of the new content. + window.addEventListener('caiwu:supplier-page-updated', () => { + cache.delete(current); + current = location.href; + remember(); + }); window.addEventListener('popstate', () => { if (/\/workspace\/supplier-payables(-final)?\//.test(location.pathname)) navigate(location.href, false); }); diff --git a/resources/views/business/pages/supplier-payables/supplier-management.blade.php b/resources/views/business/pages/supplier-payables/supplier-management.blade.php new file mode 100644 index 00000000..821b949b --- /dev/null +++ b/resources/views/business/pages/supplier-payables/supplier-management.blade.php @@ -0,0 +1,217 @@ +@php + $listUrl = route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'supplier-management']); + $localUrl = static fn (array $changes): string => $listUrl . '?' . http_build_query(array_merge(request()->except('detail_page'), $changes)); + $cell = static fn ($value): string => $value === null || $value === '' ? '—' : (string) $value; + $detailColumns = match ($supplierDetailTab) { + 'contacts' => ['sourcefrom' => '数据来源', 'linker' => '联系人', 'linkjob' => '职务', 'linkmobile' => '联系电话', 'linkweixin' => '微信', 'memo' => '备注'], + 'payments' => ['enable' => '是否启用', 'sourcefrom' => '数据来源', 'accounttype' => '账户类型', 'account' => '打款账号', 'accountname' => '账户名称', 'accountbank' => '开户银行', 'accountnature' => '账户性质', 'invoice' => '开票信息', 'memo' => '备注'], + 'logs' => ['time' => '操作时间', 'user' => '操作人', 'action' => '操作内容'], + default => ['ordernum' => '合同编号', 'originname' => '数据来源', 'title' => '合同名称', 'tradebody' => '合同主体', 'company' => '签订公司', 'eSignDocTemplateId' => '合同类型', 'type' => '合同类型名称', 'tradebtime' => '合同开始时间', 'tradeetime' => '合同截止时间', 'sendTime' => '合同发送时间', 'completeTime' => '完成时间', 'state' => '签署状态'], + }; +@endphp +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + 重置 + @if ($supplierReady && !$supplierError) + 导出 + @endif +
+
+

+ + @if ($supplierError) + + @elseif (!$supplierReady) +
供应商资料尚未完成首次同步,完成后会显示完整列表。
+ @elseif (($supplierState['status'] ?? '') === 'failed') +
最近一次同步失败,当前显示上次同步成功的资料。
+ @endif +
+ 共 {{ number_format($suppliers->total()) }} 家供应商 + 最近同步: +
+
+
+ + @foreach ($supplierColumns as $label)@endforeach + + @forelse ($suppliers as $supplier) + @php $selectUrl = $localUrl(['supplier_id' => $supplier->Id]); @endphp + $selectedSupplier && $selectedSupplier->Id == $supplier->Id])> + + @foreach ($supplierColumns as $key => $label) + + @endforeach + + @empty + + @endforelse + +
序号{{ $label }}
{{ $suppliers->firstItem() + $loop->index }} + @if ($key === 'coopersgn') + (int) $supplier->coopersgn === 1])>{{ ['1' => '合作', '2' => '不合作'][(string) $supplier->coopersgn] ?? '未维护' }} + @elseif ($key === 'scmname') + Id == $supplier->Id) aria-current="true" @endif>{{ $cell($supplier->{$key} ?? null) }} + @else + {{ $cell($supplier->{$key} ?? null) }} + @endif +
{{ $supplierError ? '资料读取失败,请稍后重试。' : ($supplierReady ? '没有符合筛选条件的供应商。' : '供应商资料同步未完成。') }}
+
+
+ 每页 {{ $suppliers->perPage() }} 条,共 {{ $suppliers->total() }} 条 + +
+
+
+
{{ $selectedSupplier ? '当前供应商:' . $selectedSupplier->scmname . '(' . $selectedSupplier->scmcode . ')' : '选择供应商后查看相关资料' }}
+ +
+ + @foreach ($detailColumns as $label)@endforeach + + @forelse ($supplierDetails as $detail) + @foreach ($detailColumns as $key => $label)@endforeach + @empty + + @endforelse + +
{{ $label }}
{{ $key === 'enable' ? ((int) ($detail->enable ?? 0) === 1 ? '启用' : '停用') : $cell($detail->{$key} ?? null) }}
{{ $supplierDetail['message'] ?? '该供应商暂无此类资料。' }}
+
+ @if ($supplierDetails->total() > 0) +
+ 共 {{ $supplierDetails->total() }} 条 + +
+ @endif +
+
+@push('body_end') + +@endpush diff --git a/routes/admin.php b/routes/admin.php index af60ffce..dc9877d6 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -446,6 +446,10 @@ Route::delete('workspace/trademark-management/store-report-data-logic/shops', 'B ->middleware('business.page:trademark-management,store-report-data-logic') ->name('business.store-report-logic.shops.destroy'); +Route::get('workspace/supplier-payables/supplier-management/export', [\App\Http\Controllers\Business\SupplierManagement::class, 'export']) + ->middleware('business.page:supplier-payables,supplier-management') + ->name('business.supplier-management.export'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/SupplierManagementPageTest.php b/tests/Feature/Business/SupplierManagementPageTest.php new file mode 100644 index 00000000..baee0360 --- /dev/null +++ b/tests/Feature/Business/SupplierManagementPageTest.php @@ -0,0 +1,74 @@ +user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + $repository = Mockery::mock(SupplierManagementMirrorRepository::class); + $repository->shouldReceive('ready')->andReturn($ready); + $repository->shouldReceive('state')->andReturn(null); + $this->app->instance(SupplierManagementMirrorRepository::class, $repository); + return $repository; + } + + private function url(array $query = []): string + { + return route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'supplier-management', ...$query]); + } + + public function test_initial_sync_pending_is_explicit_and_export_is_unavailable(): void + { + $repository = $this->repository(false); + $repository->shouldNotReceive('paginate'); + $this->loginAs()->get($this->url())->assertOk()->assertSeeText('供应商管理')->assertSeeText('供应商资料尚未完成首次同步'); + $this->withExceptionHandling()->get(route('business.supplier-management.export'))->assertStatus(503); + } + + public function test_filters_and_selected_supplier_details_render_without_full_page_navigation(): void + { + $repository = $this->repository(true); + $row = (object) ['Id' => 3, 'scmcode' => '001', 'scmname' => '测试供应商', 'coopersgn' => 1]; + $repository->shouldReceive('paginate')->once()->with(['supplier_name' => '测试', 'buyer' => '', 'cooperation_status' => '1'], 1, 20) + ->andReturn(['rows' => [$row], 'total' => 1]); + $repository->shouldReceive('details')->once()->with($row, 'contacts', 1, 20) + ->andReturn(['rows' => [(object) ['linker' => '测试联系人']], 'total' => 1, 'ready' => true, 'message' => null]); + $this->loginAs()->get($this->url(['supplier_name' => ' 测试 ', 'cooperation_status' => '1', 'supplier_id' => 3, 'detail_tab' => 'contacts'])) + ->assertOk()->assertSeeText('测试供应商')->assertSeeText('测试联系人')->assertSee('data-caiwu-replay', false)->assertSeeText('打款信息'); + } + + public function test_detail_failure_keeps_the_supplier_list_visible(): void + { + $repository = $this->repository(true); + $row = (object) ['Id' => 3, 'scmcode' => '001', 'scmname' => '测试供应商', 'coopersgn' => null]; + $repository->shouldReceive('paginate')->andReturn(['rows' => [$row], 'total' => 1]); + $repository->shouldReceive('details')->andThrow(new \RuntimeException('test detail unavailable')); + $this->loginAs()->get($this->url(['supplier_id' => 3]))->assertOk()->assertSeeText('测试供应商')->assertSeeText('该详情资料暂时无法读取'); + } + + public function test_export_uses_all_filtered_rows_and_neutralizes_formula_text(): void + { + $repository = $this->repository(true); + $repository->shouldReceive('exportRows')->once()->with(['supplier_name' => '测试', 'buyer' => '', 'cooperation_status' => '']) + ->andReturnUsing(function (): \Generator { + for ($id = 1; $id <= 23; $id++) yield (object) ['scmcode' => '123456789012345678', 'scmname' => '=SUM(1,2)', 'coopersgn' => 1]; + }); + $response = $this->loginAs()->get(route('business.supplier-management.export', ['supplier_name' => '测试', 'page' => 2]))->assertOk(); + $content = $response->streamedContent(); + $this->assertStringStartsWith("\xEF\xBB\xBF", $content); + $this->assertSame(23, substr_count($content, "'=SUM(1,2)")); + $this->assertStringContainsString("'123456789012345678", $content); + } + + public function test_unauthenticated_export_requires_login(): void + { + $this->withExceptionHandling()->get(route('business.supplier-management.export', ['company_id' => $this->company->id]))->assertRedirect(); + } +} diff --git a/tests/Unit/EcommerceFinance/SupplierManagementMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/SupplierManagementMirrorRepositoryTest.php new file mode 100644 index 00000000..be598fef --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierManagementMirrorRepositoryTest.php @@ -0,0 +1,90 @@ + ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); + 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(); + }); + $registry = new BusinessMirrorDatasetRegistry(); + foreach ([SupplierManagementMirrorRepository::DATASET, ...array_values(SupplierManagementMirrorRepository::RELATED)] as $key) { + Schema::connection('business_mirror')->create($registry->get($key)->targetTable(), function (Blueprint $table) use ($registry, $key): void { + foreach ($registry->columnSelection($key) as $column) { + if (strtolower($column) === 'id') $table->integer($column)->primary(); + else $table->string($column)->nullable(); + } + }); + } + $this->repository = new SupplierManagementMirrorRepository($registry, new GenericBusinessMirrorRepository(app('db'))); + } + + public function test_filters_treat_wildcards_literally_and_pages_are_stable(): void + { + $this->ready(SupplierManagementMirrorRepository::DATASET); + $this->table('js_supplier_management')->insert([ + ['Id' => 1, 'scmname' => 'A%_供应商', 'buyer' => '采购甲', 'coopersgn' => 1], + ['Id' => 2, 'scmname' => 'A%_供应商', 'buyer' => '采购甲', 'coopersgn' => 1], + ['Id' => 3, 'scmname' => 'A普通供应商', 'buyer' => '采购乙', 'coopersgn' => 2], + ]); + $filters = ['supplier_name' => '%_', 'buyer' => '采购甲', 'cooperation_status' => '1']; + $this->assertSame(2, $this->repository->paginate($filters, 1, 1)['total']); + $this->assertSame(2, $this->repository->paginate($filters, 2, 1)['rows'][0]->Id); + $this->assertSame([1, 2], array_map(fn ($row) => $row->Id, iterator_to_array($this->repository->exportRows($filters)))); + } + + public function test_details_do_not_mix_suppliers_or_contract_signing_companies(): void + { + foreach (SupplierManagementMirrorRepository::RELATED as $key) $this->ready($key); + $supplier = (object) ['scmname' => '甲供应商', 'tradebody' => '甲主体', 'company' => '签订公司甲']; + $this->table('js_supplier_contracts')->insert([ + ['id' => 1, 'tradebody' => '甲主体', 'company' => '签订公司甲', 'title' => '正确合同'], + ['id' => 2, 'tradebody' => '甲主体', 'company' => '签订公司乙', 'title' => '其他公司合同'], + ]); + foreach (['js_supplier_contacts', 'js_supplier_payment_accounts'] as $table) { + $this->table($table)->insert([['Id' => 1, 'scmname' => '甲供应商'], ['Id' => 2, 'scmname' => '乙供应商']]); + } + foreach (['contracts', 'contacts', 'payments'] as $tab) { + $this->assertSame(1, $this->repository->details($supplier, $tab, 1, 20)['total']); + } + $supplier->company = ''; + $this->assertSame(0, $this->repository->details($supplier, 'contracts', 1, 20)['total']); + $supplier->scmname = ''; + $this->assertSame(0, $this->repository->details($supplier, 'payments', 1, 20)['total']); + $this->assertFalse($this->repository->details($supplier, 'logs', 1, 20)['ready']); + } + + public function test_partial_initial_sync_is_not_presented_as_complete(): void + { + $this->table('js_supplier_management')->insert(['Id' => 1]); + $this->assertFalse($this->repository->ready()); + $this->expectException(\RuntimeException::class); + $this->repository->paginate([], 1, 20); + } + + private function ready(string $key): void + { + $this->table('business_mirror_table_states')->insert(['dataset_key' => $key, 'initial_completed_at' => '2026-10-11 00:00:00']); + } + + private function table(string $name): \Illuminate\Database\Query\Builder + { + return DB::connection('business_mirror')->table($name); + } +} diff --git a/tests/Unit/EcommerceFinance/SupplierManagementSyncScheduleTest.php b/tests/Unit/EcommerceFinance/SupplierManagementSyncScheduleTest.php new file mode 100644 index 00000000..39f1ba51 --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierManagementSyncScheduleTest.php @@ -0,0 +1,44 @@ +get(SupplierManagementMirrorRepository::DATASET); + $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class)->makePartial(); + $datasets->shouldReceive('all')->andReturn([$dataset->key() => $dataset]); + $datasets->shouldReceive('scheduled')->andReturnTrue(); + $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); + $command = app(SyncBusinessMirrors::class); + $command->setInput(new \Symfony\Component\Console\Input\ArrayInput([], $command->getDefinition())); + $method = new \ReflectionMethod($command, 'scheduledKeys'); + $state = ['initial_completed_at' => '2026-10-10 12:00:00', 'status' => 'succeeded', 'last_full_succeeded_at' => '2026-10-11 10:30:00']; + $mirror->shouldReceive('state')->andReturnUsing(function () use (&$state) { return $state; }); + $this->assertTrue($method->invoke($command, $datasets, $mirror)[0]['full']); + $state['last_full_succeeded_at'] = '2026-10-11 11:30:00'; + $this->assertFalse($method->invoke($command, $datasets, $mirror)[0]['full']); + $state['status'] = 'running'; + $state['run_mode'] = 'full'; + $this->assertTrue($method->invoke($command, $datasets, $mirror)[0]['full']); + $definitions = config('upstream_mirrors.datasets'); + $definitions[$dataset->key()]['full_reconcile_hours'] = 0; + config(['upstream_mirrors.datasets' => $definitions]); + $this->assertFalse($method->invoke($command, $datasets, $mirror)[0]['full']); + } finally { + Carbon::setTestNow(); + } + } +}