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