Просмотр исходного кода

Merge remote-tracking branch 'origin/master'

gogs 2 месяцев назад
Родитель
Сommit
04d023727f

+ 46 - 0
app/Exports/ItemSalaryFTMultipleSheetExport.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithMultipleSheets;
+
+class ItemSalaryFTMultipleSheetExport implements WithMultipleSheets
+{
+    protected $monthsData;
+    protected $projects;
+    protected $company;
+
+    /**
+     * @param array $monthsData 格式需要包含该月特有的项目列表:
+     * [
+     * '2024年4月' => [
+     * 'projects' => ['RD01', 'RD02'],
+     * 'data' => [ [...] ]
+     * ],
+     * '2024年5月' => [
+     * 'projects' => ['RD03'],
+     * 'data' => [ [...] ]
+     * ]
+     * ]
+     */
+    public function __construct(array $monthsData,$company)
+    {
+        $this->monthsData = $monthsData;
+        $this->company = $company;
+    }
+
+    public function sheets(): array
+    {
+        $sheets = [];
+        foreach ($this->monthsData as $month => $item) {
+            // 每次实例化 Sheet 时,传入该月特有的 projects 列表
+            $sheets[] = new ItemSalaryFTSheetExport(
+                $month,
+                $item['data'],
+                $item['projects'],
+                $this->company
+            );
+        }
+        return $sheets;
+    }
+}

+ 122 - 0
app/Exports/ItemSalaryFTSheetExport.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\FromCollection;
+use Maatwebsite\Excel\Concerns\WithEvents;
+use Maatwebsite\Excel\Concerns\WithTitle;
+use Maatwebsite\Excel\Concerns\WithCustomStartCell;
+use Maatwebsite\Excel\Events\AfterSheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+
+class ItemSalaryFTSheetExport implements FromCollection, WithEvents, WithCustomStartCell, WithTitle
+{
+    protected $month;
+    protected $data;
+    protected $projects;
+    protected $company;
+
+    public function __construct(string $month, array $data, array $projects, $company = '')
+    {
+        $this->month = $month;
+        $this->data = $data;
+        $this->projects = $projects;
+        $this->company = $company;
+    }
+
+    public function title(): string { return $this->month; }
+
+    public function startCell(): string { return 'A5'; }
+
+    public function collection() { return collect($this->data); }
+
+    public function registerEvents(): array
+    {
+        return [
+            AfterSheet::class => function(AfterSheet $event) {
+                $sheet = $event->sheet->getDelegate();
+
+                $projectCount = count($this->projects);
+                // 总列数计算:4(基础) + 项目数 + 1(合计工时) + 项目数 + 1(总计工资)
+                $totalColNum = 4 + ($projectCount + 1) + ($projectCount + 1);
+                $highestColumn = Coordinate::stringFromColumnIndex($totalColNum);
+                $highestRow = $sheet->getHighestRow();
+
+                // 保证即便数据少,也画出至少 10 行的格子,显得美观
+                $renderToRow = max($highestRow, 10);
+
+                // --- 1. 第一行:主标题 ---
+                $sheet->mergeCells("A1:{$highestColumn}1");
+                $sheet->setCellValue('A1', "{$this->month}研发工资分摊表");
+                $sheet->getStyle('A1')->getFont()->setSize(16)->setBold(true);
+
+                // --- 2. 第二行:单位名称 ---
+                $sheet->mergeCells("A2:{$highestColumn}2");
+                $sheet->setCellValue('A2', "单位:{$this->company}");
+                $sheet->getStyle('A2')->getFont()->setSize(12);
+
+                // --- 3. 第三、四行:复合表头 ---
+                // 垂直合并基础列
+                foreach (['A', 'B', 'C', 'D'] as $col) { $sheet->mergeCells("{$col}3:{$col}4"); }
+                $sheet->setCellValue('A3', '序号');
+                $sheet->setCellValue('B3', '技术人员');
+                $sheet->setCellValue('C3', '工资');
+                $sheet->setCellValue('D3', '月总工时');
+
+                // A. 月工时区域 (E列开始)
+                $workHourStartCol = 5;
+                $workHourEndCol = $workHourStartCol + $projectCount;
+                $workHourStartLetter = Coordinate::stringFromColumnIndex($workHourStartCol);
+                $workHourEndLetter = Coordinate::stringFromColumnIndex($workHourEndCol);
+                $sheet->mergeCells("{$workHourStartLetter}3:{$workHourEndLetter}3");
+                $sheet->setCellValue("{$workHourStartLetter}3", '月 工 时');
+                foreach ($this->projects as $idx => $code) {
+                    $sheet->setCellValue(Coordinate::stringFromColumnIndex($workHourStartCol + $idx) . '4', $code);
+                }
+                $sheet->setCellValue($workHourEndLetter . '4', '合计工时');
+
+                // B. 金额区域
+                $moneyStartCol = $workHourEndCol + 1;
+                $moneyEndCol = $moneyStartCol + $projectCount;
+                $moneyStartLetter = Coordinate::stringFromColumnIndex($moneyStartCol);
+                $moneyEndLetter = Coordinate::stringFromColumnIndex($moneyEndCol);
+                $sheet->mergeCells("{$moneyStartLetter}3:{$moneyEndLetter}3");
+                $sheet->setCellValue("{$moneyStartLetter}3", '项目应计工资金额');
+                foreach ($this->projects as $idx => $code) {
+                    $sheet->setCellValue(Coordinate::stringFromColumnIndex($moneyStartCol + $idx) . '4', $code);
+                }
+                $sheet->setCellValue($moneyEndLetter . '4', '总计工资');
+
+                // --- 4. 样式美化 ---
+                // 仅对表格主体(3行开始)加边框
+                $tableRange = "A3:{$highestColumn}{$renderToRow}";
+                $sheet->getStyle($tableRange)->applyFromArray([
+                    'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
+                    'alignment' => [
+                        'vertical' => Alignment::VERTICAL_CENTER,
+                        'horizontal' => Alignment::HORIZONTAL_CENTER,
+                    ],
+                    'font' => ['name' => '宋体', 'size' => 10],
+                ]);
+
+                // 标题和单位行样式
+                $sheet->getStyle("A1:A2")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
+                $sheet->getStyle("A1")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+                $sheet->getStyle("A3:{$highestColumn}4")->getFont()->setBold(true);
+
+                // 格式化金额(解决 0.02 问题)
+                $sheet->getStyle("C5:C{$renderToRow}")->getNumberFormat()->setFormatCode('#,##0.00');
+                $sheet->getStyle("{$moneyStartLetter}5:{$highestColumn}{$renderToRow}")->getNumberFormat()->setFormatCode('#,##0.00');
+
+                // 设置列宽
+                $sheet->getColumnDimension('A')->setWidth(6);
+                $sheet->getColumnDimension('B')->setWidth(12);
+                for ($i = 3; $i <= $totalColNum; $i++) {
+                    $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i))->setWidth(13);
+                }
+            },
+        ];
+    }
+}

+ 139 - 0
app/Exports/ItemSalarySheetExport.php

@@ -0,0 +1,139 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\FromCollection;
+use Maatwebsite\Excel\Concerns\WithEvents;
+use Maatwebsite\Excel\Concerns\WithStyles;
+use Maatwebsite\Excel\Concerns\WithCustomStartCell;
+use Maatwebsite\Excel\Events\AfterSheet;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+
+class ItemSalarySheetExport implements FromCollection, WithEvents, WithStyles, WithCustomStartCell
+{
+    protected $projects;
+    protected $data;
+    protected $company;
+
+    public function __construct(array $projects, array $data, $company)
+    {
+        $this->projects = $projects;
+        $this->data = $data;
+        $this->company = $company;
+    }
+
+    /**
+     * 数据从第 5 行开始写入(1-4行留给自定义表头)
+     */
+    public function startCell(): string
+    {
+        return 'A5';
+    }
+
+    /**
+     * 返回导出数据集合
+     */
+    public function collection()
+    {
+        return collect($this->data);
+    }
+
+    /**
+     * 处理复杂的表头合并、列宽、固定文字
+     */
+    public function registerEvents(): array
+    {
+        return [
+            AfterSheet::class => function(AfterSheet $event) {
+                $sheet = $event->sheet->getDelegate();
+
+                // 1. 计算总列数
+                $projectCount = count($this->projects);
+                $totalColNum = 1 + ($projectCount * 2) + 1; // 年月(1) + 每个项目2列 + 合计(1)
+                $highestColumn = Coordinate::stringFromColumnIndex($totalColNum);
+
+                // --- 2. 设置列宽 ---
+                $sheet->getColumnDimension('A')->setWidth(15); // 年月列
+
+                for ($i = 0; $i < $projectCount; $i++) {
+                    $startColNum = 2 + ($i * 2);
+                    $colLetterDays = Coordinate::stringFromColumnIndex($startColNum);     // 天数
+                    $colLetterSalary = Coordinate::stringFromColumnIndex($startColNum + 1); // 工资
+
+                    // 设置比例:工资约为天数的 2 倍强
+                    $sheet->getColumnDimension($colLetterDays)->setWidth(7);
+                    $sheet->getColumnDimension($colLetterSalary)->setWidth(16);
+
+                    // --- 3. 第三、四行:RD项目表头设置 ---
+                    // 第三行项目名水平合并 (B3:C3)
+                    $sheet->mergeCells("{$colLetterDays}3:{$colLetterSalary}3");
+                    $sheet->setCellValue("{$colLetterDays}3", $this->projects[$i]);
+
+                    // 第四行子标题
+                    $sheet->setCellValue("{$colLetterDays}4", '天数');
+                    $sheet->setCellValue("{$colLetterSalary}4", '工资');
+                }
+
+                // 合计列宽度
+                $sheet->getColumnDimension($highestColumn)->setWidth(18);
+
+                // --- 4. 第一行:主标题 ---
+                $sheet->mergeCells("A1:{$highestColumn}1");
+                $sheet->setCellValue('A1', '研发项目工资总表');
+                $sheet->getStyle('A1')->getFont()->setSize(14);
+                $sheet->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+
+                // --- 5. 第二行:单位名称 ---
+                $sheet->mergeCells("A2:{$highestColumn}2");
+                $sheet->setCellValue('A2', '单位名称:'. $this->company);
+                $sheet->getStyle('A2')->getFont()->setSize(14);
+
+                // --- 6. 垂直合并“年月”和“合计” ---
+                $sheet->mergeCells('A3:A4');
+                $sheet->setCellValue('A3', '年月');
+
+                $lastColLetter = $highestColumn;
+                $sheet->mergeCells("{$lastColLetter}3:{$lastColLetter}4");
+                $sheet->setCellValue("{$lastColLetter}3", '合计');
+
+                // 表头区域整体居中
+                $sheet->getStyle("A3:{$highestColumn}4")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+            },
+        ];
+    }
+
+    /**
+     * 设置表格基础样式和边框
+     */
+    public function styles(Worksheet $sheet)
+    {
+        $highestRow = $sheet->getHighestRow();
+        $highestColumn = $sheet->getHighestColumn();
+
+        // 定义统一的边框和对齐样式
+        $commonStyle = [
+            'borders' => [
+                'allBorders' => [
+                    'borderStyle' => Border::BORDER_THIN,
+                    'color' => ['argb' => '000000'],
+                ],
+            ],
+            'alignment' => [
+                'vertical' => Alignment::VERTICAL_CENTER,
+                'horizontal' => Alignment::HORIZONTAL_CENTER,
+            ],
+            'font' => [
+                'name' => '宋体',
+                'size' => 14,
+            ],
+        ];
+        $sheet->getStyle("A1:{$highestColumn}{$highestRow}")->applyFromArray($commonStyle);
+        $sheet->getStyle("A1")->getFont()->setSize(14);
+        $sheet->getStyle("A2")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
+
+        return [];
+    }
+}

+ 39 - 0
app/Exports/ManActivityTimeCardMultipleSheetExport.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithMultipleSheets;
+
+class ManActivityTimeCardMultipleSheetExport implements WithMultipleSheets
+{
+    protected $projectsData;
+    protected $year;
+
+    /**
+     * @param array $projectsData 格式为:[
+     * 'RD01项目名' => [ [...行数据1], [...行数据2] ],
+     * 'RD02项目名' => [ [...行数据1], [...行数据2] ],
+     * ]
+     * @param string $year
+     */
+    public function __construct(array $projectsData, string $year = '2024')
+    {
+        $this->projectsData = $projectsData;
+        $this->year = $year;
+    }
+
+    /**
+     * 定义多个 Sheet
+     */
+    public function sheets(): array
+    {
+        $sheets = [];
+
+        foreach ($this->projectsData as $projectName => $data) {
+            // 每一项都实例化之前写的那个 ProjectAnnualSalaryExport
+            $sheets[] = new ManActivityTimeCardSheetExport($projectName, $data, $this->year);
+        }
+
+        return $sheets;
+    }
+}

+ 155 - 0
app/Exports/ManActivityTimeCardSheetExport.php

@@ -0,0 +1,155 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\FromCollection;
+use Maatwebsite\Excel\Concerns\WithEvents;
+use Maatwebsite\Excel\Concerns\WithStyles;
+use Maatwebsite\Excel\Concerns\WithTitle;
+use Maatwebsite\Excel\Concerns\WithCustomStartCell;
+use Maatwebsite\Excel\Events\AfterSheet;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Fill;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+
+class ManActivityTimeCardSheetExport implements FromCollection, WithEvents, WithStyles, WithCustomStartCell, WithTitle
+{
+    protected $projectName;
+    protected $data;
+    protected $year;
+
+    public function __construct(string $projectName, array $data, string $year)
+    {
+        $this->projectName = $projectName;
+        $this->data = $data;
+        $this->year = $year;
+    }
+
+    public function title(): string
+    {
+        // Sheet名不能超过31个字符
+        return mb_substr($this->projectName, 0, 30);
+    }
+
+    public function startCell(): string
+    {
+        return 'A6'; // 数据从 A6 开始写入
+    }
+
+    public function collection()
+    {
+        return collect($this->data);
+    }
+
+    public function registerEvents(): array
+    {
+        return [
+            AfterSheet::class => function(AfterSheet $event) {
+                $sheet = $event->sheet->getDelegate();
+                $lastCol = 'R';
+
+                // --- 1. 设置列宽 ---
+                $widths = [
+                    'A' => 8, 'B' => 12, 'C' => 10, 'D' => 10, 'E' => 10, 'F' => 10,
+                    'G' => 12, 'H' => 10, 'I' => 10, 'J' => 12, 'K' => 10, 'L' => 10,
+                    'M' => 12, 'N' => 10, 'O' => 10, 'P' => 12, 'Q' => 10, 'R' => 10
+                ];
+                foreach ($widths as $col => $w) {
+                    $sheet->getColumnDimension($col)->setWidth($w);
+                }
+
+                // --- 2. 第一行:主标题 (宋体 18 加粗) ---
+                $sheet->mergeCells("A1:{$lastCol}1");
+                $sheet->setCellValue('A1', "{$this->year}年研发人员研发活动工时占比计算及研发人员费用调整表");
+                $sheet->getStyle('A1')->applyFromArray([
+                    'font' => ['name' => '宋体', 'size' => 18, 'bold' => true],
+                    'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
+                ]);
+
+                // --- 3. 第二行:项目名称 (宋体 9 加粗) ---
+                $sheet->mergeCells("A2:{$lastCol}2");
+                $sheet->setCellValue('A2', "项目名称:{$this->projectName}");
+                $sheet->getStyle('A2')->applyFromArray([
+                    'font' => ['name' => '宋体', 'size' => 9, 'bold' => true],
+                    'alignment' => ['horizontal' => Alignment::HORIZONTAL_LEFT],
+                ]);
+
+                // --- 4. 第三至五行:表头逻辑 (宋体 11 加粗 自动换行) ---
+                foreach (['A', 'B', 'C'] as $col) { $sheet->mergeCells("{$col}3:{$col}5"); }
+                $sheet->setCellValue('A3', "月份"); $sheet->setCellValue('B3', "人员类别"); $sheet->setCellValue('C3', "姓名");
+
+                $sheet->mergeCells('D3:F3'); $sheet->setCellValue('D3', '工时占比计算');
+                $sheet->mergeCells('D4:D5'); $sheet->setCellValue('D4', '应出勤工时');
+                $sheet->mergeCells('E4:E5'); $sheet->setCellValue('E4', '研发出勤工时');
+                $sheet->mergeCells('F4:F5'); $sheet->setCellValue('F4', '研发工时占比');
+
+                $sheet->mergeCells('G3:L3'); $sheet->setCellValue('G3', '账面归集金额');
+                $sheet->mergeCells('G4:I4'); $sheet->setCellValue('G4', '归集总额');
+                $sheet->setCellValue('G5', '工资总额'); $sheet->setCellValue('H5', '社保'); $sheet->setCellValue('I5', '公积金');
+
+                $sheet->mergeCells('J4:L4'); $sheet->setCellValue('J4', '其中属于研发活动期间金额');
+                $sheet->setCellValue('J5', '工资总额'); $sheet->setCellValue('K5', '社保'); $sheet->setCellValue('L5', '公积金');
+
+                $sheet->mergeCells('M3:O4'); $sheet->setCellValue('M3', '研发活动期间的确定金额');
+                $sheet->setCellValue('M5', '工资总额'); $sheet->setCellValue('N5', '社保'); $sheet->setCellValue('O5', '公积金');
+
+                $sheet->mergeCells('P3:R4'); $sheet->setCellValue('P3', '加计调整金额');
+                $sheet->setCellValue('P5', '工资总额'); $sheet->setCellValue('Q5', '社保'); $sheet->setCellValue('R5', '公积金');
+
+                // 应用三到五行样式
+                $sheet->getStyle("A3:{$lastCol}5")->applyFromArray([
+                    'font' => ['name' => '宋体', 'size' => 11, 'bold' => true],
+                    'alignment' => [
+                        'wrapText' => true,
+                        'horizontal' => Alignment::HORIZONTAL_CENTER,
+                        'vertical' => Alignment::VERTICAL_CENTER
+                    ],
+                ]);
+
+                // --- 5. 染色逻辑 ---
+                $highestRow = $sheet->getHighestRow();
+                for ($row = 6; $row <= $highestRow; $row++) {
+                    $cellA = $sheet->getCell("A{$row}")->getValue();
+                    if ($cellA === '小计:') {
+                        $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFill()
+                            ->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB('F4B084');
+                    } elseif ($cellA === '合计') {
+                        $sheet->getStyle("A{$row}:{$lastCol}{$row}")->getFill()
+                            ->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB('FFFF00');
+                    }
+                }
+            },
+        ];
+    }
+
+    public function styles(Worksheet $sheet)
+    {
+        $highestRow = $sheet->getHighestRow();
+        $lastCol = 'R';
+
+        return [
+            // 全表通用对齐和边框
+            "A1:{$lastCol}{$highestRow}" => [
+                'borders' => [
+                    'allBorders' => ['borderStyle' => Border::BORDER_THIN],
+                ],
+                'alignment' => [
+                    'vertical' => Alignment::VERTICAL_CENTER,
+                ],
+            ],
+            // 数据行:宋体 10,居中,不加粗
+            "A6:{$lastCol}{$highestRow}" => [
+                'font' => [
+                    'name' => '宋体',
+                    'size' => 10,
+                    'bold' => false
+                ],
+                'alignment' => [
+                    'horizontal' => Alignment::HORIZONTAL_CENTER,
+                ],
+            ]
+        ];
+    }
+}

+ 29 - 0
app/Exports/ManMonthlyWorkHourMultipleSheetExport.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithMultipleSheets;
+
+class ManMonthlyWorkHourMultipleSheetExport implements WithMultipleSheets
+{
+    protected $monthsData;
+
+    public function __construct(array $monthsData)
+    {
+        $this->monthsData = $monthsData;
+    }
+
+    public function sheets(): array
+    {
+        $sheets = [];
+        foreach ($this->monthsData as $monthName => $config) {
+            // 传入 3 个参数,匹配子类新的构造函数
+            $sheets[] = new ManMonthlyWorkHourSheetExport(
+                (string)$monthName,
+                $config['data'] ?? [],
+                (int)($config['days'] ?? 30)
+            );
+        }
+        return $sheets;
+    }
+}

+ 122 - 0
app/Exports/ManMonthlyWorkHourSheetExport.php

@@ -0,0 +1,122 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithEvents;
+use Maatwebsite\Excel\Concerns\WithTitle;
+use Maatwebsite\Excel\Events\AfterSheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Borders;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+
+class ManMonthlyWorkHourSheetExport implements WithEvents, WithTitle
+{
+    protected $monthName;
+    protected $data;
+    protected $daysInMonth;
+
+    public function __construct(string $monthName, array $data, int $daysInMonth)
+    {
+        $this->monthName   = $monthName;
+        $this->data        = $data;
+        $this->daysInMonth = $daysInMonth;
+    }
+
+    public function title(): string
+    {
+        return $this->monthName;
+    }
+
+    public function registerEvents(): array
+    {
+        return [
+            AfterSheet::class => function (AfterSheet $event) {
+                $sheet = $event->sheet->getDelegate();
+
+                // 计算最大列(项目B + 姓名C + 天数)
+                $totalColNum = 2 + $this->daysInMonth;
+                $highestColumn = Coordinate::stringFromColumnIndex($totalColNum);
+
+                // --- 1. 第一行:主标题 ---
+                $sheet->mergeCells("A1:{$highestColumn}1");
+                $sheet->setCellValue('A1', "{$this->monthName}研发人员工时统计表");
+                $sheet->getStyle('A1')->getFont()->setSize(14);
+                $sheet->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+
+                // --- 2. 第二行:单位行 ---
+                $sheet->mergeCells("A2:{$highestColumn}2");
+                $sheet->setCellValue('A2', "单位:");
+                $sheet->getStyle('A2')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
+
+                // --- 3. 第三行表头设置 ---
+
+                // (1) A3: 项目名称 (独立单元格)
+                $sheet->setCellValue('A3', "项目名称");
+                $sheet->getColumnDimension('A')->setWidth(14);
+
+                // (2) B3: 人员名称/日期 (斜线分割单元格)
+                $sheet->setCellValue('B3', "                        日期       \n人员名称");
+                $sheet->getStyle('B3')->getAlignment()->setWrapText(true);
+                $sheet->getStyle('B3')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+                $sheet->getStyle('B3')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
+                $sheet->getColumnDimension('B')->setWidth(16);
+
+                // 绘制 B3 斜线
+                $sheet->getStyle('B3')->getBorders()->applyFromArray([
+                    'diagonal' => [
+                        'borderStyle' => Border::BORDER_THIN,
+                        'color' => ['rgb' => '000000'],
+                    ],
+                ]);
+                $sheet->getStyle('B3')->getBorders()->setDiagonalDirection(Borders::DIAGONAL_DOWN);
+
+                // (3) C3 往后: 动态日期 (1, 2, 3...)
+                for ($day = 1; $day <= $this->daysInMonth; $day++) {
+                    $colLetter = Coordinate::stringFromColumnIndex($day + 2); // 从 C 列开始
+                    $sheet->setCellValue($colLetter . '3', $day);
+                    $sheet->getColumnDimension($colLetter)->setWidth(8);
+                }
+
+                // 设置第三行行高
+                $sheet->getRowDimension(3)->setRowHeight(50);
+
+                // --- 4. 写入数据行 (从第4行开始) ---
+                $dataStartRow = 4;
+                if (!empty($this->data)) {
+                    foreach ($this->data as $rowIndex => $row) {
+                        $currentRow = $dataStartRow + $rowIndex;
+                        foreach ($row as $colIndex => $cellValue) {
+                            $colLetter = Coordinate::stringFromColumnIndex($colIndex + 1);
+                            $sheet->setCellValue($colLetter . $currentRow, $cellValue);
+                        }
+                    }
+                }
+
+                // --- 5. 全局样式微调 ---
+                $highestRow = $sheet->getHighestRow();
+
+                // 1. 设置工时数据区域的格式为数字,并保留两位小数
+                $dataRange = "C4:{$highestColumn}{$highestRow}";
+                $sheet->getStyle($dataRange)->getNumberFormat()->setFormatCode('#,##0.00');
+                // 或者用 NumberFormat::FORMAT_NUMBER_00
+
+                // 所有单元格垂直居中
+                $sheet->getStyle("A1:{$highestColumn}{$highestRow}")->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+
+                // 表头及日期数据水平居中 (排除 A3 和 B3)
+                $sheet->getStyle("A3:{$highestColumn}{$highestRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+                $sheet->getStyle('B3')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT); // 斜线格保持左对齐
+
+                // 添加表格边框 (从 A3 开始)
+                $sheet->getStyle("A3:{$highestColumn}{$highestRow}")->applyFromArray([
+                    'borders' => [
+                        'allBorders' => [
+                            'borderStyle' => Border::BORDER_THIN,
+                        ],
+                    ],
+                ]);
+            },
+        ];
+    }
+}

+ 29 - 0
app/Exports/ProjectDepreciationMultipleSheetExport.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithMultipleSheets;
+
+class ProjectDepreciationMultipleSheetExport implements WithMultipleSheets
+{
+    protected $data; // 格式:[ '2025-RD01' => [ 'project_name' => '...', 'months' => [ 1 => [...数据...], 2 => [...] ] ] ]
+
+    public function __construct(array $data)
+    {
+        $this->data = $data;
+    }
+
+    public function sheets(): array
+    {
+        $sheets = [];
+        foreach ($this->data as $groupKey => $payload) {
+            // $groupKey 比如 "2025-RD01"
+            $sheets[] = new ProjectDepreciationSheetExport(
+                $groupKey,
+                $payload['project_name'],
+                $payload['months']
+            );
+        }
+        return $sheets;
+    }
+}

+ 128 - 0
app/Exports/ProjectDepreciationSheetExport.php

@@ -0,0 +1,128 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithEvents;
+use Maatwebsite\Excel\Concerns\WithTitle;
+use Maatwebsite\Excel\Events\AfterSheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+use PhpOffice\PhpSpreadsheet\Style\Borders;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
+
+class ProjectDepreciationSheetExport implements WithEvents, WithTitle
+{
+    protected $groupKey;
+    protected $projectName;
+    protected $monthsData;
+
+    public function __construct($groupKey, $projectName, $monthsData)
+    {
+        $this->groupKey = $groupKey;
+        $this->projectName = $projectName;
+        $this->monthsData = $monthsData;
+    }
+
+    public function title(): string { return $this->groupKey; }
+
+    public function registerEvents(): array
+    {
+        return [
+            AfterSheet::class => function (AfterSheet $event) {
+                $sheet = $event->sheet->getDelegate();
+                $currentRow = 1;
+
+                // --- 关键:预先设置列宽 ---
+                $sheet->getColumnDimension('A')->setWidth(6);
+                $sheet->getColumnDimension('B')->setWidth(25);
+                $sheet->getColumnDimension('J')->setWidth(15);
+                $sheet->getColumnDimension('L')->setWidth(12); // 给 L 列足够的空间
+
+                foreach ($this->monthsData as $month => $items) {
+                    // 记录本月表格的起始行
+                    $tableStartRow = $currentRow;
+
+                    // 1. 标题 (A-J)
+                    $sheet->mergeCells("A{$currentRow}:J{$currentRow}");
+                    $displayYear = date('Y', strtotime($month));
+                    $sheet->setCellValue("A{$currentRow}", "{$displayYear}年度研发项目折旧费用调整表");
+                    $sheet->getStyle("A{$currentRow}")->getFont()->setBold(true)->setSize(16);
+                    $sheet->getStyle("A{$currentRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+                    $currentRow++;
+
+                    // 2. 项目名称与单位 (A-J)
+                    $sheet->setCellValue("A{$currentRow}", "研发项目名称:{$this->projectName}");
+                    $sheet->mergeCells("H{$currentRow}:J{$currentRow}");
+                    $sheet->setCellValue("H{$currentRow}", "单位:元以下角分");
+                    $sheet->getStyle("H{$currentRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
+                    $currentRow++;
+
+                    // 3. 表头 (A-J)
+                    $headerRow1 = $currentRow;
+                    $sheet->mergeCells("A{$currentRow}:A" . ($currentRow + 1)); $sheet->setCellValue("A{$currentRow}", "序号");
+                    $sheet->mergeCells("B{$currentRow}:B" . ($currentRow + 1)); $sheet->setCellValue("B{$currentRow}", "设备名称");
+                    $sheet->mergeCells("C{$currentRow}:E{$currentRow}"); $sheet->setCellValue("C{$currentRow}", "设备工时");
+                    $sheet->mergeCells("F{$currentRow}:I{$currentRow}"); $sheet->setCellValue("F{$currentRow}", "设备折旧");
+                    $sheet->mergeCells("J{$currentRow}:J" . ($currentRow + 1)); $sheet->setCellValue("J{$currentRow}", "加计调整\n金额");
+                    $currentRow++;
+
+                    $sheet->setCellValue("C{$currentRow}", "设备总工时");
+                    $sheet->setCellValue("D{$currentRow}", "其中本项目\n工时");
+                    $sheet->setCellValue("E{$currentRow}", "研发活动工\n时占比(%)");
+                    $sheet->setCellValue("F{$currentRow}", "原值");
+                    $sheet->setCellValue("G{$currentRow}", "折旧额");
+                    $sheet->setCellValue("H{$currentRow}", "确定的本\n项目折旧额");
+                    $sheet->setCellValue("I{$currentRow}", "备注说明");
+                    $currentRow++;
+
+                    // 4. 数据行
+                    $dataRowsCount = count($items);
+                    foreach ($items as $index => $item) {
+                        $sheet->setCellValue("A{$currentRow}", $index + 1);
+                        $sheet->setCellValue("B{$currentRow}", $item['name'] ?? '');
+                        $sheet->setCellValue("C{$currentRow}", $item['total_hours'] ?? 0);
+                        $sheet->setCellValue("D{$currentRow}", $item['project_hours'] ?? 0);
+                        $sheet->setCellValue("E{$currentRow}", ($item['ratio'] ?? 0) . '%');
+                        $sheet->setCellValue("F{$currentRow}", $item['original_value'] ?? 0);
+                        $sheet->setCellValue("G{$currentRow}", $item['depreciation'] ?? 0);
+                        $sheet->setCellValue("H{$currentRow}", $item['confirmed_depreciation'] ?? 0);
+                        $sheet->setCellValue("I{$currentRow}", "");
+                        $sheet->setCellValue("J{$currentRow}", $item['adjust_amount'] ?? 0);
+
+                        // --- 核心修正:确保月份在 L 列显示 ---
+                        // 我们把月份放在数据行的第一行对应的 L 列
+                        if ($index === 0) {
+                            $sheet->setCellValue("L{$currentRow}", $month . "月");
+                            // 样式:宋体、加粗、靠左对齐
+                            $sheet->getStyle("L{$currentRow}")->getFont()->setName('SimSun')->setBold(true)->setSize(12);
+                            $sheet->getStyle("L{$currentRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
+                        }
+                        $currentRow++;
+                    }
+
+                    // 5. 合计行
+                    $sheet->setCellValue("A{$currentRow}", "合计");
+                    $sheet->mergeCells("A{$currentRow}:B{$currentRow}");
+                    // 这里可以填充合计逻辑...
+                    $currentRow++;
+
+                    // 6. 备注
+                    $sheet->mergeCells("A{$currentRow}:J{$currentRow}");
+                    $sheet->setCellValue("A{$currentRow}", "备注:按研发项目的研发活动工作使用情况记录等相关证据资料...");
+                    $sheet->getStyle("A{$currentRow}")->getFont()->setSize(9);
+                    $currentRow += 2; // 间隔
+
+                    // --- 7. 样式应用:仅针对 A-J 列加边框 ---
+                    $tableRange = "A{$tableStartRow}:J" . ($currentRow - 2);
+                    $sheet->getStyle($tableRange)->getFont()->setName('SimSun');
+                    $sheet->getStyle("A" . ($tableStartRow + 2) . ":J" . ($currentRow - 2))->applyFromArray([
+                        'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]],
+                    ]);
+
+                    // 居中对齐
+                    $sheet->getStyle("A{$tableStartRow}:J" . ($currentRow - 2))->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+                }
+            },
+        ];
+    }
+}

+ 36 - 0
app/Exports/ResearchExpenseMultipleSheetExport.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithMultipleSheets;
+
+class ResearchExpenseMultipleSheetExport implements WithMultipleSheets
+{
+    protected $sheetsData;
+
+    /**
+     * @param array $sheetsData 现在的格式应该是:
+     * [
+     * '2025-RD01' => ['project' => [...], 'data' => [...]],
+     * '2025-RD02' => ['project' => [...], 'data' => [...]]
+     * ]
+     */
+    public function __construct(array $sheetsData)
+    {
+        $this->sheetsData = $sheetsData;
+    }
+
+    public function sheets(): array
+    {
+        $sheets = [];
+        foreach ($this->sheetsData as $sheetTitle => $content) {
+            // 将每一个唯一的 Key (年份-项目) 传给具体的 Sheet 类
+            $sheets[] = new ResearchExpenseSheetExport(
+                (string)$sheetTitle, // 这里的 title 会显示在 Excel 标签页上
+                $content['data'],
+                $content['project'] ?? []
+            );
+        }
+        return $sheets;
+    }
+}

+ 168 - 0
app/Exports/ResearchExpenseSheetExport.php

@@ -0,0 +1,168 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\FromCollection;
+use Maatwebsite\Excel\Concerns\WithEvents;
+use Maatwebsite\Excel\Concerns\WithStyles;
+use Maatwebsite\Excel\Concerns\WithTitle;
+use Maatwebsite\Excel\Concerns\WithCustomStartCell;
+use Maatwebsite\Excel\Events\AfterSheet;
+use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+
+class ResearchExpenseSheetExport implements FromCollection, WithEvents, WithStyles, WithCustomStartCell, WithTitle
+{
+    protected $sheetTitle; // Sheet 标签名,如 "2025-RD01"
+    protected $data;
+    protected $projectInfo;
+    protected $year;
+
+    public function __construct(string $sheetTitle, array $data, array $projectInfo = [])
+    {
+        $this->sheetTitle = $sheetTitle;
+        $this->data = $data;
+        $this->projectInfo = $projectInfo;
+
+        // 自动从 Sheet 标题中提取前4位作为大标题年份
+        $this->year = substr($sheetTitle, 0, 4);
+    }
+
+    public function title(): string
+    {
+        return $this->sheetTitle;
+    }
+
+    public function startCell(): string
+    {
+        return 'A7'; // 数据从 A7 开始写入
+    }
+
+    public function collection()
+    {
+        return collect($this->data);
+    }
+
+    public function registerEvents(): array
+    {
+        return [
+            AfterSheet::class => function(AfterSheet $event) {
+                $sheet = $event->sheet->getDelegate();
+
+                // --- 1. 设置列宽 (精简适中版) ---
+                $sheet->getColumnDimension('A')->setWidth(12);  // 日期
+                $sheet->getColumnDimension('B')->setWidth(6);   // 种类
+                $sheet->getColumnDimension('C')->setWidth(6);   // 号数
+                $sheet->getColumnDimension('D')->setWidth(30);  // 摘要
+                $sheet->getColumnDimension('E')->setWidth(14);  // 会计金额
+                $sheet->getColumnDimension('F')->setWidth(14);  // 税法金额
+                $sheet->getColumnDimension('G')->setWidth(13);  // 人员人工
+                $sheet->getColumnDimension('H')->setWidth(13);  // 直接投入
+                $sheet->getColumnDimension('I')->setWidth(11);  // 折旧
+                $sheet->getColumnDimension('J')->setWidth(11);  // 无形资产
+                $sheet->getColumnDimension('K')->setWidth(11);  // 新产品
+                $sheet->getColumnDimension('L')->setWidth(11);  // 其他
+                $sheet->getColumnDimension('M')->setWidth(18);  // 委托境内
+                $sheet->getColumnDimension('N')->setWidth(18);  // 委托境外
+
+                // --- 2. 设置行高 ---
+                $sheet->getRowDimension('2')->setRowHeight(45); // 大标题行
+                $sheet->getRowDimension('3')->setRowHeight(40); // 项目信息行
+                $sheet->getRowDimension('4')->setRowHeight(22); // 表头1
+                $sheet->getRowDimension('5')->setRowHeight(25); // 表头2
+                $sheet->getRowDimension('6')->setRowHeight(60); // 表头底行 (容纳长文字)
+
+                // --- 3. 第二行:大标题 (居中/加粗) ---
+                $sheet->mergeCells("A2:N2");
+                $sheet->setCellValue('A2', "{$this->year}年研发支出辅助账");
+                $sheet->getStyle('A2')->applyFromArray([
+                    'font' => ['size' => 16],
+                    'alignment' => [
+                        'horizontal' => Alignment::HORIZONTAL_CENTER,
+                        'vertical' => Alignment::VERTICAL_CENTER,
+                    ],
+                ]);
+
+                // --- 4. 第三行:项目信息 (精准合并/无边框) ---
+                // 项目编号:标签 ABC,值 DE
+                $sheet->mergeCells("A3:C3");
+                $sheet->setCellValue('A3', '项目编号:');
+                $sheet->mergeCells("D3:E3");
+                $sheet->setCellValue('D3', $this->projectInfo['code'] ?? '');
+
+                // 项目名称:标签 F,值 G (开启换行)
+                $sheet->setCellValue('F3', '项目名称:');
+                $sheet->setCellValue('G3', $this->projectInfo['name'] ?? '');
+                $sheet->getStyle('G3')->getAlignment()->setWrapText(true);
+
+                $sheet->setCellValue('H3', '完成情况:');
+                $sheet->setCellValue('I3', '已完成');
+                $sheet->setCellValue('J3', '支出类型:');
+                $sheet->setCellValue('K3', '费用化');
+                $sheet->setCellValue('M3', '金额单位:');
+                $sheet->setCellValue('N3', '元');
+
+                // 第三行对齐
+                $sheet->getStyle('A3:N3')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+
+                // --- 5. 复杂表头绘制 (第 4-6 行) ---
+                // A-D 凭证信息
+                $sheet->mergeCells("A4:D5");
+                $sheet->setCellValue('A4', '凭证信息');
+                $sheet->setCellValue('A6', '日期');
+                $sheet->setCellValue('B6', '种类');
+                $sheet->setCellValue('C6', '号数');
+                $sheet->setCellValue('D6', '摘要');
+
+                // E-F 金额列 (垂直合并)
+                $sheet->mergeCells("E4:E6");
+                $sheet->setCellValue('E4', "会计凭证记载\n金额");
+                $sheet->mergeCells("F4:F6");
+                $sheet->setCellValue('F4', "税法规定的归\n集金额");
+
+                // G-N 费用明细总标题
+                $sheet->mergeCells("G4:N4");
+                $sheet->setCellValue('G4', '费用明细(税法规定)');
+
+                // 各科目垂直合并 (5-6行)
+                $subItems = [
+                    'G' => '人员人工费用', 'H' => '直接投入费用', 'I' => '折旧费用',
+                    'J' => '无形资产摊销', 'K' => '新产品设计费等', 'L' => '其他相关费用'
+                ];
+                foreach ($subItems as $col => $text) {
+                    $sheet->mergeCells("{$col}5:{$col}6");
+                    $sheet->setCellValue("{$col}5", $text);
+                }
+
+                // 委托研发费用
+                $sheet->mergeCells("M5:N5");
+                $sheet->setCellValue('M5', '委托研发费用');
+                $sheet->setCellValue('M6', "委托境内机构或个人进行研\n发活动所发生的费用");
+                $sheet->setCellValue('N6', "委托境外机构进行研发活动\n所发生的费用");
+
+                // --- 6. 开启换行 ---
+                $sheet->getStyle('A4:N6')->getAlignment()->setWrapText(true);
+            },
+        ];
+    }
+
+    public function styles(Worksheet $sheet)
+    {
+        $highestRow = $sheet->getHighestRow();
+
+        // 全局基础样式:从第 4 行开始加边框 (跳过第 3 行项目信息)
+        $sheet->getStyle("A4:N{$highestRow}")->applyFromArray([
+            'borders' => [
+                'allBorders' => ['borderStyle' => Border::BORDER_THIN],
+            ],
+            'alignment' => [
+                'vertical' => Alignment::VERTICAL_CENTER,
+                'horizontal' => Alignment::HORIZONTAL_CENTER,
+            ],
+            'font' => ['name' => '宋体', 'size' => 10],
+        ]);
+
+        return [];
+    }
+}

+ 24 - 0
app/Exports/ResearchExpenseSummaryMultipleSheetExport.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithMultipleSheets;
+
+class ResearchExpenseSummaryMultipleSheetExport implements WithMultipleSheets
+{
+    protected $data; // 格式: ['2026' => [ 'items' => [...], 'summary' => [...] ], '2025' => [...] ]
+
+    public function __construct(array $data)
+    {
+        $this->data = $data;
+    }
+
+    public function sheets(): array
+    {
+        $sheets = [];
+        foreach ($this->data as $year => $payload) {
+            $sheets[] = new ResearchExpenseSummarySheetExport($year, $payload);
+        }
+        return $sheets;
+    }
+}

+ 173 - 0
app/Exports/ResearchExpenseSummarySheetExport.php

@@ -0,0 +1,173 @@
+<?php
+
+namespace App\Exports;
+
+use Maatwebsite\Excel\Concerns\WithEvents;
+use Maatwebsite\Excel\Concerns\WithTitle;
+use Maatwebsite\Excel\Events\AfterSheet;
+use PhpOffice\PhpSpreadsheet\Style\Alignment;
+use PhpOffice\PhpSpreadsheet\Style\Border;
+
+class ResearchExpenseSummarySheetExport implements WithEvents, WithTitle
+{
+    protected $year;
+    protected $items;
+    protected $taxId;
+    protected $companyName;
+
+    public function __construct($year, array $payload)
+    {
+        $this->year = $year;
+        $this->items = $payload['items'] ?? [];
+        $this->taxId = $payload['tax_id'] ?? '';
+        $this->companyName = $payload['company_name'] ?? '';
+    }
+
+    public function title(): string { return $this->year . '年汇总表'; }
+
+    public function registerEvents(): array
+    {
+        return [
+            AfterSheet::class => function (AfterSheet $event) {
+                $sheet = $event->sheet->getDelegate();
+
+                // 1. 设置列宽 (A-Q)
+                $widths = ['A'=>12, 'B'=>40, 'C'=>10, 'D'=>12, 'E'=>18, 'F'=>12, 'G'=>12, 'H'=>12, 'I'=>12, 'J'=>12, 'K'=>15, 'L'=>12, 'M'=>12, 'N'=>12, 'O'=>12, 'P'=>12, 'Q'=>12];
+                foreach ($widths as $col => $w) { $sheet->getColumnDimension($col)->setWidth($w); }
+
+                // 2. 绘制表头 (去大标题样式)
+                $this->drawCorrectHeader($sheet);
+
+                // 3. 填充数据
+                $currentRow = 8;
+                $capRows = []; // 仅存 资本化且已完成(status=3) 的行
+                $expRows = []; // 存 费用化 的行
+
+                foreach ($this->items as $item) {
+                    $sheet->setCellValue("A{$currentRow}", $item['no']);
+                    $sheet->setCellValue("B{$currentRow}", $item['name']);
+                    $sheet->setCellValue("C{$currentRow}", $item['status'] == 3 ? '已完成' : '进行中');
+                    $sheet->setCellValue("D{$currentRow}", $item['type']);
+
+                    // 原始数据
+                    $sheet->setCellValue("F{$currentRow}", $item['val1']); // 1.人工
+                    $sheet->setCellValue("G{$currentRow}", $item['val2']); // 2.投入
+                    $sheet->setCellValue("H{$currentRow}", $item['val3']); // 3.折旧
+                    $sheet->setCellValue("I{$currentRow}", $item['val4']); // 4.摊销
+                    $sheet->setCellValue("J{$currentRow}", $item['val5']); // 5.新产品
+                    $sheet->setCellValue("L{$currentRow}", $item['val7_1']); // 7.1
+                    $sheet->setCellValue("N{$currentRow}", $item['val8_1']); // 8.1
+                    $sheet->setCellValue("P{$currentRow}", $item['val8_3'] ?? 0); // 8.3
+
+                    // 【逻辑修正】明细行公式
+                    $sheet->setCellValue("K{$currentRow}", "=SUM(F{$currentRow}:J{$currentRow})"); // 6列
+                    $sheet->setCellValue("O{$currentRow}", "=N{$currentRow}*0.8"); // 8.2 = 8.1 * 80%
+                    // 明细行不限制 7.2 和 8.4,通常直接取 7.1 和 8.3
+                    $sheet->setCellValue("M{$currentRow}", "=L{$currentRow}"); // 7.2
+                    $sheet->setCellValue("Q{$currentRow}", "=P{$currentRow}"); // 8.4
+                    // E列 = 6 + 7.2 + 8.2 + 8.4
+                    $sheet->setCellValue("E{$currentRow}", "=K{$currentRow}+M{$currentRow}+O{$currentRow}+Q{$currentRow}");
+
+                    if ($item['type'] == '资本化支出' && $item['status'] == 3) {
+                        $capRows[] = $currentRow;
+                    } elseif ($item['type'] == '费用化支出') {
+                        $expRows[] = $currentRow;
+                    }
+                    $currentRow++;
+                }
+
+                // 4. 合计行勾稽计算
+                $this->applySummaryLogic($sheet, $currentRow, $capRows, $expRows);
+
+                // 5. 样式设置
+                $lastDataRow = $currentRow + 3;
+                $sheet->getStyle("A5:Q{$lastDataRow}")->applyFromArray(['borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]]);
+                $sheet->getStyle("E8:Q{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
+                $sheet->getStyle("A5:Q7")->getAlignment()->setWrapText(true)->setHorizontal('center')->setVertical('center');
+
+                // 签字行
+                $sheet->setCellValue("A" . ($lastDataRow + 1), "法定代表人(签章):");
+            },
+        ];
+    }
+
+    private function drawCorrectHeader($sheet)
+    {
+        $sheet->mergeCells('A2:Q2');
+        $sheet->setCellValue('A2', '研发支出辅助账汇总表');
+        $sheet->getStyle('A2')->getAlignment()->setHorizontal('center');
+        $sheet->getStyle('A2')->getFont()->setSize(16)->setBold(true);
+
+        $sheet->setCellValue('A3', '纳税人识别号:' . $this->taxId);
+        $sheet->setCellValue('J3', '纳税人名称:' . $this->companyName);
+        $sheet->setCellValue('O3', "属期:{$this->year}年");
+        $sheet->setCellValue('Q3', '单位:元');
+
+        $vHeaders = ['A'=>'项目编号', 'B'=>'项目名称', 'C'=>'完成情况', 'D'=>'支出类型', 'E'=>"允许加计\n扣除金额合计"];
+        foreach ($vHeaders as $col => $text) {
+            $sheet->mergeCells("{$col}5:{$col}7");
+            $sheet->setCellValue("{$col}5", $text);
+        }
+
+        $subHeaders = ['F'=>'人员人工费用', 'G'=>'直接投入费用', 'H'=>'折旧费用', 'I'=>'无形资产摊销', 'J'=>'新产品设计费等', 'K'=>'前五项 小计'];
+        foreach ($subHeaders as $col => $text) {
+            $sheet->mergeCells("{$col}5:{$col}6");
+            $sheet->setCellValue("{$col}5", $text);
+        }
+
+        $sheet->mergeCells('L5:M5'); $sheet->setCellValue('L5', '其他相关费用及限额');
+        $sheet->setCellValue('L6', '其他相关费用合计'); $sheet->setCellValue('M6', "经限额调整后的\n其他相关费用");
+
+        $sheet->mergeCells('N5:Q5'); $sheet->setCellValue('N5', '委托研发费用及限额');
+        $sheet->setCellValue('N6', "委托境内机构或\n个人研发费用"); $sheet->setCellValue('O6', "允许加计扣除的\n委托境内研发费");
+        $sheet->setCellValue('P6', "委托境外机构\n进行研发活动费"); $sheet->setCellValue('Q6', "经限额调整后的\n委托境外研发费");
+
+        $nums = ['F'=>'1','G'=>'2','H'=>'3','I'=>'4','J'=>'5','K'=>'6','L'=>'7.1','M'=>'7.2','N'=>'8.1','O'=>'8.2','P'=>'8.3','Q'=>'8.4'];
+        foreach ($nums as $col => $n) { $sheet->setCellValue("{$col}7", $n); }
+    }
+
+    private function applySummaryLogic($sheet, $r, $capRows, $expRows)
+    {
+        $r_cap = $r; $r_exp = $r + 1; $r_oth = $r + 2; $r_all = $r + 3;
+        $labels = ['资本化金额小计', '费用化金额小计', '其中:其他事项', '全额合计'];
+        foreach ($labels as $i => $label) {
+            $sheet->mergeCells("A" . ($r+$i) . ":D" . ($r+$i));
+            $sheet->setCellValue("A" . ($r+$i), $label);
+        }
+
+        // 基础汇总列 (F,G,H,I,J,L,N,P)
+        $cols = ['F', 'G', 'H', 'I', 'J', 'L', 'N', 'P'];
+        foreach ($cols as $col) {
+            $f_cap = !empty($capRows) ? "=SUM(".implode(',', array_map(fn($row)=>"{$col}{$row}", $capRows)).")" : "0";
+            $f_exp = !empty($expRows) ? "=SUM(".implode(',', array_map(fn($row)=>"{$col}{$row}", $expRows)).") + {$col}{$r_oth}" : "{$col}{$r_oth}";
+            $sheet->setCellValue("{$col}{$r_cap}", $f_cap);
+            $sheet->setCellValue("{$col}{$r_exp}", $f_exp);
+            $sheet->setCellValue("{$col}{$r_all}", "={$col}{$r_cap}+{$col}{$r_exp}");
+        }
+
+        // --- 核心勾稽逻辑修正 ---
+        foreach ([$r_cap, $r_exp, $r_all] as $row) {
+            $sheet->setCellValue("K{$row}", "=SUM(F{$row}:J{$row})"); // 6列
+            $sheet->setCellValue("O{$row}", "=N{$row}*0.8");           // 8.2列
+
+            // 修正:7.2列 (规则六)
+            if ($row == $r_all) {
+                $sheet->setCellValue("M{$row}", "=MIN(L{$row}, K{$row}*0.1/0.9)");
+            } else {
+                $sheet->setCellValue("M{$row}", "=IF(L{$r_all}>0, (M{$r_all}/L{$r_all})*L{$row}, 0)");
+            }
+
+            // 修正:8.4列 (规则八:金额合计行计算公式)
+            if ($row == $r_all) {
+                // 1. 金额合计 = MIN((6列+7.2列+8.2列)*2/3, 8.3列*80%)
+                $sheet->setCellValue("Q{$row}", "=MIN((K{$row}+M{$row}+O{$row})*2/3, P{$row}*0.8)");
+            } else {
+                // 分摊逻辑
+                $sheet->setCellValue("Q{$row}", "=IF(P{$r_all}>0, (Q{$r_all}/P{$r_all})*P{$row}, 0)");
+            }
+
+            // 修正:E列 (规则四:E = 6+7.2+8.2+8.4)
+            $sheet->setCellValue("E{$row}", "=K{$row}+M{$row}+O{$row}+Q{$row}");
+        }
+    }
+}

+ 26 - 0
app/Http/Controllers/Api/DeviceWorkController.php

@@ -153,4 +153,30 @@ class DeviceWorkController extends BaseController
             return $this->json_return(201,$data);
         }
     }
+
+    public function dailyDwOrderPreview(Request $request)
+    {
+        $service = new DeviceWorkService();
+        $user = $request->userData;
+        list($status,$data) = $service->dailyDwOrderPreview($request->all(),$user);
+
+        if($status){
+            return $this->json_return(200,'',$data);
+        }else{
+            return $this->json_return(201,$data);
+        }
+    }
+
+    public function dailyDwOrderSave(Request $request)
+    {
+        $service = new DeviceWorkService();
+        $user = $request->userData;
+        list($status,$data) = $service->dailyDwOrderSave($request->all(),$user);
+
+        if($status){
+            return $this->json_return(200,'',$data);
+        }else{
+            return $this->json_return(201,$data);
+        }
+    }
 }

+ 26 - 0
app/Http/Controllers/Api/PersonWorkController.php

@@ -153,4 +153,30 @@ class PersonWorkController extends BaseController
             return $this->json_return(201,$data);
         }
     }
+
+    public function dailyPwOrderPreview(Request $request)
+    {
+        $service = new PersonWorkService();
+        $user = $request->userData;
+        list($status,$data) = $service->dailyPwOrderPreview($request->all(),$user);
+
+        if($status){
+            return $this->json_return(200,'',$data);
+        }else{
+            return $this->json_return(201,$data);
+        }
+    }
+
+    public function dailyPwOrderSave(Request $request)
+    {
+        $service = new PersonWorkService();
+        $user = $request->userData;
+        list($status,$data) = $service->dailyPwOrderSave($request->all(),$user);
+
+        if($status){
+            return $this->json_return(200,'',$data);
+        }else{
+            return $this->json_return(201,$data);
+        }
+    }
 }

+ 280 - 0
app/Service/DeviceWorkService.php

@@ -1009,4 +1009,284 @@ class DeviceWorkService extends Service
         }
         return [true, ''];
     }
+
+    public function dailyDwOrderPreview($data, $user)
+    {
+        $topDepartId = $user['top_depart_id'];
+        if (empty($data['month'])) return [false, '月份不能为空'];
+
+        $monthStart = $this->changeDateToDate($data['month']);
+
+        // 调用核心计算逻辑
+        $result = $this->calculateDailyDeviceAllocation($monthStart, $topDepartId, $user);
+
+        if (!$result['status']) return [false, $result['msg']];
+
+        return [true, [
+            'list' => $result['data'] // 返回给前端预览
+        ]];
+    }
+
+    private function calculateDailyDeviceAllocation($monthStart, $topDepartId, $user)
+    {
+        // 加载月度设备明细
+        $monthlyOrder = DB::table('monthly_dw_order_details as d')
+            ->join('monthly_dw_order as m', 'm.id', '=', 'd.main_id')
+            ->where('m.month', $monthStart)
+            ->where('m.top_depart_id', $topDepartId)
+            ->where('m.del_time', 0)
+            ->where('d.del_time', 0)
+            ->select('d.*')
+            ->get();
+
+        if ($monthlyOrder->isEmpty()) return ['status' => false, 'msg' => '未找到设备月度工时明细'];
+
+        $usedDeviceIds = $monthlyOrder->pluck('device_id')->unique()->toArray();
+
+        // 【优化点】按需查询设备名称
+        $deviceMap = DB::table('device')
+        ->whereIn('id', $usedDeviceIds)
+            ->pluck('title', 'id')
+            ->toArray();
+
+        // 加载分配规则
+        $ruleSet = DB::table('rule_set_details as rd')
+            ->join('rule_set as r', 'r.id', '=', 'rd.main_id')
+            ->where('r.month', $monthStart)
+            ->where('rd.type', RuleSetDetails::type_two) // 设备类型
+            ->where('r.del_time', 0)
+            ->where('rd.del_time', 0)
+            ->select('rd.*')
+            ->get();
+
+        // 【优化点】按需查询项目名称
+        $usedItemIds = $ruleSet->pluck('item_id')->unique()->toArray();
+        $itemMap = DB::table('item')
+            ->whereIn('id', $usedItemIds)
+            ->pluck('title', 'id')
+            ->toArray();
+
+        $ruleSetGrouped = $ruleSet->groupBy('data_id'); // 按 device_id 分组
+
+        // 标准班次 & 日历 (逻辑同前)
+        $standardWorkRanges = DB::table('work_range_details')
+            ->where('top_depart_id', $topDepartId)->where('del_time', 0)->get();
+        $dayMaxAvail = (int)$standardWorkRanges->sum('total_work_min');
+
+        $workDays = DB::table('calendar_details')
+            ->where('month', $monthStart)->where('is_work', 1)->where('del_time', 0)
+            ->orderBy('time', 'asc')->get();
+
+        if ($workDays->isEmpty()) return ['status' => false, 'msg' => '未配置工作日历'];
+
+        // --- 2. 阶段一:计算每天每台设备分配的项目分钟数 ---
+        $finalAlloc = [];
+        foreach ($monthlyOrder as $mDetail) {
+            $deviceId = $mDetail->device_id;
+            $deviceRules = $ruleSetGrouped->get($deviceId);
+            if (!$deviceRules) continue;
+
+            $remainingMin = (int)round((float)$mDetail->rd_total_hours * 60);
+            if ($remainingMin <= 0) continue;
+
+            foreach ($workDays as $dayInfo) {
+                if ($remainingMin <= 0) break;
+
+                $canAllocToday = min($remainingMin, $dayMaxAvail);
+                $allocatedInDay = 0;
+                $ruleCount = count($deviceRules);
+
+                foreach ($deviceRules as $index => $rule) {
+                    $rate = (float)$rule->rate / 100;
+                    if ($index === $ruleCount - 1) {
+                        $projectMin = $canAllocToday - $allocatedInDay;
+                    } else {
+                        $projectMin = (int)round($canAllocToday * $rate);
+                    }
+
+                    if ($projectMin > 0) {
+                        $finalAlloc[$dayInfo->time][$rule->item_id][$deviceId] = $projectMin;
+                        $allocatedInDay += $projectMin;
+                    }
+                }
+                $remainingMin -= $canAllocToday;
+            }
+        }
+
+        // --- 3. 阶段二:生成预览行 ---
+        $previewList = [];
+        $dailyDevicePools = [];
+        $tempMainIdCounter = 1;
+
+        foreach ($finalAlloc as $dayTs => $projects) {
+            foreach ($projects as $itemId => $devices) {
+
+                $currentTempMainId = $tempMainIdCounter++;
+                $itemTitle = $itemMap[$itemId] ?? '未知项目';
+
+                foreach ($devices as $deviceId => $toAllocMin) {
+                    if (!isset($dailyDevicePools[$dayTs][$deviceId])) {
+                        $pool = [];
+                        foreach ($standardWorkRanges as $swr) {
+                            $pool[] = [
+                                's' => (int)($swr->start_time_hour * 60 + $swr->start_time_min),
+                                'e' => (int)($swr->end_time_hour * 60 + $swr->end_time_min)
+                            ];
+                        }
+                        $dailyDevicePools[$dayTs][$deviceId] = $pool;
+                    }
+
+                    $tempRem = (int)$toAllocMin;
+                    foreach ($dailyDevicePools[$dayTs][$deviceId] as &$p) {
+                        if ($tempRem <= 0) break;
+                        $pMax = $p['e'] - $p['s'];
+                        if ($pMax <= 0) continue;
+
+                        $take = min($tempRem, $pMax);
+                        $start = (int)$p['s'];
+                        $end = $start + $take;
+
+                        $previewList[] = [
+                            'temp_main_id'    => $currentTempMainId,
+                            'order_time'      => date('Y-m-d', $dayTs),
+                            'order_timestamp' => $dayTs,
+                            'item_id'         => $itemId,
+                            'item_title'      => $itemTitle,
+                            'device_id'       => $deviceId,
+                            'device_title'    => $deviceMap[$deviceId] ?? '未知设备',
+                            'start_time'      => sprintf('%02d:%02d', floor($start / 60), $start % 60),
+                            'end_time'        => sprintf('%02d:%02d', floor($end / 60), $end % 60),
+                            'start_hour'      => (int)floor($start / 60),
+                            'start_min'       => (int)($start % 60),
+                            'end_hour'        => (int)floor($end / 60),
+                            'end_min'         => (int)($end % 60),
+                            'total_work_min'  => $take,
+                        ];
+
+                        $tempRem -= $take;
+                        $p['s'] = $end;
+                    }
+                }
+            }
+        }
+
+        return ['status' => true, 'data' => $previewList];
+    }
+
+    public function dailyDwOrderSave($data, $user)
+    {
+        $list = $data['list'] ?? [];
+        if (empty($list)) return [false, '提交数据不能为空'];
+
+        $topDepartId = $user['top_depart_id'];
+        $month = $data['month']; // 格式如: "2026-03"
+        $now = time();
+
+        // 1. 预加载映射
+        $deviceIds = collect($list)->pluck('device_id')->unique()->toArray();
+        $deviceMap = DB::table('device')->whereIn('id', $deviceIds)->pluck('title', 'id')->toArray();
+
+        // 2. 重新分组并记录行号
+        $groupedByOrder = [];
+        foreach ($list as $index => $item) {
+            $item['_line'] = $index + 1;
+            $groupKey = $item['order_time'] . '_' . $item['item_id'];
+            $groupedByOrder[$groupKey][] = $item;
+        }
+
+        $deviceTimeline = [];
+
+        DB::beginTransaction();
+        try {
+            // A. 清理旧数据
+            $monthStart = $this->changeDateToDate($month);
+            $monthEnd = strtotime('+1 month', $monthStart) - 1;
+
+            $oldOrderIds = DB::table('daily_dw_order')
+                ->where('top_depart_id', $topDepartId)
+                ->whereBetween('order_time', [$monthStart, $monthEnd])
+                ->where('del_time', 0)
+                ->pluck('id');
+
+            if ($oldOrderIds->isNotEmpty()) {
+                DB::table('daily_dw_order')->whereIn('id', $oldOrderIds)->update(['del_time' => $now]);
+                DB::table('daily_dw_order_details')->whereIn('main_id', $oldOrderIds)->update(['del_time' => $now]);
+            }
+
+            // B. 遍历重组后的分组
+            foreach ($groupedByOrder as $details) {
+                $first = $details[0];
+                $orderTimestamp = strtotime($first['order_time']);
+                $itemId = $first['item_id'];
+
+                $mainId = DB::table('daily_dw_order')->insertGetId([
+                    'code'          => '',
+                    'item_id'       => $itemId,
+                    'order_time'    => $orderTimestamp,
+                    'top_depart_id' => $topDepartId,
+                    'is_create'     => 1,
+                    'crt_id'        => $user['id'],
+                    'crt_time'      => $now,
+                ]);
+
+                $insertDetails = [];
+                foreach ($details as $d) {
+                    $rowNum = $d['_line'];
+                    $devId = $d['device_id'];
+                    $devName = $deviceMap[$devId] ?? "设备(ID:{$devId})";
+
+                    // --- 新增:校验 order_time 是否属于当前选择的 month ---
+                    if (date("Y-m", strtotime($d['order_time'])) !== $month) {
+                        return [false, "第 {$rowNum} 行:日期[{$d['order_time']}]不属于保存月份[{$month}],请修正!"];
+                    }
+
+                    $s = (int)$d['start_hour'] * 60 + (int)$d['start_min'];
+                    $e = (int)$d['end_hour'] * 60 + (int)$d['end_min'];
+                    $calcTotalMin = $e - $s;
+
+                    if ($calcTotalMin <= 0) {
+                        return [false, "第 {$rowNum} 行:设备[{$devName}]在[{$d['order_time']}]的结束时间必须晚于开始时间"];
+                    }
+
+                    // 冲突校验
+                    $dateStr = $d['order_time'];
+                    if (isset($deviceTimeline[$devId][$dateStr])) {
+                        foreach ($deviceTimeline[$devId][$dateStr] as $exist) {
+                            if ($s < $exist['e'] && $e > $exist['s']) {
+                                return [false, "第 {$rowNum} 行:设备[{$devName}]在[{$dateStr}]存在时间冲突({$d['start_time']}-{$d['end_time']})"];
+                            }
+                        }
+                    }
+                    $deviceTimeline[$devId][$dateStr][] = ['s' => $s, 'e' => $e];
+
+                    $insertDetails[] = [
+                        'main_id'         => $mainId,
+                        'device_id'       => $devId,
+                        'top_depart_id'   => $topDepartId,
+                        'start_time_hour' => $d['start_hour'],
+                        'start_time_min'  => $d['start_min'],
+                        'end_time_hour'   => $d['end_hour'],
+                        'end_time_min'    => $d['end_min'],
+                        'total_work_min'  => $calcTotalMin,
+                        'crt_time'        => $now,
+                        'order_time'    => $orderTimestamp,
+                    ];
+                }
+                DB::table('daily_dw_order_details')->insert($insertDetails);
+
+                $code = $this->generateBillNo([
+                    'top_depart_id' => $topDepartId,
+                    'type' => DailyDwOrder::Order_type,
+                    'period' => date("Ym", $orderTimestamp)
+                ]);
+                DB::table('daily_dw_order')->where('id', $mainId)->update(['code' => $code]);
+            }
+
+            DB::commit();
+            return [true, ''];
+        } catch (\Exception $e) {
+            DB::rollBack();
+            return [false, "保存失败:" . $e->getMessage()];
+        }
+    }
 }

+ 280 - 1
app/Service/ExportFileService.php

@@ -3,7 +3,12 @@
 namespace App\Service;
 
 use App\Exports\ExportOrder;
+use App\Exports\ItemSalaryFTMultipleSheetExport;
+use App\Exports\ItemSalarySheetExport;
+use App\Exports\ManMonthlyWorkHourMultipleSheetExport;
 use App\Exports\MultiSheetExport;
+use App\Exports\ProjectDepreciationMultipleSheetExport;
+use App\Model\Depart;
 use App\Model\PLeaveOverOrder;
 use Maatwebsite\Excel\Facades\Excel;
 
@@ -54,7 +59,7 @@ class ExportFileService extends Service
 //        $service = new TableHeadService();
 //        if(method_exists($service,$header_f)) $service->$header_f($header_default);
 
-        $user['e_header_default'] = $header['array'];
+        $user['e_header_default'] = $header['array'] ?? [];
 
         return $funcName;
     }
@@ -356,6 +361,280 @@ class ExportFileService extends Service
         return [true, $this->saveExportData($return,$header)];
     }
 
+    public function exportEmployeeSalary($data, $user)
+    {
+        $service = new StatisticService();
+        // 1. 调用你现有的查询方法获取基础数据
+        list($status, $itemMonthList) = $service->employeeMonthSalaryStatistic($data, $user);
+        if (!$status) return $itemMonthList; // 返回错误信息
+
+        // 2. 提取所有涉及到的唯一项目 (按 Code 排序,保证列顺序固定)
+        $projects = collect($itemMonthList)->pluck('item_code')->unique()->sort()->values()->toArray();
+
+        // 3. 按月份对数据进行分组
+        $groupedByMonth = collect($itemMonthList)->groupBy('month')->sortKeys();
+
+        $exportData = [];
+        $columnTotals = array_fill(0, count($projects) * 2, 0); // 用于存储每列的合计
+        $grandTotalSalary = 0; // 总计金额
+
+        // 4. 循环每个月,构造行数据
+        foreach ($groupedByMonth as $month => $items) {
+            $row = [$month]; // A列:月份
+            $monthTotalSalary = 0;
+
+            // 创建该月项目的映射,方便快速查找
+            $monthItemsMap = $items->keyBy('item_code');
+
+            foreach ($projects as $index => $code) {
+                $itemDetail = $monthItemsMap->get($code);
+                $days = $itemDetail['days'] ?? 0;
+                $salary = $itemDetail['allocated_salary'] ?? 0;
+
+                $row[] = $days > 0 ? $days : '';     // 天数列
+                $row[] = $salary > 0 ? $salary : ''; // 工资列
+
+                // 累加合计行(列合计)
+                $columnTotals[$index * 2] += $days;
+                $columnTotals[$index * 2 + 1] += $salary;
+                $monthTotalSalary += $salary;
+            }
+
+            $row[] = $monthTotalSalary; // 最后一列:该月合计工资
+            $grandTotalSalary += $monthTotalSalary;
+            $exportData[] = $row;
+        }
+
+        // 5. 构造最后的“合计”行
+        $totalRow = ['合计'];
+        foreach ($columnTotals as $val) {
+            $totalRow[] = $val > 0 ? $val : 0;
+        }
+        $totalRow[] = $grandTotalSalary;
+        $exportData[] = $totalRow;
+
+        $file_name = "项目工资统计表_" . date("Y-m-d") . "_". rand(1000,9999);
+        $filename =  $file_name . '.' . 'xlsx';
+        $bool = Excel::store(new ItemSalarySheetExport($projects, $exportData, Depart::where('id', $user['top_depart_id'])->value('title')),"/public/export/{$filename}", null, 'Xlsx', []);
+
+        return [true, $filename];
+    }
+
+    public function exportManMonthlyWorkHour($data, $user)
+    {
+        // 1. 获取报表基础数据
+        $service = new StatisticService();
+        list($status, $result) = $service->employeeDayHourStatistic($data, $user);
+        if (!$status) return $result;
+
+        $sourceData = collect($result['data']);
+
+        // 2. 按月份分组,准备多 Sheet 数据
+        // 结构:[ '2024-04' => [ 'days' => 30, 'data' => [...] ], ... ]
+        $monthsData = [];
+
+        // 按月分组数据
+        $groupedByMonth = $sourceData->groupBy(function ($item) {
+            return date('Y年m月', strtotime($item['order_date']));
+        });
+
+        foreach ($groupedByMonth as $monthName => $monthItems) {
+            // A. 计算该月总天数
+            // 转换 '2024年04月' 为 '2024-04' 获取天数
+            $formatMonth = str_replace(['年', '月'], ['-', ''], $monthName);
+            $daysInMonth = date('t', strtotime($formatMonth . '-01'));
+
+            // B. 进一步按 项目+人员 分组,因为一行显示一个项目一个人的全月工时
+            $groupedByUserItem = $monthItems->groupBy(function ($item) {
+                return $item['item_code'] . '_' . $item['employee_id'];
+            });
+
+            $sheetRows = [];
+            foreach ($groupedByUserItem as $key => $records) {
+                $first = $records->first();
+                // 初始化行:前两列是 项目编号 和 姓名
+                $row = [
+                    $first['item_code'],
+                    $first['employee_name']
+                ];
+
+                // C. 循环 1 号到该月最后一天,填充工时
+                // 将该员工该项目在这个月的记录转为 日期 => 工时 的映射
+                $dayMap = $records->keyBy(function($r){
+                    return (int)date('d', strtotime($r['order_date']));
+                });
+
+                for ($d = 1; $d <= $daysInMonth; $d++) {
+                    $workHour = $dayMap->get($d)['total_work_hours'] ?? '';
+                    // 如果工时为 0 或空,按你要求的格式传空字符串
+                    $row[] = ($workHour > 0) ?(float) $workHour : '';
+                }
+
+                $sheetRows[] = $row;
+            }
+
+            $monthsData[$monthName] = [
+                'days' => (int)$daysInMonth,
+                'data' => $sheetRows
+            ];
+        }
+
+        $file_name = "人员月工时统计表_" . date("Y-m-d") . "_". rand(1000,9999);
+        $filename =  $file_name . '.' . 'xlsx';
+
+        $bool =  Excel::store(new ManMonthlyWorkHourMultipleSheetExport($monthsData), "/public/export/{$filename}", null, 'Xlsx', []);
+        return [true, $filename];
+    }
+
+    public function exportDeviceZj(array $data, $user)
+    {
+        $service = new StatisticService();
+        list($status, $result) = $service->itemDeviceMonthStatistic($data, $user);
+        if (!$status) return $result;
+
+        // 2. 将数据按 [项目编号][月份] 进行分组
+        // 预期结构:$monthsData['RD01']['months']['2025-01'] = [设备1, 设备2...]
+        $groupedData = collect($result)->groupBy('item_code');
+
+        $finalExportData = [];
+
+        foreach ($groupedData as $itemCode => $itemRecords) {
+            $projectName = $itemRecords->first()['item_title'] ?? '';
+
+            // 2. 获取年份(从 order_month 字段提取,如 "2026-01" 取前4位)
+            $firstMonth = $firstRecord['month'] ?? date('Y-m');
+            $year = substr($firstMonth, 0, 4);
+
+            // 3. 构造新的 Key:年-项目 (例如: 2026-53code)
+            $newSheetKey = $year . '年度项目' . $itemCode;
+
+            // 按月份进一步分组
+            $monthGroups = $itemRecords->groupBy('month');
+
+            $monthsPayload = [];
+            foreach ($monthGroups as $month => $devices) {
+                $monthData = [];
+                foreach ($devices as $dev) {
+                    $monthData[] = [
+                        'device_name'         => $dev['device_title'],
+                        'original_value'      => $dev['device_original'],    // 设备原值
+                        'total_depreciation'  => $dev['total_depreciatio'], // 当月总折旧
+                        'project_hours'       => $dev['hours'],              // 本项目工时
+                        'total_hours'         => $dev['total_hours'],        // 当月总工时
+                        'ratio'               => bcmul($dev['ratio'], 100,2),              // 研发工时占比
+                        'allocated_depreciation' => $dev['allocated_depreciatio'], // 本项目分摊折旧
+                    ];
+                }
+                $monthsPayload[$month] = $monthData;
+            }
+
+            // 构造 Sheet 所需结构
+            $finalExportData[$newSheetKey] = [
+                'project_name' => $projectName,
+                'months'       => $monthsPayload
+            ];
+        }
+
+        $file_name = "项目设备折旧费用统计表_" . date("Y-m-d") . "_". rand(1000,9999);
+        $filename =  $file_name . '.' . 'xlsx';
+
+        $bool =  Excel::store(new ProjectDepreciationMultipleSheetExport($finalExportData), "/public/export/{$filename}", null, 'Xlsx', []);
+        return [true, $filename];
+    }
+
+    public function exportItemSalaryFT(array $data, $user)
+    {
+        $service = new StatisticService();
+        list($status, $result) = $service->itemDaySalaryStatistic($data, $user);
+        if (!$status) return $result;
+
+        $sourceData = collect($result);
+        $groupedByMonth = $sourceData->groupBy('month');
+        $monthsData = [];
+
+        foreach ($groupedByMonth as $month => $monthRecords) {
+            // A. 获取本月参与的所有唯一项目编码
+            $monthProjects = $monthRecords->pluck('item_code')->unique()->sort()->values()->all();
+
+            // B. 按人员分组组织数据
+            $groupedByEmployee = $monthRecords->groupBy('employee_id');
+            $sheetRows = [];
+            $index = 1;
+
+            // 初始化列合计
+            $colTotals = [
+                'total_salary' => 0,
+                'total_min_hours' => 0, // 月总工时合计
+                'project_days' => array_fill_keys($monthProjects, 0),
+                'project_salary' => array_fill_keys($monthProjects, 0),
+                'total_attendance_days' => 0, // 合计工时列的合计
+            ];
+
+            foreach ($groupedByEmployee as $employeeId => $records) {
+                $first = $records->first();
+                $empMonthSalary = (float)$first['total_salary'];
+                $empTotalHours = round((float)$first['total_min'] / 60, 2); // 月总工时(小时)
+
+                // 1-4列:序号、姓名、工资、月总工时
+                $row = [$index++, $first['employee_title'], $empMonthSalary, $empTotalHours];
+
+                // 5. 动态项目工时列
+                $empMap = $records->keyBy('item_code');
+                $rowProjectDaysSum = 0;
+                foreach ($monthProjects as $code) {
+                    $days = $empMap->has($code) ? (float)$empMap->get($code)['days'] : 0;
+                    $row[] = $days > 0 ? $days : '';
+                    $rowProjectDaysSum += $days;
+                    $colTotals['project_days'][$code] += $days;
+                }
+
+                // 6. 合计工时列
+                $row[] = $rowProjectDaysSum;
+                $colTotals['total_attendance_days'] += $rowProjectDaysSum;
+
+                // 7. 动态项目金额列
+                foreach ($monthProjects as $code) {
+                    $salary = $empMap->has($code) ? (float)$empMap->get($code)['allocated_salary'] : 0;
+                    $row[] = $salary > 0 ? $salary : '';
+                    $colTotals['project_salary'][$code] += $salary;
+                }
+
+                // 8. 总计工资列
+                $row[] = $empMonthSalary;
+
+                // 累加合计
+                $colTotals['total_salary'] += $empMonthSalary;
+                $colTotals['total_min_hours'] += $empTotalHours;
+
+                $sheetRows[] = $row;
+            }
+
+            // C. 构造合计行
+            $totalRow = ['合计', '', $colTotals['total_salary'], $colTotals['total_min_hours']];
+            foreach ($monthProjects as $code) { $totalRow[] = $colTotals['project_days'][$code]; }
+            $totalRow[] = $colTotals['total_attendance_days'];
+            foreach ($monthProjects as $code) { $totalRow[] = $colTotals['project_salary'][$code]; }
+            $totalRow[] = $colTotals['total_salary'];
+
+            $sheetRows[] = $totalRow;
+
+            $monthsData[$month] = [
+                'projects' => $monthProjects,
+                'data'     => $sheetRows
+            ];
+        }
+
+        $file_name = "项目工资分摊统计表_" . date("Y-m-d") . "_". rand(1000,9999);
+        $filename =  $file_name . '.xlsx';
+
+        Excel::store(
+            new ItemSalaryFTMultipleSheetExport($monthsData, Depart::where('id', $user['top_depart_id'])->value('title')),
+            "/public/export/{$filename}"
+        );
+
+        return [true, $filename];
+    }
+
     public function saveExportData($data, $headers, $type = 'default',$file_name = ''){
         if(empty($file_name)) $file_name = self::$filename . "_". date("Y-m-d") . "_". rand(1000,9999);
         $filename =  $file_name . '.' . 'xlsx';

+ 336 - 0
app/Service/PersonWorkService.php

@@ -1019,4 +1019,340 @@ class PersonWorkService extends Service
         }
         return $pool;
     }
+
+
+    public function dailyPwOrderPreview($data, $user)
+    {
+        $topDepartId = $user['top_depart_id'];
+        if (empty($data['month'])) return [false, '月份不能为空'];
+
+        // 1. 前置校验 (保留你之前的校验逻辑)
+        $monthStart = $this->changeDateToDate($data['month']);
+
+        // 1. 检查是否存在月度工时明细
+        $hasMonthlyOrder = DB::table('monthly_pw_order_details as d')
+            ->join('monthly_pw_order as m', 'm.id', '=', 'd.main_id')
+            ->where('m.month', $monthStart)
+            ->where('m.top_depart_id', $topDepartId)
+            ->where('m.del_time', 0)
+            ->where('d.del_time', 0)
+            ->exists();
+
+        if (!$hasMonthlyOrder) return [false, '未找到该月份的月度工时明细,请先生成人员月度工时单'];
+
+        // 2. 检查是否配置了工作日历
+        $hasCalendar = DB::table('calendar_details')
+            ->where('month', $monthStart)
+            ->where('del_time', 0)
+            ->exists();
+
+        if (!$hasCalendar) return [false, '该月份工作日历未配置'];
+
+        // 3. 检查是否配置了项目比例规则
+        $hasRules = DB::table('rule_set as r')
+            ->where('r.month', $monthStart)
+            ->where('r.top_depart_id', $topDepartId)
+            ->where('r.del_time', 0)
+            ->exists();
+
+        if (!$hasRules) return [false, '未找到该月份的规则配置单'];
+
+        // 2. 调用核心计算逻辑 (抽取出的私有方法)
+        $result = $this->calculateDailyAllocation($monthStart, $topDepartId, $user);
+
+        if (!$result['status']) return [false, $result['msg']];
+
+        // 3. 将结果存入临时表或直接返回
+        // 建议增加一个 batch_id,防止多人操作冲突
+        $batchId = uniqid('batch_');
+        $previewData = $result['data'];
+
+        return [true, [
+            'batch_id' => $batchId,
+            'list' => $previewData // 返回给前端展示
+        ]];
+    }
+
+    /**
+     * 核心分配逻辑:计算预览数据(确保全整数分钟)
+     * @param int $monthStart 月初时间戳
+     * @param int $topDepartId 顶级部门ID
+     * @param array $user 用户信息
+     * @return array
+     */
+    private function calculateDailyAllocation($monthStart, $topDepartId, $user)
+    {
+        $monthEnd = strtotime('+1 month', $monthStart) - 1;
+        $now = time();
+
+        // --- 1. 基础数据加载 ---
+
+        // 加载月度工时明细,并关联人员姓名
+        $monthlyOrder = DB::table('monthly_pw_order_details as d')
+            ->join('monthly_pw_order as m', 'm.id', '=', 'd.main_id')
+            ->leftJoin('employee as e', 'e.id', '=', 'd.employee_id') // 关联人员表
+            ->where('m.month', $monthStart)
+            ->where('m.top_depart_id', $topDepartId)
+            ->where('m.del_time', 0)
+            ->where('d.del_time', 0)
+            ->select('d.*', 'e.title as employee_title') // 获取人员姓名
+            ->get();
+
+        if ($monthlyOrder->isEmpty()) return ['status' => false, 'msg' => '未找到该月份的月度工时明细'];
+
+        // 建立人员 ID -> 姓名的映射,方便后续取用
+        $empNameMap = $monthlyOrder->pluck('employee_title', 'employee_id')->toArray();
+        $empIds = array_keys($empNameMap);
+
+        // 加载项目信息,用于获取项目名称
+        // 假设项目表名为 items,请根据你实际的表名修改
+        $itemIds = DB::table('rule_set_details as rd')
+            ->join('rule_set as r', 'r.id', '=', 'rd.main_id')
+            ->where('r.month', $monthStart)
+            ->where('r.top_depart_id', $topDepartId)
+            ->pluck('rd.item_id')->unique()->toArray();
+
+        $itemMap = DB::table('item')
+        ->whereIn('id', $itemIds)
+            ->pluck('title', 'id')
+            ->toArray();
+
+        // 加载分配规则
+        $ruleSet = DB::table('rule_set_details as rd')
+            ->join('rule_set as r', 'r.id', '=', 'rd.main_id')
+            ->where('r.month', $monthStart)
+            ->where('rd.type', 1)
+            ->where('r.del_time', 0)
+            ->where('rd.del_time', 0)
+            ->select('rd.*')
+            ->get()
+            ->groupBy('data_id');
+
+        // 加载员工/标准班次、日历、请假加班数据 (逻辑同前)
+        $empWorkRanges = DB::table('employee_work_range')->whereIn('employee_id', $empIds)->where('top_depart_id', $topDepartId)->get()->groupBy('employee_id');
+        $standardWorkRanges = DB::table('work_range_details')->where('top_depart_id', $topDepartId)->where('del_time', 0)->get();
+        $allDays = DB::table('calendar_details')->where('month', $monthStart)->where('del_time', 0)->orderBy('time', 'asc')->get();
+        $leaveOverData = DB::table('p_leave_over_order_details as d')->join('p_leave_over_order as m', 'd.main_id', '=', 'm.id')
+            ->whereBetween('m.order_time', [$monthStart, $monthEnd])->where('m.del_time', 0)
+            ->select('d.*', 'm.order_time', 'm.type as main_type')->get()->groupBy(['employee_id', 'order_time']);
+
+        // --- 2. 阶段一:计算每个人每天在每个项目上应分配的整数分钟数 ---
+        $finalAlloc = [];
+        foreach ($monthlyOrder as $mDetail) {
+            $empId = $mDetail->employee_id;
+            $empRules = $ruleSet->get($empId);
+            if (!$empRules) continue;
+
+            $empRemainingMin = (int)round((float)$mDetail->rd_total_hours * 60);
+            if ($empRemainingMin <= 0) continue;
+
+            foreach ($allDays as $dayInfo) {
+                if ($empRemainingMin <= 0) break;
+                $dayTs = $dayInfo->time;
+                $tempPool = $this->buildAvailablePool($empId, $dayTs, $allDays, $empWorkRanges, $standardWorkRanges, $leaveOverData);
+                $dayAvailableMin = 0;
+                foreach ($tempPool as $p) { $dayAvailableMin += (int)($p['e'] - $p['s']); }
+                if ($dayAvailableMin <= 0) continue;
+
+                $canAllocToday = min($empRemainingMin, $dayAvailableMin);
+                $allocatedInDay = 0;
+                $ruleCount = count($empRules);
+
+                foreach ($empRules as $index => $rule) {
+                    $rate = (float)$rule->rate / 100;
+                    if ($index === $ruleCount - 1) {
+                        $projectMin = $canAllocToday - $allocatedInDay;
+                    } else {
+                        $projectMin = (int)round($canAllocToday * $rate);
+                    }
+                    if ($projectMin > 0) {
+                        $finalAlloc[$dayTs][$rule->item_id][$empId] = $projectMin;
+                        $allocatedInDay += $projectMin;
+                    }
+                }
+                $empRemainingMin -= $canAllocToday;
+            }
+        }
+
+        // --- 3. 阶段二:打散到具体时间点并生成预览行 ---
+        $previewList = [];
+        $dailyEmpTimePools = [];
+        $tempMainIdCounter = 1;
+
+        foreach ($finalAlloc as $dayTs => $projects) {
+            foreach ($projects as $itemId => $employees) {
+                $currentTempMainId = $tempMainIdCounter++;
+
+                // 获取项目名称
+                $itemTitle = $itemMap[$itemId] ?? '未知项目';
+
+                foreach ($employees as $empId => $toAllocMin) {
+                    if (!isset($dailyEmpTimePools[$dayTs][$empId])) {
+                        $dailyEmpTimePools[$dayTs][$empId] = $this->buildAvailablePool($empId, $dayTs, $allDays, $empWorkRanges, $standardWorkRanges, $leaveOverData);
+                    }
+
+                    $tempRem = (int)$toAllocMin;
+                    foreach ($dailyEmpTimePools[$dayTs][$empId] as &$p) {
+                        if ($tempRem <= 0) break;
+                        $pMax = (int)($p['e'] - $p['s']);
+                        if ($pMax <= 0) continue;
+
+                        $take = min($tempRem, $pMax);
+                        $realStart = (int)$p['s'];
+                        $realEnd = $realStart + $take;
+
+                        // 写入带 Title 的结果
+                        $previewList[] = [
+                            'temp_main_id'    => $currentTempMainId,
+                            'order_time'      => date('Y-m-d', $dayTs),
+                            'order_timestamp' => $dayTs,
+                            'item_id'         => $itemId,
+                            'item_title'      => $itemTitle, // 项目名称
+                            'employee_id'     => $empId,
+                            'employee_title'  => $empNameMap[$empId] ?? '未知人员', // 人员姓名
+                            'start_time'      => sprintf('%02d:%02d', floor($realStart / 60), $realStart % 60),
+                            'end_time'        => sprintf('%02d:%02d', floor($realEnd / 60), $realEnd % 60),
+                            'start_hour'      => (int)floor($realStart / 60),
+                            'start_min'       => (int)($realStart % 60),
+                            'end_hour'        => (int)floor($realEnd / 60),
+                            'end_min'         => (int)($realEnd % 60),
+                            'total_work_min'  => $take,
+                        ];
+
+                        $tempRem -= $take;
+                        $p['s'] = $realEnd;
+                    }
+                }
+            }
+        }
+
+        return ['status' => true, 'data' => $previewList];
+    }
+
+    public function dailyPwOrderSave($data, $user)
+    {
+        $list = $data['list'] ?? [];
+        if (empty($list)) return [false, '没有可保存的数据'];
+
+        $topDepartId = $user['top_depart_id'];
+        $month = $data['month']; // 格式如: "2026-03"
+        $now = time();
+
+        // 1. 预加载员工名称映射 (使用 title 字段)
+        $empIds = collect($list)->pluck('employee_id')->unique()->toArray();
+        $empMap = DB::table('employee')->whereIn('id', $empIds)->pluck('title', 'id')->toArray();
+
+        // --- 2. 重新分组并记录行号 ---
+        $groupedByOrder = [];
+        foreach ($list as $index => $item) {
+            $item['_line'] = $index + 1; // 记录原始行号(从1开始)
+            // 以日期字符串和项目ID作为分组 Key
+            $groupKey = $item['order_time'] . '_' . $item['item_id'];
+            $groupedByOrder[$groupKey][] = $item;
+        }
+
+        // 冲突校验器容器:记录 [员工ID][日期字符串] 下已占用的时间段
+        $empTimeline = [];
+
+        DB::beginTransaction();
+        try {
+            // A. 清理该月份旧数据
+            $monthStart = $this->changeDateToDate($month);
+            $monthEnd = strtotime('+1 month', $monthStart) - 1;
+
+            $oldOrderIds = DB::table('daily_pw_order')
+                ->where('top_depart_id', $topDepartId)
+                ->whereBetween('order_time', [$monthStart, $monthEnd])
+                ->where('del_time', 0)
+                ->pluck('id');
+
+            if ($oldOrderIds->isNotEmpty()) {
+                DB::table('daily_pw_order')->whereIn('id', $oldOrderIds)->update(['del_time' => $now]);
+                DB::table('daily_pw_order_details')->whereIn('main_id', $oldOrderIds)->update(['del_time' => $now]);
+            }
+
+            // B. 遍历重组后的分组写入
+            foreach ($groupedByOrder as $details) {
+                $first = $details[0];
+                // 【修正】统一将前端日期字符串转为时间戳入库
+                $orderTimestamp = strtotime($first['order_time']);
+                $itemId = $first['item_id'];
+
+                // 写入主表
+                $mainId = DB::table('daily_pw_order')->insertGetId([
+                    'code'          => '',
+                    'item_id'       => $itemId,
+                    'order_time'    => $orderTimestamp,
+                    'top_depart_id' => $topDepartId,
+                    'is_create'     => 1,
+                    'crt_id'        => $user['id'],
+                    'crt_time'      => $now,
+                ]);
+
+                $insertDetails = [];
+                foreach ($details as $d) {
+                    $rowNum = $d['_line'];
+                    $empId = $d['employee_id'];
+                    $empName = $empMap[$empId] ?? "人员(ID:{$empId})";
+
+                    // --- 新增:月份一致性校验 ---
+                    // 校验这行数据的日期是否属于当前保存的月份
+                    if (date("Y-m", strtotime($d['order_time'])) !== $month) {
+                        return [false, "第 {$rowNum} 行:人员[{$empName}]的日期[{$d['order_time']}]不属于保存月份[{$month}]"];
+                    }
+
+                    // 【修正】强制由后端计算分钟数
+                    $s = (int)$d['start_hour'] * 60 + (int)$d['start_min'];
+                    $e = (int)$d['end_hour'] * 60 + (int)$d['end_min'];
+                    $calcTotalMin = $e - $s;
+
+                    // 校验1:逻辑合法性
+                    if ($calcTotalMin <= 0) {
+                        return [false, "第 {$rowNum} 行:人员[{$empName}]在[{$d['order_time']}]的时间段无效:结束时间必须晚于开始时间"];
+                    }
+
+                    // 校验2:跨单据时间重叠校验
+                    $dateStr = $d['order_time'];
+                    if (isset($empTimeline[$empId][$dateStr])) {
+                        foreach ($empTimeline[$empId][$dateStr] as $exist) {
+                            if ($s < $exist['e'] && $e > $exist['s']) {
+                                return [false, "第 {$rowNum} 行:人员[{$empName}]在[{$dateStr}]存在时间冲突({$d['start_time']}-{$d['end_time']}),请检查!"];
+                            }
+                        }
+                    }
+                    $empTimeline[$empId][$dateStr][] = ['s' => $s, 'e' => $e];
+
+                    $insertDetails[] = [
+                        'main_id'         => $mainId,
+                        'employee_id'     => $empId,
+                        'top_depart_id'   => $topDepartId,
+                        'start_time_hour' => $d['start_hour'],
+                        'start_time_min'  => $d['start_min'],
+                        'end_time_hour'   => $d['end_hour'],
+                        'end_time_min'    => $d['end_min'],
+                        'total_work_min'  => $calcTotalMin,
+                        'crt_time'        => $now,
+                        'order_time'    => $orderTimestamp,
+                    ];
+                }
+
+                // 批量写入明细
+                DB::table('daily_pw_order_details')->insert($insertDetails);
+
+                // C. 回填单号
+                $code = $this->generateBillNo([
+                    'top_depart_id' => $topDepartId,
+                    'type' => DailyPwOrder::Order_type,
+                    'period' => date("Ym", $orderTimestamp)
+                ]);
+                DB::table('daily_pw_order')->where('id', $mainId)->update(['code' => $code]);
+            }
+
+            DB::commit();
+            return [true, '保存成功'];
+        } catch (\Exception $e) {
+            DB::rollBack();
+            return [false, "保存失败:" . $e->getMessage()];
+        }
+    }
 }

+ 4 - 0
routes/api.php

@@ -134,6 +134,8 @@ Route::group(['middleware'=> ['checkLogin']],function ($route){
     $route->any('dailyPwOrderDetail', 'Api\PersonWorkController@dailyPwOrderDetail');
     //根据月工时生成
     $route->any('dailyPwOrderCreate', 'Api\PersonWorkController@dailyPwOrderCreate');
+    $route->any('dailyPwOrderPreview', 'Api\PersonWorkController@dailyPwOrderPreview');
+    $route->any('dailyPwOrderSave', 'Api\PersonWorkController@dailyPwOrderSave');
 
     //设备日工时单
     $route->any('dailyDwOrderList', 'Api\DeviceWorkController@dailyDwOrderList');
@@ -143,6 +145,8 @@ Route::group(['middleware'=> ['checkLogin']],function ($route){
     $route->any('dailyDwOrderDetail', 'Api\DeviceWorkController@dailyDwOrderDetail');
     //根据月工时生成
     $route->any('dailyDwOrderCreate', 'Api\DeviceWorkController@dailyDwOrderCreate');
+    $route->any('dailyDwOrderPreview', 'Api\DeviceWorkController@dailyDwOrderPreview');
+    $route->any('dailyDwOrderSave', 'Api\DeviceWorkController@dailyDwOrderSave');
 
     //日历设置
     $route->any('calendarList', 'Api\CalendarController@calendarList');