From 49ef5ff8922da08021cd629bc78634d73f80ded7 Mon Sep 17 00:00:00 2001 From: DoDo <1060330844@qq.com> Date: Tue, 10 Nov 2026 13:50:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=8F=B8=E7=9B=98=E7=82=B9?= =?UTF-8?q?=E5=A4=87=E6=B3=A8=E5=85=B3=E8=81=94=E5=BA=97=E9=93=BA=E4=B8=8E?= =?UTF-8?q?=E6=8C=81=E8=82=A1=E5=85=AC=E5=8F=B8=20(#1036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规划注销主体备注关联店铺未办结并支持跳转;名下有子公司主体关联公司基础信息与持股公司。 --- .../CompanyPlanCompanyRepository.php | 321 ++++++++++++++++++ .../CompanyPlan/CompanyPlanPageData.php | 4 + .../CompanyPlan/CompanyPlanShopRepository.php | 13 +- app/Business/UpstreamBusinessInformation.php | 51 ++- .../BusinessInformationMirrorRepository.php | 8 + .../company-management/company-plan.blade.php | 55 ++- .../pages/partials/directory-table.blade.php | 12 + .../Feature/Business/CompanyPlanPageTest.php | 244 +++++++++++++ .../UpstreamBusinessInformationTest.php | 74 ++++ 9 files changed, 767 insertions(+), 15 deletions(-) create mode 100644 app/Business/CompanyPlan/CompanyPlanCompanyRepository.php diff --git a/app/Business/CompanyPlan/CompanyPlanCompanyRepository.php b/app/Business/CompanyPlan/CompanyPlanCompanyRepository.php new file mode 100644 index 00000000..c5961f84 --- /dev/null +++ b/app/Business/CompanyPlan/CompanyPlanCompanyRepository.php @@ -0,0 +1,321 @@ +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 键包含原名称和归一化名称。 */ + 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 + */ + 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> $rows + * @return array{0:int,1:array} + */ + 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 $row + * @return list + */ + 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 $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 $row */ + private function nullableCell(array $row, int $column, int $length): ?string + { + $value = $this->cell($row, $column); + + return $value !== '' ? mb_substr($value, 0, $length) : null; + } +} diff --git a/app/Business/CompanyPlan/CompanyPlanPageData.php b/app/Business/CompanyPlan/CompanyPlanPageData.php index f2b76b1b..18c84668 100644 --- a/app/Business/CompanyPlan/CompanyPlanPageData.php +++ b/app/Business/CompanyPlan/CompanyPlanPageData.php @@ -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, ]; } diff --git a/app/Business/CompanyPlan/CompanyPlanShopRepository.php b/app/Business/CompanyPlan/CompanyPlanShopRepository.php index 5aa1ed19..71f75cbe 100644 --- a/app/Business/CompanyPlan/CompanyPlanShopRepository.php +++ b/app/Business/CompanyPlan/CompanyPlanShopRepository.php @@ -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" + . ")" ); } diff --git a/app/Business/UpstreamBusinessInformation.php b/app/Business/UpstreamBusinessInformation.php index 31e8bbc4..f9fd6abb 100644 --- a/app/Business/UpstreamBusinessInformation.php +++ b/app/Business/UpstreamBusinessInformation.php @@ -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 diff --git a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php index 8103d54e..3511a715 100644 --- a/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php @@ -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', diff --git a/resources/views/business/pages/company-management/company-plan.blade.php b/resources/views/business/pages/company-management/company-plan.blade.php index d7e3291d..6b02e940 100644 --- a/resources/views/business/pages/company-management/company-plan.blade.php +++ b/resources/views/business/pages/company-management/company-plan.blade.php @@ -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 }} @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) + + + {{ $notesText }} + {{ $shareholdingCount }} + + @elseif ($notesText !== '' && $shopCount > 0 && $isShopPendingNote) - 存在未完结店铺 - {{ $shopCounts[$row->company_name] ?? 0 }} + {{ $notesText }} + {{ $shopCount }} @else {{ $displayValue }} @@ -2097,7 +2130,7 @@ @endif - 点主表备注里的“存在未完结店铺”可直接定位到这里 + 点主表备注里的“关联店铺未办结”可直接定位到这里
@@ -2613,7 +2646,7 @@ }); } } - // 点备注里的“存在未完结店铺”后,自动滚到未完结店铺表。 + // 点备注里的“关联店铺未办结”后,自动滚到未完结店铺表。 if (query.get('shop_company')) { const shopSection = document.getElementById('company-plan-shops'); if (shopSection) { diff --git a/resources/views/business/pages/partials/directory-table.blade.php b/resources/views/business/pages/partials/directory-table.blade.php index 87b8e3a5..4ac2ae07 100644 --- a/resources/views/business/pages/partials/directory-table.blade.php +++ b/resources/views/business/pages/partials/directory-table.blade.php @@ -1732,6 +1732,15 @@

{{ $directoryQueryPageDescriptions[$directoryPageKey] ?? '按当前字段查询、筛选并导出业务数据' }}

+ @if ($directoryPageKey === 'company-management/basic-information' && request()->filled('shareholder_company')) +
+ + + 当前仅显示 {{ request('shareholder_company') }} 作为股东持股的其他公司,共 {{ number_format($directoryTotal) }} 家。 + 清除持股筛选 + +
+ @endif @if (!empty($upstreamError))