315 lines
12 KiB
PHP
315 lines
12 KiB
PHP
<?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');
|
||
}
|
||
}
|