From 3cdca956c4bbaeef5b6eb71431fcec16d76a5d53 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Mon, 14 Sep 2026 14:24:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E8=B5=84=E9=87=91?= =?UTF-8?q?=E9=A2=84=E6=B5=8B=E8=A1=A8=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Business/BusinessTableImporter.php | 7 + app/Business/FundForecastPage.php | 160 +++++++++++++++ app/Business/FundForecastTemplate.php | 90 +++++++++ app/Business/FundForecastWorkbookImporter.php | 162 ++++++++++++++++ app/Http/Controllers/Business/Pages.php | 6 + app/Models/Business/FundForecast.php | 162 +++++++++++++++- ...1_000000_update_business_fund_forecast.php | 44 +++++ .../fund-management/fund-forecast.blade.php | 126 ++++++++++++ .../business/pages/fund_forecast.blade.php | 108 ----------- routes/admin.php | 4 + .../Feature/Business/FundForecastPageTest.php | 183 ++++++++++++++++++ 11 files changed, 943 insertions(+), 109 deletions(-) create mode 100644 app/Business/FundForecastPage.php create mode 100644 app/Business/FundForecastTemplate.php create mode 100644 app/Business/FundForecastWorkbookImporter.php create mode 100644 database/migrations/2026_09_11_000000_update_business_fund_forecast.php create mode 100644 resources/views/business/pages/fund-management/fund-forecast.blade.php delete mode 100644 resources/views/business/pages/fund_forecast.blade.php create mode 100644 tests/Feature/Business/FundForecastPageTest.php 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 +
+ +
+
+ +
+
+
+ + + @if($filterQuery!==[]||$forecastFilters['view']!=='all')重置@endif +
+
+ + + + +
+
+ + + +
+ + @foreach($financialFields as $field)@endforeach + @foreach($forecast['groups'] as $group)@endforeach@foreach($forecast['groups'] as $group)@foreach($group['columns'] as $column)@endforeach @endforeach + + @forelse($forecastRows as $row) + + + + @foreach($forecast['groups'] as $group)@foreach($group['columns'] as $column)@php($value=$row->{$column['field']})@endforeach @endforeach + + + @empty + + @endforelse + +
预测月份公司名称{{ $group['label'] }}操作
{{ $column['label'] }}
{{ $row->budget_month?:'—' }}$row->report_status==='已审核','fund-forecast__status-dot--unreported'=>$row->report_status==='待填报']) aria-hidden="true">{{ $row->company_name?:'未填写公司名称' }}$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
当前筛选条件下没有资金预测数据
+
+ +
+ + + + + + 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 @@ -
- {{ $title ?? '资金预测表' }} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @foreach($items as $item) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @endforeach - @if($items->isEmpty()) - - @endif - -
预算月份实际月份店铺余额(预算)店铺余额(实际)银行余额(预算)银行余额(实际)其他余额(预算)其他余额(实际)预计回款(预算)预计回款(实际)收入合计(预算)收入合计(实际)往期未付(预算)往期未付(实际)借支(预算)借支(实际)供应商(预算)供应商(实际)现结(预算)现结(实际)快递(预算)快递(实际)纸厂压贴(预算)纸厂压贴(实际)封边条(预算)封边条(实际)板材(预算)板材(实际)工资(预算)工资(实际)日常费用(预算)日常费用(实际)种菜(预算)种菜(实际)红包(预算)红包(实际)对公扣款(预算)对公扣款(实际)支出合计(预算)支出合计(实际)银行预留净结余(预算)净结余(实际)
{{ $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) }}
暂无数据
-
\ 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); + } +}