diff --git a/app/Business/BusinessTableImporter.php b/app/Business/BusinessTableImporter.php
index 311ab870..0236f014 100644
--- a/app/Business/BusinessTableImporter.php
+++ b/app/Business/BusinessTableImporter.php
@@ -17,6 +17,13 @@ class BusinessTableImporter
string $page,
?string $tab = null,
): array {
+ if ($section === 'fund-management' && $page === 'fund-forecast') {
+ return app(FundForecastWorkbookImporter::class)->import(
+ $file,
+ (int) (company_id() ?: 1),
+ );
+ }
+
$spreadsheet = IOFactory::load($file->getRealPath());
[$rows, $headerRow, $columnMap] = $this->bestMatchingSheet($spreadsheet, $descriptor);
diff --git a/app/Business/FundForecastPage.php b/app/Business/FundForecastPage.php
new file mode 100644
index 00000000..422d147a
--- /dev/null
+++ b/app/Business/FundForecastPage.php
@@ -0,0 +1,160 @@
+ 'balance', 'label' => '余额', 'fields' => FundForecast::BALANCE_FIELDS],
+ ['key' => 'income', 'label' => '收入', 'fields' => FundForecast::INCOME_FIELDS],
+ ['key' => 'expense', 'label' => '支出', 'fields' => FundForecast::EXPENSE_FIELDS],
+ ['key' => 'summary', 'label' => '汇总', 'fields' => FundForecast::SUMMARY_FIELDS],
+ ];
+
+ public function data(Request $request): array
+ {
+ $filters = $this->filters($request);
+
+ if (! Schema::hasColumns('business_fund_forecast', ['company_name', 'balance_bank', 'gap_surplus'])) {
+ return $this->pendingData($request, $filters);
+ }
+
+ $base = FundForecast::query();
+ $this->applyFilters($base, $filters);
+
+ $scopeCounts = [
+ 'all' => (clone $base)->count(),
+ 'gap' => $this->applyScope(clone $base, 'gap')->count(),
+ 'unreported' => $this->applyScope(clone $base, 'unreported')->count(),
+ 'pending_review' => $this->applyScope(clone $base, 'pending_review')->count(),
+ ];
+
+ $query = $this->applyScope(clone $base, $filters['view']);
+ $summaryQuery = clone $query;
+ $rows = $query
+ ->orderByDesc('budget_month')
+ ->orderBy('company_name')
+ ->orderByDesc('id')
+ ->paginate(50)
+ ->withQueryString();
+
+ $labels = FundForecast::columnLabels();
+ $groups = array_map(function (array $group) use ($labels): array {
+ $group['columns'] = array_map(
+ fn (string $field): array => ['field' => $field, 'label' => $labels[$field]],
+ $group['fields'],
+ );
+
+ return $group;
+ }, self::GROUPS);
+
+ return [
+ 'ready' => true,
+ 'filters' => $filters,
+ 'months' => FundForecast::query()->whereNotNull('budget_month')->where('budget_month', '<>', '')
+ ->distinct()->orderByDesc('budget_month')->pluck('budget_month')->all(),
+ 'regions' => FundForecast::query()->whereNotNull('region')->where('region', '<>', '')
+ ->distinct()->orderBy('region')->pluck('region')->all(),
+ 'rows' => $rows,
+ 'groups' => $groups,
+ 'scopeCounts' => $scopeCounts,
+ 'summary' => [
+ 'company_count' => (clone $summaryQuery)->whereNotNull('company_name')->where('company_name', '<>', '')
+ ->distinct()->count('company_name'),
+ 'balance_total' => $this->sumColumns(clone $summaryQuery, FundForecast::BALANCE_FIELDS),
+ 'income_total' => (float) (clone $summaryQuery)->sum('forecast_month_income'),
+ 'gap_surplus' => (float) (clone $summaryQuery)->sum('gap_surplus'),
+ ],
+ ];
+ }
+
+ private function pendingData(Request $request, array $filters): array
+ {
+ $labels = FundForecast::columnLabels();
+ $groups = array_map(function (array $group) use ($labels): array {
+ $group['columns'] = array_map(
+ fn (string $field): array => ['field' => $field, 'label' => $labels[$field]],
+ $group['fields'],
+ );
+
+ return $group;
+ }, self::GROUPS);
+
+ return [
+ 'ready' => false,
+ 'filters' => $filters,
+ 'months' => [],
+ 'regions' => [],
+ 'rows' => new LengthAwarePaginator([], 0, 50, max((int) $request->query('page', 1), 1), [
+ 'path' => $request->url(),
+ 'query' => $request->query(),
+ ]),
+ 'groups' => $groups,
+ 'scopeCounts' => ['all' => 0, 'gap' => 0, 'unreported' => 0, 'pending_review' => 0],
+ 'summary' => ['company_count' => 0, 'balance_total' => 0.0, 'income_total' => 0.0, 'gap_surplus' => 0.0],
+ ];
+ }
+
+ private function filters(Request $request): array
+ {
+ $month = trim((string) $request->query('budget_month', ''));
+ $month = preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1 ? $month : '';
+ $status = trim((string) $request->query('report_status', ''));
+ $status = in_array($status, ['待填报', '待审核', '已审核'], true) ? $status : '';
+ $view = trim((string) $request->query('view', 'all'));
+ $view = in_array($view, ['all', 'gap', 'unreported', 'pending_review'], true) ? $view : 'all';
+
+ return [
+ 'budget_month' => $month,
+ 'region' => mb_substr(trim((string) $request->query('region', '')), 0, 64),
+ 'report_status' => $status,
+ 'company_name' => mb_substr(trim((string) $request->query('company_name', '')), 0, 191),
+ 'view' => $view,
+ ];
+ }
+
+ private function applyFilters(Builder $query, array $filters): void
+ {
+ if ($filters['budget_month'] !== '') {
+ $query->where('budget_month', $filters['budget_month']);
+ }
+ if ($filters['region'] !== '') {
+ $query->where('region', $filters['region']);
+ }
+ if ($filters['report_status'] !== '') {
+ $query->where('report_status', $filters['report_status']);
+ }
+ if ($filters['company_name'] !== '') {
+ $query->where('company_name', 'like', '%' . addcslashes($filters['company_name'], '%_\\') . '%');
+ }
+ }
+
+ private function applyScope(Builder $query, string $scope): Builder
+ {
+ return match ($scope) {
+ 'gap' => $query->where(function (Builder $nested): void {
+ $nested->where('has_gap', true)->orWhere('gap_surplus', '<', 0);
+ }),
+ 'unreported' => $query->where(function (Builder $nested): void {
+ $nested->whereNull('report_status')->orWhere('report_status', '待填报');
+ }),
+ 'pending_review' => $query->where('report_status', '待审核'),
+ default => $query,
+ };
+ }
+
+ private function sumColumns(Builder $query, array $columns): float
+ {
+ return array_reduce(
+ $columns,
+ fn (float $sum, string $column): float => $sum + (float) (clone $query)->sum($column),
+ 0.0,
+ );
+ }
+}
diff --git a/app/Business/FundForecastTemplate.php b/app/Business/FundForecastTemplate.php
new file mode 100644
index 00000000..beb60675
--- /dev/null
+++ b/app/Business/FundForecastTemplate.php
@@ -0,0 +1,90 @@
+makeSpreadsheet();
+
+ return response()->streamDownload(function () use ($spreadsheet): void {
+ $writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
+ $writer->setPreCalculateFormulas(false);
+ $writer->save('php://output');
+ $spreadsheet->disconnectWorksheets();
+ }, '资金预测表-导入模板.xlsx', [
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Cache-Control' => 'no-store, no-cache, must-revalidate',
+ ]);
+ }
+
+ private function makeSpreadsheet(): Spreadsheet
+ {
+ $spreadsheet = new Spreadsheet();
+ $spreadsheet->getProperties()->setTitle('资金预测表导入模板')->setSubject('资金预测表批量导入');
+
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->setTitle('资金预测表');
+ $sheet->setShowGridlines(false);
+ $sheet->freezePane('C3');
+ $sheet->getRowDimension(1)->setRowHeight(26);
+ $sheet->getRowDimension(2)->setRowHeight(24);
+ $sheet->setCellValueExplicit('A1', '预测月份', DataType::TYPE_STRING);
+ $sheet->setCellValueExplicit('B1', '公司名称', DataType::TYPE_STRING);
+ $sheet->mergeCells('A1:A2');
+ $sheet->mergeCells('B1:B2');
+
+ $labels = FundForecast::columnLabels();
+ $columnIndex = 3;
+ foreach (FundForecastPage::GROUPS as $group) {
+ $startColumn = Coordinate::stringFromColumnIndex($columnIndex);
+ $endColumn = Coordinate::stringFromColumnIndex($columnIndex + count($group['fields']) - 1);
+ $sheet->setCellValueExplicit("{$startColumn}1", $group['label'], DataType::TYPE_STRING);
+ $sheet->mergeCells("{$startColumn}1:{$endColumn}1");
+
+ foreach ($group['fields'] as $field) {
+ $column = Coordinate::stringFromColumnIndex($columnIndex++);
+ $sheet->setCellValueExplicit("{$column}2", $labels[$field], DataType::TYPE_STRING);
+ $sheet->getColumnDimension($column)->setWidth(16);
+ }
+ }
+
+ $lastColumn = Coordinate::stringFromColumnIndex($columnIndex - 1);
+ $sheet->getColumnDimension('A')->setWidth(14);
+ $sheet->getColumnDimension('B')->setWidth(28);
+ $sheet->getStyle("A1:{$lastColumn}2")->getAlignment()
+ ->setHorizontal(Alignment::HORIZONTAL_CENTER)->setVertical(Alignment::VERTICAL_CENTER);
+ $sheet->getStyle("A1:{$lastColumn}2")->getFont()->setBold(true);
+ $sheet->getStyle("A1:{$lastColumn}1")->getFont()->getColor()->setARGB('FFFFFFFF');
+ $sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632');
+ $sheet->getStyle("A2:{$lastColumn}2")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FFF7F0DE');
+ $sheet->getStyle("A1:{$lastColumn}2")->getBorders()->getAllBorders()
+ ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7');
+ $sheet->getStyle('A3:A' . (self::MAX_ROWS + 2))->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
+ $sheet->getStyle('C3:AD' . (self::MAX_ROWS + 2))->getNumberFormat()->setFormatCode('#,##0.00');
+
+ for ($row = 3; $row <= self::MAX_ROWS + 2; $row++) {
+ $sheet->setCellValue("AA{$row}", "=SUM(C{$row}:H{$row})");
+ $sheet->setCellValue("AB{$row}", "=SUM(I{$row}:N{$row})");
+ $sheet->setCellValue("AC{$row}", "=SUM(O{$row}:Z{$row})");
+ $sheet->setCellValue("AD{$row}", "=AA{$row}+AB{$row}-AC{$row}");
+ $sheet->setCellValue("AE{$row}", "=IF(AD{$row}<0,\"是\",\"否\")");
+ }
+
+ return $spreadsheet;
+ }
+}
diff --git a/app/Business/FundForecastWorkbookImporter.php b/app/Business/FundForecastWorkbookImporter.php
new file mode 100644
index 00000000..2ff793e8
--- /dev/null
+++ b/app/Business/FundForecastWorkbookImporter.php
@@ -0,0 +1,162 @@
+ 'budget_month',
+ '预测月份' => 'budget_month',
+ '公司名称' => 'company_name',
+ '余额/银行' => 'balance_bank',
+ '余额/京东' => 'balance_jd',
+ '余额/抖音' => 'balance_douyin',
+ '余额/得物' => 'balance_dewu',
+ '余额/淘系支付宝' => 'balance_taobao_alipay',
+ '余额/微信' => 'balance_wechat',
+ '收入/京东' => 'income_jd',
+ '收入/抖音' => 'income_douyin',
+ '收入/得物' => 'income_dewu',
+ '收入/淘系支付宝' => 'income_taobao_alipay',
+ '收入/货款' => 'income_goods_payment',
+ '收入/其他收入' => 'income_other',
+ '支出/供应商货款' => 'expense_supplier_goods',
+ '支出/快递款' => 'expense_courier',
+ '支出/纸厂压贴' => 'expense_paper_lamination',
+ '支出/封边条' => 'expense_edge_band',
+ '支出/日常报销' => 'expense_daily_reimbursement',
+ '支出/淘宝红包' => 'expense_taobao_red_packet',
+ '支出/板材' => 'expense_board',
+ '支出/店铺充值' => 'expense_store_top_up',
+ '支出/种菜' => 'expense_planting',
+ '支出/工资' => 'expense_salary',
+ '支出/税金' => 'expense_tax',
+ '支出/店铺花费暂估10%' => 'expense_store_spend',
+ ];
+
+ public function import(UploadedFile $file, int $companyId): array
+ {
+ $spreadsheet = IOFactory::load($file->getRealPath());
+ $worksheet = $spreadsheet->getSheet(0);
+ $rows = $worksheet->toArray(null, true, false, false);
+ $columnMap = $this->columnMap($rows[0] ?? [], $rows[1] ?? []);
+
+ foreach (['budget_month', 'company_name'] as $required) {
+ if (! in_array($required, $columnMap, true)) {
+ throw ValidationException::withMessages([
+ 'import' => '资金预测表缺少“预测月份”或“公司名称”表头。',
+ ]);
+ }
+ }
+
+ $created = 0;
+ $updated = 0;
+
+ DB::transaction(function () use ($rows, $columnMap, $companyId, &$created, &$updated): void {
+ foreach (array_slice($rows, 2) as $offset => $row) {
+ if (collect($row)->every(fn ($value): bool => $value === null || trim((string) $value) === '')) {
+ continue;
+ }
+
+ $rowNumber = $offset + 3;
+ $data = [];
+ foreach ($columnMap as $columnIndex => $field) {
+ $data[$field] = $this->value($row[$columnIndex] ?? null, $field, $rowNumber);
+ }
+
+ if (($data['budget_month'] ?? '') === '' || ($data['company_name'] ?? '') === '') {
+ throw ValidationException::withMessages([
+ 'import' => "第 {$rowNumber} 行必须填写预测月份和公司名称。",
+ ]);
+ }
+
+ $identity = [
+ 'company_id' => $companyId,
+ 'budget_month' => $data['budget_month'],
+ 'company_name' => $data['company_name'],
+ ];
+ $record = FundForecast::query()->where($identity)->first();
+ $data['report_status'] = '待审核';
+
+ if ($record) {
+ $record->update($data);
+ $updated++;
+ } else {
+ FundForecast::create(array_merge($identity, $data));
+ $created++;
+ }
+ }
+ });
+
+ if ($created + $updated === 0) {
+ throw ValidationException::withMessages([
+ 'import' => '表格中没有可导入的数据行。',
+ ]);
+ }
+
+ return compact('created', 'updated');
+ }
+
+ private function columnMap(array $groups, array $headers): array
+ {
+ $map = [];
+ $group = '';
+ $count = max(count($groups), count($headers));
+
+ for ($index = 0; $index < $count; $index++) {
+ $candidate = trim((string) ($groups[$index] ?? ''));
+ if ($candidate !== '') {
+ $group = $candidate;
+ }
+ $header = trim((string) ($headers[$index] ?? ''));
+ $combined = $header === '' ? $group : ($group === '' ? $header : $group . '/' . $header);
+ $field = self::HEADER_FIELDS[$this->normalizeHeader($combined)] ?? null;
+ if ($field !== null) {
+ $map[$index] = $field;
+ }
+ }
+
+ return $map;
+ }
+
+ private function normalizeHeader(string $header): string
+ {
+ return preg_replace('/[\s_\-()()]+/u', '', trim($header));
+ }
+
+ private function value(mixed $value, string $field, int $rowNumber): mixed
+ {
+ if ($value === null || trim((string) $value) === '') {
+ return null;
+ }
+ if ($field === 'budget_month') {
+ if (is_numeric($value)) {
+ return ExcelDate::excelToDateTimeObject($value)->format('Y-m');
+ }
+ $text = trim((string) $value);
+ if (preg_match('/^(\d{4})[年\/.\-](\d{1,2})(?:月)?$/u', $text, $matches) === 1) {
+ $month = (int) $matches[2];
+ if ($month >= 1 && $month <= 12) {
+ return sprintf('%04d-%02d', (int) $matches[1], $month);
+ }
+ }
+ throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行预测月份格式不正确。"]);
+ }
+ if ($field === 'company_name') {
+ return mb_substr(trim((string) $value), 0, 191);
+ }
+ $number = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
+ if (! is_numeric($number)) {
+ throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行包含无效金额。"]);
+ }
+
+ return (float) $number;
+ }
+}
diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php
index 1d0412e6..3d5a9dca 100644
--- a/app/Http/Controllers/Business/Pages.php
+++ b/app/Http/Controllers/Business/Pages.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Business;
use App\Business\BusinessTableImporter;
+use App\Business\FundForecastTemplate;
use App\Business\TableRegistry;
use App\Business\UpstreamBusinessInformation;
use App\Business\StoreReceivingAccountTemplate;
@@ -3507,6 +3508,11 @@ class Pages extends Controller
return app(StoreReceivingAccountTemplate::class)->download($tab);
}
+ public function fundForecastTemplate(FundForecastTemplate $template): StreamedResponse
+ {
+ return $template->download();
+ }
+
public function update(Request $request, string $section, string $page, $id, ?string $tab = null)
{
$descriptor = TableRegistry::describe($section, $page, $tab);
diff --git a/app/Models/Business/FundForecast.php b/app/Models/Business/FundForecast.php
index eb32d32e..79fd6ef9 100644
--- a/app/Models/Business/FundForecast.php
+++ b/app/Models/Business/FundForecast.php
@@ -8,15 +8,129 @@ class FundForecast extends Model
{
protected $table = 'business_fund_forecast';
- protected $fillable = ['company_id', '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'];
+ public const BALANCE_FIELDS = [
+ 'balance_bank', 'balance_jd', 'balance_douyin', 'balance_dewu',
+ 'balance_taobao_alipay', 'balance_wechat',
+ ];
+
+ public const INCOME_FIELDS = [
+ 'income_jd', 'income_douyin', 'income_dewu', 'income_taobao_alipay',
+ 'income_goods_payment', 'income_other',
+ ];
+
+ public const EXPENSE_FIELDS = [
+ 'expense_supplier_goods', 'expense_courier', 'expense_paper_lamination',
+ 'expense_edge_band', 'expense_daily_reimbursement', 'expense_taobao_red_packet',
+ 'expense_board', 'expense_store_top_up', 'expense_planting', 'expense_salary',
+ 'expense_tax', 'expense_store_spend',
+ ];
+
+ public const SUMMARY_FIELDS = [
+ 'previous_month_balance', 'forecast_month_income', 'forecast_month_expense',
+ 'gap_surplus', 'has_gap',
+ ];
+
+ public const PAGE_FIELDS = [
+ 'budget_month', 'company_name', 'region', 'report_status',
+ ...self::BALANCE_FIELDS,
+ ...self::INCOME_FIELDS,
+ ...self::EXPENSE_FIELDS,
+ ...self::SUMMARY_FIELDS,
+ ];
+
+ protected $fillable = [
+ 'company_id',
+ ...self::PAGE_FIELDS,
+ // Legacy fields remain fillable so old records and rollback exports stay compatible.
+ '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',
+ ];
+
+ protected $attributes = [
+ 'report_status' => '待填报',
+ ];
protected $casts = [
'company_id' => 'integer',
+ 'has_gap' => 'boolean',
+ 'balance_bank' => 'decimal:2',
+ 'balance_jd' => 'decimal:2',
+ 'balance_douyin' => 'decimal:2',
+ 'balance_dewu' => 'decimal:2',
+ 'balance_taobao_alipay' => 'decimal:2',
+ 'balance_wechat' => 'decimal:2',
+ 'income_jd' => 'decimal:2',
+ 'income_douyin' => 'decimal:2',
+ 'income_dewu' => 'decimal:2',
+ 'income_taobao_alipay' => 'decimal:2',
+ 'income_goods_payment' => 'decimal:2',
+ 'income_other' => 'decimal:2',
+ 'expense_supplier_goods' => 'decimal:2',
+ 'expense_courier' => 'decimal:2',
+ 'expense_paper_lamination' => 'decimal:2',
+ 'expense_edge_band' => 'decimal:2',
+ 'expense_daily_reimbursement' => 'decimal:2',
+ 'expense_taobao_red_packet' => 'decimal:2',
+ 'expense_board' => 'decimal:2',
+ 'expense_store_top_up' => 'decimal:2',
+ 'expense_planting' => 'decimal:2',
+ 'expense_salary' => 'decimal:2',
+ 'expense_tax' => 'decimal:2',
+ 'expense_store_spend' => 'decimal:2',
+ 'previous_month_balance' => 'decimal:2',
+ 'forecast_month_income' => 'decimal:2',
+ 'forecast_month_expense' => 'decimal:2',
+ 'gap_surplus' => 'decimal:2',
];
+ protected static function booted(): void
+ {
+ static::creating(function (self $forecast): void {
+ if (empty($forecast->company_id)) {
+ $forecast->company_id = company_id() ?: 1;
+ }
+ });
+
+ static::saving(function (self $forecast): void {
+ $balance = self::sumFields($forecast, self::BALANCE_FIELDS);
+ $income = self::sumFields($forecast, self::INCOME_FIELDS);
+ $expense = self::sumFields($forecast, self::EXPENSE_FIELDS);
+ $gap = round($balance + $income - $expense, 2);
+
+ $forecast->previous_month_balance = $balance;
+ $forecast->forecast_month_income = $income;
+ $forecast->forecast_month_expense = $expense;
+ $forecast->gap_surplus = $gap;
+ $forecast->has_gap = $gap < 0;
+ });
+ }
+
+ private static function sumFields(self $forecast, array $fields): float
+ {
+ return array_reduce(
+ $fields,
+ fn (float $sum, string $field): float => round($sum + round((float) ($forecast->{$field} ?? 0), 2), 2),
+ 0.0,
+ );
+ }
+
public static function columnLabels(): array
{
return [
+ 'company_name' => '公司名称',
+ 'region' => '所属区域',
+ 'report_status' => '填报状态',
'budget_month' => '预算月份',
'actual_month' => '实际月份',
'store_balance_budget' => '店铺余额(预算)',
@@ -60,6 +174,52 @@ class FundForecast extends Model
'bank_reservation' => '银行预留',
'net_balance_budget' => '净结余(预算)',
'net_balance_actual' => '净结余(实际)',
+ 'balance_bank' => '银行',
+ 'balance_jd' => '京东',
+ 'balance_douyin' => '抖音',
+ 'balance_dewu' => '得物',
+ 'balance_taobao_alipay' => '淘系支付宝',
+ 'balance_wechat' => '微信',
+ 'income_jd' => '京东',
+ 'income_douyin' => '抖音',
+ 'income_dewu' => '得物',
+ 'income_taobao_alipay' => '淘系支付宝',
+ 'income_goods_payment' => '货款',
+ 'income_other' => '其他收入',
+ 'expense_supplier_goods' => '供应商货款',
+ 'expense_courier' => '快递款',
+ 'expense_paper_lamination' => '纸厂压贴',
+ 'expense_edge_band' => '封边条',
+ 'expense_daily_reimbursement' => '日常报销',
+ 'expense_taobao_red_packet' => '淘宝红包',
+ 'expense_board' => '板材',
+ 'expense_store_top_up' => '店铺充值',
+ 'expense_planting' => '种菜',
+ 'expense_salary' => '工资',
+ 'expense_tax' => '税金',
+ 'expense_store_spend' => '店铺花费(暂估10%)',
+ 'previous_month_balance' => '上月末总余额',
+ 'forecast_month_income' => '预测本月收入',
+ 'forecast_month_expense' => '预测本月支付',
+ 'gap_surplus' => '缺口/盈余',
+ 'has_gap' => '是否有缺口',
];
}
+
+ public static function fieldTypes(): array
+ {
+ return array_replace(
+ array_fill_keys([
+ ...self::BALANCE_FIELDS,
+ ...self::INCOME_FIELDS,
+ ...self::EXPENSE_FIELDS,
+ 'previous_month_balance', 'forecast_month_income',
+ 'forecast_month_expense', 'gap_surplus',
+ ], 'currency'),
+ [
+ 'report_status' => 'select',
+ 'has_gap' => 'boolean',
+ ],
+ );
+ }
}
diff --git a/database/migrations/2026_09_11_000000_update_business_fund_forecast.php b/database/migrations/2026_09_11_000000_update_business_fund_forecast.php
new file mode 100644
index 00000000..e26f92f7
--- /dev/null
+++ b/database/migrations/2026_09_11_000000_update_business_fund_forecast.php
@@ -0,0 +1,44 @@
+string('company_name')->nullable();
+ $table->string('region', 64)->nullable();
+ $table->string('report_status', 16)->default('待填报');
+
+ foreach (array_slice(self::COLUMNS, 3, 28) as $column) {
+ $table->decimal($column, 15, 2)->nullable();
+ }
+ $table->boolean('has_gap')->nullable();
+
+ $table->index(['company_id', 'budget_month', 'company_name'], 'fund_forecast_month_company_idx');
+ $table->index(['company_id', 'report_status'], 'fund_forecast_status_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('business_fund_forecast', function (Blueprint $table): void {
+ $table->dropIndex('fund_forecast_month_company_idx');
+ $table->dropIndex('fund_forecast_status_idx');
+ $table->dropColumn(self::COLUMNS);
+ });
+ }
+};
diff --git a/resources/views/business/pages/fund-management/fund-forecast.blade.php b/resources/views/business/pages/fund-management/fund-forecast.blade.php
new file mode 100644
index 00000000..adfc826c
--- /dev/null
+++ b/resources/views/business/pages/fund-management/fund-forecast.blade.php
@@ -0,0 +1,126 @@
+@php
+ $forecast = app(\App\Business\FundForecastPage::class)->data(request());
+ $forecastRows = $forecast['rows'];
+ $forecastFilters = $forecast['filters'];
+ $scopeLabels = [
+ 'all' => '全部公司',
+ 'gap' => '资金缺口',
+ 'unreported' => '待填报',
+ 'pending_review' => '待审核',
+ ];
+ $pageRoute = ['section' => $section['slug'], 'page' => $page['slug']];
+ $filterQuery = array_filter([
+ 'budget_month' => $forecastFilters['budget_month'], 'region' => $forecastFilters['region'],
+ 'report_status' => $forecastFilters['report_status'], 'company_name' => $forecastFilters['company_name'],
+ ], static fn ($value) => $value !== '');
+ $financialFields = collect($forecast['groups'])->flatMap(fn ($group) => $group['fields'])->values();
+@endphp
+
+
+
+
+ @if(!$forecast['ready'])
+ 资金预测表结构尚未初始化,完成数据库升级后即可录入和导入。
+ @endif
+
+ @foreach ([
+ ['公司总数', $forecast['summary']['company_count'], 0, ''],
+ ['账户余额合计', $forecast['summary']['balance_total'], 2, ''],
+ ['预测本月收入', $forecast['summary']['income_total'], 2, ''],
+ ['缺口 / 盈余', $forecast['summary']['gap_surplus'], 2, $forecast['summary']['gap_surplus'] < 0 ? 'is-negative' : ($forecast['summary']['gap_surplus'] > 0 ? 'is-positive' : '')],
+ ] as [$label, $value, $decimals, $class])
+
{{ $label }}{{ number_format($value, $decimals) }}
+ @endforeach
+
+
+
+
+
+
+
+
+ @foreach($financialFields as $field)@endforeach
+ | 预测月份 | 公司名称 | @foreach($forecast['groups'] as $group){{ $group['label'] }} | @endforeach操作 |
|---|
@foreach($forecast['groups'] as $group)@foreach($group['columns'] as $column)| {{ $column['label'] }} | @endforeach @endforeach
+
+ @forelse($forecastRows as $row)
+
+ | {{ $row->budget_month?:'—' }} |
+ $row->report_status==='已审核','fund-forecast__status-dot--unreported'=>$row->report_status==='待填报']) aria-hidden="true">{{ $row->company_name?:'未填写公司名称' }} |
+ @foreach($forecast['groups'] as $group)@foreach($group['columns'] as $column)@php($value=$row->{$column['field']})$column['field']==='gap_surplus'&&(float)$value<0,'is-positive'=>$column['field']==='gap_surplus'&&(float)$value>0])>@if($column['field']==='has_gap'){{ $value===null?'—':($value?'是':'否') }}@else{{ $value===null?'—':number_format((float)$value,2) }}@endif | @endforeach @endforeach
+ |
+
+ @empty
+ account_balance_wallet当前筛选条件下没有资金预测数据 |
+ @endforelse
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/views/business/pages/fund_forecast.blade.php b/resources/views/business/pages/fund_forecast.blade.php
deleted file mode 100644
index 26e16aa1..00000000
--- a/resources/views/business/pages/fund_forecast.blade.php
+++ /dev/null
@@ -1,108 +0,0 @@
-
-
-
-
-
-| 预算月份 |
- 实际月份 |
- 店铺余额(预算) |
- 店铺余额(实际) |
- 银行余额(预算) |
- 银行余额(实际) |
- 其他余额(预算) |
- 其他余额(实际) |
- 预计回款(预算) |
- 预计回款(实际) |
- 收入合计(预算) |
- 收入合计(实际) |
- 往期未付(预算) |
- 往期未付(实际) |
- 借支(预算) |
- 借支(实际) |
- 供应商(预算) |
- 供应商(实际) |
- 现结(预算) |
- 现结(实际) |
- 快递(预算) |
- 快递(实际) |
- 纸厂压贴(预算) |
- 纸厂压贴(实际) |
- 封边条(预算) |
- 封边条(实际) |
- 板材(预算) |
- 板材(实际) |
- 工资(预算) |
- 工资(实际) |
- 日常费用(预算) |
- 日常费用(实际) |
- 种菜(预算) |
- 种菜(实际) |
- 红包(预算) |
- 红包(实际) |
- 对公扣款(预算) |
- 对公扣款(实际) |
- 支出合计(预算) |
- 支出合计(实际) |
- 银行预留 |
- 净结余(预算) |
- 净结余(实际) |
-
-
-
-
- @foreach($items as $item)
-
-| {{ $item->budget_month }} |
- {{ $item->actual_month }} |
- {{ number_format($item->store_balance_budget, 2) }} |
- {{ number_format($item->store_balance_actual, 2) }} |
- {{ number_format($item->bank_balance_budget, 2) }} |
- {{ number_format($item->bank_balance_actual, 2) }} |
- {{ number_format($item->other_balance_budget, 2) }} |
- {{ number_format($item->other_balance_actual, 2) }} |
- {{ number_format($item->expected_monthly_return_budget, 2) }} |
- {{ number_format($item->expected_monthly_return_actual, 2) }} |
- {{ number_format($item->income_total_budget, 2) }} |
- {{ number_format($item->income_total_actual, 2) }} |
- {{ number_format($item->prior_unpaid_budget, 2) }} |
- {{ number_format($item->prior_unpaid_actual, 2) }} |
- {{ number_format($item->advance_budget, 2) }} |
- {{ number_format($item->advance_actual, 2) }} |
- {{ number_format($item->supplier_budget, 2) }} |
- {{ number_format($item->supplier_actual, 2) }} |
- {{ number_format($item->spot_settlement_budget, 2) }} |
- {{ number_format($item->spot_settlement_actual, 2) }} |
- {{ number_format($item->courier_budget, 2) }} |
- {{ number_format($item->courier_actual, 2) }} |
- {{ number_format($item->paper_factory_budget, 2) }} |
- {{ number_format($item->paper_factory_actual, 2) }} |
- {{ number_format($item->edge_band_budget, 2) }} |
- {{ number_format($item->edge_band_actual, 2) }} |
- {{ number_format($item->board_budget, 2) }} |
- {{ number_format($item->board_actual, 2) }} |
- {{ number_format($item->salary_budget, 2) }} |
- {{ number_format($item->salary_actual, 2) }} |
- {{ number_format($item->daily_expense_budget, 2) }} |
- {{ number_format($item->daily_expense_actual, 2) }} |
- {{ number_format($item->planting_budget, 2) }} |
- {{ number_format($item->planting_actual, 2) }} |
- {{ number_format($item->red_packet_budget, 2) }} |
- {{ number_format($item->red_packet_actual, 2) }} |
- {{ number_format($item->corporate_deduction_budget, 2) }} |
- {{ number_format($item->corporate_deduction_actual, 2) }} |
- {{ number_format($item->expense_total_budget, 2) }} |
- {{ number_format($item->expense_total_actual, 2) }} |
- {{ number_format($item->bank_reservation, 2) }} |
- {{ number_format($item->net_balance_budget, 2) }} |
- {{ number_format($item->net_balance_actual, 2) }} |
-
-
- @endforeach
- @if($items->isEmpty())
- | 暂无数据 |
- @endif
-
-
-
\ No newline at end of file
diff --git a/routes/admin.php b/routes/admin.php
index fa0d7fe1..83f60d93 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -158,6 +158,10 @@ Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
Route::get('workspace/{section}/{page}/{tab}/template', 'Business\Pages@storeReceivingAccountTemplate')
->name('business.pages.store-receiving-account-template');
+Route::get('workspace/fund-management/fund-forecast/template', 'Business\Pages@fundForecastTemplate')
+ ->middleware('business.page:fund-management,fund-forecast')
+ ->name('business.fund-forecast.template');
+
Route::get('workspace/financial-operations/installation-details/details/{guid}', 'Business\Pages@installationDetails')
->where('guid', '[A-Za-z0-9-]+')
->name('business.installation-details.details');
diff --git a/tests/Feature/Business/FundForecastPageTest.php b/tests/Feature/Business/FundForecastPageTest.php
new file mode 100644
index 00000000..db3a7d7a
--- /dev/null
+++ b/tests/Feature/Business/FundForecastPageTest.php
@@ -0,0 +1,183 @@
+user->attachPermission(
+ Permission::where('name', 'read-admin-panel')->firstOrFail(),
+ );
+ }
+
+ public function testPageReplacesLegacyTableWithWorkbookGroupsAndFilters(): void
+ {
+ FundForecast::create([
+ 'company_id' => $this->company->id,
+ 'budget_month' => '2026-09',
+ 'company_name' => '测试资金公司',
+ 'region' => '华东',
+ 'report_status' => '待审核',
+ 'balance_bank' => 100,
+ 'balance_jd' => 20,
+ 'income_jd' => 80,
+ 'income_other' => 20,
+ 'expense_supplier_goods' => 40,
+ 'expense_salary' => 10,
+ 'previous_month_balance' => 30,
+ ]);
+
+ $response = $this->loginAs()->get(route('business.pages.show', [
+ 'section' => 'fund-management',
+ 'page' => 'fund-forecast',
+ ]));
+
+ $response
+ ->assertOk()
+ ->assertSeeText('公司总数')
+ ->assertSeeText('预测月份')
+ ->assertSeeText('所属区域')
+ ->assertSeeText('填报状态')
+ ->assertSeeText('测试资金公司')
+ ->assertSeeText('淘系支付宝')
+ ->assertSeeText('店铺花费(暂估10%)')
+ ->assertSeeText('上月末总余额')
+ ->assertSeeText('缺口/盈余')
+ ->assertSee('data-group="balance"', false)
+ ->assertSee('data-group="income"', false)
+ ->assertSee('data-group="expense"', false)
+ ->assertDontSeeText('店铺余额(预算)')
+ ->assertDontSeeText('银行余额(实际)');
+
+ $row = FundForecast::firstOrFail();
+ $this->assertSame('120.00', $row->previous_month_balance);
+ $this->assertSame('100.00', $row->forecast_month_income);
+ $this->assertSame('50.00', $row->forecast_month_expense);
+ $this->assertSame('170.00', $row->gap_surplus);
+ $this->assertFalse($row->has_gap);
+ $this->assertSame($this->company->id, $row->company_id);
+ }
+
+ public function testCompanyFilterOnlyShowsMatchingForecast(): void
+ {
+ foreach (['目标公司', '其他公司'] as $companyName) {
+ FundForecast::create([
+ 'company_id' => $this->company->id,
+ 'budget_month' => '2026-09',
+ 'company_name' => $companyName,
+ ]);
+ }
+
+ $this->loginAs()->get(route('business.pages.show', [
+ 'section' => 'fund-management',
+ 'page' => 'fund-forecast',
+ 'company_name' => '目标',
+ ]))
+ ->assertOk()
+ ->assertSeeText('目标公司')
+ ->assertDontSeeText('其他公司');
+ }
+
+ public function testImporterUnderstandsTwoRowWorkbookHeaders(): void
+ {
+ $spreadsheet = new Spreadsheet();
+ $sheet = $spreadsheet->getActiveSheet();
+ $sheet->fromArray([
+ ['预策月份', '公司名称', '余额', null, '收入', null, '支出', null, '汇总', null, null, null, null],
+ [null, null, '京东', '抖音', '京东', '其他收入', '供应商货款', '工资', '上月末总余额', '预策本月收入', '预策本月支付', '缺口/盈余', '是否有缺口'],
+ ['2026年9月', '导入测试公司', 12, 3, 100, 20, 30, 10, 50, 999, 999, -999, '是'],
+ ]);
+
+ $path = tempnam(sys_get_temp_dir(), 'fund-forecast-');
+ (new Xlsx($spreadsheet))->save($path);
+
+ try {
+ $result = app(FundForecastWorkbookImporter::class)->import(
+ new UploadedFile($path, '资金预测表.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', null, true),
+ (int) $this->company->id,
+ );
+ } finally {
+ @unlink($path);
+ }
+
+ $this->assertSame(['created' => 1, 'updated' => 0], $result);
+ $row = FundForecast::where('company_name', '导入测试公司')->firstOrFail();
+ $this->assertSame('2026-09', $row->budget_month);
+ $this->assertSame('12.00', $row->balance_jd);
+ $this->assertSame('100.00', $row->income_jd);
+ $this->assertSame('15.00', $row->previous_month_balance);
+ $this->assertSame('120.00', $row->forecast_month_income);
+ $this->assertSame('40.00', $row->forecast_month_expense);
+ $this->assertSame('95.00', $row->gap_surplus);
+ $this->assertFalse($row->has_gap);
+ $this->assertSame('待审核', $row->report_status);
+ }
+
+ public function testTemplateDownloadContainsImportableTwoRowHeaders(): void
+ {
+ $response = $this->loginAs()->get(route('business.fund-forecast.template'));
+
+ $response
+ ->assertOk()
+ ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+
+ $path = tempnam(sys_get_temp_dir(), 'fund-forecast-template-');
+ file_put_contents($path, $response->streamedContent());
+
+ try {
+ $sheet = IOFactory::load($path)->getActiveSheet();
+
+ $this->assertSame('资金预测表', $sheet->getTitle());
+ $this->assertSame('预测月份', $sheet->getCell('A1')->getValue());
+ $this->assertSame('公司名称', $sheet->getCell('B1')->getValue());
+ $this->assertSame('余额', $sheet->getCell('C1')->getValue());
+ $this->assertSame('银行', $sheet->getCell('C2')->getValue());
+ $this->assertSame('汇总', $sheet->getCell('AA1')->getValue());
+ $this->assertSame('是否有缺口', $sheet->getCell('AE2')->getValue());
+ $this->assertSame('=SUM(C3:H3)', $sheet->getCell('AA3')->getValue());
+ $this->assertSame('=SUM(I3:N3)', $sheet->getCell('AB3')->getValue());
+ $this->assertSame('=SUM(O3:Z3)', $sheet->getCell('AC3')->getValue());
+ $this->assertSame('=AA3+AB3-AC3', $sheet->getCell('AD3')->getValue());
+ $this->assertSame('=IF(AD3<0,"是","否")', $sheet->getCell('AE3')->getValue());
+ $this->assertSame('C3', $sheet->getFreezePane());
+ } finally {
+ @unlink($path);
+ }
+ }
+
+ public function testSummaryFieldsAreRecalculatedAndNegativeGapIsMarked(): void
+ {
+ $row = FundForecast::create([
+ 'company_id' => $this->company->id,
+ 'budget_month' => '2026-09',
+ 'company_name' => '缺口测试公司',
+ 'balance_bank' => 10,
+ 'balance_wechat' => 5,
+ 'income_other' => 20,
+ 'expense_supplier_goods' => 50,
+ 'previous_month_balance' => 999,
+ 'forecast_month_income' => 999,
+ 'forecast_month_expense' => 999,
+ 'gap_surplus' => 999,
+ 'has_gap' => false,
+ ]);
+
+ $this->assertSame('15.00', $row->previous_month_balance);
+ $this->assertSame('20.00', $row->forecast_month_income);
+ $this->assertSame('50.00', $row->forecast_month_expense);
+ $this->assertSame('-15.00', $row->gap_surplus);
+ $this->assertTrue($row->has_gap);
+ }
+}