- 种菜数据汇总新增「种菜报销工作台」:按聚工单锁单口径取单(待处理 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
207 lines
11 KiB
PHP
207 lines
11 KiB
PHP
<?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;
|
||
}
|
||
}
|