Compare commits

...

3 Commits

12 changed files with 989 additions and 57 deletions

View File

@ -0,0 +1,169 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* Import the platform-specific receiving-account workbook. The workbook is a
* template in many environments, so rows without a shop/account identifier
* are intentionally ignored rather than creating blank records.
*/
class StoreReceivingAccountSpreadsheetImporter
{
/** @return array{created:int,updated:int,cleared:int} */
public function import(UploadedFile $file): array
{
$spreadsheet = IOFactory::load($file->getRealPath());
$rows = [];
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
$sheetRows = $worksheet->toArray(null, true, false, false);
$header = $this->findHeader($sheetRows);
if ($header === null) {
continue;
}
[$headerRow, $indexes] = $header;
$sheet = $this->sheetRows($sheetRows, $headerRow, $indexes, $worksheet->getTitle());
$rows = array_merge($rows, $sheet);
}
$created = 0;
$updated = 0;
$cleared = 0;
$companyId = company_id() ?: 1;
DB::transaction(function () use ($rows, $companyId, &$created, &$updated, &$cleared): void {
// The user requested a clean re-import. Only local receiving
// accounts are removed; upstream mirror tables are never touched.
$cleared = StoreReceivingAccount::withTrashed()
->where('company_id', $companyId)
->forceDelete();
foreach ($rows as $data) {
$data['company_id'] = $companyId;
StoreReceivingAccount::create($data);
$created++;
}
});
return compact('created', 'updated', 'cleared');
}
/** @param array<int,array<int,mixed>> $rows */
private function findHeader(array $rows): ?array
{
foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) {
$indexes = [];
foreach ($row as $index => $heading) {
$key = $this->normalize($heading);
if ($key !== '') {
$indexes[$key][] = $index;
}
}
$hasShopColumn = isset($indexes['erp店铺名称']) || isset($indexes['店铺名称']);
$hasPlatformColumn = isset($indexes['类型']);
if ($hasShopColumn && ($hasPlatformColumn || isset($indexes['店铺状态']))) {
return [$rowIndex, $this->headerIndexes($row)];
}
}
return null;
}
/** @param array<int,mixed> $header @return array<string,list<int>> */
private function headerIndexes(array $header): array
{
$result = [];
foreach ($header as $index => $heading) {
$key = $this->normalize($heading);
if ($key !== '') {
$result[$key][] = $index;
}
}
return $result;
}
/** @param array<int,array<int,mixed>> $rows @param array<string,list<int>> $indexes @return list<array<string,mixed>> */
private function sheetRows(array $rows, int $headerRow, array $indexes, string $sheetName): array
{
$result = [];
foreach (array_slice($rows, $headerRow + 1) as $row) {
$type = $this->value($row, $indexes, '类型');
$data = [
'store_platform' => $type !== '' ? $type : $this->platformFromSheet($sheetName),
'store_type' => $type !== '' ? $type : $this->platformFromSheet($sheetName),
'erp_store_name' => $this->value($row, $indexes, 'erp店铺名称'),
'store_name' => $this->value($row, $indexes, '店铺名称'),
'store_id' => $this->value($row, $indexes, '店铺id'),
'store_status' => $this->value($row, $indexes, '店铺状态'),
'current_company_name' => $this->value($row, $indexes, '当前公司名称'),
'original_company_subject' => $this->value($row, $indexes, '原公司主体'),
'account_status' => $this->value($row, $indexes, '状态'),
'login_account' => $this->value($row, $indexes, '登录账号'),
'login_password' => $this->value($row, $indexes, '登录密码'),
'payment_password' => $this->value($row, $indexes, '支付密码'),
'payment_verification_method' => $this->value($row, $indexes, '支付验证方式', 0),
'financial_account' => $this->value($row, $indexes, '金融账号'),
'financial_password' => $this->value($row, $indexes, '金融密码'),
'financial_payment_verification_method' => $this->value($row, $indexes, '支付验证方式', 1),
'withdrawal_path' => $this->value($row, $indexes, '提现路径'),
'withdrawal_account_name' => $this->value($row, $indexes, '提现账户名称'),
'alipay_account' => $this->value($row, $indexes, '支付宝账号'),
'bank_account_last_four' => $this->value($row, $indexes, '银行卡账号后四位'),
];
// Type is a template enumerator, not a real account row. Require a
// concrete shop/account identifier before importing it.
if ($data['erp_store_name'] === '' && $data['store_name'] === ''
&& $data['store_id'] === '' && $data['login_account'] === '') {
continue;
}
if ($data['store_platform'] === '') {
continue;
}
$data['store_name'] = $data['store_name'] !== ''
? $data['store_name']
: $data['erp_store_name'];
$data['store_entity_name'] = $data['current_company_name'] !== ''
? $data['current_company_name']
: ($data['original_company_subject'] !== '' ? $data['original_company_subject'] : '');
$data['receiving_method'] = $data['withdrawal_path'] !== '' ? $data['withdrawal_path'] : '银行卡';
$data['receiving_account_name'] = $data['withdrawal_account_name'];
$data['alipay_or_bank_account'] = $data['alipay_account'] !== ''
? $data['alipay_account']
: $data['bank_account_last_four'];
$data['opening_bank'] = null;
$result[] = array_map(static fn ($value) => $value === '' ? null : $value, $data);
}
return $result;
}
/** @param array<int,mixed> $row @param array<string,list<int>> $indexes */
private function value(array $row, array $indexes, string $header, int $occurrence = 0): string
{
$index = $indexes[$this->normalize($header)][$occurrence] ?? null;
return $index === null ? '' : trim((string) ($row[$index] ?? ''));
}
private function platformFromSheet(string $sheet): string
{
return match (trim($sheet)) {
'京东' => '京东',
'抖音小店' => '抖音',
'拼多多' => '拼多多',
default => '淘宝',
};
}
private function normalize(mixed $value): string
{
return mb_strtolower((string) preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
}
}

View File

@ -179,7 +179,8 @@ class TableRegistry
$map = self::routeMap();
// For store-receiving-accounts tabs, we route by platform (tab slug).
// Receiving-account tabs share one local model and use platform
// groups defined by the approved four-sheet workbook.
if ($key === 'fund-management/store-receiving-accounts') {
return StoreReceivingAccount::class;
}
@ -208,6 +209,15 @@ class TableRegistry
? $instance->getFillable()
: $instance->getConnection()->getSchemaBuilder()->getColumnListing($instance->getTable());
if ($section === 'fund-management' && $page === 'store-receiving-accounts') {
$columns = method_exists($modelClass, 'columnsForTab')
? $modelClass::columnsForTab($tab)
: $columns;
if ($tab === 'jd') {
$labels['financial_payment_verification_method'] = '支付验证方式';
}
}
// Drop meta columns from the rendered table.
$visible = array_values(array_filter($columns, function (string $col) {
return ! in_array($col, ['id', 'company_id', 'created_at', 'updated_at', 'deleted_at'], true);
@ -327,21 +337,27 @@ class TableRegistry
*/
public static function tabValue(string $section, string $page, string $tabSlug): ?string
{
$key = "{$section}/{$page}";
return self::tabValues($section, $page, $tabSlug)[0] ?? null;
}
if ($key === 'fund-management/store-receiving-accounts') {
return match ($tabSlug) {
'taobao-alipay' => '淘宝',
'taobao-wechat' => '淘宝',
'pinduoduo' => '拼多多',
'jd-wallet' => '京东',
'douyin' => '抖音',
'dewu' => '得物',
'vipshop' => '唯品会',
default => null,
};
/**
* Resolve a receiving-account tab to all platform types shown on it.
* 淘宝页签同时承载淘系和唯品会,京东页签同时承载京东和京喜。
*
* @return list<string>
*/
public static function tabValues(string $section, string $page, string $tabSlug): array
{
if ("{$section}/{$page}" !== 'fund-management/store-receiving-accounts') {
return [];
}
return null;
return match ($tabSlug) {
'taobao' => ['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会'],
'pinduoduo' => ['拼多多'],
'jd' => ['京东', '京喜'],
'douyin' => ['抖音'],
default => [],
};
}
}

View File

@ -42,6 +42,50 @@ class UpstreamBusinessInformation
);
}
/**
* Return the ERP shops for a receiving-account tab's platform.
*
* Receiving-account pages are local CRUD pages, but their shop and company
* names come from the same read-only business-information mirror as the
* shop-information page. Keeping this lookup here prevents the controller
* from constructing ad-hoc upstream SQL and gives it the normal mirror /
* cached-snapshot fallback behavior.
*
* @return array<int,array<string,mixed>>
*/
public function storesByPlatform(string $platform, int $limit = 100): array
{
return $this->storesByPlatforms([$platform], $limit);
}
/** @param list<string> $platforms */
public function storesByPlatforms(array $platforms, int $limit = 100): array
{
$platforms = array_values(array_filter(array_map('trim', $platforms), static fn (string $value): bool => $value !== ''));
if ($platforms === []) {
return [];
}
$limit = min(max($limit, 1), 100);
// Do not let an account page wait for an upstream connection while the
// shared business mirror is still being built. Once the dataset is
// ready, the same call reads the mirror through the normal repository;
// until then the caller keeps its local account rows visible.
try {
if (! $this->mirror->ready('store-information.read')) {
return [];
}
} catch (\Throwable) {
return [];
}
return array_map(
fn (array $row): array => $this->mapStore($row),
$this->mirror->storesByPlatforms($platforms, $limit),
);
}
/**
* 将已登记的经营分析副本库查询还原成不经过页面缓存的固定只读查询。
*

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Business;
use App\Business\BusinessTableImporter;
use App\Business\StoreReceivingAccountSpreadsheetImporter;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Models\Banking\MarketingAccountBill;
@ -133,6 +134,17 @@ class Pages extends Controller
$tabs = collect($pageConfig['tabs'] ?? []);
if ($tabs->isEmpty()) {
if ($tab !== null
&& $section === 'fund-management'
&& $page === 'store-receiving-accounts'
) {
return redirect()->route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => $section,
'page' => $page,
]);
}
abort_if($tab !== null, 404);
return view('business.pages.show', $this->viewData(
@ -150,6 +162,18 @@ class Pages extends Controller
$activeTab = $tabs->firstWhere('slug', $tab);
if (empty($activeTab)
&& $section === 'fund-management'
&& $page === 'store-receiving-accounts'
) {
return redirect()->route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => $section,
'page' => $page,
'tab' => 'taobao',
]);
}
abort_if(empty($activeTab), 404);
return view('business.pages.show', $this->viewData(
@ -225,7 +249,10 @@ class Pages extends Controller
$tabValue = TableRegistry::tabValue($section['slug'], $page['slug'], $activeTab['slug']);
if ($tabValue !== null) {
$query->where($descriptor->tabFilterColumn, $tabValue);
$tabValues = TableRegistry::tabValues($section['slug'], $page['slug'], $activeTab['slug']);
$tabValues !== []
? $query->whereIn($descriptor->tabFilterColumn, $tabValues)
: $query->where($descriptor->tabFilterColumn, $tabValue);
}
$tabColumn = $descriptor->tabFilterColumn;
@ -235,7 +262,18 @@ class Pages extends Controller
$query->where($column, $value);
}
$rows = $query->orderByDesc('id')->paginate(50)->withQueryString();
if ($section['slug'] === 'fund-management'
&& $page['slug'] === 'store-receiving-accounts'
&& $tabValue !== null
) {
$rows = $this->receivingAccountRows(
$query,
TableRegistry::tabValues($section['slug'], $page['slug'], $activeTab['slug']),
$this->receivingAccountFilters(request()),
);
} else {
$rows = $query->orderByDesc('id')->paginate(50)->withQueryString();
}
}
}
@ -250,6 +288,9 @@ class Pages extends Controller
'upstreamError' => $upstreamError,
'tabColumn' => $tabColumn,
'tabValue' => $tabValue,
'receivingFilters' => ($section['slug'] === 'fund-management' && ($page['slug'] ?? null) === 'store-receiving-accounts')
? $this->receivingAccountFilters(request())
: [],
'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null,
'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null,
'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null,
@ -259,6 +300,174 @@ class Pages extends Controller
];
}
/**
* Merge local account details with the ERP shop/company master data for
* the active platform tab. The ERP rows are deliberately represented as
* synthetic rows until an account is created, so the page can show every
* matching shop without writing upstream data into the local account table.
*/
/** @param list<string> $platforms */
private function receivingAccountRows($query, array $platforms, array $filters = [], ?int $pageSize = null, ?int $page = null): LengthAwarePaginator
{
$this->applyReceivingAccountFilters($query, $filters);
$localRows = $query->orderByDesc('id')->get();
$upstreamRows = [];
try {
$upstream = app(UpstreamBusinessInformation::class);
$upstreamRows = count($platforms) === 1
? $upstream->storesByPlatform($platforms[0], 100)
: $upstream->storesByPlatforms($platforms, 1000);
} catch (Throwable $exception) {
// The local account list remains usable when the mirror/snapshot is
// temporarily unavailable. Do not turn an account page into a 500.
report($exception);
}
$accountsByStore = [];
foreach ($localRows as $account) {
$key = $this->receivingAccountStoreKey($account->erp_store_name ?? $account->store_name ?? null);
if ($key !== '') {
$accountsByStore[$key] ??= $account;
}
}
$usedAccountIds = [];
$merged = [];
foreach ($upstreamRows as $store) {
$storeName = trim((string) ($store['erp_store_name'] ?? ''));
if ($storeName === '') {
continue;
}
if (! $this->matchesReceivingAccountFilters($store, $filters)) {
continue;
}
$key = $this->receivingAccountStoreKey($storeName);
$account = $accountsByStore[$key] ?? null;
if ($account !== null) {
$account->store_platform = $store['platform'] ?? ($platforms[0] ?? null);
$account->store_type = $store['platform'] ?? ($account->store_type ?? null);
$account->erp_store_name = $storeName;
$account->store_name = $storeName;
$account->store_status = $store['store_status'] ?? ($account->store_status ?? null);
if (trim((string) ($store['company_name'] ?? '')) !== '') {
$account->current_company_name = $store['company_name'];
$account->store_entity_name = $store['company_name'];
}
$usedAccountIds[(string) $account->getKey()] = true;
$merged[] = $account;
continue;
}
$merged[] = (object) [
'id' => null,
'store_platform' => $store['platform'] ?? ($platforms[0] ?? null),
'store_type' => $store['platform'] ?? ($platforms[0] ?? null),
'erp_store_name' => $storeName,
'store_name' => null,
'store_id' => null,
'store_status' => $store['store_status'] ?? null,
'current_company_name' => $store['company_name'] ?? null,
'original_company_subject' => null,
'account_status' => null,
'login_account' => null,
'login_password' => null,
'payment_password' => null,
'payment_verification_method' => null,
'financial_account' => null,
'financial_password' => null,
'financial_payment_verification_method' => null,
'withdrawal_path' => null,
'withdrawal_account_name' => null,
'alipay_account' => null,
'bank_account_last_four' => null,
// Legacy aliases are kept populated for any old view code.
'wallet_number' => null,
'store_entity_name' => $store['company_name'] ?? null,
'receiving_method' => null,
'receiving_account_name' => null,
'alipay_or_bank_account' => null,
'opening_bank' => null,
'is_upstream_only' => true,
];
}
// Keep manually maintained local accounts visible even if the ERP
// master has not returned a matching shop yet.
foreach ($localRows as $account) {
if (! isset($usedAccountIds[(string) $account->getKey()])) {
$merged[] = $account;
}
}
$currentPage = max($page ?? (int) request('page', 1), 1);
$perPage = max($pageSize ?? count($merged), 1);
return new LengthAwarePaginator(
Collection::make($merged)->forPage($currentPage, $perPage)->values(),
count($merged),
$perPage,
$currentPage,
[
'path' => request()->url(),
'query' => request()->query(),
],
);
}
/** @return array<string,string> */
private function receivingAccountFilters(Request $request): array
{
$filters = [];
foreach (['erp_store_name', 'store_name', 'company_name', 'store_status', 'account_status'] as $key) {
$value = trim((string) $request->query($key, ''));
if ($value !== '') {
$filters[$key] = mb_substr($value, 0, 100);
}
}
return $filters;
}
private function applyReceivingAccountFilters($query, array $filters): void
{
foreach ($filters as $key => $value) {
if ($key === 'company_name') {
$query->where(function ($nested) use ($value): void {
$nested->where('current_company_name', 'like', "%{$value}%")
->orWhere('store_entity_name', 'like', "%{$value}%");
});
continue;
}
$query->where($key, 'like', "%{$value}%");
}
}
private function matchesReceivingAccountFilters(array $row, array $filters): bool
{
$company = (string) ($row['company_name'] ?? '');
$values = [
'erp_store_name' => (string) ($row['erp_store_name'] ?? ''),
'store_name' => (string) ($row['erp_store_name'] ?? ''),
'company_name' => $company,
'store_status' => (string) ($row['store_status'] ?? ''),
'account_status' => '',
];
foreach ($filters as $key => $needle) {
if (! isset($values[$key]) || ! str_contains(mb_strtolower($values[$key]), mb_strtolower($needle))) {
return false;
}
}
return true;
}
private function receivingAccountStoreKey(mixed $value): string
{
return mb_strtolower(trim((string) $value));
}
/** @return array<string,string> */
protected function agreementCourierAccounts(?string $page): array
{
@ -665,6 +874,10 @@ class Pages extends Controller
public function export(Request $request, string $section, string $page, string $tab)
{
if ($section === 'fund-management' && $page === 'store-receiving-accounts') {
return $this->exportReceivingAccounts($request, $tab);
}
abort_unless($section === 'billing-transactions', 404);
$sectionConfig = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', $section);
@ -745,6 +958,74 @@ class Pages extends Controller
]);
}
public function exportReceivingAccounts(Request $request, string $tab)
{
abort_unless(in_array($tab, ['taobao', 'jd', 'douyin', 'pinduoduo'], true), 404);
$platforms = TableRegistry::tabValues('fund-management', 'store-receiving-accounts', $tab);
$descriptor = TableRegistry::describe('fund-management', 'store-receiving-accounts', $tab);
abort_if(empty($descriptor), 404);
$query = ($descriptor->model)::query();
if ($descriptor->tabFilterColumn && $platforms !== []) {
$query->whereIn($descriptor->tabFilterColumn, $platforms);
}
foreach (TableRegistry::fixedFilters('fund-management', 'store-receiving-accounts') as $column => $value) {
$query->where($column, $value);
}
$rows = $this->receivingAccountRows(
$query,
$platforms,
$this->receivingAccountFilters($request),
null,
1,
);
$labels = \App\Models\Business\StoreReceivingAccount::columnLabels();
$columns = \App\Models\Business\StoreReceivingAccount::columnsForTab($tab);
$filename = '店铺账号密码_' . $tab . '_' . now()->format('Ymd_His') . '.xlsx';
return response()->streamDownload(static function () use ($rows, $columns, $labels): void {
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('店铺账号');
foreach ($columns as $index => $column) {
$cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1);
$sheet->setCellValueExplicit(
$cell . '1',
$labels[$column] ?? $column,
\PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING,
);
$sheet->getColumnDimension($cell)->setAutoSize(true);
}
foreach ($rows as $rowIndex => $row) {
foreach ($columns as $columnIndex => $column) {
$cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnIndex + 1)
. ($rowIndex + 2);
$value = $row->{$column} ?? '';
$sheet->setCellValueExplicit(
$cell,
(string) $value,
\PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING,
);
}
}
$lastColumn = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($columns));
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:{$lastColumn}1");
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
private function exportPlatformPromotionAccounts(Request $request)
{
$month = trim((string) $request->query('month', ''));
@ -2252,8 +2533,16 @@ class Pages extends Controller
$data = $request->validate($this->validationRules($descriptor));
if ($section === 'fund-management' && $page === 'store-receiving-accounts') {
$data['company_id'] = company_id() ?: 1;
$data['store_platform'] = trim((string) ($data['store_type'] ?? ''))
?: TableRegistry::tabValue($section, $page, $tab ?? '');
$data['store_name'] = $data['store_name'] ?? ($data['erp_store_name'] ?? '');
$data['store_entity_name'] = $data['current_company_name'] ?? '';
}
if ($descriptor->tabFilterColumn && $tab && ($tabValue = TableRegistry::tabValue($section, $page, $tab))) {
$data[$descriptor->tabFilterColumn] = $tabValue;
$data[$descriptor->tabFilterColumn] = trim((string) ($data['store_type'] ?? '')) ?: $tabValue;
}
foreach (TableRegistry::fixedFilters($section, $page) as $column => $value) {
@ -2268,6 +2557,7 @@ class Pages extends Controller
public function import(
Request $request,
BusinessTableImporter $importer,
StoreReceivingAccountSpreadsheetImporter $receivingImporter,
string $section,
string $page,
?string $tab = null,
@ -2279,14 +2569,63 @@ class Pages extends Controller
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
$result = $importer->import($request->file('import'), $descriptor, $section, $page, $tab);
$result = ($section === 'fund-management' && $page === 'store-receiving-accounts')
? $receivingImporter->import($request->file('import'))
: $importer->import($request->file('import'), $descriptor, $section, $page, $tab);
return $this->redirectBack($section, $page, $tab)->with(
'success',
"导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。",
($section === 'fund-management' && $page === 'store-receiving-accounts'
? "导入完成:已清理 {$result['cleared']} 条旧记录,新增 {$result['created']} 条。"
: "导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。"),
);
}
public function downloadReceivingAccountTemplate()
{
$labels = \App\Models\Business\StoreReceivingAccount::columnLabels();
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheets = ['taobao' => '淘宝', 'jd' => '京东', 'douyin' => '抖音小店', 'pinduoduo' => '拼多多'];
foreach ($sheets as $index => $sheetName) {
$sheet = $index === 'taobao'
? $spreadsheet->getActiveSheet()
: $spreadsheet->createSheet();
$sheet->setTitle($sheetName);
$fields = \App\Models\Business\StoreReceivingAccount::columnsForTab($index);
$headers = array_map(static fn (string $field): string => $labels[$field] ?? $field, $fields);
if ($index === 'jd') {
$headers[array_search('financial_payment_verification_method', $fields, true)] = '支付验证方式';
}
foreach ($headers as $column => $header) {
$cell = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($column + 1) . '1';
$sheet->setCellValue($cell, $header);
$sheet->getColumnDimensionByColumn($column + 1)->setWidth(max(14, mb_strwidth($header) + 4));
}
$lastColumn = $sheet->getHighestColumn();
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:{$lastColumn}1");
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => '8A6A2F'],
],
'alignment' => ['horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER],
]);
}
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
$filename = '店铺账号密码导入模板.xlsx';
return response()->streamDownload(static function () use ($writer): void {
$writer->save('php://output');
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
public function update(Request $request, string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);

View File

@ -8,7 +8,16 @@ class StoreReceivingAccount extends Model
{
protected $table = 'business_store_receiving_accounts';
protected $fillable = ['company_id', 'store_platform', 'store_name', 'wallet_number', 'store_entity_name', 'login_account', 'login_password', 'receiving_method', 'receiving_account_name', 'alipay_or_bank_account', 'opening_bank'];
protected $fillable = [
'company_id', 'store_platform', 'store_type', 'erp_store_name', 'store_name', 'store_id',
'store_status', 'current_company_name', 'original_company_subject', 'account_status',
'login_account', 'login_password', 'payment_password', 'payment_verification_method',
'financial_account', 'financial_password', 'financial_payment_verification_method',
'withdrawal_path', 'withdrawal_account_name', 'alipay_account', 'bank_account_last_four',
// Legacy columns remain fillable for backward-compatible records.
'wallet_number', 'store_entity_name', 'receiving_method', 'receiving_account_name',
'alipay_or_bank_account', 'opening_bank',
];
protected $casts = [
'company_id' => 'integer',
@ -17,16 +26,55 @@ class StoreReceivingAccount extends Model
public static function columnLabels(): array
{
return [
'store_platform' => '店铺平台',
'store_type' => '类型',
'erp_store_name' => 'ERP店铺名称',
'store_name' => '店铺名称',
'wallet_number' => '钱包号',
'store_entity_name' => '店铺主体名称',
'store_id' => '店铺id',
'store_status' => '店铺状态',
'current_company_name' => '当前公司名称',
'original_company_subject' => '原公司主体',
'account_status' => '状态',
'login_account' => '登录账号',
'login_password' => '登录密码',
'receiving_method' => '收款方式',
'receiving_account_name' => '收款账户名称',
'alipay_or_bank_account' => '支付宝或银行卡账号',
'opening_bank' => '开户行',
'payment_password' => '支付密码',
'payment_verification_method' => '支付验证方式',
'financial_account' => '金融账号',
'financial_password' => '金融密码',
'financial_payment_verification_method' => '金融支付验证方式',
'withdrawal_path' => '提现路径',
'withdrawal_account_name' => '提现账户名称',
'alipay_account' => '支付宝账号',
'bank_account_last_four' => '银行卡账号(后四位)',
];
}
/** @return list<string> */
public static function columnsForTab(?string $tab): array
{
$taobao = [
'store_type', 'erp_store_name', 'store_name', 'store_status',
'current_company_name', 'original_company_subject', 'account_status',
'login_account', 'login_password', 'payment_password', 'withdrawal_path',
'withdrawal_account_name', 'alipay_account',
];
$jd = [
'store_type', 'erp_store_name', 'store_name', 'store_id', 'store_status',
'current_company_name', 'original_company_subject', 'login_account',
'login_password', 'payment_verification_method', 'financial_account',
'financial_password', 'financial_payment_verification_method', 'account_status',
'withdrawal_path', 'withdrawal_account_name', 'bank_account_last_four',
];
$common = [
'erp_store_name', 'store_name', 'store_status', 'current_company_name',
'original_company_subject', 'account_status', 'login_account', 'login_password',
'payment_password', 'withdrawal_path', 'withdrawal_account_name', 'bank_account_last_four',
];
return match ($tab) {
'taobao' => $taobao,
'jd' => $jd,
'douyin', 'pinduoduo' => $common,
default => $taobao,
};
}
}

View File

@ -252,13 +252,10 @@ return [
'slug' => 'store-receiving-accounts',
'title' => '店铺收款账户',
'tabs' => [
['slug' => 'taobao-alipay', 'title' => '淘系支付宝账户', 'icon' => 'account_balance'],
['slug' => 'taobao-wechat', 'title' => '淘系微信账户', 'icon' => 'chat'],
['slug' => 'pinduoduo', 'title' => '拼多多账户', 'icon' => 'shopping_bag'],
['slug' => 'jd-wallet', 'title' => '京东网银钱包账户', 'icon' => 'account_balance_wallet'],
['slug' => 'douyin', 'title' => '抖音账户', 'icon' => 'smart_display'],
['slug' => 'dewu', 'title' => '得物账户', 'icon' => 'diamond'],
['slug' => 'vipshop', 'title' => '唯品会账户', 'icon' => 'local_mall'],
['slug' => 'taobao', 'title' => '淘宝', 'icon' => 'shopping_bag'],
['slug' => 'jd', 'title' => '京东', 'icon' => 'storefront'],
['slug' => 'douyin', 'title' => '抖音小店', 'icon' => 'smart_display'],
['slug' => 'pinduoduo', 'title' => '拼多多', 'icon' => 'local_mall'],
],
],
['slug' => 'bank-accounts', 'title' => '银行账户'],

View File

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('business_store_receiving_accounts', function (Blueprint $table): void {
$table->string('store_type')->nullable()->after('store_platform');
$table->string('erp_store_name')->nullable()->after('store_type');
$table->string('store_id')->nullable()->after('store_name');
$table->string('store_status')->nullable()->after('store_id');
$table->string('current_company_name')->nullable()->after('store_status');
$table->string('original_company_subject')->nullable()->after('current_company_name');
$table->string('account_status')->nullable()->after('original_company_subject');
$table->string('payment_password')->nullable()->after('login_password');
$table->string('payment_verification_method')->nullable()->after('payment_password');
$table->string('financial_account')->nullable()->after('payment_verification_method');
$table->string('financial_password')->nullable()->after('financial_account');
$table->string('financial_payment_verification_method')->nullable()->after('financial_password');
$table->string('withdrawal_path')->nullable()->after('financial_payment_verification_method');
$table->string('withdrawal_account_name')->nullable()->after('withdrawal_path');
$table->string('alipay_account')->nullable()->after('withdrawal_account_name');
$table->string('bank_account_last_four')->nullable()->after('alipay_account');
});
}
public function down(): void
{
Schema::table('business_store_receiving_accounts', function (Blueprint $table): void {
$table->dropColumn([
'store_type', 'erp_store_name', 'store_id', 'store_status', 'current_company_name',
'original_company_subject', 'account_status', 'payment_password',
'payment_verification_method', 'financial_account', 'financial_password',
'financial_payment_verification_method', 'withdrawal_path', 'withdrawal_account_name',
'alipay_account', 'bank_account_last_four',
]);
});
}
};

View File

@ -48,6 +48,40 @@ class BusinessInformationMirrorRepository
};
}
/**
* Read a bounded set of ERP shops for one platform from the shared mirror.
* Filtering is performed in the mirror query so a platform is not limited
* to whichever rows happen to be on the first global page.
*
* @return array<int,array<string,mixed>>
*/
public function storesByPlatform(string $platform, int $limit = 100): array
{
return $this->storesByPlatforms([$platform], $limit);
}
/** @param list<string> $platforms */
public function storesByPlatforms(array $platforms, int $limit = 100): array
{
if (! $this->ready('store-information.read')) {
return [];
}
$platforms = array_values(array_filter(array_map('trim', $platforms), static fn (string $value): bool => $value !== ''));
if ($platforms === []) {
return [];
}
$limit = min(max($limit, 1), 1000);
return $this->storesQuery()
->whereIn('s.platform', $platforms)
->orderByDesc('s.Id')
->limit($limit)
->get()
->map(static fn (object $row): array => (array) $row)
->all();
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function companies(int $page, int $perPage): array
{
@ -75,6 +109,11 @@ SQL);
/** @return array{data: array<int,array<string,mixed>>, total: int} */
private function stores(int $page, int $perPage): array
{
return $this->page($this->storesQuery(), $page, $perPage, 's.Id');
}
private function storesQuery()
{
$latestCompanies = $this->connection->table('ops_fys_company_info')
->selectRaw('unifiedSocialCreditCode, MAX(Id) AS latest_id')
@ -82,7 +121,7 @@ SQL);
->where('unifiedSocialCreditCode', '<>', '')
->groupBy('unifiedSocialCreditCode');
$query = $this->connection->table('ops_fys_shop_info AS s')
return $this->connection->table('ops_fys_shop_info AS s')
->leftJoinSub($latestCompanies, 'company_keys', function ($join): void {
$join->on('company_keys.unifiedSocialCreditCode', '=', 's.unifiedSocialCreditCode');
})
@ -102,8 +141,6 @@ s.shopState AS store_status,
s.shopType AS store_type,
s.channelType AS channel_mode
SQL);
return $this->page($query, $page, $perPage, 's.Id');
}
/** @return array{data: array<int,array<string,mixed>>, total: int} */

View File

@ -139,6 +139,13 @@
font-size: 0.75rem;
}
.biz-filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.biz-card + .biz-card {
margin-top: 1.25rem;
}
@ -162,6 +169,7 @@
.biz-card-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
@ -345,6 +353,24 @@
outline: 2px solid rgba(99, 102, 241, 0.25);
}
input.biz-filter-input,
select.biz-filter-input {
width: 8.25rem;
min-width: 0;
padding: 0.4rem 0.55rem;
font-size: 0.8125rem;
}
.biz-import-help {
margin: 0.75rem 0 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background: #f9fafb;
color: #6b7280;
font-size: 0.8125rem;
line-height: 1.6;
}
.biz-modal-foot {
display: flex;
justify-content: flex-end;
@ -496,6 +522,40 @@
</div>
@unless ($descriptor->readOnly)
<div class="biz-card-actions">
@if (($section['slug'] ?? null) === 'fund-management' && ($page['slug'] ?? null) === 'store-receiving-accounts')
<form method="GET" action="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'tab' => $activeTab['slug'] ?? null]) }}" class="biz-filter-form">
@foreach (['erp_store_name' => 'ERP店铺名称', 'company_name' => '公司名称'] as $filterKey => $filterLabel)
<input class="biz-field-input biz-filter-input" type="text" name="{{ $filterKey }}" value="{{ $receivingFilters[$filterKey] ?? '' }}" placeholder="{{ $filterLabel }}">
@endforeach
<select class="biz-field-select biz-filter-input" name="store_status" aria-label="店铺状态">
<option value="">全部店铺状态</option>
@foreach (['正常', '关店中', '已关店'] as $status)
<option value="{{ $status }}" @selected(($receivingFilters['store_status'] ?? '') === $status)>{{ $status }}</option>
@endforeach
</select>
<select class="biz-field-select biz-filter-input" name="account_status" aria-label="维护状态">
<option value="">全部维护状态</option>
@foreach (['已维护', '待维护'] as $status)
<option value="{{ $status }}" @selected(($receivingFilters['account_status'] ?? '') === $status)>{{ $status }}</option>
@endforeach
</select>
<button type="submit" class="biz-btn biz-btn-ghost" title="筛选">
<span class="material-icons-outlined" style="font-size:1.1rem">filter_alt</span>
<span>筛选</span>
</button>
@if (!empty($receivingFilters))
<a class="biz-btn biz-btn-ghost" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug'], 'tab' => $activeTab['slug'] ?? 'taobao']) }}">重置</a>
@endif
</form>
<button type="button" class="biz-btn biz-btn-ghost" onclick="bizOpenImportModal()" title="上传表格或下载模板">
<span class="material-icons-outlined" style="font-size:1.1rem">upload_file</span>
<span>导入</span>
</button>
<a class="biz-btn biz-btn-ghost" href="{{ route('business.pages.receiving-accounts-export', ['tab' => $activeTab['slug'] ?? 'taobao'] + ($receivingFilters ?? [])) }}" title="导出当前平台的全部筛选结果,包含页面显示的密码字段">
<span class="material-icons-outlined" style="font-size:1.1rem">download</span>
<span>导出数据</span>
</a>
@else
<form
class="biz-import-form"
method="POST"
@ -513,6 +573,7 @@
<span>导入</span>
</button>
</form>
@endif
<button type="button" class="biz-btn biz-btn-primary" onclick="bizOpenCreateModal()">
<span class="material-icons-outlined" style="font-size:1.1rem">add</span>
<span>新增</span>
@ -560,21 +621,25 @@
@endforeach
@unless ($descriptor->readOnly)
<td class="actions-cell">
<button type="button" class="biz-btn biz-btn-ghost" title="编辑" onclick='bizOpenEditModal({{ json_encode($row->only(array_merge(['id'], array_map(fn($f) => $f['name'], $descriptor->fields)))) }})'>
<span class="material-icons-outlined" style="font-size:1.1rem">edit</span>
</button>
<form method="POST" action="{{ route('business.pages.destroy', [
'section' => $section['slug'],
'page' => $page['slug'],
'id' => $row->id,
'tab' => $activeTab['slug'] ?? null,
]) }}" style="display:inline" onsubmit="return confirm('确认删除这条记录?')">
@csrf
@method('DELETE')
<button type="submit" class="biz-btn biz-btn-danger-ghost">
<span class="material-icons-outlined" style="font-size:1.1rem">delete</span>
</button>
</form>
@if (!empty($row->id) && empty($row->is_upstream_only))
<button type="button" class="biz-btn biz-btn-ghost" title="编辑" onclick="bizOpenEditModal(@js($row->only(array_merge(['id'], array_map(fn($f) => $f['name'], $descriptor->fields)))))">
<span class="material-icons-outlined" style="font-size:1.1rem">edit</span>
</button>
<form method="POST" action="{{ route('business.pages.destroy', [
'section' => $section['slug'],
'page' => $page['slug'],
'id' => $row->id,
'tab' => $activeTab['slug'] ?? null,
]) }}" style="display:inline" onsubmit="return confirm('确认删除这条记录?')">
@csrf
@method('DELETE')
<button type="submit" class="biz-btn biz-btn-danger-ghost">
<span class="material-icons-outlined" style="font-size:1.1rem">delete</span>
</button>
</form>
@else
<span class="biz-badge biz-badge-yes">ERP 店铺</span>
@endif
</td>
@endunless
</tr>
@ -610,6 +675,37 @@
</div>
@unless ($descriptor->readOnly)
@if (($section['slug'] ?? null) === 'fund-management' && ($page['slug'] ?? null) === 'store-receiving-accounts')
<div id="biz-import-modal" class="biz-modal" onclick="if(event.target===this)bizCloseImportModal()">
<div class="biz-modal-card" style="max-width:32rem">
<div class="biz-modal-title">导入店铺账号表格</div>
<div class="biz-import-help">
支持一次上传包含“淘宝、京东、抖音小店、拼多多”四个工作表的 Excel。没有 ERP 店铺名称、店铺名称、店铺 ID 或登录账号的示例行不会被导入。
</div>
<form method="POST" enctype="multipart/form-data" action="{{ route('business.pages.import', [
'section' => $section['slug'],
'page' => $page['slug'],
'tab' => $activeTab['slug'] ?? 'taobao',
]) }}">
@csrf
<label class="biz-field-label" for="receiving-account-import">选择 Excel 文件</label>
<input id="receiving-account-import" class="biz-field-input" type="file" name="import" accept=".xlsx,.xls" required>
<div class="biz-modal-foot">
<a class="biz-btn biz-btn-ghost" href="{{ route('business.pages.receiving-accounts-template') }}">
<span class="material-icons-outlined" style="font-size:1.1rem">download</span>
<span>下载四页模板</span>
</a>
<button type="button" class="biz-btn biz-btn-ghost" onclick="bizCloseImportModal()">取消</button>
<button type="submit" class="biz-btn biz-btn-primary">
<span class="material-icons-outlined" style="font-size:1.1rem">upload_file</span>
<span>开始导入</span>
</button>
</div>
</form>
</div>
</div>
@endif
{{-- Generic create/edit modal --}}
<div id="biz-modal" class="biz-modal" onclick="if(event.target===this)bizCloseModal()">
<div class="biz-modal-card">
@ -665,12 +761,12 @@
<script>
window.__bizDescriptor = {
section: {{ json_encode($section['slug']) }},
page: {{ json_encode($page['slug']) }},
tab: {{ json_encode($activeTab['slug'] ?? null) }},
section: @js($section['slug']),
page: @js($page['slug']),
tab: @js($activeTab['slug'] ?? null),
storeUrl: "{{ route('business.pages.store', ['section' => $section['slug'], 'page' => $page['slug'], 'tab' => $activeTab['slug'] ?? null]) }}",
updateBase: "{{ route('business.pages.update', ['section' => $section['slug'], 'page' => $page['slug'], 'id' => '__ID__', 'tab' => $activeTab['slug'] ?? null]) }}".replace('__ID__', ''),
fields: {{ json_encode($descriptor->fields) }}
fields: @js($descriptor->fields)
};
function bizOpenModal() {
@ -685,6 +781,20 @@
document.body.style.overflow = '';
}
function bizOpenImportModal() {
var m = document.getElementById('biz-import-modal');
if (!m) return;
m.classList.add('is-open');
document.body.style.overflow = 'hidden';
}
function bizCloseImportModal() {
var m = document.getElementById('biz-import-modal');
if (!m) return;
m.classList.remove('is-open');
document.body.style.overflow = '';
}
function bizResetForm() {
var form = document.getElementById('biz-form');
form.querySelectorAll('[data-default]').forEach(function (el) {
@ -725,7 +835,10 @@
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') bizCloseModal();
if (e.key === 'Escape') {
bizCloseModal();
bizCloseImportModal();
}
});
</script>
@endunless

View File

@ -114,9 +114,16 @@ Route::get('workspace/courier-payables/courier-information/export', 'Business\Pa
->middleware('business.page:courier-payables,courier-information')
->name('business.courier-payables.courier-information.export');
Route::get('workspace/fund-management/store-receiving-accounts/{tab}/export', 'Business\Pages@exportReceivingAccounts')
->middleware('business.page:fund-management,store-receiving-accounts')
->name('business.pages.receiving-accounts-export');
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
->name('business.pages.export');
Route::get('workspace/fund-management/store-receiving-accounts-template', 'Business\Pages@downloadReceivingAccountTemplate')
->name('business.pages.receiving-accounts-template');
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show');

View File

@ -3,6 +3,7 @@
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Business\StoreReceivingAccount;
use Modules\EcommerceFinance\Models\AlipayBill;
use Modules\EcommerceFinance\Repositories\NonJingxiRefundMirrorRepository;
use Tests\Feature\FeatureTestCase;
@ -79,6 +80,121 @@ class PagesTest extends FeatureTestCase
->assertDontSee('class="business-blank-page"', false);
}
public function testReceivingAccountTabsUseTheFourWorkbookPages(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'store-receiving-accounts',
]))->assertRedirect(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'fund-management',
'page' => 'store-receiving-accounts',
'tab' => 'taobao',
]));
$html = $this->get(route('business.pages.show', [
'section' => 'fund-management', 'page' => 'store-receiving-accounts', 'tab' => 'taobao',
]))->assertOk()->getContent();
$this->assertStringContainsString('淘宝', $html);
$this->assertStringContainsString('京东', $html);
$this->assertStringContainsString('抖音小店', $html);
$this->assertStringContainsString('拼多多', $html);
$this->assertStringContainsString('下载四页模板', $html);
$this->assertStringContainsString('id="biz-import-modal"', $html);
$this->assertStringContainsString('name="erp_store_name"', $html);
$this->assertStringContainsString('name="company_name"', $html);
$this->assertStringContainsString('导出数据', $html);
}
public function testReceivingAccountExportContainsVisiblePasswordFieldsAsText(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
StoreReceivingAccount::create([
'company_id' => $this->company->id,
'store_platform' => '淘宝',
'store_type' => '淘宝',
'erp_store_name' => 'Export test shop',
'store_name' => 'Export test shop',
'store_entity_name' => 'Export test company',
'login_account' => 'export-test@example.invalid',
'login_password' => 'Login-Test-Password-001',
'payment_password' => 'Payment-Test-Password-002',
]);
$this->mock(\App\Business\UpstreamBusinessInformation::class, function ($mock): void {
$mock->shouldReceive('storesByPlatforms')->once()->with(
['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会'],
1000,
)->andReturn([]);
});
$response = $this->loginAs()->get(route('business.pages.export', [
'section' => 'fund-management',
'page' => 'store-receiving-accounts',
'tab' => 'taobao',
]))->assertOk();
$this->assertStringContainsString(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
$response->headers->get('Content-Type')
);
$temporaryFile = tempnam(sys_get_temp_dir(), 'receiving-account-export-');
try {
file_put_contents($temporaryFile, $response->streamedContent());
$sheet = \PhpOffice\PhpSpreadsheet\IOFactory::load($temporaryFile)->getActiveSheet();
$headers = $sheet->rangeToArray('A1:M1', null, true, false)[0];
$values = $sheet->rangeToArray('A2:M2', null, true, false)[0];
$this->assertContains('登录密码', $headers);
$this->assertContains('支付密码', $headers);
$this->assertContains('Login-Test-Password-001', $values);
$this->assertContains('Payment-Test-Password-002', $values);
} finally {
@unlink($temporaryFile);
}
}
public function testReceivingAccountInvalidTabFallsBackToTaobao(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'store-receiving-accounts',
'tab' => 'taobao-alipay',
]))->assertRedirect(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'fund-management',
'page' => 'store-receiving-accounts',
'tab' => 'taobao',
]));
}
public function testReceivingAccountDirectoryLinkHasNoLegacyTabSlug(): void
{
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'bank-accounts',
]))->assertOk()->assertSee(route('business.pages.show', [
'section' => 'fund-management',
'page' => 'store-receiving-accounts',
]), false);
}
public function testOrderDataTablesRenderAsRawDataPageTabs()
{
$this->user->attachPermission(

View File

@ -60,11 +60,14 @@ class BusinessInformationMirrorRepositoryTest extends TestCase
$companies = $this->repository->paginate('company-information.read', 2, 2);
$store = $this->repository->paginate('store-information.read', 1, 20);
$platformStores = $this->repository->storesByPlatform('天猫', 20);
$trademarks = $this->repository->paginate('trademark-information.read', 1, 20);
$this->assertSame(3, $companies['total']);
$this->assertSame(1, $companies['data'][0]['id']);
$this->assertSame('新公司', $store['data'][0]['company_name']);
$this->assertCount(1, $platformStores);
$this->assertSame('测试店', $platformStores[0]['erp_store_name']);
$this->assertSame(1, $trademarks['total']);
$this->assertSame('有效商标', $trademarks['data'][0]['trademark_name']);
}