552 lines
19 KiB
PHP
552 lines
19 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Illuminate\Database\Query\Builder;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
|
||
/**
|
||
* 订单售后费用明细(聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入)。
|
||
*
|
||
* 聚水潭报表已经按商品编码(组合装拆成组件)给出行和成本,页面直接读这张导入表,
|
||
* 不再从订单明细里自己拆组合装。
|
||
*/
|
||
class AfterSalesCostRowRepository
|
||
{
|
||
private const TABLE = 'ops_after_sales_cost_rows';
|
||
private const INTERNAL_ORDER_MAX_PREFIX_LENGTH = 11;
|
||
|
||
private ?bool $available = null;
|
||
|
||
public function available(): bool
|
||
{
|
||
if ($this->available === null) {
|
||
try {
|
||
$this->available = Schema::hasTable(self::TABLE)
|
||
&& DB::table(self::TABLE)->exists();
|
||
} catch (\Throwable) {
|
||
$this->available = false;
|
||
}
|
||
}
|
||
|
||
return $this->available;
|
||
}
|
||
|
||
/**
|
||
* 导入数据是否覆盖本次筛选(有至少一行命中即视为覆盖)。
|
||
*
|
||
* @param array<string,mixed> $filters
|
||
*/
|
||
public function covers(array $filters): bool
|
||
{
|
||
if (! $this->available()) {
|
||
return false;
|
||
}
|
||
|
||
// 指定单号时,导入表里命中该单就完整(同单的所有明细行都在导入数据里)。
|
||
if (trim((string) ($filters['order_no'] ?? '')) !== '') {
|
||
return $this->orderNumberExists($filters);
|
||
}
|
||
|
||
$from = trim((string) ($filters['from'] ?? ''));
|
||
$to = trim((string) ($filters['to'] ?? ''));
|
||
|
||
// 不限日期时无法保证覆盖范围,回退镜像,避免把导入数据当成全量。
|
||
if ($from === '' || $to === '') {
|
||
return false;
|
||
}
|
||
|
||
$period = self::fullMonthPeriod($from, $to);
|
||
if ($period !== null && $this->periodHasAllSelectedShops($period, $filters)) {
|
||
return true;
|
||
}
|
||
|
||
// 兼容旧数据:此前导入没有 source_period,只能按订单时间范围判断覆盖。
|
||
$range = $this->legacyFilteredRange($filters)
|
||
->selectRaw('MIN(order_time) as min_time, MAX(order_time) as max_time')
|
||
->first();
|
||
|
||
if (! $range || $range->min_time === null) {
|
||
return false;
|
||
}
|
||
|
||
// 只有导入范围完整覆盖所选日期时才使用导入数据,避免跨月时数据不全。
|
||
return substr((string) $range->min_time, 0, 10) <= $from
|
||
&& substr((string) $range->max_time, 0, 10) >= $to;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $filters
|
||
* @return array{rows:list<array<string,mixed>>,has_more:bool}
|
||
*/
|
||
public function paginate(array $filters, int $page, int $limit): array
|
||
{
|
||
$page = max($page, 1);
|
||
$limit = min(max($limit, 1), 100);
|
||
|
||
if (trim((string) ($filters['order_no'] ?? '')) !== '') {
|
||
return $this->paginateByOrderNumbers($filters, $page, $limit);
|
||
}
|
||
|
||
$records = $this->filtered($filters)
|
||
->orderByDesc('order_time')
|
||
->orderByDesc('id')
|
||
->forPage($page, $limit + 1)
|
||
->get();
|
||
|
||
$rows = [];
|
||
|
||
foreach ($records as $record) {
|
||
$payload = json_decode((string) $record->payload, true);
|
||
$rows[] = is_array($payload) ? $payload : [];
|
||
}
|
||
|
||
return [
|
||
'rows' => array_slice($rows, 0, $limit),
|
||
'has_more' => count($rows) > $limit,
|
||
];
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function shopNames(): array
|
||
{
|
||
if (! $this->available()) {
|
||
return [];
|
||
}
|
||
|
||
return Cache::remember(
|
||
'order-data.after-sales.imported-shop-options',
|
||
now()->addHours(24),
|
||
static fn (): array => DB::table(self::TABLE)
|
||
->whereNotNull('source_period')
|
||
->whereNotNull('shop_name')
|
||
->where('shop_name', '<>', '')
|
||
->distinct()
|
||
->orderBy('shop_name')
|
||
->pluck('shop_name')
|
||
->map(static fn (mixed $name): string => trim((string) $name))
|
||
->filter()
|
||
->values()
|
||
->all(),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 商品成本补拆:按线上订单号批量读取导入表中已经拆好的组件行。
|
||
*
|
||
* 只返回拆分所需的商品、平台规格、数量和金额;单价仍由商品成本的
|
||
* 月加权平均价 / 线上代发价 / 普通商品资料成本价链路计算。
|
||
*
|
||
* @param list<string> $onlineOrderNos
|
||
* @return list<array{
|
||
* shop_name:string,
|
||
* online_order_no:string,
|
||
* internal_order_no:string,
|
||
* product_code:string,
|
||
* shop_product_code:string,
|
||
* shop_style_code:string,
|
||
* product_quantity:float,
|
||
* product_amount:float
|
||
* }>
|
||
*/
|
||
public function componentRowsForOrders(array $onlineOrderNos): array
|
||
{
|
||
$onlineOrderNos = array_values(array_unique(array_filter(array_map(
|
||
static fn (mixed $value): string => trim((string) $value),
|
||
$onlineOrderNos,
|
||
), static fn (string $value): bool => $value !== '')));
|
||
sort($onlineOrderNos, SORT_STRING);
|
||
|
||
if ($onlineOrderNos === [] || ! $this->available()) {
|
||
return [];
|
||
}
|
||
|
||
$rows = [];
|
||
foreach (array_chunk($onlineOrderNos, 500) as $chunk) {
|
||
foreach (DB::table(self::TABLE)
|
||
->whereIn('online_order_no', $chunk)
|
||
->orderBy('id')
|
||
->get([
|
||
'shop_name',
|
||
'online_order_no',
|
||
'internal_order_no',
|
||
'product_code',
|
||
'product_quantity',
|
||
'product_amount',
|
||
'payload',
|
||
]) as $record) {
|
||
$payload = json_decode((string) ($record->payload ?? ''), true);
|
||
$payload = is_array($payload) ? $payload : [];
|
||
$productCode = mb_strtoupper(trim((string) (
|
||
$record->product_code ?? ($payload['product_code'] ?? '')
|
||
)));
|
||
if ($productCode === '') {
|
||
continue;
|
||
}
|
||
$rows[] = [
|
||
'shop_name' => trim((string) ($record->shop_name ?? '')),
|
||
'online_order_no' => trim((string) ($record->online_order_no ?? '')),
|
||
'internal_order_no' => trim((string) ($record->internal_order_no ?? '')),
|
||
'product_code' => $productCode,
|
||
'shop_product_code' => trim((string) ($payload['shop_product_code'] ?? '')),
|
||
'shop_style_code' => trim((string) ($payload['shop_style_code'] ?? '')),
|
||
'product_quantity' => (float) ($record->product_quantity ?? 0),
|
||
'product_amount' => (float) ($record->product_amount ?? 0),
|
||
];
|
||
}
|
||
}
|
||
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* 导入一行(命令内部使用,批量写入)。
|
||
*
|
||
* @param array<string,mixed> $payload
|
||
*/
|
||
public static function toRecord(
|
||
array $payload,
|
||
?string $importedAt = null,
|
||
?string $sourcePeriod = null,
|
||
): array
|
||
{
|
||
// 以整行内容做键:同一单同一商品可能出现多行,只有完全相同的一行才合并。
|
||
ksort($payload);
|
||
|
||
$rowKey = sha1((string) json_encode(
|
||
['source_period' => $sourcePeriod, 'payload' => $payload],
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
|
||
));
|
||
|
||
$now = $importedAt ?? now()->toDateTimeString();
|
||
|
||
return [
|
||
'row_key' => $rowKey,
|
||
'shop_name' => mb_substr((string) ($payload['shop_name'] ?? ''), 0, 191),
|
||
'shop_code' => mb_substr((string) ($payload['shop_code'] ?? ''), 0, 64),
|
||
'online_order_no' => mb_substr((string) ($payload['online_order_no'] ?? ''), 0, 64),
|
||
'internal_order_no' => mb_substr((string) ($payload['internal_order_no'] ?? ''), 0, 64),
|
||
'product_code' => mb_substr((string) ($payload['product_code'] ?? ''), 0, 191),
|
||
'style_code' => mb_substr((string) ($payload['style_code'] ?? ''), 0, 191),
|
||
'shop_product_code' => mb_substr((string) ($payload['shop_product_code'] ?? ''), 0, 191),
|
||
'shop_style_code' => mb_substr((string) ($payload['shop_style_code'] ?? ''), 0, 191),
|
||
'order_time' => self::dateTime($payload['order_time'] ?? null),
|
||
'source_period' => $sourcePeriod,
|
||
'product_quantity' => (float) ($payload['product_quantity'] ?? 0),
|
||
'product_amount' => (float) ($payload['product_amount'] ?? 0),
|
||
'product_cost' => (float) ($payload['product_cost'] ?? 0),
|
||
'cost_source' => mb_substr((string) ($payload['cost_source'] ?? ''), 0, 32),
|
||
'combine_type' => mb_substr((string) ($payload['combine_type'] ?? ''), 0, 32),
|
||
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||
'imported_at' => $now,
|
||
'created_at' => $now,
|
||
'updated_at' => $now,
|
||
];
|
||
}
|
||
|
||
/** @param array<string,mixed> $filters */
|
||
private function filtered(array $filters): Builder
|
||
{
|
||
$query = DB::table(self::TABLE);
|
||
$from = trim((string) ($filters['from'] ?? ''));
|
||
$to = trim((string) ($filters['to'] ?? ''));
|
||
$sku = trim((string) ($filters['sku_id'] ?? ''));
|
||
$period = self::fullMonthPeriod($from, $to);
|
||
|
||
if ($period !== null) {
|
||
$query->where(function (Builder $range) use ($period, $from, $to): void {
|
||
$range->where('source_period', $period)
|
||
->orWhere(function (Builder $legacy) use ($from, $to): void {
|
||
$legacy->whereNull('source_period')
|
||
->where('order_time', '>=', $from . ' 00:00:00')
|
||
->where('order_time', '<=', $to . ' 23:59:59');
|
||
});
|
||
});
|
||
} elseif ($from !== '' && $to !== '') {
|
||
$query->where(function (Builder $range) use ($from, $to): void {
|
||
$range->whereBetween('source_period', [substr($from, 0, 7), substr($to, 0, 7)])
|
||
->orWhere(function (Builder $legacy) use ($from, $to): void {
|
||
$legacy->whereNull('source_period')
|
||
->where('order_time', '>=', $from . ' 00:00:00')
|
||
->where('order_time', '<=', $to . ' 23:59:59');
|
||
});
|
||
});
|
||
} elseif ($from !== '') {
|
||
$query->where('order_time', '>=', $from . ' 00:00:00');
|
||
} elseif ($to !== '') {
|
||
$query->where('order_time', '<=', $to . ' 23:59:59');
|
||
}
|
||
|
||
$this->applyShopFilters($query, $filters);
|
||
|
||
$skuValues = self::batchValues($sku);
|
||
if ($skuValues !== []) {
|
||
$query->where(function (Builder $codes) use ($skuValues): void {
|
||
foreach ($skuValues as $value) {
|
||
$contains = self::contains($value);
|
||
$codes->orWhere(function (Builder $group) use ($contains): void {
|
||
$group->whereRaw("product_code LIKE ? ESCAPE '!'", [$contains])
|
||
->orWhereRaw("style_code LIKE ? ESCAPE '!'", [$contains])
|
||
->orWhereRaw("shop_product_code LIKE ? ESCAPE '!'", [$contains])
|
||
->orWhereRaw("shop_style_code LIKE ? ESCAPE '!'", [$contains]);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
return $query;
|
||
}
|
||
|
||
/**
|
||
* 单号查询拆成线上/内部两个独立索引查询,避免 OR 让数据库扫描整个月份。
|
||
*
|
||
* @param array<string,mixed> $filters
|
||
* @return array{rows:list<array<string,mixed>>,has_more:bool}
|
||
*/
|
||
private function paginateByOrderNumbers(array $filters, int $page, int $limit): array
|
||
{
|
||
$values = self::batchValues((string) ($filters['order_no'] ?? ''));
|
||
if ($values === []) {
|
||
return ['rows' => [], 'has_more' => false];
|
||
}
|
||
|
||
$baseFilters = array_replace($filters, ['order_no' => '']);
|
||
$cap = max($limit * 20, 1000);
|
||
$ids = [];
|
||
|
||
foreach (['online_order_no', 'internal_order_no'] as $field) {
|
||
$fieldValues = self::orderValuesForField($field, $values);
|
||
if ($fieldValues === []) {
|
||
continue;
|
||
}
|
||
|
||
$query = $this->orderNumberBranch($baseFilters, $field, $fieldValues)
|
||
->select('id')
|
||
->limit($cap);
|
||
|
||
foreach ($query->pluck('id') as $id) {
|
||
$ids[(int) $id] = true;
|
||
}
|
||
}
|
||
|
||
if ($ids === []) {
|
||
return ['rows' => [], 'has_more' => false];
|
||
}
|
||
|
||
$records = DB::table(self::TABLE)
|
||
->whereIn('id', array_keys($ids))
|
||
->orderByDesc('order_time')
|
||
->orderByDesc('id')
|
||
->forPage($page, $limit + 1)
|
||
->get();
|
||
$rows = [];
|
||
|
||
foreach ($records as $record) {
|
||
$payload = json_decode((string) $record->payload, true);
|
||
$rows[] = is_array($payload) ? $payload : [];
|
||
}
|
||
|
||
return [
|
||
'rows' => array_slice($rows, 0, $limit),
|
||
'has_more' => count($rows) > $limit,
|
||
];
|
||
}
|
||
|
||
/** @param array<string,mixed> $filters */
|
||
private function orderNumberExists(array $filters): bool
|
||
{
|
||
$values = self::batchValues((string) ($filters['order_no'] ?? ''));
|
||
if ($values === []) {
|
||
return false;
|
||
}
|
||
|
||
$baseFilters = array_replace($filters, ['order_no' => '']);
|
||
foreach (['online_order_no', 'internal_order_no'] as $field) {
|
||
$fieldValues = self::orderValuesForField($field, $values);
|
||
if ($fieldValues !== []
|
||
&& $this->orderNumberBranch($baseFilters, $field, $fieldValues)->limit(1)->exists()) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $filters
|
||
* @param list<string> $values
|
||
*/
|
||
private function orderNumberBranch(array $filters, string $field, array $values): Builder
|
||
{
|
||
$query = $this->filtered($filters);
|
||
|
||
if (count($values) === 1) {
|
||
return $query->where($field, 'like', self::likePrefix($values[0]));
|
||
}
|
||
|
||
return $query->whereIn($field, $values);
|
||
}
|
||
|
||
/** @param array<string,mixed> $filters */
|
||
private function legacyFilteredRange(array $filters): Builder
|
||
{
|
||
$from = trim((string) ($filters['from'] ?? ''));
|
||
$to = trim((string) ($filters['to'] ?? ''));
|
||
$query = DB::table(self::TABLE)->whereNull('source_period');
|
||
|
||
if ($from !== '') {
|
||
$query->where('order_time', '>=', $from . ' 00:00:00');
|
||
}
|
||
if ($to !== '') {
|
||
$query->where('order_time', '<=', $to . ' 23:59:59');
|
||
}
|
||
|
||
$this->applyShopFilters($query, $filters);
|
||
|
||
return $query;
|
||
}
|
||
|
||
/** @param array<string,mixed> $filters */
|
||
private function periodHasAllSelectedShops(string $period, array $filters): bool
|
||
{
|
||
$query = DB::table(self::TABLE)->where('source_period', $period);
|
||
$this->applyShopFilters($query, $filters);
|
||
|
||
$shops = self::selectedShopNames($filters);
|
||
if ($shops === []) {
|
||
return $query->exists();
|
||
}
|
||
|
||
$covered = $query->distinct()->pluck('shop_name')
|
||
->map(static fn (mixed $name): string => trim((string) $name))
|
||
->all();
|
||
$exact = ! empty($filters['shop_names_exact']);
|
||
|
||
foreach ($shops as $shop) {
|
||
$found = false;
|
||
foreach ($covered as $candidate) {
|
||
if ($exact ? $candidate === $shop : mb_stripos($candidate, $shop) !== false) {
|
||
$found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (! $found) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/** @param array<string,mixed> $filters */
|
||
private function applyShopFilters(Builder $query, array $filters): void
|
||
{
|
||
$shops = self::selectedShopNames($filters);
|
||
if ($shops === []) {
|
||
return;
|
||
}
|
||
|
||
if (! empty($filters['shop_names_exact'])) {
|
||
$query->whereIn('shop_name', $shops);
|
||
|
||
return;
|
||
}
|
||
|
||
$query->where(function (Builder $shop) use ($shops): void {
|
||
foreach ($shops as $name) {
|
||
$shop->orWhereRaw("shop_name LIKE ? ESCAPE '!'", [self::contains($name)]);
|
||
}
|
||
});
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private static function selectedShopNames(array $filters): array
|
||
{
|
||
$values = (array) ($filters['shop_names'] ?? []);
|
||
if ($values === [] && trim((string) ($filters['shop_name'] ?? '')) !== '') {
|
||
$values = [$filters['shop_name']];
|
||
}
|
||
|
||
$names = [];
|
||
foreach ($values as $value) {
|
||
$name = trim((string) $value);
|
||
if ($name !== '') {
|
||
$names[$name] = true;
|
||
}
|
||
if (count($names) >= 50) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return array_keys($names);
|
||
}
|
||
|
||
private static function fullMonthPeriod(string $from, string $to): ?string
|
||
{
|
||
if (preg_match('/^(\d{4}-\d{2})-01$/', $from, $matches) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
$period = $matches[1];
|
||
|
||
return $to === date('Y-m-t', strtotime($period . '-01')) ? $period : null;
|
||
}
|
||
|
||
private static function contains(string $value): string
|
||
{
|
||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||
}
|
||
|
||
private static function likePrefix(string $value): string
|
||
{
|
||
return str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||
}
|
||
|
||
/** @param list<string> $values @return list<string> */
|
||
private static function orderValuesForField(string $field, array $values): array
|
||
{
|
||
if ($field !== 'internal_order_no') {
|
||
return $values;
|
||
}
|
||
|
||
return array_values(array_filter(
|
||
$values,
|
||
static fn (string $value): bool => mb_strlen($value) <= self::INTERNAL_ORDER_MAX_PREFIX_LENGTH,
|
||
));
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private static 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 static function dateTime(mixed $value): ?string
|
||
{
|
||
$value = trim((string) $value);
|
||
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
|
||
$timestamp = strtotime($value);
|
||
|
||
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
|
||
}
|
||
}
|