实际修改: - 店铺维度报表新增真实数据库条件查询:店铺字段走共享镜像 SQL WHERE,主体/能否变更/生效/失效走生命周期行级过滤(左闭右开) - 店铺负责人/部门负责人/利润中心改为店铺账号 PostgreSQL 镜像值, 按店铺名称关联,未匹配时回退经营分析副本库原值 - 能否变更按主表 platform 判定(唯品会MP/淘工厂=否,其余=是), 筛选改为 是/否 下拉;默认生效时间 2026-07-01,失效未记录显示存续中 - 主体维度经营报表接入生命周期收入窗口与注册地址解析市字段 - 同步更新视图测试并重写 StoreReportRepositoryTest 匹配 SQLite 实现 验证:容器内 tests/Feature/Business + tests/Unit/EcommerceFinance + tests/Unit/Business 共 324 passed / 4 skipped;真实数据端到端验证 (无筛选 1935 行、负责人/利润中心/失效区间筛选抽样全对)。 无新增数据库迁移,不涉及生产数据写入。
441 lines
19 KiB
PHP
441 lines
19 KiB
PHP
<?php
|
||
|
||
namespace App\Business;
|
||
|
||
use App\Models\Business\BankAccount;
|
||
use App\Models\Business\BusinessModel;
|
||
use App\Models\Business\CompanyCancellation;
|
||
use App\Models\Business\CompanyChange;
|
||
use App\Models\Business\CompanyInformation;
|
||
use App\Models\Business\CompanyRegistration;
|
||
use App\Models\Business\CompanyStoreData;
|
||
use App\Models\Business\ExpressReceiving;
|
||
use App\Models\Business\FundForecast;
|
||
use App\Models\Business\InvoiceBasic;
|
||
use App\Models\Business\InstallationDetail;
|
||
use App\Models\Business\StoreChange;
|
||
use App\Models\Business\StoreChangeLog;
|
||
use App\Models\Business\StoreClosing;
|
||
use App\Models\Business\StoreInformation;
|
||
use App\Models\Business\StoreInvoice;
|
||
use App\Models\Business\StoreOpening;
|
||
use App\Models\Business\StoreReceivingAccount;
|
||
use App\Models\Business\SwActualStatistics;
|
||
use App\Models\Business\SwPlanning;
|
||
use App\Models\Business\TaxData;
|
||
use App\Models\Business\Trademark;
|
||
use App\Utilities\BusinessNavigation;
|
||
use Illuminate\Support\Arr;
|
||
|
||
/**
|
||
* Central registry that maps a navigation (section, page[, tab]) tuple to the
|
||
* corresponding Eloquent model, field metadata, and Excel sheet name. This
|
||
* drives the dynamic CRUD views so we don't have to hand-write 11 controllers
|
||
* and 11 blade templates.
|
||
*/
|
||
class TableRegistry
|
||
{
|
||
/**
|
||
* Field type hints used by the Blade form renderer.
|
||
* text|number|date|boolean|select|textarea|currency
|
||
*/
|
||
private const FIELD_TYPES = [
|
||
'change_date' => 'date',
|
||
'opened_at' => 'date',
|
||
'establishment_date' => 'date',
|
||
'registration_date' => 'date',
|
||
'expiry_date' => 'date',
|
||
'invoice_date' => 'date',
|
||
'trigger_store_change' => 'boolean',
|
||
'trigger_company_register' => 'boolean',
|
||
'invoice_type' => 'select',
|
||
'account_type' => 'select',
|
||
'receiving_method' => 'select',
|
||
'platform' => 'select',
|
||
'receiving_type' => 'select',
|
||
'notes' => 'textarea',
|
||
'business_scope' => 'textarea',
|
||
'shareholders' => 'textarea',
|
||
'subscribed_capital' => 'number',
|
||
];
|
||
|
||
private const SELECT_OPTIONS = [
|
||
'invoice_type' => ['普票', '专票'],
|
||
'account_type' => ['基本户', '一般户', '个人账户', '个人微信', '个人支付宝'],
|
||
'receiving_method' => ['支付宝', '银行卡'],
|
||
'platform' => ['京东', '抖音', '拼多多', '淘宝', '天猫', '淘工厂', '唯品会', '得物'],
|
||
'receiving_type' => ['对公', '个人'],
|
||
];
|
||
|
||
private const COLUMN_LABELS = [
|
||
'unified_social_credit_code' => '统一社会信用代码',
|
||
'company_name' => '公司名称',
|
||
'subscribed_capital' => '认缴注册资本(万元)',
|
||
'company_type' => '类型',
|
||
'legal_representative' => '法定代表人',
|
||
'establishment_date' => '成立日期',
|
||
'registration_status' => '注册状态',
|
||
'registered_address' => '注册地址(住所)',
|
||
'business_scope' => '经营范围',
|
||
'basic_deposit_account_number' => '基本存款账户编号',
|
||
'company_status' => '公司状态',
|
||
'financial_officer' => '财务负责人',
|
||
'supervisor' => '监事',
|
||
'taxpayer_category' => '纳税类别及类型',
|
||
'shareholders' => '股东',
|
||
'notes' => '备注',
|
||
'registration_type' => '注册类型',
|
||
'change_type' => '变更类型',
|
||
'change_status' => '变更状态',
|
||
'change_date' => '变更日期',
|
||
'cancellation_status' => '注销状态',
|
||
'trademark_name' => '商标名称',
|
||
'trademark_type' => '商标类型',
|
||
'trademark_status' => '商标状态',
|
||
'registration_date' => '注册日期',
|
||
'expiry_date' => '到期日期',
|
||
'opened_at' => '开店时间',
|
||
'erp_store_name' => 'ERP系统店铺名称',
|
||
'trademark_number' => '商标号',
|
||
'platform' => '平台',
|
||
'profit_center' => '店铺所属利润中心',
|
||
'store_manager' => '店铺负责人',
|
||
'store_status' => '店铺状态',
|
||
'store_type' => '店铺类型',
|
||
'channel_mode' => '渠道模式',
|
||
'store_name' => '店铺名称',
|
||
'store_platform' => '店铺平台',
|
||
'opening_status' => '开店状态',
|
||
'closing_status' => '关店状态',
|
||
'store_entity' => '店铺主体',
|
||
'receiving_account_info' => '收款账户信息',
|
||
'historical_entity' => '历史主体',
|
||
];
|
||
|
||
/**
|
||
* Currency columns (rendered with two decimals and right-aligned).
|
||
*/
|
||
private const CURRENCY_HINTS = [
|
||
'real_ship_revenue', 'zhongcai_revenue', 'refund_amount', 'net_revenue',
|
||
'tax_revenue', 'tax_refund', 'tax_net_revenue', 'revenue_diff', 'final_tax_revenue',
|
||
'product_cost', 'logistics_cost', 'platform_invoice', 'labor_cost',
|
||
'other_reimbursement', 'profit',
|
||
'provided_revenue', 'declared_revenue',
|
||
'goods_special_amount', 'goods_special_tax', 'goods_normal_amount', 'goods_normal_tax',
|
||
'goods_unbilled_amount', 'goods_unbilled_tax', 'goods_subtotal_amount', 'goods_subtotal_tax',
|
||
'service_special_amount', 'service_special_tax', 'service_normal_amount', 'service_normal_tax',
|
||
'service_unbilled_amount', 'service_unbilled_tax', 'service_subtotal_amount', 'service_subtotal_tax',
|
||
'total_revenue', 'total_tax',
|
||
'input_goods_amount', 'input_goods_tax', 'input_service_amount', 'input_service_tax',
|
||
'input_other_amount', 'input_other_tax', 'input_subtotal_amount', 'input_subtotal_tax',
|
||
'extra_credit', 'retained_credit', 'vat_payable', 'city_tax', 'education_surcharge',
|
||
'local_education_surcharge', 'stamp_tax', 'union_fee', 'individual_tax', 'income_tax',
|
||
'garbage_fee', 'other_penalty', 'total_tax_paid',
|
||
'store_revenue', 'company_revenue',
|
||
'invoice_limit', 'output_amount', 'input_amount',
|
||
'invoice_amount',
|
||
];
|
||
|
||
/**
|
||
* Map (section_slug / page_slug) -> Model class.
|
||
* Tabs reuse the same model but filter by an extra column.
|
||
*/
|
||
private static function routeMap(): array
|
||
{
|
||
return [
|
||
'company-management/basic-information' => CompanyInformation::class,
|
||
'company-management/subject-operating-report' => StoreInformation::class,
|
||
'company-management/registration' => CompanyRegistration::class,
|
||
'company-management/changes' => CompanyChange::class,
|
||
'company-management/cancellation' => CompanyCancellation::class,
|
||
'company-management/tax-data' => TaxData::class,
|
||
'company-management/store-data' => CompanyStoreData::class,
|
||
'trademark-management/registration' => Trademark::class,
|
||
'trademark-management/changes' => Trademark::class,
|
||
'trademark-management/renewal' => Trademark::class,
|
||
'trademark-management/information' => Trademark::class,
|
||
'store-management/basic-information' => StoreInformation::class,
|
||
'store-management/store-accounts' => StoreInformation::class,
|
||
'store-management/store-report' => StoreInformation::class,
|
||
'store-management/opening-process' => StoreOpening::class,
|
||
'store-management/changes' => StoreChange::class,
|
||
'store-management/closing-process' => StoreClosing::class,
|
||
'financial-sw/planning' => SwPlanning::class,
|
||
'financial-sw/actual-statistics' => SwActualStatistics::class,
|
||
'store-management/change-log' => StoreChangeLog::class,
|
||
'invoice-management/basic-information' => InvoiceBasic::class,
|
||
'invoice-management/store-backend-invoicing' => StoreInvoice::class,
|
||
'fund-management/fund-forecast' => FundForecast::class,
|
||
'fund-management/store-receiving-accounts' => StoreReceivingAccount::class,
|
||
'fund-management/bank-accounts' => BankAccount::class,
|
||
'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤
|
||
'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class,
|
||
'financial-operations/installation-details' => InstallationDetail::class,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Resolve the model class for a given navigation triple.
|
||
* Returns null if no model is registered (page is purely descriptive).
|
||
*/
|
||
public static function modelClassFor(string $section, string $page, ?string $tab = null): ?string
|
||
{
|
||
$key = "{$section}/{$page}";
|
||
|
||
$map = self::routeMap();
|
||
|
||
// For store-receiving-accounts tabs, we route by platform (tab slug).
|
||
if ($key === 'fund-management/store-receiving-accounts') {
|
||
return StoreReceivingAccount::class;
|
||
}
|
||
|
||
return $map[$key] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Build the descriptor used by the Blade view to render the table.
|
||
*/
|
||
public static function describe(string $section, string $page, ?string $tab = null): ?TableDescriptor
|
||
{
|
||
if ($section === 'fund-management' && $page === 'store-receiving-accounts') {
|
||
return new TableDescriptor(
|
||
model: StoreReceivingAccount::class,
|
||
table: (new StoreReceivingAccount())->getTable(),
|
||
fields: self::storeReceivingAccountFields($tab),
|
||
tab: $tab,
|
||
tabFilterColumn: $tab ? 'store_platform' : null,
|
||
readOnly: false,
|
||
);
|
||
}
|
||
|
||
$modelClass = self::modelClassFor($section, $page, $tab);
|
||
if (! $modelClass) {
|
||
return null;
|
||
}
|
||
|
||
$instance = new $modelClass();
|
||
$labels = method_exists($modelClass, 'columnLabels')
|
||
? $modelClass::columnLabels()
|
||
: [];
|
||
$fieldTypes = method_exists($modelClass, 'fieldTypes')
|
||
? $modelClass::fieldTypes()
|
||
: [];
|
||
$columns = self::isUpstreamReadOnly($section, $page)
|
||
? $instance->getFillable()
|
||
: $instance->getConnection()->getSchemaBuilder()->getColumnListing($instance->getTable());
|
||
|
||
// 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);
|
||
}));
|
||
|
||
$fields = [];
|
||
foreach ($visible as $col) {
|
||
$fields[] = [
|
||
'name' => $col,
|
||
'label' => $labels[$col] ?? self::COLUMN_LABELS[$col] ?? ucfirst(str_replace('_', ' ', $col)),
|
||
'type' => $fieldTypes[$col] ?? self::fieldType($col),
|
||
'options' => self::selectOptions($col),
|
||
'currency' => in_array($col, self::CURRENCY_HINTS, true),
|
||
];
|
||
}
|
||
|
||
return new TableDescriptor(
|
||
model: $modelClass,
|
||
table: $instance->getTable(),
|
||
fields: $fields,
|
||
tab: $tab,
|
||
tabFilterColumn: $tab ? self::tabFilterColumn($section, $page) : null,
|
||
readOnly: self::isUpstreamReadOnly($section, $page),
|
||
);
|
||
}
|
||
|
||
private static function isUpstreamReadOnly(string $section, string $page): bool
|
||
{
|
||
return in_array("{$section}/{$page}", [
|
||
'company-management/basic-information',
|
||
'company-management/subject-operating-report',
|
||
'store-management/basic-information',
|
||
'store-management/store-accounts',
|
||
'store-management/store-report',
|
||
'trademark-management/information',
|
||
'financial-operations/installation-details',
|
||
], true);
|
||
}
|
||
|
||
private static function fieldType(string $column): string
|
||
{
|
||
return self::FIELD_TYPES[$column] ?? (
|
||
str_contains($column, '_budget') || str_contains($column, '_actual')
|
||
? 'currency'
|
||
: (in_array($column, self::CURRENCY_HINTS, true) ? 'currency' : 'text')
|
||
);
|
||
}
|
||
|
||
private static function selectOptions(string $column): ?array
|
||
{
|
||
return self::SELECT_OPTIONS[$column] ?? null;
|
||
}
|
||
|
||
/**
|
||
* For pages with tabs, the column used to filter by tab slug.
|
||
*/
|
||
private static function tabFilterColumn(string $section, string $page): ?string
|
||
{
|
||
$key = "{$section}/{$page}";
|
||
|
||
return match ($key) {
|
||
'fund-management/store-receiving-accounts' => 'store_platform',
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* For "shared-company-accounts" page we only show bank accounts that are
|
||
* marked as shared (account_type == '基本户' by convention).
|
||
*/
|
||
public static function fixedFilters(string $section, string $page): array
|
||
{
|
||
$key = "{$section}/{$page}";
|
||
|
||
return match ($key) {
|
||
'fund-management/shared-company-accounts' => ['account_type' => '基本户'],
|
||
default => [],
|
||
};
|
||
}
|
||
|
||
public static function importIdentityFields(string $section, string $page): array
|
||
{
|
||
return match ("{$section}/{$page}") {
|
||
'company-management/basic-information' => ['unified_social_credit_code'],
|
||
'trademark-management/information',
|
||
'trademark-management/registration',
|
||
'trademark-management/changes',
|
||
'trademark-management/renewal' => ['trademark_name'],
|
||
'store-management/basic-information' => ['erp_store_name', 'platform'],
|
||
default => [],
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve a tab slug to its display title from the navigation config.
|
||
*/
|
||
public static function tabTitle(string $section, string $page, string $tabSlug): ?string
|
||
{
|
||
foreach (BusinessNavigation::all() as $navSection) {
|
||
if ($navSection['slug'] !== $section) {
|
||
continue;
|
||
}
|
||
foreach ($navSection['pages'] ?? [] as $navPage) {
|
||
if (($navPage['slug'] ?? null) !== $page) {
|
||
continue;
|
||
}
|
||
foreach ($navPage['tabs'] ?? [] as $tab) {
|
||
if (($tab['slug'] ?? null) === $tabSlug) {
|
||
return $tab['title'] ?? $tabSlug;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Map tab slug -> filter value for the tab filter column.
|
||
* For example the "taobao-alipay" tab maps to "淘宝" platform.
|
||
*/
|
||
public static function tabValue(string $section, string $page, string $tabSlug): ?string
|
||
{
|
||
$key = "{$section}/{$page}";
|
||
|
||
if ($key === 'fund-management/store-receiving-accounts') {
|
||
return match ($tabSlug) {
|
||
'taobao' => '淘宝',
|
||
'jd' => '京东',
|
||
'douyin' => '抖音',
|
||
'pinduoduo' => '拼多多',
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public static function tabValues(string $section, string $page, string $tabSlug): array
|
||
{
|
||
if ($section !== 'fund-management' || $page !== 'store-receiving-accounts') {
|
||
$value = self::tabValue($section, $page, $tabSlug);
|
||
|
||
return $value === null ? [] : [$value];
|
||
}
|
||
|
||
return match ($tabSlug) {
|
||
'taobao' => ['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会'],
|
||
'jd' => ['京东', '京喜'],
|
||
'douyin' => ['抖音'],
|
||
'pinduoduo' => ['拼多多'],
|
||
default => [],
|
||
};
|
||
}
|
||
|
||
/** @return list<array{name:string,label:string,type:string,options?:list<string>,currency:bool}> */
|
||
private static function storeReceivingAccountFields(?string $tab): array
|
||
{
|
||
$text = static fn (string $name, string $label): array => [
|
||
'name' => $name, 'label' => $label, 'type' => 'text', 'options' => null, 'currency' => false,
|
||
];
|
||
$select = static fn (string $name, string $label, array $options): array => [
|
||
'name' => $name, 'label' => $label, 'type' => 'select', 'options' => $options, 'currency' => false,
|
||
];
|
||
|
||
$fields = [];
|
||
if ($tab === 'taobao' || $tab === 'jd') {
|
||
$fields[] = $select('store_platform', '类型', self::tabValues('fund-management', 'store-receiving-accounts', $tab));
|
||
}
|
||
|
||
$fields[] = $text('erp_store_name', 'ERP店铺名称');
|
||
$fields[] = $text('shop_name', '店铺名称');
|
||
|
||
if ($tab === 'jd') {
|
||
$fields[] = $text('store_id', '店铺id');
|
||
}
|
||
|
||
$fields[] = $select('store_status', '店铺状态', ['正常', '关店中', '已关店']);
|
||
$fields[] = $text('store_entity_name', '店铺主体');
|
||
$fields[] = $text('original_store_entity', '原店铺主体');
|
||
|
||
if ($tab === 'taobao') {
|
||
$fields[] = $select('account_status', '状态', ['已维护', '待维护']);
|
||
$fields[] = $text('login_account', '登录账号');
|
||
$fields[] = $text('login_password', '登录密码');
|
||
$fields[] = $text('payment_password', '支付密码');
|
||
$fields[] = $select('withdrawal_path', '提现路径', ['公户', '网商银行', '支付宝']);
|
||
$fields[] = $text('withdrawal_account_name', '提现账户名称');
|
||
$fields[] = $text('alipay_or_bank_account', '支付宝账号');
|
||
} elseif ($tab === 'jd') {
|
||
$fields[] = $text('login_account', '登录账号');
|
||
$fields[] = $text('login_password', '登录密码');
|
||
$fields[] = $text('payment_verification_method', '支付验证方式');
|
||
$fields[] = $text('financial_account', '金融账号');
|
||
$fields[] = $text('financial_password', '金融密码');
|
||
$fields[] = $text('payment_verification_method_secondary', '支付验证方式');
|
||
$fields[] = $select('account_status', '状态', ['已维护', '待维护']);
|
||
$fields[] = $select('withdrawal_path', '提现路径', ['公户', '网商银行', '支付宝']);
|
||
$fields[] = $text('withdrawal_account_name', '提现账户名称');
|
||
$fields[] = $text('bank_card_last_four', '银行卡账号(后四位)');
|
||
} else {
|
||
$fields[] = $select('account_status', '状态', ['已维护', '待维护']);
|
||
$fields[] = $text('login_account', '登录账号');
|
||
$fields[] = $text('login_password', '登录密码');
|
||
$fields[] = $text('payment_password', '支付密码');
|
||
$fields[] = $select('withdrawal_path', '提现路径', ['公户', '网商银行', '支付宝']);
|
||
$fields[] = $text('withdrawal_account_name', '提现账户名称');
|
||
$fields[] = $text('bank_card_last_four', '银行卡账号(后四位)');
|
||
}
|
||
|
||
return $fields;
|
||
}
|
||
}
|