feat: 重构资金预测表工作台
This commit is contained in:
parent
fb691d9127
commit
3cdca956c4
@ -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);
|
||||
|
||||
|
||||
160
app/Business/FundForecastPage.php
Normal file
160
app/Business/FundForecastPage.php
Normal file
@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\FundForecast;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FundForecastPage
|
||||
{
|
||||
public const GROUPS = [
|
||||
['key' => '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,
|
||||
);
|
||||
}
|
||||
}
|
||||
90
app/Business/FundForecastTemplate.php
Normal file
90
app/Business/FundForecastTemplate.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\FundForecast;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class FundForecastTemplate
|
||||
{
|
||||
private const MAX_ROWS = 5000;
|
||||
|
||||
public function download(): StreamedResponse
|
||||
{
|
||||
$spreadsheet = $this->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;
|
||||
}
|
||||
}
|
||||
162
app/Business/FundForecastWorkbookImporter.php
Normal file
162
app/Business/FundForecastWorkbookImporter.php
Normal file
@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Business;
|
||||
|
||||
use App\Models\Business\FundForecast;
|
||||
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 FundForecastWorkbookImporter
|
||||
{
|
||||
private const HEADER_FIELDS = [
|
||||
'预策月份' => '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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const COLUMNS = [
|
||||
'company_name', 'region', 'report_status',
|
||||
'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',
|
||||
'previous_month_balance', 'forecast_month_income', 'forecast_month_expense', 'gap_surplus', 'has_gap',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('business_fund_forecast', function (Blueprint $table): void {
|
||||
$table->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);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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
|
||||
|
||||
<style>
|
||||
.fund-forecast{margin-top:.75rem;overflow:hidden;border:1px solid var(--vip-border,#d8d2c7);border-radius:8px;background:#fff;color:#171717}
|
||||
.fund-forecast__summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-bottom:1px solid var(--vip-border,#d8d2c7)}
|
||||
.fund-forecast__metric{min-width:0;padding:.75rem 1rem}.fund-forecast__metric+.fund-forecast__metric{border-left:1px solid #ece8df}
|
||||
.fund-forecast__metric-label{color:#595959;font-size:.75rem;font-weight:500}.fund-forecast__metric-value{display:block;margin-top:.2rem;overflow:hidden;color:#171717;font-size:1.25rem;font-weight:700;font-variant-numeric:tabular-nums;text-overflow:ellipsis;white-space:nowrap}
|
||||
.fund-forecast .is-positive{color:#53793e}.fund-forecast .is-negative{color:#b80000}
|
||||
.fund-forecast__toolbar{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;padding:.75rem 1rem;border-bottom:1px solid #ece8df}
|
||||
.fund-forecast__filters{display:flex;flex:1 1 auto;align-items:flex-end;gap:.65rem;min-width:0;flex-wrap:wrap}.fund-forecast__field{display:grid;gap:.25rem;min-width:8.5rem}.fund-forecast__field--search{min-width:13rem}
|
||||
.fund-forecast__field label,.fund-forecast-modal__field label{color:#595959;font-size:.75rem;font-weight:500}
|
||||
.fund-forecast__control,.fund-forecast-modal__field input,.fund-forecast-modal__field select{width:100%;height:2.125rem;border:1px solid var(--vip-border,#d8d2c7);border-radius:8px;background:#fff;color:#171717;font:inherit;font-size:.8125rem;padding:0 .625rem}
|
||||
.fund-forecast__control:focus,.fund-forecast-modal__field input:focus,.fund-forecast-modal__field select:focus{border-color:#856632;box-shadow:0 0 0 3px rgba(133,102,50,.2);outline:none}
|
||||
.fund-forecast__actions{position:relative;display:flex;flex:0 0 auto;align-items:center;gap:.5rem}.fund-forecast__button{display:inline-flex;align-items:center;justify-content:center;gap:.35rem;min-height:2.125rem;padding:0 .75rem;border:1px solid var(--vip-border,#d8d2c7);border-radius:8px;background:#fff;color:#171717;font-size:.8125rem;font-weight:500;white-space:nowrap}.fund-forecast__button:hover{border-color:#b9954f;background:#fffdf8}.fund-forecast__button:focus-visible{outline:2px solid #856632;outline-offset:2px}.fund-forecast__button--primary{border-color:#856632;background:#856632;color:#fff}.fund-forecast__button--primary:hover{border-color:#6a5129;background:#6a5129}.fund-forecast__button .material-icons-outlined{font-size:1rem}.fund-forecast__button:disabled{cursor:not-allowed;opacity:.55}
|
||||
.fund-forecast__scope-bar{display:flex;align-items:center;gap:1.25rem;min-height:2.75rem;padding:0 1rem;overflow-x:auto;border-bottom:1px solid #ece8df}.fund-forecast__scope{position:relative;display:inline-flex;flex:0 0 auto;align-items:center;gap:.35rem;min-height:2.75rem;color:#595959;font-size:.78125rem;font-weight:500}.fund-forecast__scope.is-active{color:#856632}.fund-forecast__scope.is-active:after{position:absolute;right:0;bottom:0;left:0;height:3px;background:#b9954f;content:''}.fund-forecast__scope-count{font-variant-numeric:tabular-nums}
|
||||
.fund-forecast__table-shell{max-height:max(24rem,calc(100vh - 24rem));overflow:auto;scrollbar-color:#b9954f #f1eee7;scrollbar-width:thin}.fund-forecast__table-shell::-webkit-scrollbar{width:8px;height:8px}.fund-forecast__table-shell::-webkit-scrollbar-track{background:#f1eee7}.fund-forecast__table-shell::-webkit-scrollbar-thumb{border-radius:4px;background:#b9954f}
|
||||
.fund-forecast__table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;color:#171717;font-size:.75rem;font-variant-numeric:tabular-nums}.fund-forecast__table th,.fund-forecast__table td{height:2.25rem;padding:.4rem .625rem;border-right:1px solid #e5e1d9;border-bottom:1px solid #e5e1d9;background:#fff;white-space:nowrap}.fund-forecast__table th{position:sticky;top:2.125rem;z-index:3;height:2.125rem;background:#faf8f3;font-size:.75rem;font-weight:600;text-align:center}.fund-forecast__table thead tr:first-child th{top:0;z-index:4}.fund-forecast__table th[rowspan='2']{top:0}.fund-forecast__table td{text-align:right}.fund-forecast__table td.is-text{text-align:left}.fund-forecast__table tbody tr:hover td{background:#fffcf4}
|
||||
.fund-forecast__group--balance{background:#f2f3f2!important}.fund-forecast__group--income{background:#edf4ea!important}.fund-forecast__group--expense{background:#fbf1dc!important}.fund-forecast__group--summary{background:#f7f0de!important}
|
||||
.fund-forecast__sticky-month,.fund-forecast__sticky-company{position:sticky!important;z-index:5!important}.fund-forecast__sticky-month{left:0;min-width:6rem;max-width:6rem}.fund-forecast__sticky-company{left:6rem;min-width:13rem;max-width:13rem}thead .fund-forecast__sticky-month,thead .fund-forecast__sticky-company{z-index:7!important}
|
||||
.fund-forecast__company{display:flex;align-items:center;gap:.4rem;max-width:12rem;overflow:hidden;text-overflow:ellipsis}.fund-forecast__status-dot{width:.45rem;height:.45rem;flex:0 0 auto;border-radius:50%;background:#935f07}.fund-forecast__status-dot--approved{background:#53793e}.fund-forecast__status-dot--unreported{background:#b80000}
|
||||
.fund-forecast__row-action{width:2rem;height:2rem;border:0;border-radius:6px;background:transparent;color:#856632}.fund-forecast__row-action:hover{background:#f7f0de}.fund-forecast__row-action .material-icons-outlined{font-size:1rem}
|
||||
.fund-forecast__empty{display:grid;place-items:center;gap:.35rem;min-height:15rem;color:#595959;text-align:center}.fund-forecast__empty .material-icons-outlined{color:#856632;font-size:2rem}.fund-forecast__footer{display:flex;align-items:center;justify-content:space-between;min-height:2.75rem;padding:0 1rem;color:#595959;font-size:.75rem}.fund-forecast__pagination{display:flex;gap:1rem}.fund-forecast__pagination a{color:#856632}
|
||||
.fund-forecast__column-panel{position:absolute;top:calc(100% + .4rem);right:0;z-index:30;width:18rem;max-height:24rem;overflow:auto;padding:.75rem;border:1px solid var(--vip-border,#d8d2c7);border-radius:8px;background:#fff;box-shadow:0 4px 8px rgba(23,23,23,.1)}.fund-forecast__column-panel[hidden]{display:none}.fund-forecast__column-group+.fund-forecast__column-group{margin-top:.7rem}.fund-forecast__column-group strong{display:block;margin-bottom:.35rem;font-size:.75rem}.fund-forecast__column-option{display:flex;align-items:center;gap:.45rem;min-height:1.8rem;font-size:.75rem}
|
||||
.fund-forecast-modal{position:fixed;inset:0;z-index:1000;display:none;place-items:center;padding:1rem;background:rgba(5,5,6,.62)}.fund-forecast-modal.is-open{display:grid}.fund-forecast-modal__card{width:min(64rem,100%);max-height:min(50rem,calc(100vh - 2rem));overflow:auto;border:1px solid var(--vip-border,#d8d2c7);border-radius:8px;background:#fff}.fund-forecast-modal__card--small{width:min(32rem,100%)}.fund-forecast-modal__head,.fund-forecast-modal__foot{position:sticky;z-index:2;display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.8rem 1rem;background:#fff}.fund-forecast-modal__head{top:0;border-bottom:1px solid #ece8df}.fund-forecast-modal__foot{bottom:0;justify-content:flex-end;border-top:1px solid #ece8df}.fund-forecast-modal__title{font-size:.875rem;font-weight:600}.fund-forecast-modal__body{padding:1rem}.fund-forecast-modal__template{display:flex;justify-content:flex-end;margin-bottom:.75rem}.fund-forecast-modal__section+.fund-forecast-modal__section{margin-top:1rem}.fund-forecast-modal__section-title{margin-bottom:.55rem;color:#856632;font-size:.75rem;font-weight:600}.fund-forecast-modal__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem}.fund-forecast-modal__field{display:grid;gap:.25rem;min-width:0}.fund-forecast-modal__field input[readonly]{background:#f5f3ee;color:#595959}.fund-forecast-modal__close{width:2rem;height:2rem;border:0;border-radius:6px;background:transparent;color:#595959}.fund-forecast__spinner{animation:fund-forecast-spin .8s linear infinite}@keyframes fund-forecast-spin{to{transform:rotate(360deg)}}
|
||||
@media(max-width:900px){.fund-forecast__summary{grid-template-columns:repeat(2,minmax(0,1fr))}.fund-forecast__metric:nth-child(3){border-top:1px solid #ece8df;border-left:0}.fund-forecast__metric:nth-child(4){border-top:1px solid #ece8df}.fund-forecast__toolbar{align-items:stretch;flex-direction:column}.fund-forecast__actions{justify-content:flex-end}.fund-forecast-modal__grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:560px){.fund-forecast__field,.fund-forecast__field--search{width:100%;min-width:0}.fund-forecast__actions{flex-wrap:wrap;justify-content:flex-start}.fund-forecast-modal__grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
<section class="fund-forecast" aria-label="资金预测表">
|
||||
@if(!$forecast['ready'])
|
||||
<div class="biz-alert biz-alert-error" role="status" style="margin:.75rem 1rem 0">资金预测表结构尚未初始化,完成数据库升级后即可录入和导入。</div>
|
||||
@endif
|
||||
<div class="fund-forecast__summary" aria-label="资金预测汇总">
|
||||
@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])
|
||||
<div class="fund-forecast__metric"><span class="fund-forecast__metric-label">{{ $label }}</span><strong class="fund-forecast__metric-value {{ $class }}">{{ number_format($value, $decimals) }}</strong></div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="fund-forecast__toolbar">
|
||||
<form class="fund-forecast__filters" method="GET" action="{{ route('business.pages.show', $pageRoute) }}" data-forecast-filter-form>
|
||||
<input type="hidden" name="view" value="{{ $forecastFilters['view'] }}">
|
||||
<div class="fund-forecast__field"><label for="forecast-month">预测月份</label><select class="fund-forecast__control" id="forecast-month" name="budget_month"><option value="">全部月份</option>@foreach($forecast['months'] as $month)<option value="{{ $month }}" @selected($forecastFilters['budget_month']===$month)>{{ $month }}</option>@endforeach</select></div>
|
||||
<div class="fund-forecast__field"><label for="forecast-region">所属区域</label><select class="fund-forecast__control" id="forecast-region" name="region"><option value="">全部区域</option>@foreach($forecast['regions'] as $region)<option value="{{ $region }}" @selected($forecastFilters['region']===$region)>{{ $region }}</option>@endforeach</select></div>
|
||||
<div class="fund-forecast__field"><label for="forecast-report-status">填报状态</label><select class="fund-forecast__control" id="forecast-report-status" name="report_status"><option value="">全部状态</option>@foreach(['待填报','待审核','已审核'] as $status)<option value="{{ $status }}" @selected($forecastFilters['report_status']===$status)>{{ $status }}</option>@endforeach</select></div>
|
||||
<div class="fund-forecast__field fund-forecast__field--search"><label for="forecast-company">公司查询</label><input class="fund-forecast__control" id="forecast-company" type="search" name="company_name" value="{{ $forecastFilters['company_name'] }}" placeholder="输入公司名称"></div>
|
||||
<button class="fund-forecast__button fund-forecast__button--primary" type="submit" data-filter-submit><span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span></button>
|
||||
@if($filterQuery!==[]||$forecastFilters['view']!=='all')<a class="fund-forecast__button" href="{{ route('business.pages.show',$pageRoute) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span></a>@endif
|
||||
</form>
|
||||
<div class="fund-forecast__actions">
|
||||
<button class="fund-forecast__button" type="button" data-forecast-import-open @disabled(!$forecast['ready'])><span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span></button>
|
||||
<button class="fund-forecast__button fund-forecast__button--primary" type="button" data-forecast-create @disabled(!$forecast['ready'])><span class="material-icons-outlined" aria-hidden="true">add</span><span>新增</span></button>
|
||||
<button class="fund-forecast__button" type="button" aria-expanded="false" data-forecast-columns-toggle><span class="material-icons-outlined" aria-hidden="true">view_column</span><span>列设置</span></button>
|
||||
<div class="fund-forecast__column-panel" data-forecast-columns-panel hidden>
|
||||
@foreach($forecast['groups'] as $group)<div class="fund-forecast__column-group"><strong>{{ $group['label'] }}</strong>@foreach($group['columns'] as $column)<label class="fund-forecast__column-option"><input type="checkbox" value="{{ $column['field'] }}" checked data-forecast-column-option><span>{{ $column['label'] }}</span></label>@endforeach</div>@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="fund-forecast__scope-bar" aria-label="资金预测状态筛选">
|
||||
@foreach($scopeLabels as $scope=>$label)<a href="{{ route('business.pages.show',array_merge($pageRoute,$filterQuery,['view'=>$scope])) }}" @class(['fund-forecast__scope','is-active'=>$forecastFilters['view']===$scope]) @if($forecastFilters['view']===$scope) aria-current="page" @endif><span>{{ $label }}</span><span class="fund-forecast__scope-count">{{ number_format($forecast['scopeCounts'][$scope]) }}</span></a>@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="fund-forecast__table-shell">
|
||||
<table class="fund-forecast__table" data-forecast-table>
|
||||
<colgroup><col data-column="budget_month"><col data-column="company_name">@foreach($financialFields as $field)<col data-column="{{ $field }}">@endforeach<col data-column="actions"></colgroup>
|
||||
<thead><tr><th class="fund-forecast__sticky-month" rowspan="2" data-field="budget_month">预测月份</th><th class="fund-forecast__sticky-company" rowspan="2" data-field="company_name">公司名称</th>@foreach($forecast['groups'] as $group)<th class="fund-forecast__group--{{ $group['key'] }}" colspan="{{ count($group['fields']) }}" data-group="{{ $group['key'] }}">{{ $group['label'] }}</th>@endforeach<th rowspan="2" data-field="actions">操作</th></tr><tr>@foreach($forecast['groups'] as $group)@foreach($group['columns'] as $column)<th class="fund-forecast__group--{{ $group['key'] }}" data-field="{{ $column['field'] }}" data-group-field="{{ $group['key'] }}">{{ $column['label'] }}</th>@endforeach @endforeach</tr></thead>
|
||||
<tbody>
|
||||
@forelse($forecastRows as $row)
|
||||
<tr>
|
||||
<td class="fund-forecast__sticky-month is-text" data-field="budget_month">{{ $row->budget_month?:'—' }}</td>
|
||||
<td class="fund-forecast__sticky-company is-text" data-field="company_name" title="{{ $row->company_name }}"><span class="fund-forecast__company"><span @class(['fund-forecast__status-dot','fund-forecast__status-dot--approved'=>$row->report_status==='已审核','fund-forecast__status-dot--unreported'=>$row->report_status==='待填报']) aria-hidden="true"></span><span>{{ $row->company_name?:'未填写公司名称' }}</span></span></td>
|
||||
@foreach($forecast['groups'] as $group)@foreach($group['columns'] as $column)@php($value=$row->{$column['field']})<td data-field="{{ $column['field'] }}" @class(['is-negative'=>$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</td>@endforeach @endforeach
|
||||
<td data-field="actions"><button class="fund-forecast__row-action" type="button" title="编辑" data-forecast-edit data-row='@json($row->only(array_merge(['id'],\App\Models\Business\FundForecast::PAGE_FIELDS)))'><span class="material-icons-outlined" aria-hidden="true">edit</span></button></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="32"><div class="fund-forecast__empty"><span class="material-icons-outlined" aria-hidden="true">account_balance_wallet</span><span>当前筛选条件下没有资金预测数据</span></div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer class="fund-forecast__footer"><span>共 {{ number_format($forecastRows->total()) }} 条,第 {{ $forecastRows->currentPage() }} / {{ $forecastRows->lastPage() }} 页</span><span class="fund-forecast__pagination">@if($forecastRows->onFirstPage())<span>‹ 上一页</span>@else<a href="{{ $forecastRows->previousPageUrl() }}">‹ 上一页</a>@endif @if($forecastRows->hasMorePages())<a href="{{ $forecastRows->nextPageUrl() }}">下一页 ›</a>@else<span>下一页 ›</span>@endif</span></footer>
|
||||
</section>
|
||||
|
||||
<div class="fund-forecast-modal" data-forecast-modal aria-hidden="true">
|
||||
<div class="fund-forecast-modal__card" role="dialog" aria-modal="true" aria-labelledby="fund-forecast-modal-title">
|
||||
<div class="fund-forecast-modal__head"><strong class="fund-forecast-modal__title" id="fund-forecast-modal-title">新增资金预测</strong><button class="fund-forecast-modal__close" type="button" title="关闭" data-forecast-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div>
|
||||
<form method="POST" action="{{ route('business.pages.store',$pageRoute) }}" data-forecast-form data-store-url="{{ route('business.pages.store',$pageRoute) }}" data-update-url="{{ route('business.pages.update',array_merge($pageRoute,['id'=>'__ID__'])) }}">@csrf<input type="hidden" name="_method" value="POST" data-forecast-method>
|
||||
<div class="fund-forecast-modal__body">
|
||||
<section class="fund-forecast-modal__section"><div class="fund-forecast-modal__section-title">基础信息</div><div class="fund-forecast-modal__grid">
|
||||
<div class="fund-forecast-modal__field"><label for="forecast-form-month">预测月份</label><input id="forecast-form-month" type="month" name="budget_month" required></div><div class="fund-forecast-modal__field"><label for="forecast-form-company">公司名称</label><input id="forecast-form-company" type="text" name="company_name" maxlength="191" required></div><div class="fund-forecast-modal__field"><label for="forecast-form-region">所属区域</label><input id="forecast-form-region" type="text" name="region" maxlength="64"></div><div class="fund-forecast-modal__field"><label for="forecast-form-status">填报状态</label><select id="forecast-form-status" name="report_status"><option value="待填报">待填报</option><option value="待审核">待审核</option><option value="已审核">已审核</option></select></div>
|
||||
</div></section>
|
||||
@foreach($forecast['groups'] as $group)<section class="fund-forecast-modal__section"><div class="fund-forecast-modal__section-title">{{ $group['label'] }}</div><div class="fund-forecast-modal__grid">@foreach($group['columns'] as $column)@php($calculated=in_array($column['field'],\App\Models\Business\FundForecast::SUMMARY_FIELDS,true))<div class="fund-forecast-modal__field"><label for="forecast-form-{{ $column['field'] }}">{{ $column['label'] }}</label>@if($column['field']==='has_gap')<input id="forecast-form-has_gap" type="text" readonly tabindex="-1" data-has-gap-display>@else<input id="forecast-form-{{ $column['field'] }}" type="number" step="0.01" name="{{ $column['field'] }}" @if($calculated) readonly tabindex="-1" @endif>@endif</div>@endforeach</div></section>@endforeach
|
||||
</div>
|
||||
<div class="fund-forecast-modal__foot"><button class="fund-forecast__button" type="button" data-forecast-close>取消</button><button class="fund-forecast__button fund-forecast__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">save</span><span>保存</span></button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fund-forecast-modal" data-forecast-import-modal aria-hidden="true"><div class="fund-forecast-modal__card fund-forecast-modal__card--small" role="dialog" aria-modal="true" aria-labelledby="fund-forecast-import-title"><div class="fund-forecast-modal__head"><strong class="fund-forecast-modal__title" id="fund-forecast-import-title">导入资金预测表</strong><button class="fund-forecast-modal__close" type="button" title="关闭" data-forecast-import-close><span class="material-icons-outlined" aria-hidden="true">close</span></button></div><form method="POST" enctype="multipart/form-data" action="{{ route('business.pages.import',$pageRoute) }}">@csrf<div class="fund-forecast-modal__body"><div class="fund-forecast-modal__template"><a class="fund-forecast__button" href="{{ route('business.fund-forecast.template') }}"><span class="material-icons-outlined" aria-hidden="true">download</span><span>下载模板</span></a></div><div class="fund-forecast-modal__field"><label for="fund-forecast-import-file">Excel 文件</label><input id="fund-forecast-import-file" type="file" name="import" accept=".xlsx,.xls" required></div></div><div class="fund-forecast-modal__foot"><button class="fund-forecast__button" type="button" data-forecast-import-close>取消</button><button class="fund-forecast__button fund-forecast__button--primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span></button></div></form></div></div>
|
||||
|
||||
<script>
|
||||
(()=>{const page=document.querySelector('.fund-forecast');if(!page||page.dataset.ready==='1')return;page.dataset.ready='1';const modal=document.querySelector('[data-forecast-modal]'),form=document.querySelector('[data-forecast-form]'),title=document.getElementById('fund-forecast-modal-title'),method=form.querySelector('[data-forecast-method]'),balance=@json(\App\Models\Business\FundForecast::BALANCE_FIELDS),income=@json(\App\Models\Business\FundForecast::INCOME_FIELDS),expense=@json(\App\Models\Business\FundForecast::EXPENSE_FIELDS);const close=()=>{modal.classList.remove('is-open');modal.setAttribute('aria-hidden','true');document.body.style.overflow=''};const calculate=()=>{const number=name=>Number(form.elements[name]?.value||0),sum=fields=>fields.reduce((total,name)=>total+number(name),0),balanceTotal=sum(balance),incomeTotal=sum(income),expenseTotal=sum(expense),gap=balanceTotal+incomeTotal-expenseTotal;form.elements.previous_month_balance.value=balanceTotal.toFixed(2);form.elements.forecast_month_income.value=incomeTotal.toFixed(2);form.elements.forecast_month_expense.value=expenseTotal.toFixed(2);form.elements.gap_surplus.value=gap.toFixed(2);form.querySelector('[data-has-gap-display]').value=gap<0?'是':'否'};const open=row=>{form.reset();form.action=row?form.dataset.updateUrl.replace('__ID__',row.id):form.dataset.storeUrl;method.value=row?'PATCH':'POST';title.textContent=row?'编辑资金预测':'新增资金预测';form.elements.report_status.value=row?.report_status||'待填报';if(row)Object.keys(row).forEach(name=>{if(form.elements[name])form.elements[name].value=row[name]??''});calculate();modal.classList.add('is-open');modal.setAttribute('aria-hidden','false');document.body.style.overflow='hidden';form.elements.budget_month.focus()};document.querySelector('[data-forecast-create]')?.addEventListener('click',()=>open(null));document.querySelectorAll('[data-forecast-edit]').forEach(button=>button.addEventListener('click',()=>open(JSON.parse(button.dataset.row))));document.querySelectorAll('[data-forecast-close]').forEach(button=>button.addEventListener('click',close));modal.addEventListener('click',event=>{if(event.target===modal)close()});[...balance,...income,...expense].forEach(name=>form.elements[name]?.addEventListener('input',calculate));
|
||||
const importModal=document.querySelector('[data-forecast-import-modal]'),closeImport=()=>{importModal.classList.remove('is-open');importModal.setAttribute('aria-hidden','true');document.body.style.overflow=''};document.querySelector('[data-forecast-import-open]')?.addEventListener('click',()=>{importModal.classList.add('is-open');importModal.setAttribute('aria-hidden','false');document.body.style.overflow='hidden'});document.querySelectorAll('[data-forecast-import-close]').forEach(button=>button.addEventListener('click',closeImport));importModal.addEventListener('click',event=>{if(event.target===importModal)closeImport()});document.querySelector('[data-forecast-filter-form]')?.addEventListener('submit',()=>{const button=document.querySelector('[data-filter-submit]');button.disabled=true;button.innerHTML='<span class="material-icons-outlined fund-forecast__spinner" aria-hidden="true">progress_activity</span><span>查询中</span>'});
|
||||
const toggle=document.querySelector('[data-forecast-columns-toggle]'),panel=document.querySelector('[data-forecast-columns-panel]'),storageKey='fund-forecast-columns-{{ user()->id }}',saved=JSON.parse(localStorage.getItem(storageKey)||'{}');const updateGroups=()=>document.querySelectorAll('[data-group]').forEach(header=>{const count=document.querySelectorAll(`[data-group-field="${header.dataset.group}"]:not([hidden])`).length;header.colSpan=Math.max(count,1);header.hidden=count===0});const setColumn=(field,visible)=>{document.querySelectorAll(`[data-column="${field}"], [data-field="${field}"]`).forEach(cell=>cell.hidden=!visible);updateGroups()};document.querySelectorAll('[data-forecast-column-option]').forEach(option=>{if(saved[option.value]===false)option.checked=false;setColumn(option.value,option.checked);option.addEventListener('change',()=>{saved[option.value]=option.checked;localStorage.setItem(storageKey,JSON.stringify(saved));setColumn(option.value,option.checked)})});toggle?.addEventListener('click',()=>{panel.hidden=!panel.hidden;toggle.setAttribute('aria-expanded',String(!panel.hidden))});document.addEventListener('click',event=>{if(!panel.hidden&&!panel.contains(event.target)&&!toggle.contains(event.target)){panel.hidden=true;toggle.setAttribute('aria-expanded','false')}});document.addEventListener('keydown',event=>{if(event.key==='Escape'){close();closeImport()}})})();
|
||||
</script>
|
||||
@ -1,108 +0,0 @@
|
||||
<div class="business-page-header">
|
||||
<span class="business-page-title">{{ $title ?? '资金预测表' }}</span>
|
||||
</div>
|
||||
<div class="business-table-container">
|
||||
<table class="business-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>预算月份</th>
|
||||
<th>实际月份</th>
|
||||
<th>店铺余额(预算)</th>
|
||||
<th>店铺余额(实际)</th>
|
||||
<th>银行余额(预算)</th>
|
||||
<th>银行余额(实际)</th>
|
||||
<th>其他余额(预算)</th>
|
||||
<th>其他余额(实际)</th>
|
||||
<th>预计回款(预算)</th>
|
||||
<th>预计回款(实际)</th>
|
||||
<th>收入合计(预算)</th>
|
||||
<th>收入合计(实际)</th>
|
||||
<th>往期未付(预算)</th>
|
||||
<th>往期未付(实际)</th>
|
||||
<th>借支(预算)</th>
|
||||
<th>借支(实际)</th>
|
||||
<th>供应商(预算)</th>
|
||||
<th>供应商(实际)</th>
|
||||
<th>现结(预算)</th>
|
||||
<th>现结(实际)</th>
|
||||
<th>快递(预算)</th>
|
||||
<th>快递(实际)</th>
|
||||
<th>纸厂压贴(预算)</th>
|
||||
<th>纸厂压贴(实际)</th>
|
||||
<th>封边条(预算)</th>
|
||||
<th>封边条(实际)</th>
|
||||
<th>板材(预算)</th>
|
||||
<th>板材(实际)</th>
|
||||
<th>工资(预算)</th>
|
||||
<th>工资(实际)</th>
|
||||
<th>日常费用(预算)</th>
|
||||
<th>日常费用(实际)</th>
|
||||
<th>种菜(预算)</th>
|
||||
<th>种菜(实际)</th>
|
||||
<th>红包(预算)</th>
|
||||
<th>红包(实际)</th>
|
||||
<th>对公扣款(预算)</th>
|
||||
<th>对公扣款(实际)</th>
|
||||
<th>支出合计(预算)</th>
|
||||
<th>支出合计(实际)</th>
|
||||
<th>银行预留</th>
|
||||
<th>净结余(预算)</th>
|
||||
<th>净结余(实际)</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($items as $item)
|
||||
<tr>
|
||||
<td>{{ $item->budget_month }}</td>
|
||||
<td>{{ $item->actual_month }}</td>
|
||||
<td>{{ number_format($item->store_balance_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->store_balance_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->bank_balance_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->bank_balance_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->other_balance_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->other_balance_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->expected_monthly_return_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->expected_monthly_return_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->income_total_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->income_total_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->prior_unpaid_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->prior_unpaid_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->advance_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->advance_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->supplier_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->supplier_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->spot_settlement_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->spot_settlement_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->courier_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->courier_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->paper_factory_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->paper_factory_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->edge_band_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->edge_band_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->board_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->board_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->salary_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->salary_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->daily_expense_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->daily_expense_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->planting_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->planting_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->red_packet_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->red_packet_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->corporate_deduction_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->corporate_deduction_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->expense_total_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->expense_total_actual, 2) }}</td>
|
||||
<td>{{ number_format($item->bank_reservation, 2) }}</td>
|
||||
<td>{{ number_format($item->net_balance_budget, 2) }}</td>
|
||||
<td>{{ number_format($item->net_balance_actual, 2) }}</td>
|
||||
|
||||
</tr>
|
||||
@endforeach
|
||||
@if($items->isEmpty())
|
||||
<tr><td colspan=43 style="text-align:center; color:#9ca3af; padding:2rem;">暂无数据</td></tr>
|
||||
@endif
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -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');
|
||||
|
||||
183
tests/Feature/Business/FundForecastPageTest.php
Normal file
183
tests/Feature/Business/FundForecastPageTest.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Business\FundForecastWorkbookImporter;
|
||||
use App\Models\Auth\Permission;
|
||||
use App\Models\Business\FundForecast;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class FundForecastPageTest extends FeatureTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user