feat: 公司盘点备注关联店铺与持股公司 (#1036)
规划注销主体备注关联店铺未办结并支持跳转;名下有子公司主体关联公司基础信息与持股公司。
This commit is contained in:
parent
dcd7ea8b49
commit
49ef5ff892
321
app/Business/CompanyPlan/CompanyPlanCompanyRepository.php
Normal file
321
app/Business/CompanyPlan/CompanyPlanCompanyRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
. ")"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user