CaiWuXiTong/modules/EcommerceFinance/Repositories/GoodsProfitAfsRepository.php

485 lines
18 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\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\GoodsProfitAfsLookupSchema;
/**
* 订单售后费用明细:聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」原表。
*
* 原表 rpt.jst_goods_profit_kpi_items_afs 的列名是 undefined1..undefinedN,
* 中文表头放在 rpt.jst_goods_profit_kpi_items_afs_schemas(column → name)。
* 两张表都镜像到共享业务库后,页面按 schemas 映射读取字段。
*/
class GoodsProfitAfsRepository
{
public const MAIN_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs';
public const SCHEMA_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs_schemas';
private const MAIN_TABLE = 'ops_jst_goods_profit_kpi_items_afs';
private const SCHEMA_TABLE = 'ops_jst_goods_profit_kpi_items_afs_schemas';
/** @var array<string,string>|null */
private ?array $fieldColumns = null;
private ?bool $hasDateLookup = null;
public function ready(): bool
{
try {
return Schema::connection($this->connectionName())->hasTable(self::MAIN_TABLE)
&& Schema::connection($this->connectionName())->hasTable(self::SCHEMA_TABLE)
&& $this->initialSyncCompleted()
&& $this->connection()->table(self::MAIN_TABLE)->exists();
} catch (\Throwable) {
return false;
}
}
/** 首次全量同步完成前不对外使用,避免页面读到不完整的数据。 */
private function initialSyncCompleted(): bool
{
$state = $this->connection()->table('business_mirror_table_states')
->where('dataset_key', self::MAIN_DATASET)
->first(['initial_completed_at']);
return $state !== null && $state->initial_completed_at !== null;
}
/**
* 页面字段键 => 上游列名(undefinedN)。
*
* @return array<string,string>
*/
public function fieldColumns(): array
{
if ($this->fieldColumns !== null) {
return $this->fieldColumns;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$map = [];
$existing = array_map(
'strtolower',
Schema::connection($this->connectionName())->getColumnListing(self::MAIN_TABLE),
);
foreach ($this->connection()->table(self::SCHEMA_TABLE)->orderBy('id')->get(['column', 'name']) as $schema) {
$label = self::normalizeLabel((string) $schema->name);
$field = $headers[$label] ?? null;
$column = (string) $schema->column;
// 同一科目出现多次(如 800105佣金 / 800105平台佣金)时保留第一次映射。
// schemas 里有 170 条,但原表只有 160 列,提前过滤掉不存在的列。
if ($field !== null && ! isset($map[$field]) && in_array(strtolower($column), $existing, true)) {
$map[$field] = $column;
}
}
return $this->fieldColumns = $map;
}
/**
* 按「店铺 + 店铺商品编码 + 店铺款式编码」批量读取售后镜像中的内部商品编码。
*
* 仅作为商品成本历史拆分映射失败后的补充证据,不直接修改售后页面数据。
*
* @param list<array{shop_name:string,shop_sku_id:string,shop_i_id:string}> $candidates
* @return list<array{shop_name:string,shop_sku_id:string,shop_i_id:string,product_code:string}>
*/
public function platformSkuMappings(array $candidates): array
{
$keys = [];
foreach ($candidates as $candidate) {
$shopName = trim((string) ($candidate['shop_name'] ?? ''));
$shopSkuId = trim((string) ($candidate['shop_sku_id'] ?? ''));
$shopItemId = trim((string) ($candidate['shop_i_id'] ?? ''));
if ($shopName === '' || $shopSkuId === '' || $shopItemId === '') {
continue;
}
$keys[$shopName . "\0" . $shopSkuId . "\0" . $shopItemId] = [
'shop_name' => $shopName,
'shop_sku_id' => $shopSkuId,
'shop_i_id' => $shopItemId,
];
}
if ($keys === []) {
return [];
}
$columns = $this->fieldColumns();
foreach (['shop_name', 'shop_product_code', 'shop_style_code', 'product_code'] as $field) {
if (! isset($columns[$field])
|| preg_match('/^[A-Za-z0-9_]+$/', (string) $columns[$field]) !== 1) {
return [];
}
}
$connection = $this->connection();
$table = '`' . self::MAIN_TABLE . '`';
$shopName = '`' . $columns['shop_name'] . '`';
$shopProductCode = '`' . $columns['shop_product_code'] . '`';
$shopStyleCode = '`' . $columns['shop_style_code'] . '`';
$productCode = '`' . $columns['product_code'] . '`';
$connection->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_afs_sku_keys');
$connection->statement(
'CREATE TEMPORARY TABLE tmp_cost_afs_sku_keys ('
. 'shop_name varchar(191) NOT NULL, '
. 'shop_sku_id varchar(128) NOT NULL, '
. 'shop_i_id varchar(128) NOT NULL, '
. 'PRIMARY KEY (shop_name, shop_sku_id, shop_i_id)'
. ') ENGINE=InnoDB',
);
$rows = [];
try {
foreach (array_chunk(array_values($keys), 500) as $chunk) {
$connection->table('tmp_cost_afs_sku_keys')->insertOrIgnore($chunk);
}
$records = $connection->select(
"SELECT DISTINCT
m.{$shopName} AS shop_name,
m.{$shopProductCode} AS shop_sku_id,
m.{$shopStyleCode} AS shop_i_id,
m.{$productCode} AS product_code
FROM {$table} m
STRAIGHT_JOIN tmp_cost_afs_sku_keys k
ON k.shop_name = m.{$shopName}
AND k.shop_sku_id = m.{$shopProductCode}
AND k.shop_i_id = m.{$shopStyleCode}
WHERE COALESCE(TRIM(m.{$productCode}), '') <> ''",
);
foreach ($records as $record) {
$rows[] = [
'shop_name' => trim((string) $record->shop_name),
'shop_sku_id' => trim((string) $record->shop_sku_id),
'shop_i_id' => trim((string) $record->shop_i_id),
'product_code' => mb_strtoupper(trim((string) $record->product_code)),
];
}
} finally {
$connection->statement('DROP TEMPORARY TABLE IF EXISTS tmp_cost_afs_sku_keys');
}
return $rows;
}
/** @param array<string,mixed> $filters */
public function covers(array $filters): bool
{
if (! $this->ready()) {
return false;
}
$columns = $this->fieldColumns();
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
// 只按单号(不限日期)查询:原表命中该单就用原表,否则交给导入历史数据兜底。
if ($from === '' && $to === '' && $orderNo !== '') {
$dateColumn = $columns['order_time'] ?? null;
foreach ($this->orderNumberFields($orderNo) as $field) {
if (! isset($columns[$field])) {
continue;
}
$query = $this->connection()->table(self::MAIN_TABLE)
->where($columns[$field], 'like', $orderNo . '%');
// 只有带订单时间的行才是完整明细;只有费用行的历史订单交给导入数据。
if ($dateColumn !== null) {
$query->whereNotNull($dateColumn)->where($dateColumn, '<>', '');
}
if ($query->limit(1)->exists()) {
return true;
}
}
return false;
}
$start = (string) config('order_after_sales_cost.goods_profit_from', '2026-07-01');
if ($start !== '' && $from !== '' && $from < $start) {
return false;
}
// “是否覆盖”只看日期范围,不掺业务筛选:筛选条件由 paginate 应用,
// 筛不到就该显示空结果,而不是回退到旧订单镜像(否则筛选会被忽略)。
if ($from === '' && $to === '') {
return true;
}
if (! isset($columns['order_time'])) {
return false;
}
$expression = $this->dateExpression($columns['order_time']);
$range = $this->connection()->table(self::MAIN_TABLE)
->selectRaw("MAX({$expression}) as max_time")
->first();
if (! $range || $range->max_time === null) {
return false;
}
// 起始日受 goods_profit_from 约束,结束日按原表实际最大日期判断(今天往往晚于最后一次同步)。
$anchor = $from !== '' ? $from : $to;
return $anchor === '' || substr((string) $range->max_time, 0, 10) >= $anchor;
}
/**
* @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);
$columns = $this->fieldColumns();
$select = array_values(array_unique(array_merge(['id'], array_values($columns))));
$orderColumn = $columns['order_time'] ?? null;
$orderNo = trim((string) ($filters['order_no'] ?? ''));
if ($orderNo !== '') {
return $this->paginateByOrderNumber($orderNo, $filters, $columns, $select, $page, $limit);
}
$query = $this->filtered($filters, $columns)->select($select);
if ($orderColumn !== null) {
$query->orderByRaw($this->dateExpression($orderColumn) . ' desc');
}
$records = $query->orderByDesc('id')->offset(($page - 1) * $limit)->limit($limit + 1)->get();
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/**
* 单号查询:分别走 online/internal 两个索引,合并后在 PHP 排序分页。
*
* @param array<string,mixed> $filters
* @param array<string,string> $columns
* @param list<string> $select
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
private function paginateByOrderNumber(
string $orderNo,
array $filters,
array $columns,
array $select,
int $page,
int $limit,
): array {
$records = [];
$cap = max($limit * 4, 400);
foreach ($this->orderNumberFields($orderNo) as $field) {
if (! isset($columns[$field])) {
continue;
}
$query = $this->connection()->table(self::MAIN_TABLE)
->select($select)
->where($columns[$field], 'like', $orderNo . '%');
$this->applyDateRange($query, $filters, $columns);
foreach ($query->limit($cap)->get() as $record) {
$records[(int) $record->id] = $record;
}
}
$timeColumn = $columns['order_time'] ?? null;
usort($records, static function ($left, $right) use ($timeColumn): int {
$leftTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($left->{$timeColumn} ?? ''))) : false;
$rightTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($right->{$timeColumn} ?? ''))) : false;
return [$rightTime ?: 0, (int) $right->id] <=> [$leftTime ?: 0, (int) $left->id];
});
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
$offset = ($page - 1) * $limit;
return [
'rows' => array_slice($rows, $offset, $limit),
'has_more' => count($rows) > $offset + $limit,
];
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function filtered(array $filters, array $columns): \Illuminate\Database\Query\Builder
{
$query = $this->connection()->table(self::MAIN_TABLE);
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$sku = trim((string) ($filters['sku_id'] ?? ''));
$shops = array_values(array_filter((array) ($filters['shop_names'] ?? []), static fn ($v) => trim((string) $v) !== ''));
// 原表的订单时间是字符串(如 2026/8/23 14:58:51,不补零),不能直接和 ISO 日期做字符串比较。
if (($from !== '' || $to !== '') && isset($columns['order_time'])) {
$expression = $this->dateExpression($columns['order_time']);
if ($from !== '') {
$query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']);
}
if ($to !== '') {
$query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']);
}
}
if ($shops !== [] && isset($columns['shop_name'])) {
$query->whereIn($columns['shop_name'], $shops);
}
if ($orderNo !== '') {
$query->where(function ($inner) use ($columns, $orderNo): void {
$matched = false;
foreach (['online_order_no', 'internal_order_no'] as $field) {
if (! isset($columns[$field])) {
continue;
}
$inner->orWhere($columns[$field], 'like', $orderNo . '%');
$matched = true;
}
if (! $matched) {
$inner->whereRaw('1 = 0');
}
});
}
if ($sku !== '' && isset($columns['product_code'])) {
$query->where($columns['product_code'], 'like', '%' . addcslashes($sku, '%_\\') . '%');
}
return $query;
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function applyDateRange(\Illuminate\Database\Query\Builder $query, array $filters, array $columns): void
{
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
if (($from === '' && $to === '') || ! isset($columns['order_time'])) {
return;
}
$expression = $this->dateExpression($columns['order_time']);
if ($from !== '') {
$query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']);
}
if ($to !== '') {
$query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']);
}
}
private function dateExpression(string $column): string
{
// Dynamic schemas can move the source field. Keep the original parser
// until the matching additive lookup has been installed.
if ($column === 'undefined7') {
$this->hasDateLookup ??= Schema::connection($this->connectionName())
->hasColumn(self::MAIN_TABLE, GoodsProfitAfsLookupSchema::DATE_COLUMN);
if ($this->hasDateLookup) {
return '`' . GoodsProfitAfsLookupSchema::DATE_COLUMN . '`';
}
}
return "STR_TO_DATE(REPLACE(`{$column}`, '/', '-'), '%Y-%m-%d %H:%i:%s')";
}
private function connection(): \Illuminate\Database\ConnectionInterface
{
return DB::connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
private static function normalizeLabel(string $label): string
{
$label = str_replace(["\u{3000}", "\u{00A0}"], ' ', $label);
$label = preg_replace('/\s+/u', ' ', $label) ?? $label;
return trim($label);
}
/**
* 线上订单号和内部订单号长度差很多(19 位 vs 7~8 位)。长单号只查线上订单号,
* 避免用内部订单号列做 `like '长单号%'` 的全表扫描。
*
* @return list<string>
*/
private function orderNumberFields(string $orderNo): array
{
return mb_strlen($orderNo) >= 12
? ['online_order_no']
: ['online_order_no', 'internal_order_no'];
}
}