Merge remote-tracking branch 'origin/main' into feature/WuYongJie-marketing-account-jd-ledgers

# Conflicts:
#	resources/views/business/pages/show.blade.php
This commit is contained in:
WuYongJie 2026-08-04 13:04:25 +08:00
commit 8ceae88719
30 changed files with 2633 additions and 63 deletions

View File

@ -0,0 +1,181 @@
<?php
namespace App\Business;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
class BusinessTableImporter
{
public function import(
UploadedFile $file,
TableDescriptor $descriptor,
string $section,
string $page,
?string $tab = null,
): array {
$spreadsheet = IOFactory::load($file->getRealPath());
[$rows, $headerRow, $columnMap] = $this->bestMatchingSheet($spreadsheet, $descriptor);
if ($rows === null || $columnMap === []) {
throw ValidationException::withMessages([
'import' => '未找到可识别的表头,请使用当前页面显示的中文表头。',
]);
}
$created = 0;
$updated = 0;
$model = $descriptor->model;
$identityFields = TableRegistry::importIdentityFields($section, $page);
DB::transaction(function () use (
$rows,
$headerRow,
$columnMap,
$descriptor,
$section,
$page,
$tab,
$model,
$identityFields,
&$created,
&$updated,
) {
foreach (array_slice($rows, $headerRow + 1) as $offset => $row) {
$data = [];
foreach ($columnMap as $columnIndex => $field) {
$data[$field['name']] = $this->normalizeValue(
$row[$columnIndex] ?? null,
$field['type'],
$headerRow + $offset + 2,
$field['label'],
);
}
if (collect($data)->every(fn ($value) => $value === null || $value === '')) {
continue;
}
if ($descriptor->tabFilterColumn && $tab) {
$tabValue = TableRegistry::tabValue($section, $page, $tab);
if ($tabValue !== null) {
$data[$descriptor->tabFilterColumn] = $tabValue;
}
}
foreach (TableRegistry::fixedFilters($section, $page) as $column => $value) {
$data[$column] = $value;
}
$data['company_id'] = company_id() ?: 1;
$identity = collect($identityFields)
->filter(fn ($field) => array_key_exists($field, $data) && $data[$field] !== null && $data[$field] !== '')
->mapWithKeys(fn ($field) => [$field => $data[$field]])
->all();
$record = count($identity) === count($identityFields) && $identity !== []
? $model::query()->where($identity)->first()
: null;
if ($record) {
$record->update($data);
$updated++;
} else {
$model::create($data);
$created++;
}
}
});
if ($created + $updated === 0) {
throw ValidationException::withMessages([
'import' => '表格中没有可导入的数据行。',
]);
}
return compact('created', 'updated');
}
private function bestMatchingSheet($spreadsheet, TableDescriptor $descriptor): array
{
$fieldLookup = [];
foreach ($descriptor->fields as $field) {
$fieldLookup[$this->normalizeHeader($field['name'])] = $field;
$fieldLookup[$this->normalizeHeader($field['label'])] = $field;
}
$best = [null, null, []];
$bestScore = 0;
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
$rows = $worksheet->toArray(null, true, false, false);
foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) {
$columnMap = [];
foreach ($row as $columnIndex => $heading) {
$key = $this->normalizeHeader($heading);
if ($key !== '' && isset($fieldLookup[$key])) {
$columnMap[$columnIndex] = $fieldLookup[$key];
}
}
if (count($columnMap) > $bestScore) {
$best = [$rows, $rowIndex, $columnMap];
$bestScore = count($columnMap);
}
}
}
return $best;
}
private function normalizeHeader($value): string
{
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
}
private function normalizeValue($value, string $type, int $rowNumber, string $label)
{
if ($value === null || trim((string) $value) === '') {
return null;
}
if ($type === 'date') {
try {
return is_numeric($value)
? ExcelDate::excelToDateTimeObject($value)->format('Y-m-d')
: \Illuminate\Support\Carbon::parse($value)->format('Y-m-d');
} catch (\Throwable $e) {
throw ValidationException::withMessages([
'import' => "第 {$rowNumber} 行“{$label}”不是有效日期。",
]);
}
}
if (in_array($type, ['number', 'currency'], true)) {
$number = str_replace([',', ',', '¥', '¥'], '', (string) $value);
if (! is_numeric($number)) {
throw ValidationException::withMessages([
'import' => "第 {$rowNumber} 行“{$label}”不是有效数字。",
]);
}
return (float) $number;
}
if ($type === 'boolean') {
return in_array(mb_strtolower(trim((string) $value)), ['1', '是', 'true', 'yes', 'y'], true) ? 1 : 0;
}
return trim((string) $value);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Business;
/**
* Immutable descriptor used by the Blade views to render a CRUD table.
*/
class TableDescriptor
{
public function __construct(
public readonly string $model,
public readonly string $table,
public readonly array $fields,
public readonly ?string $tab = null,
public readonly ?string $tabFilterColumn = null,
public readonly bool $readOnly = false,
) {
}
}

View File

@ -0,0 +1,347 @@
<?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\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/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/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,
];
}
/**
* 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
{
$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',
'store-management/basic-information',
'trademark-management/information',
], 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-alipay' => '淘宝',
'taobao-wechat' => '淘宝',
'pinduoduo' => '拼多多',
'jd-wallet' => '京东',
'douyin' => '抖音',
'dewu' => '得物',
'vipshop' => '唯品会',
default => null,
};
}
return null;
}
}

View File

@ -0,0 +1,208 @@
<?php
namespace App\Business;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
class UpstreamBusinessInformation
{
private const SOURCE = 'operations_replica';
public function __construct(private readonly UpstreamQueryCacheFactory $caches)
{
}
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
{
$currentPage = max((int) request('page', 1), 1);
$perPage = min(max($perPage, 1), 100);
$key = "{$section}/{$page}";
$payload = match ($key) {
'company-management/basic-information' => $this->companyPage($currentPage, $perPage),
'store-management/basic-information' => $this->storePage($currentPage, $perPage),
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
default => ['data' => [], 'total' => 0],
};
return new LengthAwarePaginator(
array_map(static fn (array $row) => (object) $row, $payload['data']),
(int) $payload['total'],
$perPage,
$currentPage,
[
'path' => request()->url(),
'query' => request()->query(),
],
);
}
private function companyPage(int $page, int $perPage): array
{
return $this->remember('company-information.read', $page, $perPage, function (UpstreamReadOnlyQuery $query) use ($page, $perPage) {
$offset = ($page - 1) * $perPage;
$rows = $query->select(<<<SQL
SELECT
Id AS id,
unifiedSocialCreditCode AS unified_social_credit_code,
businessLicenseName AS company_name,
registeredCapital AS subscribed_capital,
businessLicenseType AS company_type,
IncorporationDate AS establishment_date,
legalRepresentative AS legal_representative,
registeredAddress AS registered_address,
businessScope AS business_scope,
accountSerialNumber AS basic_deposit_account_number,
companyStatus AS company_status,
financialManager AS financial_officer,
supervisor AS supervisor,
taxCategoryAndType AS taxpayer_category,
NULL AS shareholders
FROM rpt.fys_companyInfo
ORDER BY Id DESC
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY
SQL);
return [
'data' => array_map(fn (array $row) => $this->mapCompany($row), $rows),
'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_companyInfo'),
];
});
}
private function storePage(int $page, int $perPage): array
{
return $this->remember('store-information.read', $page, $perPage, function (UpstreamReadOnlyQuery $query) use ($page, $perPage) {
$offset = ($page - 1) * $perPage;
$rows = $query->select(<<<SQL
SELECT
s.Id AS id,
s.openTime AS opened_at,
s.erpShopName AS erp_store_name,
s.unifiedSocialCreditCode AS unified_social_credit_code,
c.businessLicenseName AS company_name,
s.trademarkNumber AS trademark_number,
NULL AS trademark_name,
s.platform AS platform,
s.responsibleDepartment AS profit_center,
s.shopHead AS store_manager,
s.shopState AS store_status,
s.shopType AS store_type,
s.channelType AS channel_mode
FROM rpt.fys_shopInfo AS s
OUTER APPLY (
SELECT TOP 1 company.businessLicenseName
FROM rpt.fys_companyInfo AS company
WHERE company.unifiedSocialCreditCode = NULLIF(s.unifiedSocialCreditCode, '')
ORDER BY company.Id DESC
) AS c
ORDER BY s.Id DESC
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY
SQL);
return [
'data' => array_map(fn (array $row) => $this->mapStore($row), $rows),
'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_shopInfo'),
];
});
}
private function trademarkPage(int $page, int $perPage): array
{
return $this->remember('trademark-information.read', $page, $perPage, function (UpstreamReadOnlyQuery $query) use ($page, $perPage) {
$offset = ($page - 1) * $perPage;
$rows = $query->select(<<<SQL
SELECT
Id AS id,
trademarkName AS trademark_name,
internationalClassification AS trademark_type,
trademarkStatus AS trademark_status,
registrationDate AS registration_date,
termOfValidity AS expiry_date,
remark AS notes
FROM rpt.fys_TrademarkInfo
WHERE isDelete = 0
ORDER BY Id DESC
OFFSET {$offset} ROWS FETCH NEXT {$perPage} ROWS ONLY
SQL);
return [
'data' => array_map(fn (array $row) => $this->mapTrademark($row), $rows),
'total' => (int) $query->scalar('SELECT COUNT(*) FROM rpt.fys_TrademarkInfo WHERE isDelete = 0'),
];
});
}
private function remember(string $scope, int $page, int $perPage, callable $resolver): array
{
$cache = $this->caches->forSource(self::SOURCE);
return $cache->remember($scope, compact('page', 'perPage'), function () use ($resolver) {
return $resolver(new UpstreamReadOnlyQuery(self::SOURCE));
});
}
private function mapCompany(array $row): array
{
$row = $this->toUtf8($row);
$row['establishment_date'] = $this->date($row['establishment_date'] ?? null);
$row['company_status'] = match ((string) ($row['company_status'] ?? '')) {
'0' => '正常',
'1' => '已注销',
default => $row['company_status'] ?? null,
};
return $row;
}
private function mapStore(array $row): array
{
$row = $this->toUtf8($row);
$row['opened_at'] = $this->date($row['opened_at'] ?? null);
$row['store_status'] = match ((string) ($row['store_status'] ?? '')) {
'0' => '正常',
'3' => '关闭中',
'6' => '已关闭',
default => $row['store_status'] ?? null,
};
return $row;
}
private function mapTrademark(array $row): array
{
$row = $this->toUtf8($row);
$row['registration_date'] = $this->date($row['registration_date'] ?? null);
$row['expiry_date'] = $this->date($row['expiry_date'] ?? null);
$row['trademark_status'] = match ((string) ($row['trademark_status'] ?? '')) {
'0' => '未启用',
'1' => '有效',
default => $row['trademark_status'] ?? null,
};
return $row;
}
private function toUtf8(array $row): array
{
foreach ($row as &$value) {
if (is_string($value) && ! mb_check_encoding($value, 'UTF-8')) {
$value = mb_convert_encoding($value, 'UTF-8', 'GB18030');
}
}
unset($value);
return $row;
}
private function date($value): ?string
{
if ($value === null || $value === '') {
return null;
}
return substr((string) $value, 0, 10);
}
}

View File

@ -2,9 +2,13 @@
namespace App\Http\Controllers\Business;
use App\Business\BusinessTableImporter;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Models\Banking\MarketingAccountBill;
use App\Models\StoreReceivables\StoreZhongcaiReconciliation;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
@ -36,6 +40,8 @@ class Pages extends Controller
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
/**
@ -45,6 +51,7 @@ class Pages extends Controller
'taobao-alipay', 'taobao-wechat', 'pinduoduo',
'jd-wallet', 'douyin', 'dewu', 'vipshop',
];
public function home()
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
@ -76,12 +83,7 @@ class Pages extends Controller
if ($pages->isEmpty()) {
abort_if($page !== null || $tab !== null, 404);
return view('business.pages.show', [
'section' => $sectionConfig,
'page' => null,
'tabs' => collect(),
'activeTab' => null,
]);
return view('business.pages.show', $this->viewData($sectionConfig));
}
if ($page === null) {
@ -97,7 +99,6 @@ class Pages extends Controller
abort_if(empty($pageConfig), 404);
// 如果该 page 是一个 children-only 的父级分组,访问它时跳到第一个子页面
if (! empty($pageConfig['children']) && empty($pageConfig['tabs'])) {
return $this->redirectToPage($sectionConfig, $pageConfig['children'][0]);
}
@ -107,18 +108,13 @@ class Pages extends Controller
if ($tabs->isEmpty()) {
abort_if($tab !== null, 404);
$content = $this->resolveContent($page, null);
return view('business.pages.show', [
'section' => $sectionConfig,
'page' => $pageConfig,
'tabs' => $tabs,
'activeTab' => null,
'content' => $content,
'edgeBanding' => $this->edgeBandingContent($page),
'inventoryData' => $this->inventoryContent($page),
'rawBoardData' => $this->rawBoardContent($page),
]);
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
null,
$this->resolveContent($page, null),
));
}
if ($tab === null) {
@ -129,23 +125,94 @@ class Pages extends Controller
abort_if(empty($activeTab), 404);
$content = $this->resolveContent($page, $tab);
return view('business.pages.show', [
'section' => $sectionConfig,
'page' => $pageConfig,
'tabs' => $tabs,
'activeTab' => $activeTab,
'content' => $content,
'edgeBanding' => $this->edgeBandingContent($page),
'inventoryData' => $this->inventoryContent($page),
'rawBoardData' => $this->rawBoardContent($page),
]);
return view('business.pages.show', $this->viewData(
$sectionConfig,
$pageConfig,
$tabs,
$activeTab,
$this->resolveContent($page, $tab),
));
}
protected function viewData(
array $section,
?array $page = null,
$tabs = null,
?array $activeTab = null,
?array $content = null,
): array {
$descriptor = null;
$rows = collect();
$tabValue = null;
$tabColumn = null;
if ($page && $content === null) {
$descriptor = TableRegistry::describe(
$section['slug'],
$page['slug'],
$activeTab['slug'] ?? null,
);
if ($descriptor) {
if ($descriptor->readOnly) {
$rows = app(UpstreamBusinessInformation::class)->paginate(
$section['slug'],
$page['slug'],
50,
);
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'tabColumn' => null,
'tabValue' => null,
'edgeBanding' => $this->edgeBandingContent($page['slug']),
'inventoryData' => $this->inventoryContent($page['slug']),
'rawBoardData' => $this->rawBoardContent($page['slug']),
];
}
$query = ($descriptor->model)::query();
if ($descriptor->tabFilterColumn && $activeTab) {
$tabValue = TableRegistry::tabValue($section['slug'], $page['slug'], $activeTab['slug']);
if ($tabValue !== null) {
$query->where($descriptor->tabFilterColumn, $tabValue);
}
$tabColumn = $descriptor->tabFilterColumn;
}
foreach (TableRegistry::fixedFilters($section['slug'], $page['slug']) as $column => $value) {
$query->where($column, $value);
}
$rows = $query->orderByDesc('id')->paginate(50)->withQueryString();
}
}
return [
'section' => $section,
'page' => $page,
'tabs' => $tabs ?? collect(),
'activeTab' => $activeTab,
'content' => $content,
'descriptor' => $descriptor,
'rows' => $rows,
'tabColumn' => $tabColumn,
'tabValue' => $tabValue,
'edgeBanding' => $page ? $this->edgeBandingContent($page['slug']) : null,
'inventoryData' => $page ? $this->inventoryContent($page['slug']) : null,
'rawBoardData' => $page ? $this->rawBoardContent($page['slug']) : null,
];
}
/**
* 根据页面和标签加载对应的业务内容数据
*/
protected function resolveContent(string $page, ?string $tab): ?array
{
return match ($page) {
@ -609,7 +676,7 @@ class Pages extends Controller
/**
* 种菜应收对账 - 加载对账数据(21字段)
*/
protected function contentForZhongcaiReconciliation(): ?array
protected function contentForZhongcaiReconciliation(): array
{
return [
'view' => 'store_receivables.zhongcai_reconciliations.partials.embedded-table',
@ -619,13 +686,9 @@ class Pages extends Controller
];
}
/**
* 营销账户账单 - 加载指定平台的数据
* 淘系支付宝、拼多多使用独立的流水账单表,其他平台使用通用汇总账单表
*/
protected function contentForMarketingAccountBills(string $tab): ?array
protected function contentForMarketingAccountBills(?string $tab): ?array
{
if (!in_array($tab, self::MARKETING_PLATFORMS, true)) {
if (! in_array($tab, self::MARKETING_PLATFORMS, true)) {
return null;
}
@ -779,7 +842,6 @@ class Pages extends Controller
];
}
// 其他平台 - 使用通用汇总账单表(15字段)
return [
'view' => 'banking.marketing_account_bills.partials.embedded-table',
'platform' => $tab,
@ -790,6 +852,86 @@ class Pages extends Controller
];
}
public function store(Request $request, string $section, string $page, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$data = $request->validate($this->validationRules($descriptor));
if ($descriptor->tabFilterColumn && $tab && ($tabValue = TableRegistry::tabValue($section, $page, $tab))) {
$data[$descriptor->tabFilterColumn] = $tabValue;
}
foreach (TableRegistry::fixedFilters($section, $page) as $column => $value) {
$data[$column] = $value;
}
($descriptor->model)::create($data);
return $this->redirectBack($section, $page, $tab);
}
public function import(
Request $request,
BusinessTableImporter $importer,
string $section,
string $page,
?string $tab = null,
) {
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
]);
$result = $importer->import($request->file('import'), $descriptor, $section, $page, $tab);
return $this->redirectBack($section, $page, $tab)->with(
'success',
"导入完成:新增 {$result['created']} 条,更新 {$result['updated']} 条。",
);
}
public function update(Request $request, string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
$row = ($descriptor->model)::findOrFail($id);
$row->update($request->validate($this->validationRules($descriptor)));
return $this->redirectBack($section, $page, $tab);
}
public function destroy(string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
abort_if(! $descriptor, 404);
($descriptor->model)::findOrFail($id)->delete();
return $this->redirectBack($section, $page, $tab);
}
protected function validationRules($descriptor): array
{
$rules = [];
foreach ($descriptor->fields as $field) {
$rules[$field['name']] = match ($field['type']) {
'number', 'currency' => ['nullable', 'numeric'],
'boolean' => ['nullable', 'boolean'],
'date' => ['nullable', 'date'],
'select' => ['nullable', 'string', 'max:64'],
default => ['nullable', 'string', 'max:255'],
};
}
return $rules;
}
protected function redirectToPage(array $section, array $page)
{
$parameters = [
@ -816,4 +958,19 @@ class Pages extends Controller
return checkdate($month, $day, $year) ? $date : '';
}
protected function redirectBack(string $section, string $page, ?string $tab)
{
$parameters = [
'company_id' => company_id() ?: 1,
'section' => $section,
'page' => $page,
];
if ($tab) {
$parameters['tab'] = $tab;
}
return redirect()->route('business.pages.show', $parameters)->with('success', true);
}
}

View File

@ -15,7 +15,7 @@ class ShowInAdmin
$menu = $event->menu;
foreach (SidebarPermissions::navigationFor(user()) as $sectionIndex => $section) {
$pages = $section['pages'] ?? [];
$pages = $this->sortByTitleLength($section['pages'] ?? []);
$order = ($sectionIndex + 1) * 10;
if (empty($pages)) {
@ -44,7 +44,7 @@ class ShowInAdmin
$parameters['tab'] = $page['tabs'][0]['slug'];
}
$children = $page['children'] ?? [];
$children = $this->sortByTitleLength($page['children'] ?? []);
if (! empty($children)) {
// 父级 page 渲染为 dropdown:标题可点击跳到第一个子页面,子项作为嵌套菜单
@ -103,4 +103,24 @@ class ShowInAdmin
]);
}
}
private function sortByTitleLength(array $items): array
{
$indexed = [];
foreach (array_values($items) as $index => $item) {
$indexed[] = [
'item' => $item,
'index' => $index,
'length' => mb_strlen((string) ($item['title'] ?? '')),
];
}
usort($indexed, static function (array $left, array $right): int {
return ($left['length'] <=> $right['length'])
?: ($left['index'] <=> $right['index']);
});
return array_column($indexed, 'item');
}
}

View File

@ -13,4 +13,18 @@ class BankAccount extends Model
protected $casts = [
'company_id' => 'integer',
];
public static function columnLabels(): array
{
return [
'account_name' => '账户名称',
'account_type' => '账户类型',
'bank_account_number' => '银行账号',
'opening_bank' => '开户行',
'ukey_password_maker' => 'UKey密码(制单)',
'login_password_maker' => '登录密码(制单)',
'ukey_password_reviewer' => 'UKey密码(审核)',
'login_password_reviewer' => '登录密码(审核)',
];
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Models\Business;
use Illuminate\Database\Eloquent\Model;
/**
* Base class for all "business finance" tables that mirror the Excel sheets
* the user supplied (店铺后台税务数据、财务SW规划表、实际SW统计表 etc.).
*
* Each table is scoped to a company (multi-tenant) and the column list drives
* the dynamic Blade views / forms so we don't have to hand-write 11 sets of
* controllers and templates.
*/
abstract class BusinessModel extends Model
{
public $timestamps = true;
protected $guarded = ['id', 'company_id', 'created_at', 'updated_at'];
protected static function booted(): void
{
static::addGlobalScope('company', function ($builder) {
if (company_id()) {
$builder->where($builder->getModel()->getTable() . '.company_id', company_id());
}
});
static::creating(function (self $model) {
if (empty($model->company_id)) {
$model->company_id = company_id();
}
});
}
/**
* Ordered columns (id, then declared columns, then timestamps).
* Used by the Blade views to render the table headers and form fields.
*/
public static function declaredColumns(): array
{
static $cache = [];
$key = static::class;
if (isset($cache[$key])) {
return $cache[$key];
}
/** @var \Illuminate\Database\Schema\SQLiteBuilder $schema */
$schema = (new static())->getConnection()->getSchemaBuilder();
$columns = $schema->getColumnListing((new static())->getTable());
// Re-order: primary key first, then declared columns, then timestamps.
$ordered = array_values(array_filter($columns, function (string $col) {
return ! in_array($col, ['id', 'company_id', 'created_at', 'updated_at'], true);
}));
return $cache[$key] = ['id' => 'id'] + array_combine($ordered, $ordered);
}
/**
* Column human-readable labels (overridable per model).
*/
public static function columnLabels(): array
{
return [];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class CompanyInformation extends Model
{
protected $table = 'business_company_information';
protected $fillable = [
'company_id',
'unified_social_credit_code',
'company_name',
'subscribed_capital',
'company_type',
'establishment_date',
'legal_representative',
'registered_address',
'business_scope',
'basic_deposit_account_number',
'company_status',
'financial_officer',
'supervisor',
'taxpayer_category',
'shareholders',
];
protected $casts = [
'company_id' => 'integer',
'subscribed_capital' => 'decimal:2',
'establishment_date' => 'date:Y-m-d',
];
public static function columnLabels(): array
{
return [
'unified_social_credit_code' => '统一社会信用代码',
'company_name' => '公司名称',
'subscribed_capital' => '认缴注册资本(万元)',
'company_type' => '类型',
'establishment_date' => '成立日期',
'legal_representative' => '法定代表人',
'registered_address' => '注册地址(住所)',
'business_scope' => '经营范围',
'basic_deposit_account_number' => '基本存款账户编号',
'company_status' => '公司状态',
'financial_officer' => '财务负责人',
'supervisor' => '监事',
'taxpayer_category' => '纳税类别及类型',
'shareholders' => '股东',
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models\Business;
class CompanyStore extends BusinessModel
{
protected $table = 'biz_company_stores';
protected $casts = [
'store_revenue' => 'decimal:2',
'company_revenue' => 'decimal:2',
'trigger_store_change' => 'boolean',
'trigger_company_register' => 'boolean',
];
public static function columnLabels(): array
{
return [
'company_name' => '公司名称',
'store_name' => '店铺名称',
'store_revenue' => '店铺业绩',
'company_revenue' => '公司业绩',
'trigger_store_change' => '是否触发店铺变更流程',
'trigger_company_register' => '是否触发公司注册流程',
'period' => '期间',
];
}
}

View File

@ -15,4 +15,16 @@ class CompanyStoreData extends Model
'trigger_store_change' => 'boolean',
'trigger_company_registration' => 'boolean',
];
public static function columnLabels(): array
{
return [
'company_name' => '公司名称',
'store_name' => '店铺名称',
'store_performance' => '店铺业绩',
'company_performance' => '公司业绩',
'trigger_store_change' => '是否触发店铺变更流程',
'trigger_company_registration' => '是否触发公司注册流程',
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Models\Business;
class ExpressReceiving extends BusinessModel
{
protected $table = 'ak_biz_express_receiving';
public static function columnLabels(): array
{
return [
'express_name' => '快递名称',
'receiving_type' => '收款类型',
'receiving_name' => '收款名称',
'account_number' => '收款账号',
'bank_name' => '开户行',
];
}
}

View File

@ -13,4 +13,53 @@ class FundForecast extends Model
protected $casts = [
'company_id' => 'integer',
];
public static function columnLabels(): array
{
return [
'budget_month' => '预算月份',
'actual_month' => '实际月份',
'store_balance_budget' => '店铺余额(预算)',
'store_balance_actual' => '店铺余额(实际)',
'bank_balance_budget' => '银行余额(预算)',
'bank_balance_actual' => '银行余额(实际)',
'other_balance_budget' => '其他余额(预算)',
'other_balance_actual' => '其他余额(实际)',
'expected_monthly_return_budget' => '预计回款(预算)',
'expected_monthly_return_actual' => '预计回款(实际)',
'income_total_budget' => '收入合计(预算)',
'income_total_actual' => '收入合计(实际)',
'prior_unpaid_budget' => '往期未付(预算)',
'prior_unpaid_actual' => '往期未付(实际)',
'advance_budget' => '借支(预算)',
'advance_actual' => '借支(实际)',
'supplier_budget' => '供应商(预算)',
'supplier_actual' => '供应商(实际)',
'spot_settlement_budget' => '现结(预算)',
'spot_settlement_actual' => '现结(实际)',
'courier_budget' => '快递(预算)',
'courier_actual' => '快递(实际)',
'paper_factory_budget' => '纸厂压贴(预算)',
'paper_factory_actual' => '纸厂压贴(实际)',
'edge_band_budget' => '封边条(预算)',
'edge_band_actual' => '封边条(实际)',
'board_budget' => '板材(预算)',
'board_actual' => '板材(实际)',
'salary_budget' => '工资(预算)',
'salary_actual' => '工资(实际)',
'daily_expense_budget' => '日常费用(预算)',
'daily_expense_actual' => '日常费用(实际)',
'planting_budget' => '种菜(预算)',
'planting_actual' => '种菜(实际)',
'red_packet_budget' => '红包(预算)',
'red_packet_actual' => '红包(实际)',
'corporate_deduction_budget' => '对公扣款(预算)',
'corporate_deduction_actual' => '对公扣款(实际)',
'expense_total_budget' => '支出合计(预算)',
'expense_total_actual' => '支出合计(实际)',
'bank_reservation' => '银行预留',
'net_balance_budget' => '净结余(预算)',
'net_balance_actual' => '净结余(实际)',
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models\Business;
class InvoiceBasic extends BusinessModel
{
protected $table = 'ak_biz_invoice_basic';
protected $casts = [
'invoice_limit' => 'decimal:2',
'output_amount' => 'decimal:2',
'input_amount' => 'decimal:2',
];
public static function columnLabels(): array
{
return [
'store_name' => '店铺名称',
'store_entity' => '店铺主体',
'invoice_issuer' => '开票员',
'invoice_limit' => '开票额度',
'output_amount' => '销项金额',
'input_amount' => '进项金额',
'period' => '期间',
];
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Models\Business;
use App\Abstracts\Model;
class StoreInformation extends Model
{
protected $table = 'business_store_information';
protected $fillable = [
'company_id',
'opened_at',
'erp_store_name',
'unified_social_credit_code',
'company_name',
'trademark_number',
'trademark_name',
'platform',
'profit_center',
'store_manager',
'store_status',
'store_type',
'channel_mode',
];
protected $casts = [
'company_id' => 'integer',
'opened_at' => 'date:Y-m-d',
];
public static function columnLabels(): array
{
return [
'opened_at' => '开店时间',
'erp_store_name' => 'ERP系统店铺名称',
'unified_social_credit_code' => '公司社会统一信用码',
'company_name' => '公司名称',
'trademark_number' => '商标号',
'trademark_name' => '商标名称',
'platform' => '平台',
'profit_center' => '店铺所属利润中心',
'store_manager' => '店铺负责人',
'store_status' => '店铺状态',
'store_type' => '店铺类型',
'channel_mode' => '渠道模式',
];
}
public static function fieldTypes(): array
{
return [
'opened_at' => 'date',
'platform' => 'text',
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models\Business;
class StoreInvoice extends BusinessModel
{
protected $table = 'ak_biz_store_invoice';
protected $casts = [
'invoice_amount' => 'decimal:2',
'invoice_date' => 'date',
];
public static function columnLabels(): array
{
return [
'store' => '店铺',
'seller_name' => '销售方名称',
'seller_tax_no' => '销售方税号',
'buyer_name' => '购买方名称',
'buyer_tax_no' => '购买方税号',
'invoice_amount' => '开票金额',
'invoice_category' => '类目',
'invoice_type' => '开票类型',
'invoice_date' => '开票日期',
];
}
}

View File

@ -13,4 +13,20 @@ class StoreReceivingAccount extends Model
protected $casts = [
'company_id' => 'integer',
];
public static function columnLabels(): array
{
return [
'store_platform' => '店铺平台',
'store_name' => '店铺名称',
'wallet_number' => '钱包号',
'store_entity_name' => '店铺主体名称',
'login_account' => '登录账号',
'login_password' => '登录密码',
'receiving_method' => '收款方式',
'receiving_account_name' => '收款账户名称',
'alipay_or_bank_account' => '支付宝或银行卡账号',
'opening_bank' => '开户行',
];
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Models\Business;
class SwActual extends BusinessModel
{
protected $table = 'biz_sw_actual';
protected $casts = [
'provided_revenue' => 'decimal:2',
'declared_revenue' => 'decimal:2',
'goods_special_amount' => 'decimal:2',
'goods_special_tax' => 'decimal:2',
'goods_normal_amount' => 'decimal:2',
'goods_normal_tax' => 'decimal:2',
'goods_unbilled_amount' => 'decimal:2',
'goods_unbilled_tax' => 'decimal:2',
'goods_subtotal_amount' => 'decimal:2',
'goods_subtotal_tax' => 'decimal:2',
'service_special_amount' => 'decimal:2',
'service_special_tax' => 'decimal:2',
'service_normal_amount' => 'decimal:2',
'service_normal_tax' => 'decimal:2',
'service_unbilled_amount' => 'decimal:2',
'service_unbilled_tax' => 'decimal:2',
'service_subtotal_amount' => 'decimal:2',
'service_subtotal_tax' => 'decimal:2',
'total_revenue' => 'decimal:2',
'total_tax' => 'decimal:2',
'input_goods_amount' => 'decimal:2',
'input_goods_tax' => 'decimal:2',
'input_service_amount' => 'decimal:2',
'input_service_tax' => 'decimal:2',
'input_other_amount' => 'decimal:2',
'input_other_tax' => 'decimal:2',
'input_subtotal_amount' => 'decimal:2',
'input_subtotal_tax' => 'decimal:2',
'extra_credit' => 'decimal:2',
'retained_credit' => 'decimal:2',
'vat_payable' => 'decimal:2',
'city_tax' => 'decimal:2',
'education_surcharge' => 'decimal:2',
'local_education_surcharge' => 'decimal:2',
'stamp_tax' => 'decimal:2',
'union_fee' => 'decimal:2',
'individual_tax' => 'decimal:2',
'income_tax' => 'decimal:2',
'garbage_fee' => 'decimal:2',
'other_penalty' => 'decimal:2',
'total_tax_paid' => 'decimal:2',
'tax_burden_rate' => 'decimal:4',
];
public static function columnLabels(): array
{
return [
'region' => '区域',
'company_type' => '公司类型',
'contact_person' => '对接人',
'bookkeeping_company' => '代账公司',
'period' => '期间',
'company_name' => '公司名称',
'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' => '税金合计',
'tax_burden_rate' => '税负',
];
}
}

View File

@ -13,4 +13,58 @@ class SwActualStatistics extends Model
protected $casts = [
'company_id' => 'integer',
];
public static function columnLabels(): array
{
return [
'region' => '区域',
'company_type' => '公司类型',
'contact_person' => '对接人',
'bookkeeping_company' => '代账公司',
'period' => '期间',
'company_name' => '公司名称',
'provided_performance' => '提供业绩',
'actual_declared_performance' => '实际申报业绩',
'goods_special_invoice_amount' => '商品收入(专票金额)',
'goods_special_invoice_tax' => '商品收入(专票税额)',
'goods_general_invoice_amount' => '商品收入(普票金额)',
'goods_general_invoice_tax' => '商品收入(普票税额)',
'goods_uninvoiced_amount' => '商品收入(未开票金额)',
'goods_uninvoiced_tax' => '商品收入(未开票税额)',
'goods_income_subtotal' => '商品收入小计',
'goods_tax_subtotal' => '商品税金小计',
'service_special_invoice_amount' => '服务收入(专票金额)',
'service_special_invoice_tax' => '服务收入(专票税额)',
'service_general_invoice_amount' => '服务收入(普票金额)',
'service_general_invoice_tax' => '服务收入(普票税额)',
'service_uninvoiced_amount' => '服务收入(未开票金额)',
'service_uninvoiced_tax' => '服务收入(未开票税额)',
'service_income_subtotal' => '服务收入小计',
'service_tax_subtotal' => '服务税金小计',
'total_income' => '合计收入',
'total_tax' => '合计税金',
'input_goods_amount' => '进项商品金额',
'input_goods_tax' => '进项商品税额',
'input_service_amount' => '进项服务费金额',
'input_service_tax' => '进项服务费税额',
'input_other_amount' => '进项其他金额',
'input_other_tax' => '进项其他税额',
'input_total_amount' => '进项金额合计',
'input_total_tax' => '进项税额合计',
'additional_deduction' => '加计抵减',
'retained_tax' => '留抵税金',
'monthly_vat' => '本月应交增值税',
'urban_maintenance_tax' => '城建税金',
'education_surcharge' => '教育费附加',
'local_education_surcharge' => '地方教育费附加',
'stamp_tax' => '印花税',
'union_fund' => '工会经费',
'personal_income_tax' => '个税',
'corporate_income_tax' => '所得税',
'garbage_fee' => '垃圾处理费',
'other_late_fee' => '其他(滞纳金)',
'total_paid_tax' => '税金合计',
'tax_burden' => '税负',
];
}
}

View File

@ -13,4 +13,18 @@ class SwPlanning extends Model
protected $casts = [
'company_id' => 'integer',
];
public static function columnLabels(): array
{
return [
'company_name' => '公司名称',
'final_tax_performance' => '最终税务业绩',
'product_cost' => '产品成本',
'logistics_cost' => '物流成本',
'platform_invoice' => '平台开票',
'labor_cost' => '人工成本',
'other_expenses' => '其他费用',
'profit' => '利润',
];
}
}

View File

@ -13,4 +13,20 @@ class TaxData extends Model
protected $casts = [
'company_id' => 'integer',
];
public static function columnLabels(): array
{
return [
'company_name' => '公司名称',
'real_shipping_performance' => '公司真实发货业绩',
'planting_performance' => '公司种菜业绩',
'refund_amount' => '退款金额',
'net_performance_income' => '业绩净收入',
'tax_performance' => '税务业绩',
'tax_refund' => '税务退款',
'tax_net_income' => '税务净收入',
'performance_difference' => '业绩差额',
'final_tax_performance' => '最终报税业绩',
];
}
}

View File

@ -6,6 +6,7 @@ return [
'title' => '公司管理',
'icon' => 'business',
'pages' => [
['slug' => 'basic-information', 'title' => '公司信息'],
['slug' => 'registration', 'title' => '公司注册'],
['slug' => 'changes', 'title' => '公司变更'],
['slug' => 'cancellation', 'title' => '公司注销'],
@ -19,7 +20,7 @@ return [
'icon' => 'verified',
'pages' => [
['slug' => 'registration', 'title' => '商标注册'],
['slug' => 'changes', 'title' => '商标表更'],
['slug' => 'changes', 'title' => '商标变更'],
['slug' => 'renewal', 'title' => '商标续展'],
['slug' => 'information', 'title' => '商标信息'],
],
@ -29,6 +30,7 @@ return [
'title' => '店铺管理',
'icon' => 'storefront',
'pages' => [
['slug' => 'basic-information', 'title' => '店铺基础信息'],
['slug' => 'opening-process', 'title' => '开店流程'],
['slug' => 'changes', 'title' => '店铺变更'],
['slug' => 'closing-process', 'title' => '关店流程'],

View File

@ -28,6 +28,8 @@ return [
// 只限制冷查询和后台刷新;共享快照命中不占槽位。
'max_concurrency' => (int) env('UPSTREAM_MAX_CONCURRENCY', 4),
'slot_wait_ms' => (int) env('UPSTREAM_SLOT_WAIT_MS', 1500),
// 开发机可覆盖系统数据库中记录的 ODBC 驱动名。
'odbc_driver' => env('UPSTREAM_ODBC_DRIVER'),
],
'stored_configuration' => [

View File

@ -0,0 +1,276 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. 店铺后台税务数据
Schema::create('ak_biz_tax_data', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('company_name')->default('')->comment('公司名称');
$table->decimal('real_ship_revenue', 18, 2)->default(0)->comment('公司真实发货业绩');
$table->decimal('zhongcai_revenue', 18, 2)->default(0)->comment('公司种菜业绩');
$table->decimal('refund_amount', 18, 2)->default(0)->comment('退款金额');
$table->decimal('net_revenue', 18, 2)->default(0)->comment('业绩净收入');
$table->decimal('tax_revenue', 18, 2)->default(0)->comment('税务业绩');
$table->decimal('tax_refund', 18, 2)->default(0)->comment('税务退款');
$table->decimal('tax_net_revenue', 18, 2)->default(0)->comment('税务净收入');
$table->decimal('revenue_diff', 18, 2)->default(0)->comment('业绩差额');
$table->decimal('final_tax_revenue', 18, 2)->default(0)->comment('最终报税业绩');
$table->string('period')->default('')->comment('期间,例如 2026-07');
$table->timestamps();
});
// 2. 财务SW规划表
Schema::create('ak_biz_sw_planning', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('company_name')->default('');
$table->decimal('final_tax_revenue', 18, 2)->default(0)->comment('最终报税业绩');
$table->decimal('product_cost', 18, 2)->default(0)->comment('商品成本');
$table->decimal('logistics_cost', 18, 2)->default(0)->comment('物流成本');
$table->decimal('platform_invoice', 18, 2)->default(0)->comment('平台来票');
$table->decimal('labor_cost', 18, 2)->default(0)->comment('人工');
$table->decimal('other_reimbursement', 18, 2)->default(0)->comment('其他报销');
$table->decimal('profit', 18, 2)->default(0)->comment('利润');
$table->string('period')->default('');
$table->timestamps();
});
// 3. 实际SW统计表(多级表头,扁平化存储)
Schema::create('biz_sw_actual', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('region')->default('')->comment('区域');
$table->string('company_type')->default('')->comment('公司类型');
$table->string('contact_person')->default('')->comment('对接人');
$table->string('bookkeeping_company')->default('')->comment('代账公司');
$table->string('period')->default('')->comment('期间');
$table->string('company_name')->default('')->comment('公司名称');
$table->decimal('provided_revenue', 18, 2)->default(0)->comment('提供业绩');
$table->decimal('declared_revenue', 18, 2)->default(0)->comment('实际申报业绩');
// 商品收入 13%
$table->decimal('goods_special_amount', 18, 2)->default(0)->comment('商品专票金额');
$table->decimal('goods_special_tax', 18, 2)->default(0)->comment('商品专票税额');
$table->decimal('goods_normal_amount', 18, 2)->default(0)->comment('商品普票金额');
$table->decimal('goods_normal_tax', 18, 2)->default(0)->comment('商品普票税额');
$table->decimal('goods_unbilled_amount', 18, 2)->default(0)->comment('商品未开票金额');
$table->decimal('goods_unbilled_tax', 18, 2)->default(0)->comment('商品未开票税额');
$table->decimal('goods_subtotal_amount', 18, 2)->default(0)->comment('商品收入小计');
$table->decimal('goods_subtotal_tax', 18, 2)->default(0)->comment('商品税金小计');
// 服务收入 6%
$table->decimal('service_special_amount', 18, 2)->default(0)->comment('服务专票金额');
$table->decimal('service_special_tax', 18, 2)->default(0)->comment('服务专票税额');
$table->decimal('service_normal_amount', 18, 2)->default(0)->comment('服务普票金额');
$table->decimal('service_normal_tax', 18, 2)->default(0)->comment('服务普票税额');
$table->decimal('service_unbilled_amount', 18, 2)->default(0)->comment('服务未开票金额');
$table->decimal('service_unbilled_tax', 18, 2)->default(0)->comment('服务未开票税额');
$table->decimal('service_subtotal_amount', 18, 2)->default(0)->comment('服务收入小计');
$table->decimal('service_subtotal_tax', 18, 2)->default(0)->comment('服务税金小计');
$table->decimal('total_revenue', 18, 2)->default(0)->comment('合计收入');
$table->decimal('total_tax', 18, 2)->default(0)->comment('合计税金');
// 进项发票
$table->decimal('input_goods_amount', 18, 2)->default(0)->comment('进项商品金额');
$table->decimal('input_goods_tax', 18, 2)->default(0)->comment('进项商品税额');
$table->decimal('input_service_amount', 18, 2)->default(0)->comment('进项服务费金额');
$table->decimal('input_service_tax', 18, 2)->default(0)->comment('进项服务费税额');
$table->decimal('input_other_amount', 18, 2)->default(0)->comment('进项其他金额');
$table->decimal('input_other_tax', 18, 2)->default(0)->comment('进项其他税额');
$table->decimal('input_subtotal_amount', 18, 2)->default(0)->comment('进项金额合计');
$table->decimal('input_subtotal_tax', 18, 2)->default(0)->comment('进项税额合计');
$table->decimal('extra_credit', 18, 2)->default(0)->comment('加计抵减');
$table->decimal('retained_credit', 18, 2)->default(0)->comment('留抵税金');
$table->decimal('vat_payable', 18, 2)->default(0)->comment('本月应交增值税');
$table->decimal('city_tax', 18, 2)->default(0)->comment('城建税金');
$table->decimal('education_surcharge', 18, 2)->default(0)->comment('教育费附加');
$table->decimal('local_education_surcharge', 18, 2)->default(0)->comment('地方教育费附加');
$table->decimal('stamp_tax', 18, 2)->default(0)->comment('印花税');
$table->decimal('union_fee', 18, 2)->default(0)->comment('工会经费');
$table->decimal('individual_tax', 18, 2)->default(0)->comment('个税');
$table->decimal('income_tax', 18, 2)->default(0)->comment('所得税');
$table->decimal('garbage_fee', 18, 2)->default(0)->comment('垃圾处理费');
$table->decimal('other_penalty', 18, 2)->default(0)->comment('其他(滞纳金)');
$table->decimal('total_tax_paid', 18, 2)->default(0)->comment('税金合计');
$table->decimal('tax_burden_rate', 8, 4)->default(0)->comment('税负');
$table->timestamps();
});
// 4. 公司店铺数据
Schema::create('biz_company_stores', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('company_name')->default('')->comment('公司名称');
$table->string('store_name')->default('')->comment('店铺名称');
$table->decimal('store_revenue', 18, 2)->default(0)->comment('店铺业绩');
$table->decimal('company_revenue', 18, 2)->default(0)->comment('公司业绩');
$table->boolean('trigger_store_change')->default(false)->comment('是否触发店铺变更流程');
$table->boolean('trigger_company_register')->default(false)->comment('是否触发公司注册流程');
$table->string('period')->default('');
$table->timestamps();
});
// 5. 店铺变更统计表
Schema::create('ak_biz_store_changes', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('store_name')->default('')->comment('店铺名称');
$table->string('store_entity')->default('')->comment('店铺主体');
$table->date('change_date')->nullable()->comment('变更时间');
$table->string('receiving_account')->default('')->comment('收款账户信息');
$table->string('previous_entity')->default('')->comment('历史主体');
$table->timestamps();
});
// 6. 发票基础信息+发票数据报表
Schema::create('ak_biz_invoice_basic', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('store_name')->default('')->comment('店铺名称');
$table->string('store_entity')->default('')->comment('店铺主体');
$table->string('invoice_issuer')->default('')->comment('开票员');
$table->decimal('invoice_limit', 18, 2)->default(0)->comment('开票额度');
$table->decimal('output_amount', 18, 2)->default(0)->comment('销项金额');
$table->decimal('input_amount', 18, 2)->default(0)->comment('进项金额');
$table->string('period')->default('');
$table->timestamps();
});
// 7. 店铺后台开票
Schema::create('ak_biz_store_invoice', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('store')->default('')->comment('店铺');
$table->string('seller_name')->default('')->comment('销售方名称');
$table->string('seller_tax_no')->default('')->comment('销售方税号');
$table->string('buyer_name')->default('')->comment('购买方名称');
$table->string('buyer_tax_no')->default('')->comment('购买方税号');
$table->decimal('invoice_amount', 18, 2)->default(0)->comment('开票金额');
$table->string('invoice_category')->default('')->comment('类目');
$table->string('invoice_type')->default('')->comment('开票类型,普票/专票');
$table->date('invoice_date')->nullable()->comment('开票日期');
$table->timestamps();
});
// 8. 资金预测表(月度资金收支预算表)
Schema::create('ak_biz_fund_forecast', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('period')->default('')->comment('预算填报月份');
$table->string('actual_period')->default('')->comment('实际填报月份');
// 收入明细
$table->decimal('store_balance_budget', 18, 2)->default(0)->comment('店铺余额预算');
$table->decimal('store_balance_actual', 18, 2)->default(0)->comment('店铺余额实际');
$table->decimal('bank_balance_budget', 18, 2)->default(0)->comment('银行余额预算');
$table->decimal('bank_balance_actual', 18, 2)->default(0)->comment('银行余额实际');
$table->decimal('other_balance_budget', 18, 2)->default(0)->comment('其他账户余额预算');
$table->decimal('other_balance_actual', 18, 2)->default(0)->comment('其他账户余额实际');
$table->decimal('expected_inbound_budget', 18, 2)->default(0)->comment('预计本月回款预算');
$table->decimal('expected_inbound_actual', 18, 2)->default(0)->comment('预计本月回款实际');
$table->decimal('income_total_budget', 18, 2)->default(0)->comment('收入合计预算');
$table->decimal('income_total_actual', 18, 2)->default(0)->comment('收入合计实际');
// 支出明细
$table->decimal('previous_unpaid_budget', 18, 2)->default(0)->comment('往期未付预算');
$table->decimal('previous_unpaid_actual', 18, 2)->default(0)->comment('往期未付实际');
$table->decimal('advance_budget', 18, 2)->default(0)->comment('借支预算');
$table->decimal('advance_actual', 18, 2)->default(0)->comment('借支实际');
$table->decimal('supplier_budget', 18, 2)->default(0)->comment('供应商预算');
$table->decimal('supplier_actual', 18, 2)->default(0)->comment('供应商实际');
$table->decimal('cash_budget', 18, 2)->default(0)->comment('现结预算');
$table->decimal('cash_actual', 18, 2)->default(0)->comment('现结实际');
$table->decimal('express_budget', 18, 2)->default(0)->comment('快递预算');
$table->decimal('express_actual', 18, 2)->default(0)->comment('快递实际');
$table->decimal('paper_press_budget', 18, 2)->default(0)->comment('纸厂压贴预算');
$table->decimal('paper_press_actual', 18, 2)->default(0)->comment('纸厂压贴实际');
$table->decimal('edge_banding_budget', 18, 2)->default(0)->comment('封边条预算');
$table->decimal('edge_banding_actual', 18, 2)->default(0)->comment('封边条实际');
$table->decimal('board_budget', 18, 2)->default(0)->comment('板材、三合板预算');
$table->decimal('board_actual', 18, 2)->default(0)->comment('板材、三合板实际');
$table->decimal('salary_budget', 18, 2)->default(0)->comment('工资预算');
$table->decimal('salary_actual', 18, 2)->default(0)->comment('工资实际');
$table->decimal('daily_expense_budget', 18, 2)->default(0)->comment('日常费用预算');
$table->decimal('daily_expense_actual', 18, 2)->default(0)->comment('日常费用实际');
$table->decimal('zhongcai_budget', 18, 2)->default(0)->comment('种菜预算');
$table->decimal('zhongcai_actual', 18, 2)->default(0)->comment('种菜实际');
$table->decimal('red_packet_budget', 18, 2)->default(0)->comment('红包预算');
$table->decimal('red_packet_actual', 18, 2)->default(0)->comment('红包实际');
$table->decimal('corporate_deduction_budget', 18, 2)->default(0)->comment('对公扣款预算');
$table->decimal('corporate_deduction_actual', 18, 2)->default(0)->comment('对公扣款实际');
$table->decimal('expense_total_budget', 18, 2)->default(0)->comment('支出合计预算');
$table->decimal('expense_total_actual', 18, 2)->default(0)->comment('支出合计实际');
// 资金结余
$table->decimal('reserved_balance_budget', 18, 2)->default(0)->comment('银行账户、店铺预留预算');
$table->decimal('reserved_balance_actual', 18, 2)->default(0)->comment('银行账户、店铺预留实际');
$table->decimal('net_balance_budget', 18, 2)->default(0)->comment('本月净结余预算');
$table->decimal('net_balance_actual', 18, 2)->default(0)->comment('本月净结余实际');
$table->timestamps();
});
// 9. 店铺收款账户信息
Schema::create('ak_biz_store_receiving_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('platform')->default('')->comment('店铺平台');
$table->string('store_name')->default('')->comment('店铺名称');
$table->string('wallet_no')->default('')->comment('钱包号');
$table->string('store_entity')->default('')->comment('店铺主体名称');
$table->string('login_account')->default('')->comment('登录账号');
$table->string('login_password')->default('')->comment('登录密码');
$table->string('receiving_method')->default('')->comment('收款方式');
$table->string('receiving_account_name')->default('')->comment('收款账户名称');
$table->string('account_number')->default('')->comment('支付宝账号/银行账号');
$table->string('bank_name')->default('')->comment('开户银行');
$table->timestamps();
});
// 10. 银行账户信息
Schema::create('ak_biz_bank_accounts', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('account_name')->default('')->comment('账户名称');
$table->string('account_type')->default('')->comment('账户类型:基本户/一般户/个人账户/个人微信/个人支付宝');
$table->string('bank_account')->default('')->comment('银行账号');
$table->string('bank_name')->default('')->comment('开户行');
$table->string('maker_ukey_password')->default('')->comment('ukey密码(制单)');
$table->string('maker_login_password')->default('')->comment('登录密码(制单)');
$table->string('auditor_ukey_password')->default('')->comment('ukey密码(审核)');
$table->string('auditor_login_password')->default('')->comment('登录密码(审核)');
$table->timestamps();
});
// 11. 快递收款系统
Schema::create('ak_biz_express_receiving', function (Blueprint $table) {
$table->id();
$table->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$table->string('express_name')->default('')->comment('快递名称');
$table->string('receiving_type')->default('')->comment('收款类型:对公/个人');
$table->string('receiving_name')->default('')->comment('收款名称');
$table->string('account_number')->default('')->comment('收款账号');
$table->string('bank_name')->default('')->comment('开户行');
$table->timestamps();
});
}
public function down(): void
{
foreach ([
'ak_biz_tax_data',
'ak_biz_sw_planning',
'biz_sw_actual',
'biz_company_stores',
'ak_biz_store_changes',
'ak_biz_invoice_basic',
'ak_biz_store_invoice',
'ak_biz_fund_forecast',
'ak_biz_store_receiving_accounts',
'ak_biz_bank_accounts',
'ak_biz_express_receiving',
] as $table) {
Schema::dropIfExists($table);
}
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('business_company_information', function (Blueprint $table) {
$table->increments('id');
$table->integer('company_id')->default(1)->index();
$table->string('unified_social_credit_code')->nullable()->comment('统一社会信用代码');
$table->string('company_name')->nullable()->comment('公司名称');
$table->decimal('subscribed_capital', 15, 2)->nullable()->comment('认缴注册资本(万元)');
$table->string('company_type')->nullable()->comment('类型');
$table->date('establishment_date')->nullable()->comment('成立日期');
$table->string('legal_representative')->nullable()->comment('法定代表人');
$table->string('registered_address')->nullable()->comment('注册地址(住所)');
$table->text('business_scope')->nullable()->comment('经营范围');
$table->string('basic_deposit_account_number')->nullable()->comment('基本存款账户编号');
$table->string('company_status')->nullable()->comment('公司状态');
$table->string('financial_officer')->nullable()->comment('财务负责人');
$table->string('supervisor')->nullable()->comment('监事');
$table->string('taxpayer_category')->nullable()->comment('纳税类别及类型');
$table->text('shareholders')->nullable()->comment('股东');
$table->timestamps();
$table->softDeletes();
});
Schema::create('business_store_information', function (Blueprint $table) {
$table->increments('id');
$table->integer('company_id')->default(1)->index();
$table->date('opened_at')->nullable()->comment('开店时间');
$table->string('erp_store_name')->nullable()->comment('ERP系统店铺名称');
$table->string('unified_social_credit_code')->nullable()->comment('公司社会统一信用码');
$table->string('company_name')->nullable()->comment('公司名称');
$table->string('trademark_number')->nullable()->comment('商标号');
$table->string('trademark_name')->nullable()->comment('商标名称');
$table->string('platform')->nullable()->comment('平台');
$table->string('profit_center')->nullable()->comment('店铺所属利润中心');
$table->string('store_manager')->nullable()->comment('店铺负责人');
$table->string('store_status')->nullable()->comment('店铺状态');
$table->string('store_type')->nullable()->comment('店铺类型');
$table->string('channel_mode')->nullable()->comment('渠道模式');
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('business_store_information');
Schema::dropIfExists('business_company_information');
}
};

View File

@ -145,7 +145,7 @@ PHP;
$db = trim((string) ($connection['database'] ?? ''));
$user = trim((string) ($connection['username'] ?? ''));
$pass = (string) ($connection['password'] ?? '');
$driver = trim((string) ($connection['odbc_driver'] ?? 'FreeTDS'), '{} ');
$driver = self::resolveDriver((string) ($connection['odbc_driver'] ?? 'FreeTDS'));
$tdsVersion = trim((string) ($connection['tds_version'] ?? '7.4'));
$encryption = strtolower(trim((string) ($connection['encryption'] ?? 'off')));
$connectTimeout = min(max((int) ($connection['connect_timeout'] ?? 5), 1), 30);
@ -167,17 +167,30 @@ PHP;
$applicationIntent = $applicationIntent === '' ? '' : 'ReadOnly';
$dsn = sprintf(
'odbc:Driver={%s};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=%s;ClientCharset=UTF-8;Connect Timeout=%d',
str_replace('}', '}}', $driver),
self::quoteOdbcValue($host),
$port,
self::quoteOdbcValue($db),
self::quoteOdbcValue($user),
self::quoteOdbcValue($pass),
$tdsVersion,
$connectTimeout
);
if (strcasecmp($driver, 'FreeTDS') === 0) {
$dsn = sprintf(
'odbc:Driver={%s};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=%s;ClientCharset=UTF-8;Connect Timeout=%d',
str_replace('}', '}}', $driver),
self::quoteOdbcValue($host),
$port,
self::quoteOdbcValue($db),
self::quoteOdbcValue($user),
self::quoteOdbcValue($pass),
$tdsVersion,
$connectTimeout
);
} else {
$dsn = sprintf(
'odbc:Driver={%s};Server=%s;Database=%s;UID=%s;PWD=%s;Connection Timeout=%d',
str_replace('}', '}}', $driver),
self::quoteOdbcValue($host . ',' . $port),
self::quoteOdbcValue($db),
self::quoteOdbcValue($user),
self::quoteOdbcValue($pass),
$connectTimeout
);
}
$dsn .= self::encryptionAttributes($driver, $encryption);
if ($applicationIntent !== '') {
@ -238,4 +251,21 @@ PHP;
? $attributes
: $attributes . ';TrustServerCertificate=yes';
}
private static function resolveDriver(string $configured): string
{
$override = trim((string) config('upstreams.runtime.odbc_driver', ''), '{} ');
if ($override !== '') {
return $override;
}
$configured = trim($configured, '{} ');
if (PHP_OS_FAMILY === 'Windows' && strcasecmp($configured, 'FreeTDS') === 0) {
return 'SQL Server';
}
return $configured;
}
}

View File

@ -1,6 +1,6 @@
<x-layouts.admin>
<x-slot name="title">
{{ $page['title'] ?? $section['title'] }}
{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}
</x-slot>
<x-slot name="content">
@ -102,12 +102,272 @@
box-shadow: none;
}
.business-blank-page {
min-height: 34rem;
margin-top: 1.5rem;
.biz-card {
background: #ffffff;
border: 1px solid #eef0f4;
border-radius: 1rem;
box-shadow: 0 6px 18px rgba(31, 41, 55, 0.04);
}
.biz-alert {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
background: #f0fdf4;
color: #166534;
font-size: 0.875rem;
}
.biz-alert-error {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
.biz-import-form {
display: flex;
align-items: center;
gap: 0.5rem;
}
.biz-import-input {
max-width: 15rem;
font-size: 0.75rem;
}
.biz-card + .biz-card {
margin-top: 1.25rem;
}
.biz-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid #f3f4f6;
}
.biz-card-title {
font-size: 1rem;
font-weight: 700;
color: #111827;
display: flex;
align-items: center;
gap: 0.5rem;
}
.biz-card-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.biz-table-wrap {
overflow-x: auto;
}
.biz-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem;
}
.biz-table thead th {
position: sticky;
top: 0;
background: #f9fafb;
color: #4b5563;
font-weight: 600;
text-align: left;
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
white-space: nowrap;
}
.biz-table tbody td {
padding: 0.65rem 1rem;
border-bottom: 1px solid #f3f4f6;
color: #1f2937;
white-space: nowrap;
}
.biz-table tbody tr:hover td {
background: #f9fafb;
}
.biz-table .num {
text-align: right;
font-variant-numeric: tabular-nums;
color: #111827;
}
.biz-table .actions-cell {
white-space: nowrap;
text-align: right;
}
.biz-empty {
padding: 4rem 1rem;
text-align: center;
color: #6b7280;
}
.biz-empty .material-icons-outlined {
font-size: 3rem;
color: #d1d5db;
}
.biz-btn {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.85rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
transition: background 150ms ease, color 150ms ease;
border: 1px solid transparent;
cursor: pointer;
}
.biz-btn-primary {
background: #4f46e5;
color: #ffffff;
}
.biz-btn-primary:hover {
background: #4338ca;
}
.biz-btn-ghost {
background: transparent;
color: #4b5563;
border-color: #e5e7eb;
}
.biz-btn-ghost:hover {
background: #f3f4f6;
}
.biz-btn-danger-ghost {
background: transparent;
color: #dc2626;
border-color: #fecaca;
}
.biz-btn-danger-ghost:hover {
background: #fef2f2;
}
.biz-badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.biz-badge-yes { background: #dcfce7; color: #15803d; }
.biz-badge-no { background: #f3f4f6; color: #6b7280; }
/* Modal */
.biz-modal {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: none;
align-items: flex-start;
justify-content: center;
padding: 4rem 1rem;
z-index: 60;
overflow-y: auto;
}
.biz-modal.is-open {
display: flex;
}
.biz-modal-card {
background: #ffffff;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
width: 100%;
max-width: 720px;
padding: 1.5rem;
}
.biz-modal-title {
font-size: 1.125rem;
font-weight: 700;
color: #111827;
margin-bottom: 1rem;
}
.biz-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.85rem;
}
.biz-form-grid .full {
grid-column: span 2;
}
.biz-field-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.25rem;
}
.biz-field-input,
.biz-field-select,
.biz-field-textarea {
width: 100%;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 0.5rem 0.65rem;
font-size: 0.875rem;
color: #111827;
background: #ffffff;
}
.biz-field-input:focus,
.biz-field-select:focus,
.biz-field-textarea:focus {
border-color: #6366f1;
outline: 2px solid rgba(99, 102, 241, 0.25);
}
.biz-modal-foot {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
}
.biz-pagination {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1.5rem;
border-top: 1px solid #f3f4f6;
color: #4b5563;
font-size: 0.85rem;
align-items: center;
justify-content: space-between;
}
.biz-pagination a,
.biz-pagination span {
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
}
.biz-pagination a:hover {
background: #f3f4f6;
}
.business-content-section {
@ -134,6 +394,13 @@
'my-6',
'business-page--marketing-account-bills' => $isMarketingAccountBillsPage,
])>
@if (is_string(session('success')))
<div class="biz-alert" role="status">{{ session('success') }}</div>
@endif
@if ($errors->has('import'))
<div class="biz-alert biz-alert-error" role="alert">{{ $errors->first('import') }}</div>
@endif
@if ($tabs->isNotEmpty())
<nav class="business-tab-bar" aria-label="{{ $page['title'] }}">
@foreach ($tabs as $tab)
@ -192,8 +459,258 @@
</section>
@elseif ($pageView)
@include($pageView)
@elseif ($descriptor)
<div class="biz-card mt-6">
<div class="biz-card-head">
<div class="biz-card-title">
<span class="material-icons-outlined" aria-hidden="true">table_view</span>
<span>{{ $activeTab['title'] ?? $page['title'] }} · 数据列表</span>
@if ($descriptor->readOnly)
<span class="biz-badge biz-badge-yes">工作平台数据库</span>
@endif
</div>
@unless ($descriptor->readOnly)
<div class="biz-card-actions">
<form
class="biz-import-form"
method="POST"
enctype="multipart/form-data"
action="{{ route('business.pages.import', [
'section' => $section['slug'],
'page' => $page['slug'],
'tab' => $activeTab['slug'] ?? null,
]) }}"
>
@csrf
<input class="biz-import-input" type="file" name="import" accept=".xlsx,.xls,.csv" required>
<button type="submit" class="biz-btn biz-btn-ghost" title="导入 Excel 或 CSV">
<span class="material-icons-outlined" style="font-size:1.1rem">upload_file</span>
<span>导入</span>
</button>
</form>
<button type="button" class="biz-btn biz-btn-primary" onclick="bizOpenCreateModal()">
<span class="material-icons-outlined" style="font-size:1.1rem">add</span>
<span>新增</span>
</button>
</div>
@endunless
</div>
<div class="biz-table-wrap">
<table class="biz-table">
<thead>
<tr>
<th>#</th>
@foreach ($descriptor->fields as $field)
<th class="@if ($field['currency']) num @endif">{{ $field['label'] }}</th>
@endforeach
@unless ($descriptor->readOnly)
<th class="actions-cell">操作</th>
@endunless
</tr>
</thead>
<tbody>
@forelse ($rows as $row)
<tr>
<td>{{ $row->id }}</td>
@foreach ($descriptor->fields as $field)
@php
$val = $row->{$field['name']};
@endphp
<td class="@if ($field['currency']) num @endif">
@if ($field['type'] === 'boolean')
@if ($val)
<span class="biz-badge biz-badge-yes">是</span>
@else
<span class="biz-badge biz-badge-no">否</span>
@endif
@elseif ($field['type'] === 'date' && $val)
{{ \Illuminate\Support\Carbon::parse($val)->format('Y-m-d') }}
@elseif ($field['currency'])
{{ number_format((float) $val, 2) }}
@else
{{ $val }}
@endif
</td>
@endforeach
@unless ($descriptor->readOnly)
<td class="actions-cell">
<button type="button" class="biz-btn biz-btn-ghost" title="编辑" onclick='bizOpenEditModal({{ json_encode($row->only(array_merge(['id'], array_map(fn($f) => $f['name'], $descriptor->fields)))) }})'>
<span class="material-icons-outlined" style="font-size:1.1rem">edit</span>
</button>
<form method="POST" action="{{ route('business.pages.destroy', [
'section' => $section['slug'],
'page' => $page['slug'],
'id' => $row->id,
'tab' => $activeTab['slug'] ?? null,
]) }}" style="display:inline" onsubmit="return confirm('确认删除这条记录?')">
@csrf
@method('DELETE')
<button type="submit" class="biz-btn biz-btn-danger-ghost">
<span class="material-icons-outlined" style="font-size:1.1rem">delete</span>
</button>
</form>
</td>
@endunless
</tr>
@empty
<tr>
<td colspan="{{ count($descriptor->fields) + ($descriptor->readOnly ? 1 : 2) }}">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">inbox</span>
<div>{{ $descriptor->readOnly ? '暂无工作平台数据' : '暂无数据,点击右上角"新增"开始录入' }}</div>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="biz-pagination">
<span>共 {{ $rows->total() }} 条 · 第 {{ $rows->currentPage() }} / {{ $rows->lastPage() }} 页</span>
<span>
@if ($rows->onFirstPage())
<span>‹ 上一页</span>
@else
<a href="{{ $rows->previousPageUrl() }}">‹ 上一页</a>
@endif
@if ($rows->hasMorePages())
<a href="{{ $rows->nextPageUrl() }}">下一页 ›</a>
@else
<span>下一页 ›</span>
@endif
</span>
</div>
</div>
@unless ($descriptor->readOnly)
{{-- Generic create/edit modal --}}
<div id="biz-modal" class="biz-modal" onclick="if(event.target===this)bizCloseModal()">
<div class="biz-modal-card">
<div class="biz-modal-title" id="biz-modal-title">新增记录</div>
<form id="biz-form" method="POST" action="">
@csrf
<input type="hidden" name="_method" value="POST" id="biz-form-method">
<div class="biz-form-grid">
@foreach ($descriptor->fields as $field)
<div class="{{ $field['type'] === 'textarea' ? 'full' : '' }}">
<label class="biz-field-label">{{ $field['label'] }}</label>
@switch($field['type'])
@case('boolean')
<select class="biz-field-select" name="{{ $field['name'] }}" data-default="">
<option value="0">否</option>
<option value="1">是</option>
</select>
@break
@case('select')
<select class="biz-field-select" name="{{ $field['name'] }}" data-default="">
<option value="">— 请选择 —</option>
@foreach ($field['options'] ?? [] as $opt)
<option value="{{ $opt }}">{{ $opt }}</option>
@endforeach
</select>
@break
@case('date')
<input type="date" class="biz-field-input" name="{{ $field['name'] }}" data-default="">
@break
@case('currency')
@case('number')
<input type="number" step="0.01" class="biz-field-input" name="{{ $field['name'] }}" data-default="">
@break
@case('textarea')
<textarea class="biz-field-textarea" name="{{ $field['name'] }}" rows="3" data-default=""></textarea>
@break
@default
<input type="text" class="biz-field-input" name="{{ $field['name'] }}" data-default="">
@endswitch
</div>
@endforeach
</div>
<div class="biz-modal-foot">
<button type="button" class="biz-btn biz-btn-ghost" onclick="bizCloseModal()">取消</button>
<button type="submit" class="biz-btn biz-btn-primary">
<span class="material-icons-outlined" style="font-size:1.1rem">save</span>
<span>保存</span>
</button>
</div>
</form>
</div>
</div>
<script>
window.__bizDescriptor = {
section: {{ json_encode($section['slug']) }},
page: {{ json_encode($page['slug']) }},
tab: {{ json_encode($activeTab['slug'] ?? null) }},
storeUrl: "{{ route('business.pages.store', ['section' => $section['slug'], 'page' => $page['slug'], 'tab' => $activeTab['slug'] ?? null]) }}",
updateBase: "{{ route('business.pages.update', ['section' => $section['slug'], 'page' => $page['slug'], 'id' => '__ID__', 'tab' => $activeTab['slug'] ?? null]) }}".replace('__ID__', ''),
fields: {{ json_encode($descriptor->fields) }}
};
function bizOpenModal() {
var m = document.getElementById('biz-modal');
m.classList.add('is-open');
document.body.style.overflow = 'hidden';
}
function bizCloseModal() {
var m = document.getElementById('biz-modal');
m.classList.remove('is-open');
document.body.style.overflow = '';
}
function bizResetForm() {
var form = document.getElementById('biz-form');
form.querySelectorAll('[data-default]').forEach(function (el) {
el.value = el.getAttribute('data-default') || '';
});
}
function bizOpenCreateModal() {
document.getElementById('biz-modal-title').textContent = '新增记录';
var form = document.getElementById('biz-form');
form.action = window.__bizDescriptor.storeUrl;
document.getElementById('biz-form-method').value = 'POST';
bizResetForm();
bizOpenModal();
}
function bizOpenEditModal(rowData) {
document.getElementById('biz-modal-title').textContent = '编辑记录 # ' + rowData.id;
var form = document.getElementById('biz-form');
form.action = window.__bizDescriptor.updateBase + rowData.id;
document.getElementById('biz-form-method').value = 'PATCH';
form.querySelectorAll('[data-default]').forEach(function (el) {
var name = el.getAttribute('name');
var val = rowData[name] === null ? '' : rowData[name];
// Format dates to YYYY-MM-DD for the date input
if (el.type === 'date' && val) {
val = String(val).substr(0, 10);
}
// Booleans -> "1" / "0"
if (typeof val === 'boolean') {
val = val ? '1' : '0';
}
el.value = val;
});
bizOpenModal();
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') bizCloseModal();
});
</script>
@endunless
@else
<section class="business-blank-page" aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}"></section>
<section class="biz-card mt-6" aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}">
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">description</span>
<div>该页面暂未配置数据表,敬请期待。</div>
</div>
</section>
@endif
</div>

View File

@ -64,6 +64,19 @@ Route::prefix('workspace-data/supplier-payables')
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show');
Route::post('workspace/{section}/{page}/import/{tab?}', 'Business\Pages@import')
->middleware('import')
->name('business.pages.import');
Route::post('workspace/{section}/{page}/{tab?}', 'Business\Pages@store')
->name('business.pages.store');
Route::patch('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@update')
->name('business.pages.update');
Route::delete('workspace/{section}/{page}/{id}/{tab?}', 'Business\Pages@destroy')
->name('business.pages.destroy');
Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], function () {
Route::get('users', 'SystemSettings\Users@index')->name('users.index');
Route::get('users/create', 'SystemSettings\Users@create')->name('users.create');

View File

@ -0,0 +1,177 @@
<?php
namespace Tests\Feature\Business;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Models\Auth\Permission;
use Illuminate\Pagination\LengthAwarePaginator;
use Mockery;
use Tests\Feature\FeatureTestCase;
class ManagementCrudTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
}
public function testCompanyInformationUsesWorkbookHeadersAndReadOnlyUpstreamRows(): void
{
$this->mockUpstreamPage((object) [
'id' => 1045,
'unified_social_credit_code' => '91320404MADJRTTA0U',
'company_name' => '测试上游公司',
'subscribed_capital' => '10.00',
'company_type' => '店铺-普通公司',
'establishment_date' => '2024-04-28',
'legal_representative' => '测试法人',
'registered_address' => '测试地址',
'business_scope' => '测试经营范围',
'basic_deposit_account_number' => 'J0001',
'company_status' => '正常',
'financial_officer' => '测试财务',
'supervisor' => '',
'taxpayer_category' => '小规模纳税人',
'shareholders' => null,
]);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'company-management',
'page' => 'basic-information',
]))
->assertOk()
->assertSeeText('公司信息')
->assertSeeText('认缴注册资本(万元)')
->assertSeeText('基本存款账户编号')
->assertSeeText('纳税类别及类型')
->assertSeeText('测试上游公司')
->assertSeeText('工作平台数据库')
->assertDontSeeText('导入')
->assertDontSeeText('新增');
}
public function testStoreInformationKeepsTwelveHeadersAndShowsReadOnlyUpstreamRows(): void
{
$this->mockUpstreamPage((object) [
'id' => 2047,
'opened_at' => '2026-07-18',
'erp_store_name' => '测试上游店铺',
'unified_social_credit_code' => '91320412MA216EYU49',
'company_name' => '测试关联公司',
'trademark_number' => '35774784',
'trademark_name' => null,
'platform' => '天猫',
'profit_center' => '运营中心A',
'store_manager' => '测试负责人',
'store_status' => '正常',
'store_type' => '旗舰店',
'channel_mode' => '直销',
]);
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'store-management',
'page' => 'basic-information',
]));
$response
->assertOk()
->assertSeeText('ERP系统店铺名称')
->assertSeeText('公司社会统一信用码')
->assertSeeText('渠道模式')
->assertSeeText('测试上游店铺')
->assertSeeText('测试关联公司')
->assertSeeText('工作平台数据库')
->assertDontSeeText('导入')
->assertDontSeeText('新增');
preg_match_all('/<th(?:\s|>)/', $response->getContent(), $headers);
$this->assertCount(13, $headers[0]);
}
public function testTrademarkInformationShowsReadOnlyUpstreamRows(): void
{
$this->mockUpstreamPage((object) [
'id' => 600,
'trademark_name' => '测试上游商标',
'trademark_type' => '20',
'trademark_status' => '有效',
'registration_date' => '2025-12-21',
'expiry_date' => '2035-12-20',
'notes' => null,
]);
$this->loginAs()
->get(route('business.pages.show', [
'section' => 'trademark-management',
'page' => 'information',
]))
->assertOk()
->assertSeeText('商标名称')
->assertSeeText('注册日期')
->assertSeeText('测试上游商标')
->assertSeeText('工作平台数据库')
->assertDontSeeText('导入')
->assertDontSeeText('新增');
}
public function testTargetManagementSectionsOnlyUseChineseTableHeaders(): void
{
$pages = [
['company-management', 'basic-information'],
['company-management', 'registration'],
['company-management', 'changes'],
['company-management', 'cancellation'],
['company-management', 'tax-data'],
['company-management', 'store-data'],
['trademark-management', 'registration'],
['trademark-management', 'changes'],
['trademark-management', 'renewal'],
['trademark-management', 'information'],
['store-management', 'basic-information'],
['store-management', 'opening-process'],
['store-management', 'changes'],
['store-management', 'closing-process'],
['store-management', 'change-log'],
['fund-management', 'store-receiving-accounts', 'taobao-alipay'],
['fund-management', 'bank-accounts'],
['fund-management', 'shared-company-accounts'],
['fund-management', 'fund-forecast'],
['financial-sw', 'planning'],
['financial-sw', 'actual-statistics'],
];
foreach ($pages as $route) {
[$section, $page] = $route;
$tab = $route[2] ?? null;
$descriptor = TableRegistry::describe($section, $page, $tab);
$this->assertNotNull($descriptor, "{$section}/{$page} 缺少表格描述配置");
if ($page === 'store-receiving-accounts') {
$this->assertSame('store_platform', $descriptor->tabFilterColumn);
}
foreach ($descriptor->fields as $field) {
$this->assertMatchesRegularExpression(
'/\p{Han}/u',
$field['label'],
"{$section}/{$page} 的 {$field['name']} 仍在使用英文表头 {$field['label']}"
);
}
}
}
private function mockUpstreamPage(object $row): void
{
$paginator = new LengthAwarePaginator([$row], 1, 50, 1, ['path' => '/']);
$service = Mockery::mock(UpstreamBusinessInformation::class);
$service->shouldReceive('paginate')->once()->andReturn($paginator);
$this->app->instance(UpstreamBusinessInformation::class, $service);
}
}

View File

@ -36,9 +36,10 @@ class NavigationTest extends FeatureTestCase
])->assertOk();
$navigation = BusinessNavigation::all();
$companyRegistration = collect($navigation[0]['pages'])->firstWhere('slug', 'registration');
$this->assertSame('企业档案', $navigation[0]['title']);
$this->assertSame('企业设立', $navigation[0]['pages'][0]['title']);
$this->assertSame('企业设立', $companyRegistration['title']);
}
}