208 lines
11 KiB
PHP
208 lines
11 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\View;
|
||
|
||
use Illuminate\Pagination\LengthAwarePaginator;
|
||
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository as Repository;
|
||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
|
||
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
|
||
use Modules\EcommerceFinance\Services\SupplierBookkeepingValuation;
|
||
use Modules\EcommerceFinance\Services\SupplierBookkeepingProductResolver;
|
||
|
||
/** 供应商应付 → 厂家记账(聚工单“记账”工单镜像)。 */
|
||
class SupplierBookkeepingPageData
|
||
{
|
||
public const TITLE = '厂家记账';
|
||
|
||
public const PER_PAGE = 50;
|
||
|
||
public const TEXT_FILTERS = [
|
||
'work_order_no' => '工单编号',
|
||
'online_order_no' => '线上订单号',
|
||
'tracking_no' => '物流单号',
|
||
'shop_name' => '店铺名称',
|
||
'supplier' => '供应商',
|
||
'product_code' => '商品编码',
|
||
];
|
||
|
||
public const COLUMNS = [
|
||
'started_at' => '发起时间', 'work_order_no' => '工单编号', 'initiator' => '发起人',
|
||
'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项',
|
||
'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额',
|
||
'original_amount' => '原记账金额',
|
||
'matched_cost' => '匹配商品成本', 'matched_freight' => '核算运费', 'matched_quantity' => '匹配发货数量',
|
||
'matched_tracking_no' => '匹配快递单号', 'valuation_window' => '发货匹配期间',
|
||
'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明',
|
||
'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号',
|
||
'internal_order_no' => '内部单号', 'actual_paid_amount' => '实付金额', 'carrier' => '物流公司',
|
||
'tracking_no' => '物流单号', 'shop_name' => '店铺名称', 'platform' => '平台', 'shipping_warehouse' => '订单发货仓库',
|
||
'status' => '工单状态', 'processing_status' => '记账处理状态', 'counterparty_agreed' => '被记账方是否同意记账',
|
||
'counterparty_note' => '被记账方处理说明', 'specialist_result' => '专员记账判定结果', 'specialist_note' => '专员处理说明',
|
||
'updated_at' => '修改时间', 'work_order_type' => '工单类型', 'product_code' => '商品编码', 'resolved_product_code' => '实际商品编码',
|
||
'supplier_match_basis' => '供应商匹配说明', 'style_code' => '款式编码',
|
||
'other_attribute_6' => '其它属性6', 'product_name' => '商品名称', 'platform_sku_id' => '平台商品skuId',
|
||
'platform_product_id' => '平台商品id', 'product_quantity' => '商品数量', 'product_price' => '商品价格',
|
||
'product_amount' => '商品金额', 'buyer_paid_amount' => '买家实付', 'supplier' => '供应商',
|
||
'accepting_department' => '受理部门', 'order_shipped_at' => '订单发货时间',
|
||
];
|
||
|
||
public const MONEY_COLUMNS = ['amount', 'original_amount', 'matched_cost', 'matched_freight', 'actual_paid_amount'];
|
||
|
||
public function __construct(
|
||
private readonly Repository $repository,
|
||
private readonly SupplierBookkeepingConfigStore $configs,
|
||
private readonly SupplierBookkeepingValuation $valuation,
|
||
private readonly SupplierBookkeepingProductResolver $products,
|
||
) {}
|
||
|
||
/** @return array<string,string> */
|
||
public function filters(): array
|
||
{
|
||
$filters = [];
|
||
foreach (['started_from', 'started_to'] as $key) {
|
||
$value = request()->query($key, '');
|
||
abort_unless(is_string($value) && ($value === '' || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)), 422, '发起日期格式无效。');
|
||
$filters[$key] = $value;
|
||
}
|
||
$reason = request()->query('reason', '');
|
||
abort_unless(is_string($reason) && ($reason === '' || in_array($reason, Repository::reasonPaths(), true) || array_key_exists($reason, Repository::REASONS)), 422, '记账原因无效。');
|
||
$filters['reason'] = $reason;
|
||
$status = request()->query('status', '');
|
||
abort_unless(is_string($status) && ($status === '' || in_array($status, Repository::STATUS_LABELS, true)), 422, '工单状态无效。');
|
||
$filters['status'] = $status;
|
||
$deduction = request()->query('deduction', '');
|
||
abort_unless(is_string($deduction) && ($deduction === '' || in_array($deduction, Rule::STATUSES, true)), 422, '扣款判定无效。');
|
||
$filters['deduction'] = $deduction;
|
||
foreach (['bookkeeping_type' => 50, ...array_fill_keys(array_keys(self::TEXT_FILTERS), 200)] as $key => $max) {
|
||
$value = request()->query($key, '');
|
||
abort_unless(is_string($value) && mb_strlen($value) <= $max, 422, '筛选条件格式无效。');
|
||
$filters[$key] = trim($value);
|
||
}
|
||
|
||
return $filters;
|
||
}
|
||
|
||
/** @return array<string,string> 可导出列(按请求顺序) */
|
||
public function exportColumns(): array
|
||
{
|
||
$requested = request()->query('columns');
|
||
if ($requested === null || $requested === '') {
|
||
return self::COLUMNS;
|
||
}
|
||
abort_unless(is_string($requested) && strlen($requested) <= 1000, 422, '导出列无效。');
|
||
$keys = explode(',', $requested);
|
||
abort_unless(count($keys) === count(array_unique($keys)) && array_diff($keys, array_keys(self::COLUMNS)) === [], 422, '导出列无效。');
|
||
|
||
return array_replace(array_flip($keys), array_intersect_key(self::COLUMNS, array_flip($keys)));
|
||
}
|
||
|
||
/**
|
||
* @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
|
||
{
|
||
$from = $filters['started_from'] !== '' ? $filters['started_from'] . ' 00:00:00' : '';
|
||
$to = $filters['started_to'] !== '' ? $filters['started_to'] . ' 23:59:59' : '';
|
||
$deduction = $filters['deduction'] ?? '';
|
||
$configs = $this->configs->activeConfigs();
|
||
$matched = [];
|
||
foreach ($rows ?? $this->repository->rows() as $row) {
|
||
$row['original_amount'] = $row['amount'];
|
||
$row += ['matched_cost' => null, 'matched_freight' => null, 'matched_quantity' => '', 'matched_tracking_no' => '', 'valuation_window' => '', 'resolved_product_code' => '', 'supplier_match_basis' => ''];
|
||
if ($from !== '' && $row['started_at'] < $from) continue;
|
||
if ($to !== '' && $row['started_at'] > $to) continue;
|
||
if ($filters['reason'] !== '' && $row['bookkeeping_reason'] !== $filters['reason'] && $row['reason_level_1'] !== $filters['reason']) continue;
|
||
if ($filters['status'] !== '' && $row['status'] !== $filters['status']) continue;
|
||
if ($filters['bookkeeping_type'] !== '' && $row['bookkeeping_type'] !== $filters['bookkeeping_type']) continue;
|
||
$matched[] = $row;
|
||
}
|
||
$resolved = [];
|
||
foreach ($this->products->apply($matched) as $row) {
|
||
// 先补全供应商,再按当前配置及既定金额顺序判断;不缓存判定。
|
||
$row = Rule::decide($row, $configs) + $row;
|
||
if ($row['supplier_match_basis'] !== '') $row['deduction_basis'] = $row['supplier_match_basis'] . ';' . $row['deduction_basis'];
|
||
foreach (self::TEXT_FILTERS as $key => $label) {
|
||
$value = (string) ($row[$key] ?? '');
|
||
if ($key === 'product_code') $value .= "\n" . $row['resolved_product_code'];
|
||
if ($filters[$key] !== '' && mb_stripos($value, $filters[$key]) === false) continue 2;
|
||
}
|
||
$resolved[] = $row;
|
||
}
|
||
foreach ($this->valuation->apply($resolved) as $row) {
|
||
if ($deduction !== '' && $row['deduction_status'] !== $deduction) continue;
|
||
yield $row;
|
||
}
|
||
}
|
||
|
||
public static function cents(mixed $amount): int
|
||
{
|
||
if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $m)) {
|
||
return 0;
|
||
}
|
||
$value = ((int) $m[2]) * 100 + (int) $m[3];
|
||
|
||
return $m[1] === '-' ? -$value : $value;
|
||
}
|
||
|
||
public function page(): array
|
||
{
|
||
if (request()->query('bk_view') === 'config') {
|
||
return app(SupplierBookkeepingConfigPageData::class)->page();
|
||
}
|
||
$filters = $this->filters();
|
||
$page = max(1, (int) request('page', 1));
|
||
$sync = $this->repository->syncStatus();
|
||
$summary = ['count' => 0, 'amount_cents' => 0, 'supplier_reason' => 0, 'warehouse_reason' => 0, 'deduction_count' => 0, 'deduction_cents' => 0, 'review' => 0];
|
||
$items = [];
|
||
$types = [];
|
||
$error = null;
|
||
|
||
if (! in_array($sync['code'], ['ready', 'failed'], true)) {
|
||
$error = $sync['code'] === 'initial_failed'
|
||
? '聚工单工单镜像首次同步失败,暂不能展示厂家记账,请联系管理员重新同步。'
|
||
: '聚工单工单镜像尚未完成首次同步,完成后自动展示厂家记账;当前为空不代表没有记账工单。';
|
||
} else {
|
||
try {
|
||
$all = $this->repository->rows();
|
||
foreach ($all as $row) {
|
||
if ($row['bookkeeping_type'] !== '') $types[$row['bookkeeping_type']] = true;
|
||
}
|
||
foreach ($this->rows($filters, $all) as $row) {
|
||
if ($summary['count'] >= ($page - 1) * self::PER_PAGE && count($items) < self::PER_PAGE) $items[] = $row;
|
||
$summary['count']++;
|
||
$summary['amount_cents'] += self::cents($row['amount']);
|
||
$summary[$row['reason_level_1'] === '厂家记账' ? 'supplier_reason' : 'warehouse_reason']++;
|
||
if ($row['deduction_status'] === Rule::STATUS_DEDUCT) {
|
||
$summary['deduction_count']++;
|
||
$summary['deduction_cents'] += self::cents($row['amount']);
|
||
} elseif ($row['deduction_status'] === Rule::STATUS_REVIEW) {
|
||
$summary['review']++;
|
||
}
|
||
}
|
||
} catch (\Throwable $exception) {
|
||
report($exception);
|
||
$error = '厂家记账数据暂时无法读取,请稍后重试。';
|
||
$summary = array_fill_keys(array_keys($summary), 0);
|
||
$items = [];
|
||
}
|
||
}
|
||
ksort($types);
|
||
|
||
return [
|
||
'view' => 'business.pages.supplier-payables.manufacturer-bookkeeping',
|
||
'bookkeepingView' => 'details',
|
||
'bookkeepingFilters' => $filters,
|
||
'bookkeepingSummary' => $summary,
|
||
'bookkeepingSync' => $sync,
|
||
'bookkeepingError' => $error,
|
||
'bookkeepingTypes' => array_keys($types),
|
||
'bookkeepingRows' => new LengthAwarePaginator($items, $summary['count'], self::PER_PAGE, $page, [
|
||
'path' => request()->url(),
|
||
'query' => request()->except('page'),
|
||
]),
|
||
];
|
||
}
|
||
}
|