接入供应商管理页面与共享镜像 (#297)

This commit is contained in:
limeng 2026-10-11 12:11:15 +08:00
parent ad6d70a0a2
commit 665b8fafd2
14 changed files with 711 additions and 1 deletions

View File

@ -155,9 +155,18 @@ class SyncBusinessMirrors extends Command
if (($state['status'] ?? '') === 'failed' && ! empty($state['last_attempted_at']) if (($state['status'] ?? '') === 'failed' && ! empty($state['last_attempted_at'])
&& now()->diffInSeconds(\Illuminate\Support\Carbon::parse($state['last_attempted_at'])) < 300) continue; && 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[] = [ $scheduled[] = [
'key' => $key, 'key' => $key,
'full' => $initial || (bool) $this->option('full'), 'full' => $initial || $reconcile || $resumeFull || (bool) $this->option('full'),
'catchup' => $initial || ($state['status'] ?? '') === 'running', 'catchup' => $initial || ($state['status'] ?? '') === 'running',
'last_attempted_at' => (string) ($state['last_attempted_at'] ?? ''), 'last_attempted_at' => (string) ($state['last_attempted_at'] ?? ''),
]; ];

View File

@ -2398,6 +2398,7 @@ class Pages extends Controller
'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(), 'third-party-warehouse-storage' => $this->contentForThirdPartyWarehouseStorage(),
'board-deductions' => $this->contentForBoardDeductions(), 'board-deductions' => $this->contentForBoardDeductions(),
'supplier-payables-report' => $this->contentForSupplierPayablesReport(), 'supplier-payables-report' => $this->contentForSupplierPayablesReport(),
'supplier-management' => app(\Modules\EcommerceFinance\View\SupplierManagementPageData::class)->page(),
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(), 'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'online-daifa-price' => $this->contentForOnlineDaifaPrice(), 'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(), 'product-costs' => $this->contentForProductCosts(),

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\View\SupplierManagementPageData;
class SupplierManagement extends Controller
{
public function export(Request $request, SupplierManagementMirrorRepository $repository, SupplierManagementPageData $page)
{
abort_unless($repository->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']);
}
}

View File

@ -674,6 +674,7 @@ $navigation = [
'title' => '供应商应付', 'title' => '供应商应付',
'icon' => 'local_shipping', 'icon' => 'local_shipping',
'pages' => [ 'pages' => [
['slug' => 'supplier-management', 'title' => '供应商管理'],
['slug' => 'product-prices', 'title' => '商品价格'], ['slug' => 'product-prices', 'title' => '商品价格'],
['slug' => 'payment-reconciliation', 'title' => '货款对账'], ['slug' => 'payment-reconciliation', 'title' => '货款对账'],
['slug' => 'return-reconciliation', 'title' => '退货对账'], ['slug' => 'return-reconciliation', 'title' => '退货对账'],

View File

@ -203,6 +203,43 @@ return [
], ],
'datasets' => [ '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_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'], 'operations_replica.rpt.fys_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'fys_shopInfo', 'target' => 'ops_fys_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],

View File

@ -66,6 +66,7 @@
| 店铺收款账单旧来源(已停用) | `dbo.alipay_Bill_Detail` | | 店铺收款账单旧来源(已停用) | `dbo.alipay_Bill_Detail` |
| 营销账户 | `dbo.tmall_spread_bills`、`dbo.pdd_spread_bills`、`dbo.alibaba_bill`、`dbo.jd_spreadBills`、`dbo.jd_costBills` | | 营销账户 | `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` | | 供应商应付 | `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.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.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`,商品价格管理的进销存报表读取全仓库) | | 聚水潭进销存 | `dbo.jst_inoutWater`、`dbo.jst_inoutWaterItem`、`dbo.jst_inventoryInfo`(全部仓库、`io_date >= 2026-08-01`;专用稳定游标与完整代次镜像。三方仓页面仍固定 `wms_co_id = 15045364`,商品价格管理的进销存报表读取全仓库) |

View File

@ -0,0 +1,116 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
/** 工作平台供应商主档案,HTTP 请求仅访问共享镜像。 */
class SupplierManagementMirrorRepository
{
public const DATASET = 'jiansun.dbo.js_supplier_View';
public const RELATED = [
'contracts' => '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;
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Throwable;
class SupplierManagementPageData
{
public const TABS = ['contracts' => '合同信息', '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')]),
];
}
}

View File

@ -90,6 +90,13 @@
event.preventDefault(); event.preventDefault();
navigate(link.href); 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', () => { window.addEventListener('popstate', () => {
if (/\/workspace\/supplier-payables(-final)?\//.test(location.pathname)) navigate(location.href, false); if (/\/workspace\/supplier-payables(-final)?\//.test(location.pathname)) navigate(location.href, false);
}); });

View File

@ -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
<section class="supplier-management" data-supplier-management aria-label="供应商管理">
<style>
.supplier-management { color:var(--vip-content-ink,#171717); font-size:.8125rem; }
.supplier-management [hidden] { display:none !important; }
.supplier-management-toolbar { display:flex; align-items:flex-end; flex-wrap:wrap; gap:12px; padding:16px; margin-bottom:12px; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; background:var(--vip-surface,#fff); }
.supplier-management-field { display:flex; flex-direction:column; gap:6px; min-width:160px; }
.supplier-management-field label { font-size:.75rem; color:var(--vip-content-muted,#595959); }
.supplier-management-field input,.supplier-management-field select { height:34px; padding:0 10px; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; font:inherit; background:#fff; }
.supplier-management a:focus-visible,.supplier-management button:focus-visible,.supplier-management input:focus-visible,.supplier-management select:focus-visible { outline:2px solid var(--vip-content-accent-dark,#856632); outline-offset:2px; }
.supplier-management-actions { display:flex; flex-wrap:wrap; gap:8px; }
.supplier-management-button { display:inline-flex; gap:6px; align-items:center; justify-content:center; height:34px; padding:0 14px; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; background:#fff; color:var(--vip-content-ink,#171717); font:inherit; cursor:pointer; }
.supplier-management-button.is-primary { background:var(--vip-content-accent-dark,#856632); color:#fff; border-color:var(--vip-content-accent-dark,#856632); }
.supplier-management-button.is-primary:hover { background:#6A5129; }
.supplier-management-status { display:flex; flex-wrap:wrap; justify-content:space-between; gap:8px; margin:10px 0; color:var(--vip-content-muted,#595959); font-size:.75rem; }
.supplier-management-status time { font-variant-numeric:tabular-nums; }
.supplier-management-panel { border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; overflow:hidden; background:var(--vip-surface,#fff); }
.supplier-management-scroll { max-height:440px; overflow:auto; }
.supplier-management-table { width:100%; border-collapse:separate; border-spacing:0; font-size:.78125rem; font-variant-numeric:tabular-nums; }
.supplier-management-table th,.supplier-management-table td { padding:10px 12px; text-align:left; border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; }
.supplier-management-table th { position:sticky; top:0; background:var(--vip-content-accent-soft,#F7F0DE); z-index:1; font-weight:600; }
.supplier-management-table tr.is-selected td { background:var(--vip-content-accent-soft,#F7F0DE); }
.supplier-management-table tbody tr[data-supplier-select]:hover td { background:var(--vip-content-bg,#F8F6F1); cursor:pointer; }
.supplier-management-table a { color:var(--vip-content-accent-dark,#856632); text-decoration:underline; text-underline-offset:3px; }
.supplier-management-empty { height:120px; text-align:center !important; color:var(--vip-content-muted,#595959); white-space:normal !important; }
.supplier-management-badge { padding:3px 8px; border-radius:4px; background:var(--vip-content-bg,#F8F6F1); color:var(--vip-content-muted,#595959); }
.supplier-management-badge.is-cooperating { color:var(--vip-success,#53793E); }
.supplier-management-pagination { padding:12px; display:flex; align-items:center; flex-wrap:wrap; justify-content:space-between; gap:12px; }
.supplier-management-pagination nav { display:flex; align-items:center; gap:12px; }
.supplier-management-pagination a { color:var(--vip-content-accent-dark,#856632); }
.supplier-management-detail { margin-top:16px; }
.supplier-management-detail-heading { padding:12px 16px; border-bottom:1px solid var(--vip-border,#D8D2C7); color:var(--vip-content-muted,#595959); }
.supplier-management-detail-tabs { display:flex; gap:20px; padding:0 16px; border-bottom:1px solid var(--vip-border,#D8D2C7); }
.supplier-management-detail-tabs a { padding:10px 0; color:var(--vip-content-muted,#595959); border-bottom:2px solid transparent; font-size:.75rem; }
.supplier-management-detail-tabs a[aria-selected="true"] { color:var(--vip-content-accent-dark,#856632); border-color:var(--vip-content-accent,#B9954F); }
.supplier-management-alert { margin-bottom:12px; padding:12px; border:1px solid var(--vip-border,#D8D2C7); border-radius:8px; background:var(--vip-content-accent-soft,#F7F0DE); }
.supplier-management-alert.is-error { color:var(--vip-danger,#B80000); border-color:var(--vip-danger,#B80000); }
@media (max-width:640px) { .supplier-management-field { width:100%; } .supplier-management-detail-tabs { gap:12px; overflow-x:auto; white-space:nowrap; } }
</style>
<form method="GET" action="{{ $listUrl }}" class="supplier-management-toolbar" data-supplier-query aria-label="供应商管理筛选">
<div class="supplier-management-field">
<label for="supplier-management-name">供应商名称</label>
<input id="supplier-management-name" name="supplier_name" value="{{ $supplierFilters['supplier_name'] }}" placeholder="输入名称搜索" maxlength="191">
</div>
<div class="supplier-management-field">
<label for="supplier-management-buyer">采购人员</label>
<input id="supplier-management-buyer" name="buyer" value="{{ $supplierFilters['buyer'] }}" placeholder="输入采购人员" maxlength="191">
</div>
<div class="supplier-management-field">
<label for="supplier-management-status">合作状态</label>
<select id="supplier-management-status" name="cooperation_status">
<option value="">全部</option>
<option value="1" @selected($supplierFilters['cooperation_status'] === '1')>合作</option>
<option value="2" @selected($supplierFilters['cooperation_status'] === '2')>不合作</option>
</select>
</div>
<div class="supplier-management-actions">
<button type="submit" class="supplier-management-button is-primary">查询</button>
<a href="{{ $listUrl }}" class="supplier-management-button" data-supplier-local>重置</a>
@if ($supplierReady && !$supplierError)
<a class="supplier-management-button" href="{{ route('business.supplier-management.export', $supplierFilters) }}">导出</a>
@endif
</div>
</form>
<p role="status" aria-live="polite" data-supplier-load-status></p>
<div class="supplier-management-alert is-error" role="alert" data-supplier-load-error hidden>加载失败,当前内容已保留,请重新查询。</div>
@if ($supplierError)
<div class="supplier-management-alert is-error" role="alert">{{ $supplierError }}</div>
@elseif (!$supplierReady)
<div class="supplier-management-alert" role="status">供应商资料尚未完成首次同步,完成后会显示完整列表。</div>
@elseif (($supplierState['status'] ?? '') === 'failed')
<div class="supplier-management-alert" role="status">最近一次同步失败,当前显示上次同步成功的资料。</div>
@endif
<div class="supplier-management-status">
<span>共 {{ number_format($suppliers->total()) }} 家供应商</span>
<span>最近同步:<time>{{ $supplierState['last_succeeded_at'] ?? '尚未同步' }}</time></span>
</div>
<div class="supplier-management-panel">
<div class="supplier-management-scroll">
<table class="supplier-management-table" aria-label="供应商列表">
<thead><tr><th scope="col">序号</th>@foreach ($supplierColumns as $label)<th scope="col">{{ $label }}</th>@endforeach</tr></thead>
<tbody>
@forelse ($suppliers as $supplier)
@php $selectUrl = $localUrl(['supplier_id' => $supplier->Id]); @endphp
<tr data-supplier-select="{{ $selectUrl }}" @class(['is-selected' => $selectedSupplier && $selectedSupplier->Id == $supplier->Id])>
<td>{{ $suppliers->firstItem() + $loop->index }}</td>
@foreach ($supplierColumns as $key => $label)
<td>
@if ($key === 'coopersgn')
<span @class(['supplier-management-badge', 'is-cooperating' => (int) $supplier->coopersgn === 1])>{{ ['1' => '合作', '2' => '不合作'][(string) $supplier->coopersgn] ?? '未维护' }}</span>
@elseif ($key === 'scmname')
<a href="{{ $selectUrl }}" data-supplier-local @if ($selectedSupplier && $selectedSupplier->Id == $supplier->Id) aria-current="true" @endif>{{ $cell($supplier->{$key} ?? null) }}</a>
@else
{{ $cell($supplier->{$key} ?? null) }}
@endif
</td>
@endforeach
</tr>
@empty
<tr><td colspan="{{ count($supplierColumns) + 1 }}" class="supplier-management-empty">{{ $supplierError ? '资料读取失败,请稍后重试。' : ($supplierReady ? '没有符合筛选条件的供应商。' : '供应商资料同步未完成。') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div class="supplier-management-pagination">
<span>每页 {{ $suppliers->perPage() }} 条,共 {{ $suppliers->total() }} 条</span>
<nav aria-label="供应商分页">
@if ($suppliers->currentPage() > 1)<a href="{{ $suppliers->url(1) }}" data-supplier-local>首页</a><a href="{{ $suppliers->previousPageUrl() }}" data-supplier-local>上一页</a>@endif
<span>{{ $suppliers->currentPage() }} / {{ $suppliers->lastPage() }}</span>
@if ($suppliers->hasMorePages())<a href="{{ $suppliers->nextPageUrl() }}" data-supplier-local>下一页</a><a href="{{ $suppliers->url($suppliers->lastPage()) }}" data-supplier-local>末页</a>@endif
</nav>
</div>
</div>
<div class="supplier-management-panel supplier-management-detail">
<div class="supplier-management-detail-heading">{{ $selectedSupplier ? '当前供应商:' . $selectedSupplier->scmname . '(' . $selectedSupplier->scmcode . ')' : '选择供应商后查看相关资料' }}</div>
<nav class="supplier-management-detail-tabs" role="tablist" aria-label="供应商详情">
@foreach (\Modules\EcommerceFinance\View\SupplierManagementPageData::TABS as $key => $label)
<a id="supplier-detail-tab-{{ $key }}" href="{{ $localUrl(['detail_tab' => $key]) }}" data-supplier-local role="tab" aria-selected="{{ $supplierDetailTab === $key ? 'true' : 'false' }}" aria-controls="supplier-detail-panel">{{ $label }}</a>
@endforeach
</nav>
<div class="supplier-management-scroll" role="tabpanel" id="supplier-detail-panel" aria-labelledby="supplier-detail-tab-{{ $supplierDetailTab }}">
<table class="supplier-management-table" aria-label="{{ \Modules\EcommerceFinance\View\SupplierManagementPageData::TABS[$supplierDetailTab] }}">
<thead><tr>@foreach ($detailColumns as $label)<th scope="col">{{ $label }}</th>@endforeach</tr></thead>
<tbody>
@forelse ($supplierDetails as $detail)
<tr>@foreach ($detailColumns as $key => $label)<td>{{ $key === 'enable' ? ((int) ($detail->enable ?? 0) === 1 ? '启用' : '停用') : $cell($detail->{$key} ?? null) }}</td>@endforeach</tr>
@empty
<tr><td colspan="{{ count($detailColumns) }}" class="supplier-management-empty">{{ $supplierDetail['message'] ?? '该供应商暂无此类资料。' }}</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($supplierDetails->total() > 0)
<div class="supplier-management-pagination">
<span>共 {{ $supplierDetails->total() }} 条</span>
<nav aria-label="供应商详情分页">
@if ($supplierDetails->currentPage() > 1)<a href="{{ $supplierDetails->previousPageUrl() }}" data-supplier-local>上一页</a>@endif
<span>{{ $supplierDetails->currentPage() }} / {{ $supplierDetails->lastPage() }}</span>
@if ($supplierDetails->hasMorePages())<a href="{{ $supplierDetails->nextPageUrl() }}" data-supplier-local>下一页</a>@endif
</nav>
</div>
@endif
</div>
</section>
@push('body_end')
<script data-caiwu-replay>
(() => {
if (window.caiwuSupplierManagementBound) return;
window.caiwuSupplierManagementBound = true;
let pending = false;
async function load(href) {
const current = document.querySelector('[data-supplier-management]');
if (!current || pending) return;
pending = true;
current.setAttribute('aria-busy', 'true');
current.querySelector('[data-supplier-load-status]').textContent = '正在加载供应商资料…';
current.querySelector('[data-supplier-load-error]').hidden = true;
try {
const response = await fetch(href, {credentials:'same-origin',headers:{Accept:'text/html'}});
if (!response.ok) throw new Error('load');
const doc = new DOMParser().parseFromString(await response.text(),'text/html');
const next = doc.querySelector('[data-supplier-management]');
if (!next) throw new Error('session');
if (!current.isConnected) return;
const scroll = current.querySelector('.supplier-management-scroll');
const scrollLeft = scroll.scrollLeft;
const mounted = document.importNode(next,true);
current.replaceWith(mounted);
mounted.querySelector('.supplier-management-scroll').scrollLeft = scrollLeft;
history.pushState({},'',href);
window.dispatchEvent(new Event('caiwu:supplier-page-updated'));
} catch (error) {
current.querySelector('[data-supplier-load-error]').hidden = false;
} finally {
current.querySelector('[data-supplier-load-status]').textContent = '';
current.removeAttribute('aria-busy');
pending = false;
}
}
document.addEventListener('click', event => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const link = event.target.closest('[data-supplier-management] a[data-supplier-local]');
const row = event.target.closest('[data-supplier-select]');
if (!link && !row) return;
event.preventDefault();
load(link ? link.href : row.dataset.supplierSelect);
});
document.addEventListener('submit', event => {
const form = event.target.closest('[data-supplier-query]');
if (!form) return;
event.preventDefault();
load(form.action + '?' + new URLSearchParams(new FormData(form)).toString());
});
document.addEventListener('keydown', event => {
const tab = event.target.closest('[data-supplier-management] [role="tab"]');
if (!tab || !['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return;
const tabs = [...tab.parentElement.querySelectorAll('[role="tab"]')];
const index = tabs.indexOf(tab);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
event.preventDefault();
tabs[next].focus();
load(tabs[next].href);
});
})();
</script>
@endpush

View File

@ -446,6 +446,10 @@ Route::delete('workspace/trademark-management/store-report-data-logic/shops', 'B
->middleware('business.page:trademark-management,store-report-data-logic') ->middleware('business.page:trademark-management,store-report-data-logic')
->name('business.store-report-logic.shops.destroy'); ->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') Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show'); ->name('business.pages.show');

View File

@ -0,0 +1,74 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Tests\Feature\FeatureTestCase;
class SupplierManagementPageTest extends FeatureTestCase
{
private function repository(bool $ready): \Mockery\MockInterface
{
$this->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();
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Tests\TestCase;
class SupplierManagementMirrorRepositoryTest extends TestCase
{
private SupplierManagementMirrorRepository $repository;
protected function setUp(): void
{
parent::setUp();
config(['database.connections.business_mirror' => ['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);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use App\Console\Commands\SyncBusinessMirrors;
use Illuminate\Support\Carbon;
use Mockery;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Tests\TestCase;
class SupplierManagementSyncScheduleTest extends TestCase
{
public function test_supplier_reconciliation_refreshes_old_rows_and_resumes_partial_full_runs_only_when_opted_in(): void
{
Carbon::setTestNow('2026-10-11 12:00:00');
try {
$registry = new BusinessMirrorDatasetRegistry();
$dataset = $registry->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();
}
}
}