财务确认(2026-10-20):种菜损耗是查种菜报销的单有没有真实发出有价值的货—— - 发货数据中交易单号下出现信誉订单虚拟条码或透明挂钩(含条形码 GG 开头)即整单不扣款; - 任务备注/卖家备注含拍A发B/拍A发A 的不扣款,且无论是否匹配到发货都从种菜报销中挑出发运营; - “导出拍A发B(发运营)”直接按「种菜报销」页字段输出;其余真实发货的单按成本价×数量+运费扣款。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
136 lines
6.1 KiB
PHP
136 lines
6.1 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\View;
|
||
|
||
use Illuminate\Pagination\LengthAwarePaginator;
|
||
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
|
||
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
|
||
|
||
/** 种菜数据汇总 → 种菜损耗:种菜报销明细匹配近两个月发货数据,拍A发B/拍A发A、只发虚拟条码/透明挂钩的不扣款,其余按成本价×数量+运费扣款。 */
|
||
class PlantingLossPageData
|
||
{
|
||
use PlantingPageFilters;
|
||
|
||
public const TITLE = '种菜损耗';
|
||
|
||
public const PER_PAGE = 50;
|
||
|
||
public const TEXT_FILTERS = [
|
||
'work_order_no' => '工单编号',
|
||
'online_order_no' => '线上订单号',
|
||
'shop_name' => '店铺名称',
|
||
'task_remark' => '任务备注',
|
||
'seller_remark' => '卖家备注',
|
||
'express_no' => '快递单号',
|
||
];
|
||
|
||
public const COLUMNS = [
|
||
'loss_result' => '损耗判定', 'deduction_amount' => '扣款金额', 'loss_basis' => '判定说明', 'deduction_detail' => '扣款计算(条码×数量×成本价+运费)',
|
||
'completed_at' => '报销完成时间', 'work_order_no' => '工单编号', 'platform' => '平台', 'shop_name' => '店铺名称',
|
||
'online_order_no' => '线上订单号', 'task_remark' => '任务备注', 'seller_remark' => '卖家备注',
|
||
'actual_paid_amount' => '实付金额', 'task_payment' => '任务付款金额', 'reimbursement_amount' => '最终报销金额',
|
||
'shipped_at' => '发货时间', 'express_no' => '快递单号', 'warehouse_name' => '发货仓库',
|
||
'shipped_barcode' => '发货条码', 'shipped_product_name' => '发货商品名称', 'shipped_qty' => '发货数量',
|
||
'shipped_amount' => '发货金额', 'shipped_freight' => '发货运费',
|
||
'initiator' => '发起人', 'payee_name' => '信誉账号名称', 'product_code' => '报销商品编码', 'product_name' => '报销商品名称',
|
||
'internal_order_no' => '内部单号', 'erp_order_status' => 'ERP订单状态',
|
||
];
|
||
|
||
public const MONEY_COLUMNS = ['deduction_amount', 'actual_paid_amount', 'task_payment', 'reimbursement_amount', 'shipped_amount', 'shipped_freight'];
|
||
|
||
private const SUM_KEYS = ['deduction_amount', 'task_payment', 'reimbursement_amount', 'shipped_amount', 'shipped_freight'];
|
||
|
||
public function __construct(
|
||
private readonly Calculator $calculator,
|
||
private readonly Repository $repository,
|
||
) {}
|
||
|
||
/** @return array<string,string> */
|
||
public function filters(): array
|
||
{
|
||
$filters = $this->baseFilters();
|
||
$result = request()->query('result', Calculator::RESULT_DEDUCT);
|
||
abort_unless(is_string($result) && ($result === 'all' || in_array($result, Calculator::RESULTS, true)), 422, '损耗判定无效。');
|
||
$filters['result'] = $result;
|
||
|
||
return $filters;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,string> $filters
|
||
* @param list<array<string,mixed>>|null $rows
|
||
* @return \Generator<int,array<string,mixed>>
|
||
*/
|
||
public function rows(array $filters, ?array $rows = null): \Generator
|
||
{
|
||
foreach ($rows ?? $this->calculator->rows($filters['month']) as $row) {
|
||
if ($filters['result'] !== 'all' && $row['loss_result'] !== $filters['result']) {
|
||
continue;
|
||
}
|
||
if ($this->matchesText($row, $filters)) {
|
||
yield $row;
|
||
}
|
||
}
|
||
}
|
||
|
||
public function page(): array
|
||
{
|
||
$filters = $this->filters();
|
||
$page = max(1, (int) request('page', 1));
|
||
$sync = $this->repository->syncStatus();
|
||
$shippingSync = $this->calculator->shippingStatus();
|
||
$empty = ['count' => 0, 'cost_missing' => 0] + array_fill_keys(self::SUM_KEYS, 0);
|
||
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||
$items = [];
|
||
$error = $this->syncError($sync);
|
||
|
||
if ($error === null) {
|
||
try {
|
||
$all = $this->calculator->rows($filters['month']);
|
||
foreach ($all as $row) {
|
||
$summary['total']++;
|
||
$summary['matched'] += ($row['shipped_at'] ?? '') !== '' ? 1 : 0;
|
||
$this->add($summary[$row['loss_result']], $row);
|
||
}
|
||
foreach ($this->rows($filters, $all) as $row) {
|
||
if ($summary['filtered']['count'] >= ($page - 1) * self::PER_PAGE && count($items) < self::PER_PAGE) {
|
||
$items[] = $row;
|
||
}
|
||
$this->add($summary['filtered'], $row);
|
||
}
|
||
} catch (\Throwable $exception) {
|
||
report($exception);
|
||
$error = '种菜损耗暂时无法计算(工单镜像或发货数据读取失败),请稍后重试。';
|
||
$summary = ['total' => 0, 'matched' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
|
||
$items = [];
|
||
}
|
||
}
|
||
|
||
return [
|
||
'view' => 'business.pages.billing-transactions.planting-loss',
|
||
'plantingFilters' => $filters,
|
||
'plantingWindow' => Repository::window($filters['month']),
|
||
'plantingShippingWindow' => Calculator::shippingWindow($filters['month']),
|
||
'plantingMonths' => Repository::months(),
|
||
'plantingSummary' => $summary,
|
||
'plantingSync' => $sync,
|
||
'plantingShippingSync' => $shippingSync,
|
||
'plantingError' => $error,
|
||
'plantingRows' => new LengthAwarePaginator($items, $summary['filtered']['count'], self::PER_PAGE, $page, [
|
||
'path' => request()->url(),
|
||
'query' => request()->except('page'),
|
||
]),
|
||
];
|
||
}
|
||
|
||
/** @param array<string,int> $bucket @param array<string,mixed> $row */
|
||
private function add(array &$bucket, array $row): void
|
||
{
|
||
$bucket['count']++;
|
||
$bucket['cost_missing'] += ! empty($row['cost_missing']) ? 1 : 0;
|
||
foreach (self::SUM_KEYS as $key) {
|
||
$bucket[$key] += self::cents($row[$key] ?? null);
|
||
}
|
||
}
|
||
}
|