CaiWuXiTong/modules/EcommerceFinance/Repositories/CostFreightAnomalyRepository.php
renshen d79a1eedfe feat: 成本运费异常接入完整订单商品明细 (#1004)
合并 PR #1004:补全订单商品明细并按条码识别缺成本。
2026-11-09 11:02:21 +08:00

315 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\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\ShengsuanOrderProfitDetailMapper;
use Modules\EcommerceFinance\Services\ShengsuanOrderProfitDisplayMapper;
use Modules\EcommerceFinance\Services\ShengsuanOrderProfitRowMapper;
class CostFreightAnomalyRepository
{
private const TABLE = ShengsuanOrderProfitRowMapper::TABLE;
private const DETAIL_TABLE = ShengsuanOrderProfitDetailMapper::TABLE;
public function ready(): bool
{
try {
$schema = Schema::connection($this->connectionName());
return $schema->hasTable(self::TABLE)
&& $schema->hasColumns(self::TABLE, [
'shop_name', 'online_order_no', 'internal_order_no',
'ship_time', 'product_cost', 'matched_freight_fee',
'matched_package_fee', 'matched_bill_fee',
]);
} catch (\Throwable) {
return false;
}
}
/**
* @return array{
* ready:bool,last_synced_at:?string,total:int,shops:list<string>,
* detail_ready:bool,detail_total:int,detail_last_synced_at:?string
* }
*/
public function status(): array
{
try {
$query = $this->connection()->table(self::TABLE);
$detailReady = $this->detailReady();
$detailQuery = $detailReady ? $this->connection()->table(self::DETAIL_TABLE) : null;
return [
'ready' => $this->ready(),
'last_synced_at' => (clone $query)->max('synced_at'),
'total' => (int) (clone $query)->count(),
'shops' => (clone $query)->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->distinct()
->orderBy('shop_name')
->pluck('shop_name')
->map(static fn (mixed $shop): string => trim((string) $shop))
->filter()
->values()
->all(),
'detail_ready' => $detailReady,
'detail_total' => $detailQuery === null ? 0 : (int) (clone $detailQuery)->count(),
'detail_last_synced_at' => $detailQuery?->max('synced_at'),
];
} catch (\Throwable) {
return [
'ready' => false,
'last_synced_at' => null,
'total' => 0,
'shops' => [],
'detail_ready' => false,
'detail_total' => 0,
'detail_last_synced_at' => null,
];
}
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,total:int,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
$query = $this->filtered($this->connection()->table(self::TABLE), $filters);
$total = (int) (clone $query)->count();
$rows = $query
->select([
'shop_name', 'online_order_no', 'internal_order_no', 'order_key',
'ship_time', 'product_cost', 'matched_package_fee',
'matched_freight_fee', 'matched_bill_fee', 'source_payload', 'synced_at',
])
->orderByDesc('ship_time')
->orderByDesc('order_key')
->forPage(max($page, 1), min(max($limit, 1), 100))
->get()
->map(function (object $row): array {
$anomalyTypes = self::anomalyTypes($row);
return [
'shop_name' => trim((string) ($row->shop_name ?? '')),
'online_order_no' => trim((string) ($row->online_order_no ?? '')),
'internal_order_no' => trim((string) ($row->internal_order_no ?? '')),
'order_key' => trim((string) ($row->order_key ?? '')),
'ship_time' => $row->ship_time,
'product_cost' => $row->product_cost === null ? null : (float) $row->product_cost,
'matched_package_fee' => $row->matched_package_fee === null ? null : (float) $row->matched_package_fee,
'matched_freight_fee' => $row->matched_freight_fee === null ? null : (float) $row->matched_freight_fee,
'matched_bill_fee' => $row->matched_bill_fee === null ? null : (float) $row->matched_bill_fee,
'synced_at' => $row->synced_at,
'order_source_payload' => $this->decodeJson($row->source_payload),
'anomaly_types' => $anomalyTypes,
'anomaly_type' => implode('、', $anomalyTypes),
];
})
->all();
$this->hydrateDetails($rows);
return [
'rows' => $rows,
'total' => $total,
'has_more' => $page * min(max($limit, 1), 100) < $total,
];
}
/**
* @param array<string,mixed> $filters
* @return list<array{order_key:string,shop_name:string,online_order_no:?string,internal_order_no:?string,order_no:string}>
*/
public function references(array $filters, bool $missingDetails = false, int $limit = 0): array
{
$query = $this->filtered($this->connection()->table(self::TABLE), $filters);
$query->select([
self::TABLE . '.shop_name',
self::TABLE . '.online_order_no',
self::TABLE . '.internal_order_no',
self::TABLE . '.order_key',
]);
if ($missingDetails && $this->detailReady()) {
$query->leftJoin(
self::DETAIL_TABLE,
self::DETAIL_TABLE . '.order_key',
'=',
self::TABLE . '.order_key',
)->whereNull(self::DETAIL_TABLE . '.order_key');
}
$query->orderByDesc(self::TABLE . '.ship_time')
->orderByDesc(self::TABLE . '.order_key');
if ($limit > 0) {
$query->limit($limit);
}
return $query->get()->map(static function (object $row): array {
$online = trim((string) ($row->online_order_no ?? ''));
$internal = trim((string) ($row->internal_order_no ?? ''));
return [
'order_key' => trim((string) ($row->order_key ?? '')),
'shop_name' => trim((string) ($row->shop_name ?? '')),
'online_order_no' => $online === '' ? null : $online,
'internal_order_no' => $internal === '' ? null : $internal,
'order_no' => $online !== '' ? $online : $internal,
];
})->filter(static fn (array $row): bool => $row['order_no'] !== '')->values()->all();
}
private function filtered(\Illuminate\Database\Query\Builder $query, array $filters): \Illuminate\Database\Query\Builder
{
$query->where(function ($anomaly) {
$anomaly->where(function ($cost) {
$cost->whereNull('product_cost')->orWhere('product_cost', 0);
})->orWhere(function ($freight) {
$freight->whereNull('matched_freight_fee')->orWhere('matched_freight_fee', 0);
});
});
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
if ($from !== '') {
$query->where('ship_time', '>=', $from . ' 00:00:00');
}
if ($to !== '') {
$query->where('ship_time', '<=', $to . ' 23:59:59');
}
$shops = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
(array) ($filters['shop_names'] ?? []),
))));
if ($shops !== []) {
$query->whereIn('shop_name', array_slice($shops, 0, 50));
}
$orders = $this->batchValues((string) ($filters['order_no'] ?? ''));
if ($orders !== []) {
$query->where(function ($order) use ($orders): void {
$order->whereIn('online_order_no', $orders)
->orWhereIn('internal_order_no', $orders);
});
}
return $query;
}
/** @return list<string> */
private static function anomalyTypes(object $row): array
{
$types = [];
if (self::isZeroOrMissing($row->product_cost)) {
$types[] = '成本异常';
}
if (self::isZeroOrMissing($row->matched_freight_fee)) {
$types[] = '运费异常';
}
return $types;
}
private static function isZeroOrMissing(mixed $value): bool
{
return $value === null || (float) $value === 0.0;
}
/** @param list<array<string,mixed>> $rows */
private function hydrateDetails(array &$rows): void
{
if ($rows === []) {
return;
}
$snapshots = collect();
if ($this->detailReady()) {
$keys = array_values(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['order_key'] ?? '')),
$rows,
)));
if ($keys !== []) {
$snapshots = $this->connection()->table(self::DETAIL_TABLE)
->whereIn('order_key', array_slice(array_unique($keys), 0, 100))
->get()
->keyBy('order_key');
}
}
foreach ($rows as &$row) {
$row['order_fields'] = ShengsuanOrderProfitDisplayMapper::fields(
(array) ($row['order_source_payload'] ?? []),
);
$snapshot = $snapshots->get($row['order_key']);
$row['detail_synced_at'] = $snapshot->synced_at ?? null;
$row['detail_item_count'] = (int) ($snapshot->item_count ?? 0);
$row['detail_after_item_count'] = (int) ($snapshot->after_item_count ?? 0);
$row['detail_missing_cost_item_count'] = (int) ($snapshot->missing_cost_item_count ?? 0);
$row['missing_cost_barcodes'] = $this->decodeJson($snapshot->missing_cost_barcodes ?? null);
$row['detail_order_headers'] = $this->decodeJson($snapshot->order_headers ?? null);
$row['detail_order_items'] = $this->decodeJson($snapshot->order_items ?? null);
$row['detail_after_headers'] = $this->decodeJson($snapshot->after_headers ?? null);
$row['detail_after_items'] = $this->decodeJson($snapshot->after_items ?? null);
$types = array_values(array_filter((array) ($row['anomaly_types'] ?? [])));
if (($row['missing_cost_barcodes'] ?? []) !== [] && ! in_array('成本异常', $types, true)) {
array_unshift($types, '成本异常');
}
$row['anomaly_types'] = $types;
$row['anomaly_type'] = implode('、', $types);
}
}
private function detailReady(): bool
{
try {
return Schema::connection($this->connectionName())->hasTable(self::DETAIL_TABLE);
} catch (\Throwable) {
return false;
}
}
/** @return array<mixed> */
private function decodeJson(mixed $value): array
{
if (! is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
/** @return list<string> */
private function batchValues(string $value): array
{
$parts = preg_split('/[\s,,;;]+/u', trim($value)) ?: [];
$values = [];
foreach ($parts as $part) {
$part = trim($part);
if ($part !== '') {
$values[$part] = true;
}
if (count($values) >= 100) {
break;
}
}
return array_keys($values);
}
private function connection(): ConnectionInterface
{
return DB::connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
}