CaiWuXiTong/modules/EcommerceFinance/Services/PlantingReimbursementReferences.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

316 lines
12 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 Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
/**
* 种菜报销工作台的参考名单:财务上传的报销佣金人员、花名册在职姓名、种菜监控看板、店铺信息。
* 每次上传整类替换,存为 storage/app/planting-reimbursement-references/<公司>/<类型>.json;
* 花名册只取「在职」表的姓名列,不保存证件号、手机号等其他列。
*/
class PlantingReimbursementReferences
{
public const KIND_COMMISSION = 'commission';
public const KIND_ROSTER = 'roster';
public const KIND_MONITOR = 'monitor';
public const KIND_SHOP = 'shop';
public const KINDS = [
self::KIND_MONITOR => '种菜监控看板',
self::KIND_COMMISSION => '报销佣金人员',
self::KIND_ROSTER => '花名册(在职)',
self::KIND_SHOP => '店铺信息',
];
public const DIRECTORY = 'planting-reimbursement-references';
/** @var array<string,array<string,mixed>|null> 本次请求内已读过的名单 */
private array $loaded = [];
/** 看板导出列 → 存储字段。 */
private const MONITOR_COLUMNS = [
'risk' => '风险', 'hint' => '提示', 'risk_type' => '风险类型', 'owner' => 'ID负责人', 'rank' => '职级',
'daily_total' => '日累计', 'daily_limit' => '日限额', 'over_amount' => '超额金额', 'rule' => '规则来源',
'paid_at' => '支付时间', 'applied_at' => '申请时间', 'work_order_no' => '工单号', 'status' => '状态', 'order_paid' => '订单实付金额',
];
/**
* 解析上传文件。
*
* @return list<array{key:string,payload:?array<string,string>}>
*/
public function parse(string $kind, string $path, string $fileName): array
{
$table = $this->table($kind, $path, $fileName);
return match ($kind) {
self::KIND_COMMISSION => $this->names($table, '人员', '平台', '报销佣金人员表里没找到「人员」列。'),
self::KIND_ROSTER => $this->names($table, '姓名', null, '花名册「在职」表里没找到「姓名」列。'),
self::KIND_SHOP => $this->shops($table),
self::KIND_MONITOR => $this->monitorRows($table),
default => throw new InvalidArgumentException('未知的名单类型。'),
};
}
/**
* 整类替换。存成 storage/app 下的 JSON(持久卷),不建表、不写业务库。
*
* @param list<array{key:string,payload:?array<string,string>}> $rows
*/
public function replace(int $companyId, string $kind, array $rows, string $fileName, ?int $userId): void
{
$document = [
'file_name' => mb_substr($fileName, 0, 191),
'row_count' => count($rows),
'uploaded_at' => now()->format('Y-m-d H:i:s'),
'uploaded_by' => $userId,
'rows' => $rows,
];
$json = json_encode($document, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (! Storage::disk('local')->put($this->path($companyId, $kind), $json)) {
throw new RuntimeException('名单保存失败。');
}
unset($this->loaded[$companyId . ':' . $kind]);
}
/** @return array<string,bool> 姓名集合 */
public function nameSet(int $companyId, string $kind): array
{
$set = [];
foreach ($this->load($companyId, $kind)['rows'] ?? [] as $row) {
$set[PlantingReimbursementAuditor::normName((string) $row['key'])] = true;
}
return $set;
}
/** @return array<string,string> 店铺 → 利润中心 */
public function shopCenters(int $companyId): array
{
$map = [];
foreach ($this->load($companyId, self::KIND_SHOP)['rows'] ?? [] as $row) {
$center = trim((string) ($row['payload']['profit_center'] ?? ''));
if ($center !== '') {
$map[(string) $row['key']] = $center;
}
}
return $map;
}
/** @return array<string,array<string,string>>|null 订单号 → 看板字段;从未上传过看板返回 null */
public function monitor(int $companyId): ?array
{
$document = $this->load($companyId, self::KIND_MONITOR);
if ($document === null) {
return null;
}
$map = [];
foreach ($document['rows'] ?? [] as $row) {
$map[(string) $row['key']] = (array) ($row['payload'] ?? []);
}
return $map;
}
/** @return array<string,array{file_name:string,row_count:int,uploaded_at:string}|null> */
public function uploads(int $companyId): array
{
$uploads = [];
foreach (array_keys(self::KINDS) as $kind) {
$document = $this->load($companyId, $kind);
$uploads[$kind] = $document === null ? null : [
'file_name' => (string) ($document['file_name'] ?? ''),
'row_count' => (int) ($document['row_count'] ?? 0),
'uploaded_at' => (string) ($document['uploaded_at'] ?? ''),
];
}
return $uploads;
}
private function path(int $companyId, string $kind): string
{
if (! isset(self::KINDS[$kind])) {
throw new InvalidArgumentException('未知的名单类型。');
}
return self::DIRECTORY . '/' . $companyId . '/' . $kind . '.json';
}
/** @return array<string,mixed>|null */
private function load(int $companyId, string $kind): ?array
{
$cacheKey = $companyId . ':' . $kind;
if (! array_key_exists($cacheKey, $this->loaded)) {
$disk = Storage::disk('local');
$path = $this->path($companyId, $kind);
$document = $disk->exists($path) ? json_decode((string) $disk->get($path), true) : null;
$this->loaded[$cacheKey] = is_array($document) ? $document : null;
}
return $this->loaded[$cacheKey];
}
/** @return list<list<string>> 表格(字符串单元格) */
private function table(string $kind, string $path, string $fileName): array
{
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
if ($extension === 'csv') {
return $this->csv($path);
}
if (! in_array($extension, ['xlsx', 'xls'], true)) {
throw new InvalidArgumentException('只支持 xlsx / xls / csv 文件。');
}
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
if ($kind === self::KIND_ROSTER && method_exists($reader, 'listWorksheetNames')) {
$names = $reader->listWorksheetNames($path);
if (in_array('在职', $names, true)) {
$reader->setLoadSheetsOnly(['在职']);
}
}
$sheet = $reader->load($path)->getActiveSheet();
if ($kind === self::KIND_ROSTER && $sheet->getParent()?->getSheetByName('在职') !== null) {
$sheet = $sheet->getParent()->getSheetByName('在职');
}
$rows = [];
foreach ($sheet->toArray(null, true, false, false) as $row) {
$rows[] = array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $row);
}
return $rows;
}
/** @return list<list<string>> */
private function csv(string $path): array
{
$content = (string) file_get_contents($path);
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$content = substr($content, 3);
} elseif (! mb_check_encoding($content, 'UTF-8')) {
// 看板导出是 GBK 编码。
$content = (string) mb_convert_encoding($content, 'UTF-8', 'GB18030');
}
$stream = fopen('php://temp', 'r+');
fwrite($stream, $content);
rewind($stream);
$rows = [];
while (($row = fgetcsv($stream, 0, ',', '"', '')) !== false) {
$rows[] = array_map(static fn (mixed $v): string => trim((string) $v), $row);
}
fclose($stream);
return $rows;
}
/**
* 在前 10 行里找表头行,返回 [表头行号, 列名 → 列号]。
*
* @param list<list<string>> $table
* @param list<string> $required
* @return array{0:int,1:array<string,list<int>>}|null
*/
private function header(array $table, array $required): ?array
{
foreach (array_slice($table, 0, 10, true) as $index => $row) {
$columns = [];
foreach ($row as $col => $value) {
$columns[$value][] = $col;
}
if (array_diff($required, array_keys($columns)) === []) {
return [$index, $columns];
}
}
return null;
}
/** @return list<array{key:string,payload:null}> */
private function names(array $table, string $column, ?string $preferNeighbor, string $missing): array
{
$header = $this->header($table, [$column]);
if ($header === null) {
throw new InvalidArgumentException($missing);
}
[$headerRow, $columns] = $header;
$col = $columns[$column][0];
// 报销佣金人员表左右各有一组「人员」,以右侧「人员 / 平台」那组为准。
foreach ($columns[$column] as $candidate) {
if ($preferNeighbor !== null && ($table[$headerRow][$candidate + 1] ?? '') === $preferNeighbor) {
$col = $candidate;
}
}
$names = [];
foreach (array_slice($table, $headerRow + 1) as $row) {
$name = PlantingReimbursementAuditor::normName($row[$col] ?? '');
if ($name !== '' && ! preg_match('/^\d+$/', $name)) {
$names[$name] = ['key' => $name, 'payload' => null];
}
}
if ($names === []) {
throw new InvalidArgumentException('文件里没有读到姓名。');
}
return array_values($names);
}
/** @return list<array{key:string,payload:array<string,string>}> */
private function shops(array $table): array
{
$header = $this->header($table, ['ERP系统店铺名称', '店铺所属利润中心']);
if ($header === null) {
throw new InvalidArgumentException('店铺信息里没找到「ERP系统店铺名称」「店铺所属利润中心」列。');
}
[$headerRow, $columns] = $header;
$shops = [];
foreach (array_slice($table, $headerRow + 1) as $row) {
$shop = trim($row[$columns['ERP系统店铺名称'][0]] ?? '');
$center = trim($row[$columns['店铺所属利润中心'][0]] ?? '');
if ($shop !== '' && $center !== '') {
$shops[$shop] = ['key' => $shop, 'payload' => ['profit_center' => $center]];
}
}
return array_values($shops);
}
/** @return list<array{key:string,payload:array<string,string>}> */
private function monitorRows(array $table): array
{
$header = $this->header($table, ['订单号', '风险']);
if ($header === null) {
throw new InvalidArgumentException('看板文件里没找到「订单号」「风险」列,请上传种菜监控看板导出的明细。');
}
[$headerRow, $columns] = $header;
$rows = [];
foreach (array_slice($table, $headerRow + 1) as $row) {
$orderNo = trim((string) ($row[$columns['订单号'][0]] ?? ''), " \t'\"=");
if ($orderNo === '') {
continue;
}
$payload = [];
foreach (self::MONITOR_COLUMNS as $key => $label) {
$payload[$key] = isset($columns[$label]) ? trim((string) ($row[$columns[$label][0]] ?? '')) : '';
}
$payload['paid_at'] = str_replace('/', '-', $payload['paid_at']);
$rows[$orderNo] = ['key' => $orderNo, 'payload' => $payload];
}
if ($rows === []) {
throw new InvalidArgumentException('看板文件里没有订单。');
}
return array_values($rows);
}
}