CaiWuXiTong/modules/EcommerceFinance/Services/PlantingReimbursementWorkbookExporter.php
limeng 951e553fae feat(种菜报销): 新增种菜报销工作台,逐单校验并生成打款明细与钉钉流程明细 (#1140)
- 种菜数据汇总新增「种菜报销工作台」:按聚工单锁单口径取单(待处理 WAIT_CONFIRM、付款 D-7~D-1、ERP订单状态非取消/删除/空、售后单号为空;可切换处理中 CONFIRMING 和付款起始日),逐单校验重复报销、金额公式、佣金资格、收款人在职、以旧换新买家实付(取买家实付/看板订单实付中最低,查不到标签的待复查)、看板超额(同日按付款先后报到日限额)与看板未匹配
- 导出前须勾选「已在聚工单核对条数和金额一致」;页面给出应核对的条数/金额、各口径合计自检,聚工单镜像停更超过 20 分钟时提示先别报销
- 页面给出可报销合计(三大天猫/其他)、钉钉「种菜报销流程」明细行、按发起人的发群通知与取消清单
- 导出种菜对账、种菜报销明细(打款卡号明细表)、聚工单种菜任务工单导入模版,格式对齐手工文件
- 财务可上传种菜监控看板、报销佣金人员、花名册在职、店铺信息;名单存 storage 持久卷 JSON,花名册只存姓名
- 聚工单镜像读取补充 ERP订单标签、待报销工单和已报销订单查询
涉及:只读聚工单镜像;不涉及迁移、上游写入、钉钉和聚工单写操作。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-11-12 18:54:21 +08:00

207 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\PlantingReimbursementAuditor as Auditor;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 种菜报销工作台导出,格式对齐手工的三个文件:
* - 种菜对账M.D:总 / 三大天猫 / 其他 透视 / 每日汇总表 / 打款卡号明细表 / 取消工单;
* - 种菜报销明细M.D:两张打款卡号明细表(给出纳打款);
* - 导入模版M.D:聚工单「种菜任务工单」批量导入模板。
*/
class PlantingReimbursementWorkbookExporter
{
public const TYPES = [
'reconciliation' => '种菜对账',
'details' => '种菜报销明细',
'import-template' => '导入模版',
];
public const DETAIL_HEADERS = [
'日期', '订单编号', '订单编号', '财务售价', '财务运费', '财务佣金', '京喜差价', '扣款', '财务空包费', '财务打款金额',
'笔数', '备注', '正确店铺', '负责人', '账户', '任务备注', '创建人', '卖家备注', '所属利润中心', 'ERP订单标签', '负责平台', 'ID',
];
public const TEMPLATE_NOTICE = "导入填写须知(必看)\n\n1、请勿修改模板自带内容的排序、请勿删除默认列、不需填写的列空着即可,需要增加的列在优先级列后面增加,以避免导入失败\n2、如果工单类型有设置默认值(如默认的执行人、抄送人等),且导入的表格里该单元格内容为空时自动取默认值\n3、标黄为必填字段,工单类型默认种菜任务工单,执行人是运营花名,导入需要按照此模板导入,不要调整字段顺序和文字\n4、利润中心为固定选项:运营中心A、运营中心B、运营中心C、运营中心G、运营中心E、分销部、山东项目组、客服中心,按照左侧中心填写,不要写错";
public const TEMPLATE_HEADERS = ['工单类型', '工单标题', '工单内容', '执行人', '抄送人', '开始时间', '截止时间', '优先级', '利润中心', '店铺名称', '真名', '卡号', '财务打款金额'];
/** @param array{rows:list<array<string,mixed>>,summary:array<string,mixed>} $result */
public function build(string $type, array $result): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->removeSheetByIndex(0);
$summary = $result['summary'];
$passed = array_values(array_filter($result['rows'], static fn (array $row): bool => $row['decision'] === Auditor::DECISION_PASS));
$byGroup = static fn (string $group): array => array_values(array_filter($passed, static fn (array $row): bool => $row['group'] === $group));
switch ($type) {
case 'reconciliation':
$this->detailSheet($spreadsheet, '总', $result['rows'], $summary['bill_date'], true);
$this->pivotSheet($spreadsheet, Auditor::GROUP_BIG_TMALL, $summary['pivots'][Auditor::GROUP_BIG_TMALL]);
$this->pivotSheet($spreadsheet, Auditor::GROUP_OTHER, $summary['pivots'][Auditor::GROUP_OTHER]);
$this->detailSheet($spreadsheet, '每日汇总表-其他', $byGroup(Auditor::GROUP_OTHER), $summary['bill_date']);
$this->detailSheet($spreadsheet, '每日汇总表-三大天猫', $byGroup(Auditor::GROUP_BIG_TMALL), $summary['bill_date']);
$this->cardSheets($spreadsheet, $summary);
$this->cancelSheet($spreadsheet, $result['rows']);
break;
case 'details':
$this->cardSheets($spreadsheet, $summary);
break;
case 'import-template':
$this->templateSheet($spreadsheet, $summary);
break;
default:
throw new InvalidArgumentException('未知的导出类型。');
}
$spreadsheet->setActiveSheetIndex(0);
return $spreadsheet;
}
public function fileName(string $type, array $summary): string
{
$date = (int) substr($summary['bill_date'], 5, 2) . '.' . (int) substr($summary['bill_date'], 8, 2);
return self::TYPES[$type] . $date . '.xlsx';
}
/** @param list<array<string,mixed>> $rows */
private function detailSheet(Spreadsheet $spreadsheet, string $title, array $rows, string $billDate, bool $withDecision = false): void
{
$headers = self::DETAIL_HEADERS;
if ($withDecision) {
array_push($headers, '工单编号', '工单状态', '处理结果', '原因');
}
$data = [];
foreach ($rows as $row) {
$line = [
$billDate, $row['online_order_no'], $row['online_order_no'],
self::num($row['task_payment']), self::num($row['task_freight']), self::num($row['task_commission']),
self::num($row['jingxi_price_diff']), self::num($row['task_deduction']), self::num($row['task_empty_package_fee']),
self::num($row['reimbursement_amount']), 1, $row['decision'] === Auditor::DECISION_PASS ? '' : $row['decision_label'],
$row['shop_name'], $row['payee_name'], $row['payee_account'], $row['task_remark'], $row['initiator'],
$row['seller_remark'], $row['profit_center'], $row['erp_order_label'] ?? '', '', '',
];
if ($withDecision) {
array_push($line, $row['work_order_no'], $row['status'], $row['decision_label'], $row['issue_text']);
}
$data[] = $line;
}
$this->sheet($spreadsheet, $title, $headers, $data, [2, 3, 15]);
}
/** @param list<array<string,mixed>> $pivot */
private function pivotSheet(Spreadsheet $spreadsheet, string $title, array $pivot): void
{
$data = [];
$total = 0;
foreach ($pivot as $line) {
$data[] = [$line['initiator'], $line['profit_center'], $line['shop_name'], $line['payee_name'], $line['payee_account'], $line['cents'] / 100];
$total += $line['cents'];
}
$data[] = ['总计', '', '', '', '', $total / 100];
$this->sheet($spreadsheet, $title, ['创建人', '所属利润中心', '正确店铺', '负责人', '账户', '求和项:财务打款金额'], $data, [5]);
}
/** @param array<string,mixed> $summary */
private function cardSheets(Spreadsheet $spreadsheet, array $summary): void
{
foreach ([Auditor::GROUP_OTHER, Auditor::GROUP_BIG_TMALL] as $group) {
$big = $group === Auditor::GROUP_BIG_TMALL;
$headers = $big ? ['所属利润中心', '店铺', '负责人', '卡号', '财务打款金额', '银行', '手机号'] : ['所属利润中心', '负责人', '卡号', '财务打款金额', '银行', '手机号'];
$data = [];
foreach ($summary['cards'][$group] as $card) {
$data[] = $big
? [$card['profit_center'], $card['shop_name'], $card['payee_name'], $card['payee_account'], $card['cents'] / 100, $card['payee_bank'], $card['payee_mobile']]
: [$card['profit_center'], $card['payee_name'], $card['payee_account'], $card['cents'] / 100, $card['payee_bank'], $card['payee_mobile']];
}
$sheet = $this->sheet($spreadsheet, '打款卡号明细表-' . $group, $headers, $data, $big ? [4, 7] : [3, 6]);
$col = count($headers) + 2;
$sheet->setCellValue([$col, 1], '汇总');
$sheet->setCellValue([$col + 1, 1], (float) $summary['group_amounts'][$group]);
$sheet->setCellValue([$col, 2], '笔数');
$sheet->setCellValue([$col + 1, 2], count($data));
}
}
/** @param list<array<string,mixed>> $rows */
private function cancelSheet(Spreadsheet $spreadsheet, array $rows): void
{
$data = [];
foreach ($rows as $row) {
if ($row['decision'] !== Auditor::DECISION_PASS) {
$data[] = [$row['initiator'], $row['online_order_no'], $row['work_order_no'], $row['shop_name'], self::num($row['reimbursement_amount']), $row['decision_label'], $row['issue_text']];
}
}
$this->sheet($spreadsheet, '取消工单', ['发起人', '线上订单号', '工单编号', '店铺', '最终报销金额', '处理结果', '原因'], $data, [2]);
}
/** @param array<string,mixed> $summary */
private function templateSheet(Spreadsheet $spreadsheet, array $summary): void
{
$data = [];
foreach ([Auditor::GROUP_BIG_TMALL, Auditor::GROUP_OTHER] as $group) {
foreach ($summary['pivots'][$group] as $line) {
$data[] = [
'种菜任务工单', '', '', $line['initiator'], '', '', '', '', $line['profit_center'],
$group === Auditor::GROUP_BIG_TMALL ? $line['shop_name'] : Auditor::SAMPLE_SHOP,
$line['payee_name'], $line['payee_account'], $line['cents'] / 100,
];
}
}
$sheet = $this->sheet($spreadsheet, 'Sheet1', self::TEMPLATE_HEADERS, $data, [12], 2);
$sheet->setCellValue('A1', self::TEMPLATE_NOTICE);
$sheet->mergeCells('A1:M1');
$sheet->getStyle('A1')->getAlignment()->setWrapText(true);
$sheet->getRowDimension(1)->setRowHeight(120);
$sheet->getStyle('A2:A2')->getFill()->setFillType('solid')->getStartColor()->setRGB('FFFF00');
foreach (['D2', 'I2', 'J2', 'K2', 'L2', 'M2'] as $cell) {
$sheet->getStyle($cell)->getFill()->setFillType('solid')->getStartColor()->setRGB('FFFF00');
}
}
/**
* @param list<string> $headers
* @param list<list<mixed>> $data
* @param list<int> $textColumns 1 起的列号,按文本写入(长单号、卡号)
*/
private function sheet(Spreadsheet $spreadsheet, string $title, array $headers, array $data, array $textColumns, int $headerRow = 1): Worksheet
{
$sheet = $spreadsheet->createSheet();
$sheet->setTitle($title);
foreach ($headers as $i => $header) {
$sheet->setCellValue([$i + 1, $headerRow], $header);
}
$last = Coordinate::stringFromColumnIndex(count($headers));
$sheet->getStyle("A{$headerRow}:{$last}{$headerRow}")->getFont()->setBold(true);
foreach ($data as $r => $line) {
foreach (array_values($line) as $c => $value) {
$coordinate = [$c + 1, $headerRow + 1 + $r];
if (in_array($c + 1, $textColumns, true) || (is_string($value) && preg_match('/^[=+\-@]/', $value))) {
$sheet->setCellValueExplicit($coordinate, (string) $value, DataType::TYPE_STRING);
} else {
$sheet->setCellValue($coordinate, $value);
}
}
}
foreach (range(1, count($headers)) as $col) {
$sheet->getColumnDimensionByColumn($col)->setWidth(16);
}
$sheet->freezePane('A' . ($headerRow + 1));
return $sheet;
}
private static function num(mixed $value): float
{
return Auditor::cents($value) / 100;
}
}