feat: 公司盘点备注关联店铺与持股公司 (#1036)

规划注销主体备注关联店铺未办结并支持跳转;名下有子公司主体关联公司基础信息与持股公司。
This commit is contained in:
DoDo 2026-11-10 13:50:23 +08:00
parent dcd7ea8b49
commit 49ef5ff892
9 changed files with 767 additions and 15 deletions

View File

@ -0,0 +1,321 @@
<?php
namespace App\Business\CompanyPlan;
use PDO;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
use Throwable;
/**
* 公司盘点中的公司持股关系。
*
* 数据来自资产盘点工作簿“主体明细”的“股东”列,只保留股东本身也是盘点主体的公司关系,
* 供公司盘点备注跳转到公司基础信息并按股东过滤。
*/
class CompanyPlanCompanyRepository
{
public const SUBSIDIARY_NOTE = '名下有子公司,暂不注销';
private ?PDO $pdo = null;
public function pdo(): PDO
{
if ($this->pdo instanceof PDO) {
return $this->pdo;
}
$path = (string) config('company_plan.database_path');
$directory = dirname($path);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new RuntimeException('无法创建公司盘点原型数据目录。');
}
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA busy_timeout = 5000');
$pdo->exec('PRAGMA journal_mode = WAL');
$this->ensureSchema($pdo);
return $this->pdo = $pdo;
}
private function ensureSchema(PDO $pdo): void
{
$pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS company_plan_company_relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,
plan_row_id INTEGER,
shareholder_company_name TEXT NOT NULL,
related_company_name TEXT NOT NULL,
related_unified_social_credit_code TEXT,
share_ratio TEXT,
shareholders_text TEXT NOT NULL,
source_sheet TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (company_id, shareholder_company_name, related_company_name)
)
SQL);
$pdo->exec(
'CREATE INDEX IF NOT EXISTS company_plan_company_relations_shareholder_index '
. 'ON company_plan_company_relations (company_id, shareholder_company_name)'
);
}
/**
* @return array{source_sheet:string,scanned:int,relationships:int,shareholders:int}
*/
public function importFromWorkbook(string $path, int $companyId): array
{
if (! is_file($path)) {
throw new RuntimeException('源工作簿不存在:' . $path);
}
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$book = $reader->load($path);
try {
$sheet = $book->getSheetByName('主体明细');
if (! $sheet instanceof Worksheet) {
throw new RuntimeException('源工作簿缺少“主体明细”工作表。');
}
$rows = $sheet->toArray(null, true, false, false);
[$headerRow, $headers] = $this->findHeaders($rows);
if (! isset($headers['company_name'], $headers['shareholders'])) {
throw new RuntimeException('“主体明细”缺少“公司名称”或“股东”表头。');
}
$planRows = [];
$statement = $this->pdo()->prepare(
'SELECT id, company_name FROM company_plan_rows WHERE company_id = ?'
);
$statement->execute([$companyId]);
foreach ($statement->fetchAll() as $planRow) {
$name = trim((string) $planRow['company_name']);
$planRows[self::normalizeCompanyName($name)] = [
'id' => (int) $planRow['id'],
'name' => $name,
];
}
$relations = [];
$shareholders = [];
$scanned = 0;
foreach (array_slice($rows, $headerRow + 1) as $row) {
$relatedCompany = $this->cell($row, $headers['company_name']);
$shareholderText = $this->cell($row, $headers['shareholders']);
if ($relatedCompany === '' || $shareholderText === '') {
continue;
}
$scanned++;
foreach ($this->shareholderEntries($shareholderText) as [$shareholderName, $ratio]) {
$match = $planRows[self::normalizeCompanyName($shareholderName)] ?? null;
if ($match === null) {
continue;
}
$key = $match['name'] . '|' . $relatedCompany;
$relations[$key] = [
'plan_row_id' => $match['id'],
'shareholder_company_name' => $match['name'],
'related_company_name' => $relatedCompany,
'related_unified_social_credit_code' => isset($headers['credit_code'])
? $this->nullableCell($row, $headers['credit_code'], 64)
: null,
'share_ratio' => $ratio !== '' ? $ratio : null,
'shareholders_text' => $ratio !== ''
? $match['name'] . ':' . $ratio
: $match['name'],
'source_sheet' => '主体明细',
];
$shareholders[$match['name']] = true;
}
}
$pdo = $this->pdo();
$pdo->beginTransaction();
try {
$pdo->prepare('DELETE FROM company_plan_company_relations WHERE company_id = ?')
->execute([$companyId]);
$insert = $pdo->prepare(<<<'SQL'
INSERT INTO company_plan_company_relations (
company_id, plan_row_id, shareholder_company_name, related_company_name,
related_unified_social_credit_code, share_ratio, shareholders_text,
source_sheet, created_at, updated_at
) VALUES (
:company_id, :plan_row_id, :shareholder_company_name, :related_company_name,
:related_unified_social_credit_code, :share_ratio, :shareholders_text,
:source_sheet, :created_at, :updated_at
)
SQL);
$now = now()->toDateTimeString();
foreach ($relations as $relation) {
$insert->execute([
':company_id' => $companyId,
':plan_row_id' => $relation['plan_row_id'],
':shareholder_company_name' => mb_substr($relation['shareholder_company_name'], 0, 255),
':related_company_name' => mb_substr($relation['related_company_name'], 0, 255),
':related_unified_social_credit_code' => $relation['related_unified_social_credit_code'],
':share_ratio' => $relation['share_ratio'],
':shareholders_text' => mb_substr($relation['shareholders_text'], 0, 500),
':source_sheet' => $relation['source_sheet'],
':created_at' => $now,
':updated_at' => $now,
]);
}
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $exception;
}
return [
'source_sheet' => '主体明细',
'scanned' => $scanned,
'relationships' => count($relations),
'shareholders' => count($shareholders),
];
} finally {
$book->disconnectWorksheets();
}
}
/** @return array<string,int> 键包含原名称和归一化名称。 */
public function countsByShareholder(int $companyId): array
{
$statement = $this->pdo()->prepare(
'SELECT shareholder_company_name, COUNT(*) AS total '
. 'FROM company_plan_company_relations WHERE company_id = ? '
. 'GROUP BY shareholder_company_name'
);
$statement->execute([$companyId]);
$counts = [];
foreach ($statement->fetchAll() as $row) {
$name = (string) $row['shareholder_company_name'];
$counts[$name] = (int) $row['total'];
$counts[self::normalizeCompanyName($name)] = (int) $row['total'];
}
return $counts;
}
/**
* @return array<string,array{company_name:string,shareholders_text:string,share_ratio:?string}>
*/
public function relationsForShareholder(int $companyId, string $shareholderCompany): array
{
$statement = $this->pdo()->prepare(
'SELECT related_company_name, shareholders_text, share_ratio '
. 'FROM company_plan_company_relations '
. 'WHERE company_id = :company_id AND shareholder_company_name = :shareholder_company '
. 'ORDER BY related_company_name ASC'
);
$statement->execute([
':company_id' => $companyId,
':shareholder_company' => trim($shareholderCompany),
]);
$relations = [];
foreach ($statement->fetchAll() as $row) {
$name = trim((string) $row['related_company_name']);
$relations[self::normalizeCompanyName($name)] = [
'company_name' => $name,
'shareholders_text' => (string) $row['shareholders_text'],
'share_ratio' => $row['share_ratio'] !== null ? (string) $row['share_ratio'] : null,
];
}
return $relations;
}
public static function normalizeCompanyName(string $name): string
{
$normalized = preg_replace('/[\s\x{00A0}\x{3000}]+/u', '', trim($name)) ?? trim($name);
return mb_strtolower(str_replace(['(', ')'], ['(', ')'], $normalized));
}
/**
* @param list<array<int,mixed>> $rows
* @return array{0:int,1:array<string,int>}
*/
private function findHeaders(array $rows): array
{
$aliases = [
'公司名称' => 'company_name',
'统一社会信用代码' => 'credit_code',
'股东' => 'shareholders',
];
foreach (array_slice($rows, 0, 20, true) as $index => $row) {
$headers = [];
foreach ($row as $column => $value) {
$header = preg_replace('/\s+/u', '', trim((string) $value)) ?? '';
if (isset($aliases[$header])) {
$headers[$aliases[$header]] = $column;
}
}
if (isset($headers['company_name'], $headers['shareholders'])) {
return [$index, $headers];
}
}
throw new RuntimeException('未识别到“主体明细”的公司名称和股东表头。');
}
/**
* @param array<int,mixed> $row
* @return list<array{0:string,1:string}>
*/
private function shareholderEntries(string $text): array
{
$entries = [];
foreach (preg_split('/[|;;]/u', $text) ?: [] as $part) {
$part = trim(str_replace("\u{00A0}", ' ', $part));
if ($part === '') {
continue;
}
if (! preg_match('/^(.+?)[::]\s*(.*)$/u', $part, $matches)) {
$entries[] = [$part, ''];
continue;
}
$name = trim($matches[1]);
$ratio = trim($matches[2]);
if ($name !== '') {
$entries[] = [$name, $ratio];
}
}
return $entries;
}
/** @param array<int,mixed> $row */
private function cell(array $row, int $column): string
{
$value = $row[$column] ?? null;
return $value === null ? '' : trim(str_replace("\u{00A0}", ' ', (string) $value));
}
/** @param array<int,mixed> $row */
private function nullableCell(array $row, int $column, int $length): ?string
{
$value = $this->cell($row, $column);
return $value !== '' ? mb_substr($value, 0, $length) : null;
}
}

View File

@ -12,6 +12,7 @@ class CompanyPlanPageData
private readonly CompanyPlanRepository $repository,
private readonly CompanyPlanWorkbookImporter $importer,
private readonly CompanyPlanShopRepository $shops,
private readonly CompanyPlanCompanyRepository $companies,
) {}
/**
@ -56,6 +57,7 @@ class CompanyPlanPageData
$shopSummary = ['total' => 0, 'companies' => 0, 'statuses' => []];
$shopStatusOptions = [];
$shopCounts = [];
$shareholdingCounts = [];
try {
$payload = $this->repository->paginate(
@ -90,6 +92,7 @@ class CompanyPlanPageData
$shopSummary = $this->shops->summary($companyId, $shopFilters);
$shopStatusOptions = $this->shops->statusOptions($companyId);
$shopCounts = $this->shops->countsByCompany($companyId);
$shareholdingCounts = $this->companies->countsByShareholder($companyId);
} catch (Throwable $exception) {
report($exception);
$error = '公司盘点本地原型数据暂时无法读取。请确认 storage/app 可写后重新加载。';
@ -125,6 +128,7 @@ class CompanyPlanPageData
'companyPlanShopSummary' => $shopSummary,
'companyPlanShopStatuses' => $shopStatusOptions,
'companyPlanShopCounts' => $shopCounts,
'companyPlanShareholdingCounts' => $shareholdingCounts,
];
}

View File

@ -77,10 +77,17 @@ SQL);
$pdo->exec('CREATE INDEX IF NOT EXISTS company_plan_shops_company_index ON company_plan_shops (company_id, company_name)');
$pdo->exec('CREATE INDEX IF NOT EXISTS company_plan_shops_status_index ON company_plan_shops (company_id, shop_status)');
// 备注口径归一:附件里的“明细见sheet3/6”改为统一的“存在未完结店铺”,幂等执行。
// 备注口径归一:有未完结店铺的规划注销主体统一显示“关联店铺未办结”,幂等执行。
$pdo->exec(
"UPDATE company_plan_rows SET notes = '存在未完结店铺' "
. "WHERE notes LIKE '存在未完结店铺,明细见sheet%'"
"UPDATE company_plan_rows SET notes = '关联店铺未办结' "
. "WHERE TRIM(COALESCE(plan, '')) = '注销' "
. "AND TRIM(COALESCE(company_status, '')) = '正常' "
. "AND notes LIKE '存在未完结店铺%' "
. "AND EXISTS ("
. "SELECT 1 FROM company_plan_shops AS shops "
. "WHERE shops.company_id = company_plan_rows.company_id "
. "AND shops.company_name = company_plan_rows.company_name"
. ")"
);
}

View File

@ -2,6 +2,7 @@
namespace App\Business;
use App\Business\CompanyPlan\CompanyPlanCompanyRepository;
use Illuminate\Pagination\LengthAwarePaginator;
use InvalidArgumentException;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
@ -124,7 +125,44 @@ class UpstreamBusinessInformation
private function companyPage(int $page, int $perPage): array
{
$filters = $this->pageFilters('company-management', 'basic-information');
return $this->page(
$shareholderCompany = mb_substr(trim((string) request('shareholder_company', '')), 0, 255);
if ($shareholderCompany === '') {
return $this->page(
'company-information.read',
$page,
$perPage,
$filters,
fn () => $this->snapshot('company-information.read', [
'page' => $page,
'perPage' => $perPage,
]),
);
}
$relations = app(CompanyPlanCompanyRepository::class)->relationsForShareholder(
(int) (company_id() ?: 1),
$shareholderCompany,
);
$relatedNames = array_values(array_map(
static fn (array $relation): string => $relation['company_name'],
$relations,
));
if ($relatedNames === []) {
return [
'data' => [],
'total' => 0,
'summary' => [
'total' => 0,
'normal' => 0,
'deregistered' => 0,
'general_taxpayer' => 0,
'small_taxpayer' => 0,
],
];
}
$filters['_company_names'] = $relatedNames;
$payload = $this->page(
'company-information.read',
$page,
$perPage,
@ -134,6 +172,17 @@ class UpstreamBusinessInformation
'perPage' => $perPage,
]),
);
foreach ($payload['data'] as &$row) {
$relation = $relations[CompanyPlanCompanyRepository::normalizeCompanyName(
(string) ($row['company_name'] ?? '')
)] ?? null;
if ($relation !== null) {
$row['shareholders'] = $relation['shareholders_text'];
}
}
unset($row);
return $payload;
}
private function companySnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array

View File

@ -227,6 +227,14 @@ finance_company.business_type AS business_registration_type,
NULL AS shareholders
SQL);
$relatedCompanyNames = array_values(array_unique(array_filter(array_map(
static fn (mixed $name): string => mb_substr(trim((string) $name), 0, 255),
(array) ($filters['_company_names'] ?? []),
))));
if ($relatedCompanyNames !== []) {
$query->whereIn('company.businessLicenseName', array_slice($relatedCompanyNames, 0, 500));
}
$this->applyFieldFilters($query, $filters, [
'unified_social_credit_code' => 'company.unifiedSocialCreditCode', 'company_name' => 'company.businessLicenseName',
'establishment_date' => 'company.IncorporationDate',

View File

@ -13,6 +13,7 @@
$shopSummary = $companyPlanShopSummary;
$shopStatuses = $companyPlanShopStatuses;
$shopCounts = $companyPlanShopCounts;
$shareholdingCounts = $companyPlanShareholdingCounts;
$shopTotal = $companyPlanShopTotal;
$shopPage = $companyPlanShopPage;
$shopLastPage = $companyPlanShopLastPage;
@ -1153,7 +1154,8 @@
background: #b23a3a;
}
.cp-shop-link {
.cp-shop-link,
.cp-company-link {
display: inline-flex;
align-items: center;
gap: 0.28rem;
@ -1166,15 +1168,18 @@
white-space: nowrap;
}
.cp-shop-link:hover {
.cp-shop-link:hover,
.cp-company-link:hover {
background: var(--cp-gold-soft);
}
.cp-shop-link .material-icons-outlined {
.cp-shop-link .material-icons-outlined,
.cp-company-link .material-icons-outlined {
font-size: 0.95rem;
}
.cp-shop-link__count {
.cp-shop-link__count,
.cp-company-link__count {
min-width: 1.1rem;
padding: 0 0.28rem;
border-radius: 999px;
@ -1955,15 +1960,43 @@
{{ in_array($displayValue, ['✅', '✔', '✓'], true) ? '已完成' : $displayValue }}
</span>
@elseif ($field['name'] === 'notes')
@if (filled($value) && str_contains((string) $value, '未完结店铺') && ($shopCounts[$row->company_name] ?? 0) > 0)
@php
$notesText = trim((string) $value);
$shopCount = $shopCounts[$row->company_name] ?? 0;
$shareholdingCount = $shareholdingCounts[$row->company_name] ?? 0;
$isShopPendingNote = str_contains($notesText, '关联店铺未办结')
|| str_contains($notesText, '未完结店铺');
@endphp
@if ($notesText === '名下有子公司,暂不注销' && $shareholdingCount > 0)
<a
class="cp-company-link"
data-company-plan-shareholding-link="{{ $row->id }}"
href="{{ route('business.pages.show', [
'section' => 'company-management',
'page' => 'basic-information',
'shareholder_company' => $row->company_name,
]) }}"
title="点击查看 {{ $row->company_name }} 作为股东持股的其他公司"
>
<span class="material-icons-outlined" aria-hidden="true">account_tree</span>
{{ $notesText }}
<span class="cp-company-link__count">{{ $shareholdingCount }}</span>
</a>
@elseif ($notesText !== '' && $shopCount > 0 && $isShopPendingNote)
<a
class="cp-shop-link"
href="{{ request()->fullUrlWithQuery(['shop_company' => $row->company_name, 'shop_page' => 1]) }}#company-plan-shops"
title="点击查看 {{ $row->company_name }} 名下的未完结店铺"
data-company-plan-shop-link="{{ $row->id }}"
href="{{ request()->fullUrlWithQuery([
'shop_company' => $row->company_name,
'shop_keyword' => null,
'shop_status' => null,
'shop_page' => 1,
]) }}#company-plan-shops"
title="点击查看 {{ $row->company_name }} 名下的关联店铺"
>
<span class="material-icons-outlined" aria-hidden="true">storefront</span>
存在未完结店铺
<span class="cp-shop-link__count">{{ $shopCounts[$row->company_name] ?? 0 }}</span>
{{ $notesText }}
<span class="cp-shop-link__count">{{ $shopCount }}</span>
</a>
@else
<span class="cp-note" title="{{ $displayValue }}">{{ $displayValue }}</span>
@ -2097,7 +2130,7 @@
@endif
</div>
</div>
<span class="cp-shops__hint">点主表备注里的“存在未完结店铺”可直接定位到这里</span>
<span class="cp-shops__hint">点主表备注里的“关联店铺未办结”可直接定位到这里</span>
</div>
<form class="cp-shops__filters" method="GET" action="{{ request()->url() }}#company-plan-shops">
@ -2613,7 +2646,7 @@
});
}
}
// 点备注里的“存在未完结店铺”后,自动滚到未完结店铺表。
// 点备注里的“关联店铺未办结”后,自动滚到未完结店铺表。
if (query.get('shop_company')) {
const shopSection = document.getElementById('company-plan-shops');
if (shopSection) {

View File

@ -1732,6 +1732,15 @@
<p>{{ $directoryQueryPageDescriptions[$directoryPageKey] ?? '按当前字段查询、筛选并导出业务数据' }}</p>
</div>
</div>
@if ($directoryPageKey === 'company-management/basic-information' && request()->filled('shareholder_company'))
<div class="directory-table-notice" role="status">
<span class="material-icons-outlined" aria-hidden="true">account_tree</span>
<span>
当前仅显示 <strong>{{ request('shareholder_company') }}</strong> 作为股东持股的其他公司,共 {{ number_format($directoryTotal) }} 家。
<a href="{{ route('business.pages.show', ['section' => 'company-management', 'page' => 'basic-information']) }}">清除持股筛选</a>
</span>
</div>
@endif
@if (!empty($upstreamError))
<div class="directory-table-notice" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
@ -2179,6 +2188,9 @@
@if (request()->has('columns'))
<input type="hidden" form="{{ $directoryQueryFormId }}" name="columns" value="{{ implode(',', $visibleDirectoryColumns) }}">
@endif
@if ($directoryPageKey === 'company-management/basic-information' && request()->filled('shareholder_company'))
<input type="hidden" form="{{ $directoryQueryFormId }}" name="shareholder_company" value="{{ request('shareholder_company') }}">
@endif
@foreach ($directoryPrimaryQueryFields as $field)
@php $kind = $field['kind'] ?? 'text'; @endphp
@continue($kind === 'secret' && $field['name'] !== 'withdrawal_account')

View File

@ -2,11 +2,16 @@
namespace Tests\Feature\Business;
use App\Business\CompanyPlan\CompanyPlanCompanyRepository;
use App\Business\CompanyPlan\CompanyPlanRepository;
use App\Business\CompanyPlan\CompanyPlanShopRepository;
use App\Business\CompanyPlan\CompanyPlanWorkbookImporter;
use App\Business\UpstreamBusinessInformation;
use Illuminate\Http\UploadedFile;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Mockery;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
@ -142,6 +147,190 @@ class CompanyPlanPageTest extends FeatureTestCase
$this->assertSame('2024-03-02', $updated['registered_at']);
}
public function testPendingCompanyWithLinkedShopRendersNavigableNoteOnlyWhenShopExists(): void
{
$this->seedRows();
$repository = app(CompanyPlanRepository::class);
$companyId = (int) company_id();
$row = $repository->paginate($companyId, ['keyword' => '上海测试注销主体'], 1, 25)['rows'][0];
$repository->update($companyId, (int) $row['id'], [
...$row,
'plan' => '注销',
'company_status' => '正常',
'notes' => '关联店铺未办结',
], (int) auth()->id());
$repository->importRows($companyId, [[
'sequence_no' => 3,
'company_name' => '常州测试无店铺主体',
'company_type' => '普通公司',
'taxpayer_type' => '小规模纳税人',
'registered_at' => '2024-03-03',
'business_registration_type' => '/',
'unified_social_credit_code' => 'TEST-CREDIT-003',
'plan' => '注销',
'company_status' => '正常',
'tax_cancellation' => null,
'business_cancellation' => null,
'bank_cancellation' => null,
'notes' => '关联店铺未办结',
]], 'update', (int) auth()->id());
$this->insertShop($companyId, (int) $row['id'], '上海测试注销主体', '上海测试店铺');
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'company-management',
'page' => 'company-plan',
'plan' => '注销',
'company_status' => '正常',
'shop_keyword' => '旧关键词',
'shop_status' => '正常',
]))->assertOk();
$html = $response->getContent();
$this->assertSame(1, substr_count($html, 'data-company-plan-shop-link'));
$this->assertSame(
1,
preg_match('/<a[^>]*data-company-plan-shop-link="\d+"[^>]*href="([^"]+)"/', $html, $matches),
);
$link = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
parse_str((string) parse_url($link, PHP_URL_QUERY), $query);
$this->assertSame('上海测试注销主体', $query['shop_company'] ?? null);
$this->assertSame('1', (string) ($query['shop_page'] ?? ''));
$this->assertSame('', (string) ($query['shop_keyword'] ?? ''));
$this->assertSame('', (string) ($query['shop_status'] ?? ''));
$this->assertSame('company-plan-shops', parse_url($link, PHP_URL_FRAGMENT));
}
public function testShareholdingNoteLinksToCompanyInformationFilteredByShareholder(): void
{
$this->seedRows();
$companyId = (int) company_id();
$companyName = '常州测试母体有限公司';
$relatedCompany = '常州测试子公司有限公司';
$repository = app(CompanyPlanRepository::class);
$repository->importRows($companyId, [[
'sequence_no' => 4,
'company_name' => $companyName,
'company_type' => '母公司',
'taxpayer_type' => '小规模纳税人',
'registered_at' => '2024-03-04',
'business_registration_type' => '/',
'unified_social_credit_code' => 'TEST-PARENT-004',
'plan' => '注销',
'company_status' => '正常',
'tax_cancellation' => null,
'business_cancellation' => null,
'bank_cancellation' => null,
'notes' => CompanyPlanCompanyRepository::SUBSIDIARY_NOTE,
]], 'update', (int) auth()->id());
$this->insertCompanyRelation($companyId, $companyName, $relatedCompany);
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'company-management',
'page' => 'company-plan',
'keyword' => $companyName,
'plan' => '注销',
'company_status' => '正常',
]))->assertOk();
$html = $response->getContent();
$this->assertSame(1, substr_count($html, 'data-company-plan-shareholding-link'));
$this->assertSame(
1,
preg_match('/<a[^>]*data-company-plan-shareholding-link="\d+"[^>]*href="([^"]+)"/', $html, $matches),
);
$link = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
parse_str((string) parse_url($link, PHP_URL_QUERY), $query);
$this->assertSame($companyName, $query['shareholder_company'] ?? null);
$paginator = new LengthAwarePaginator([(object) [
'id' => 1001,
'unified_social_credit_code' => 'TEST-CHILD-1001',
'company_name' => $relatedCompany,
'subscribed_capital' => 5,
'company_type' => '店铺-普通公司',
'establishment_date' => '2025-01-02',
'legal_representative' => '测试法人',
'registered_address' => '测试地址',
'business_scope' => '测试经营范围',
'basic_deposit_account_number' => null,
'company_status' => '正常',
'financial_officer' => '测试财务',
'supervisor' => null,
'taxpayer_category' => '小规模纳税人',
'business_registration_type' => '/',
'shareholders' => $companyName . ':100.00',
]], 1, 50, 1, ['path' => '/']);
$service = Mockery::mock(UpstreamBusinessInformation::class);
$service->shouldReceive('paginate')
->once()
->with('company-management', 'basic-information', 50)
->andReturn($paginator);
$this->app->instance(UpstreamBusinessInformation::class, $service);
$this->get(route('business.pages.show', [
'section' => 'company-management',
'page' => 'basic-information',
'shareholder_company' => $companyName,
]))
->assertOk()
->assertSeeText($relatedCompany)
->assertSeeText($companyName)
->assertSeeText($companyName . ':100.00')
->assertSeeText('作为股东持股的其他公司')
->assertSee('name="shareholder_company"', false);
}
public function testShareholdingWorkbookImportParsesCompanyShareholderAndRatio(): void
{
$companyId = (int) company_id();
$companyName = '常州测试母体有限公司';
$relatedCompany = '常州测试子公司有限公司';
app(CompanyPlanRepository::class)->importRows($companyId, [[
'sequence_no' => 1,
'company_name' => $companyName,
'company_type' => '母公司',
'taxpayer_type' => '小规模纳税人',
'registered_at' => '2024-03-04',
'business_registration_type' => '/',
'unified_social_credit_code' => 'TEST-PARENT-IMPORT',
'plan' => '注销',
'company_status' => '正常',
'tax_cancellation' => null,
'business_cancellation' => null,
'bank_cancellation' => null,
'notes' => CompanyPlanCompanyRepository::SUBSIDIARY_NOTE,
]], 'update', (int) auth()->id());
$path = $this->tempDirectory . '/company-details.xlsx';
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('主体明细');
$sheet->fromArray([
['公司名称', '统一社会信用代码', '股东'],
[$relatedCompany, 'TEST-CHILD-IMPORT', $companyName . ':100.00'],
], null, 'A1');
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
$repository = app(CompanyPlanCompanyRepository::class);
$result = $repository->importFromWorkbook($path, $companyId);
$relations = $repository->relationsForShareholder($companyId, $companyName);
$this->assertSame(1, $result['relationships']);
$this->assertSame(1, $result['shareholders']);
$this->assertArrayHasKey(
CompanyPlanCompanyRepository::normalizeCompanyName($relatedCompany),
$relations,
);
$this->assertSame(
$companyName . ':100.00',
$relations[CompanyPlanCompanyRepository::normalizeCompanyName($relatedCompany)]['shareholders_text'],
);
}
public function testWorkbookPreviewAndConfirmImportsOnlyValidRows(): void
{
$path = $this->tempDirectory . '/subject-inventory.xlsx';
@ -271,4 +460,59 @@ class CompanyPlanPageTest extends FeatureTestCase
],
], 'update', (int) auth()->id());
}
private function insertShop(int $companyId, int $planRowId, string $companyName, string $shopName): void
{
$statement = app(CompanyPlanShopRepository::class)->pdo()->prepare(<<<'SQL'
INSERT INTO company_plan_shops (
company_id, plan_row_id, company_name, shop_name, shop_status,
profit_center, owner, opened_on, successor, source_sheet, created_at, updated_at
) VALUES (
:company_id, :plan_row_id, :company_name, :shop_name, :shop_status,
:profit_center, :owner, :opened_on, :successor, :source_sheet, :created_at, :updated_at
)
SQL);
$now = now()->toDateTimeString();
$statement->execute([
':company_id' => $companyId,
':plan_row_id' => $planRowId,
':company_name' => $companyName,
':shop_name' => $shopName,
':shop_status' => '正常',
':profit_center' => null,
':owner' => null,
':opened_on' => null,
':successor' => null,
':source_sheet' => 'Sheet3',
':created_at' => $now,
':updated_at' => $now,
]);
}
private function insertCompanyRelation(int $companyId, string $companyName, string $relatedCompany): void
{
$statement = app(CompanyPlanCompanyRepository::class)->pdo()->prepare(<<<'SQL'
INSERT INTO company_plan_company_relations (
company_id, shareholder_company_name, related_company_name,
related_unified_social_credit_code, share_ratio, shareholders_text,
source_sheet, created_at, updated_at
) VALUES (
:company_id, :shareholder_company_name, :related_company_name,
:related_unified_social_credit_code, :share_ratio, :shareholders_text,
:source_sheet, :created_at, :updated_at
)
SQL);
$now = now()->toDateTimeString();
$statement->execute([
':company_id' => $companyId,
':shareholder_company_name' => $companyName,
':related_company_name' => $relatedCompany,
':related_unified_social_credit_code' => 'TEST-CHILD-1001',
':share_ratio' => '100.00',
':shareholders_text' => $companyName . ':100.00',
':source_sheet' => '主体明细',
':created_at' => $now,
':updated_at' => $now,
]);
}
}

View File

@ -2,6 +2,7 @@
namespace Tests\Unit\EcommerceFinance;
use App\Business\CompanyPlan\CompanyPlanCompanyRepository;
use App\Business\UpstreamBusinessInformation;
use Closure;
use Mockery;
@ -261,6 +262,79 @@ class UpstreamBusinessInformationTest extends TestCase
$this->assertNull($items[5]->establishment_date);
}
public function test_company_page_filters_and_overlays_shareholding_relations(): void
{
$path = sys_get_temp_dir() . '/caiwu-shareholding-' . bin2hex(random_bytes(6)) . '.sqlite';
config(['company_plan.database_path' => $path]);
$shareholder = '常州测试母体有限公司';
$relatedCompany = '常州测试子公司有限公司';
try {
$relations = app(CompanyPlanCompanyRepository::class);
$pdo = $relations->pdo();
$statement = $pdo->prepare(<<<'SQL'
INSERT INTO company_plan_company_relations (
company_id, shareholder_company_name, related_company_name,
related_unified_social_credit_code, share_ratio, shareholders_text,
source_sheet, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
SQL);
$now = now()->toDateTimeString();
$statement->execute([
1,
$shareholder,
$relatedCompany,
'TEST-CHILD-UNIT',
'100.00',
$shareholder . ':100.00',
'主体明细',
$now,
$now,
]);
$mirror = Mockery::mock(BusinessInformationMirrorRepository::class);
$mirror->shouldReceive('ready')
->once()
->with('company-information.read')
->andReturnTrue();
$mirror->shouldReceive('paginate')
->once()
->withArgs(static fn (
string $scope,
int $page,
int $perPage,
array $filters,
): bool => $scope === 'company-information.read'
&& $page === 1
&& $perPage === 50
&& $filters['_company_names'] === [$relatedCompany])
->andReturn([
'data' => [[
'id' => 901,
'company_name' => $relatedCompany,
'company_status' => '0',
'taxpayer_category' => '小规模纳税人',
]],
'total' => 1,
]);
$this->app['request']->merge(['shareholder_company' => $shareholder]);
$paginator = (new UpstreamBusinessInformation(
Mockery::mock(UpstreamQueryCacheFactory::class),
$mirror,
null,
))->paginate('company-management', 'basic-information', 50);
$this->assertSame(1, $paginator->total());
$this->assertSame($relatedCompany, $paginator->items()[0]->company_name);
$this->assertSame($shareholder . ':100.00', $paginator->items()[0]->shareholders);
} finally {
@unlink($path);
@unlink($path . '-wal');
@unlink($path . '-shm');
}
}
public function test_bootstrap_without_mirror_explains_shared_connection_instead_of_requesting_upstream_credentials(): void
{
config(['system_database.mode' => 'bootstrap', 'upstreams.local_configuration.enabled' => false]);