358 lines
17 KiB
PHP
358 lines
17 KiB
PHP
<?php
|
||
|
||
namespace Modules\EcommerceFinance\Repositories;
|
||
|
||
use Illuminate\Database\ConnectionInterface;
|
||
use Illuminate\Database\Query\Builder;
|
||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||
|
||
class OrderDataMirrorRepository
|
||
{
|
||
private const DATASETS = [
|
||
'operations_replica.rpt.jst_Orders',
|
||
'operations_replica.rpt.jst_OrderItem',
|
||
'operations_replica.rpt.jst_sku',
|
||
'operations_replica.rpt.jst_warehouseInfo',
|
||
'operations_replica.rpt.jst_returnAndRefundInfo',
|
||
'operations_replica.rpt.jst_returnAndRefundItem',
|
||
];
|
||
|
||
private const PROMOTION_DATASETS = [
|
||
'operations_replica.rpt.ads_itemid_sales_cost_ratio_v2_di',
|
||
];
|
||
|
||
private readonly ConnectionInterface $connection;
|
||
|
||
public function __construct(
|
||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||
private readonly GenericBusinessMirrorRepository $mirror,
|
||
) {
|
||
$this->connection = $mirror->connection();
|
||
}
|
||
|
||
public function ready(): bool
|
||
{
|
||
return $this->datasetsReady(self::DATASETS);
|
||
}
|
||
|
||
public function promotionReady(): bool
|
||
{
|
||
return $this->datasetsReady(self::PROMOTION_DATASETS);
|
||
}
|
||
|
||
private function datasetsReady(array $datasets): bool
|
||
{
|
||
try {
|
||
foreach ($datasets as $dataset) {
|
||
if (! $this->mirror->ready($this->datasets->get($dataset))) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
} catch (\Throwable) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** @return array{rows:array<int,array<string,mixed>>} */
|
||
public function afterSales(array $filters, int $page, int $limit): array
|
||
{
|
||
$limit = min(max($limit, 1), 100);
|
||
$query = $this->baseQuery();
|
||
$this->applyFilters($query, $filters);
|
||
$rows = $query
|
||
->select($this->columns())
|
||
// 跨表追加 i.oi_id 会让 MariaDB 对日期范围内全部订单建立临时表
|
||
// 再 filesort;o_id 已唯一确定订单,明细通过 (o_id, Id) 索引稳定读取。
|
||
->orderByDesc('o.order_date')->orderByDesc('o.o_id')
|
||
->offset((max($page, 1) - 1) * $limit)
|
||
->limit($limit + 1)
|
||
->get()->map(static fn (object $row): array => (array) $row)->all();
|
||
|
||
$this->hydrateSkus($rows);
|
||
$rows = array_map(fn (array $row): array => $this->baseRow($row), $rows);
|
||
$this->hydrateRefunds($rows);
|
||
|
||
return ['rows' => $rows];
|
||
}
|
||
|
||
/** @return array{rows:array<int,array<string,mixed>>,date:?string} */
|
||
public function promotionExpenses(array $filters, int $page, int $limit): array
|
||
{
|
||
$limit = min(max($limit, 1), 100);
|
||
$date = trim((string) ($filters['date'] ?? ''));
|
||
if ($date === '') {
|
||
$date = (string) ($this->connection->table('ops_item_sales_cost_ratio')->max('stat_date') ?? '');
|
||
$date = substr($date, 0, 10);
|
||
}
|
||
if ($date === '') {
|
||
return ['rows' => [], 'date' => null];
|
||
}
|
||
$query = $this->connection->table('ops_item_sales_cost_ratio as c')
|
||
->where('c.stat_date', '>=', $date . ' 00:00:00')
|
||
->where('c.stat_date', '<', date('Y-m-d 00:00:00', strtotime($date . ' +1 day')))
|
||
->where('c.push_fee', '<>', 0);
|
||
if (($filters['shop_name'] ?? '') !== '') {
|
||
$query->whereRaw("c.shop_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['shop_name'])]);
|
||
}
|
||
if (($filters['sku_id'] ?? '') !== '') {
|
||
$needle = $this->contains((string) $filters['sku_id']);
|
||
// jst_skumap 的业务编码是 LONGTEXT 且有两千多万行,不能在页面
|
||
// 请求中做模糊关联;推广账单自身的 item_id 是稳定可筛选字段。
|
||
$query->whereRaw("c.item_id LIKE ? ESCAPE '!'", [$needle]);
|
||
}
|
||
$rows = $query->select(['c.shop_name', 'c.item_id as shop_style_code', 'c.push_fee as promotion_fee'])
|
||
->orderByDesc('c.push_fee')->orderBy('c.shop_name')->orderBy('c.item_id')
|
||
->offset((max($page, 1) - 1) * $limit)->limit($limit + 1)
|
||
->get()->map(static fn (object $row): array => (array) $row)->all();
|
||
foreach ($rows as &$row) {
|
||
$row['product_code'] = null;
|
||
$row['style_code'] = null;
|
||
$row['shop_product_code'] = $row['shop_style_code'];
|
||
$row['online_color_spec'] = null;
|
||
$row['promotion_fee'] = round((float) $row['promotion_fee'], 2);
|
||
}
|
||
unset($row);
|
||
|
||
return ['rows' => $rows, 'date' => $date];
|
||
}
|
||
|
||
private function baseQuery(): Builder
|
||
{
|
||
return $this->connection->table('ops_jst_orders as o')
|
||
->join('ops_jst_order_item as i', 'i.o_id', '=', 'o.o_id')
|
||
->leftJoin('ops_jst_warehouse_info as wh', function ($join): void {
|
||
$join->on('wh.wms_co_id', '=', $this->connection->raw('COALESCE(i.send_warehouse, o.wms_co_id)'));
|
||
});
|
||
}
|
||
|
||
private function applyFilters(Builder $query, array $filters): void
|
||
{
|
||
if (($filters['from'] ?? '') !== '') {
|
||
$query->where('o.order_date', '>=', $filters['from'] . ' 00:00:00');
|
||
}
|
||
if (($filters['to'] ?? '') !== '') {
|
||
$query->where('o.order_date', '<', date('Y-m-d 00:00:00', strtotime($filters['to'] . ' +1 day')));
|
||
}
|
||
if (($filters['order_no'] ?? '') !== '') {
|
||
$needle = $this->contains((string) $filters['order_no']);
|
||
$query->where(function (Builder $order) use ($needle): void {
|
||
$order->whereRaw("CAST(o.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle])
|
||
->orWhereRaw("o.so_id LIKE ? ESCAPE '!'", [$needle])
|
||
->orWhereRaw("i.outer_oi_id LIKE ? ESCAPE '!'", [$needle]);
|
||
});
|
||
}
|
||
if (($filters['shop_name'] ?? '') !== '') {
|
||
$query->whereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains((string) $filters['shop_name'])]);
|
||
}
|
||
if (($filters['sku_id'] ?? '') !== '') {
|
||
$needle = $this->contains((string) $filters['sku_id']);
|
||
$query->where(function (Builder $sku) use ($needle): void {
|
||
$sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle])
|
||
->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle])
|
||
->orWhereRaw("i.shop_i_id LIKE ? ESCAPE '!'", [$needle]);
|
||
});
|
||
}
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function columns(): array
|
||
{
|
||
return [
|
||
'o.o_id as internal_order_no', 'o.so_id as online_order_no',
|
||
'i.outer_oi_id as sub_order_no', 'o.pay_date as pay_time',
|
||
'o.end_time as order_complete_time', 'o.send_date as send_time',
|
||
'o.order_date as order_time', 'o.shop_name', 'o.shop_id as shop_code',
|
||
'i.sku_id as product_code', 'i.i_id as style_code',
|
||
'i.shop_sku_id as shop_product_code', 'i.shop_i_id as shop_style_code',
|
||
'o.shop_site', 'i.properties_value as color_spec',
|
||
'i.name as online_product_name', 'i.qty as product_quantity',
|
||
'i.amount as product_amount', 'i.buyer_paid_amount as payment_amount',
|
||
'i.seller_income_amount', 'i.is_gift', 'i.src_combine_sku_id',
|
||
'o.type as order_type', 'o.labels as order_tags',
|
||
'o.free_amount as merchant_discount', 'o.freight as freight_income',
|
||
'i.send_warehouse', 'o.wms_co_id', 'o.l_id as express_no',
|
||
'o.logistics_company as express_company', 'o.receiver_state as province',
|
||
'o.receiver_city as city', 'o.creator_name as salesperson',
|
||
'wh.name as shipping_warehouse', 'wh.remark1 as operation_cloud_warehouse',
|
||
];
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows */
|
||
private function hydrateSkus(array &$rows): void
|
||
{
|
||
$skuIds = array_values(array_unique(array_filter(array_map(
|
||
static fn (array $row): string => trim((string) ($row['product_code'] ?? '')),
|
||
$rows,
|
||
))));
|
||
$skus = $skuIds === [] ? collect() : $this->connection->table('ops_jst_sku')
|
||
->where('sku_type', 'normal')->whereIn('sku_id', $skuIds)
|
||
->orderByDesc('autoid')
|
||
->get(['sku_id', 'brand', 'category', 'vc_name', 'cost_price', 'name', 'supplier_id', 'supplier_name'])
|
||
->unique('sku_id')->keyBy('sku_id');
|
||
|
||
foreach ($rows as &$row) {
|
||
$sku = $skus->get((string) ($row['product_code'] ?? ''));
|
||
$row['brand'] = $sku->brand ?? null;
|
||
$row['category'] = $sku->category ?? null;
|
||
$row['virtual_category'] = $sku->vc_name ?? null;
|
||
$row['unit_cost'] = $sku->cost_price ?? 0;
|
||
$row['sku_name'] = $sku->name ?? null;
|
||
$row['supplier_code'] = $sku->supplier_id ?? null;
|
||
$row['supplier_name'] = $sku->supplier_name ?? null;
|
||
unset($row['sku_id']);
|
||
}
|
||
unset($row);
|
||
}
|
||
|
||
private function baseRow(array $row): array
|
||
{
|
||
$quantity = (float) ($row['product_quantity'] ?? 0);
|
||
$amount = (float) ($row['product_amount'] ?? 0);
|
||
$cost = (float) ($row['unit_cost'] ?? 0);
|
||
$payment = (float) ($row['payment_amount'] ?? $amount);
|
||
$sellerIncome = (float) ($row['seller_income_amount'] ?? 0);
|
||
$row += [
|
||
'after_sales_no' => null, 'refund_type' => null, 'question_type' => null,
|
||
'detail_return_type' => null, 'after_sales_tags' => null,
|
||
];
|
||
$row['product_name'] = ($row['sku_name'] ?? '') !== '' ? $row['sku_name'] : ($row['color_spec'] ?? null);
|
||
$row['product_quantity'] = $quantity;
|
||
$row['product_amount'] = $amount;
|
||
$row['product_cost'] = $cost * $quantity;
|
||
$row['reversal_order_cost'] = $amount < 0 ? $cost * abs($quantity) : 0.0;
|
||
$row['merchant_discount'] = (float) ($row['merchant_discount'] ?? 0);
|
||
$row['freight_income'] = (float) ($row['freight_income'] ?? 0);
|
||
$row['payment_amount'] = $payment;
|
||
$row['platform_discount'] = $sellerIncome > $payment ? $sellerIncome - $payment : 0.0;
|
||
$row['reversal_order_amount'] = $amount < 0 ? abs($amount) : 0.0;
|
||
$row['actual_shipped_amount'] = $amount;
|
||
$row['actual_shipped_quantity'] = $quantity;
|
||
$row['actual_shipped_cost'] = $cost * $quantity;
|
||
foreach (['pre', 'post'] as $phase) {
|
||
foreach (['application_quantity', 'application_amount', 'application_cost', 'return_quantity',
|
||
'return_amount', 'return_cost', 'refund_amount', 'refund_cost', 'platform_subsidy'] as $field) {
|
||
$row[$phase . '_' . $field] = 0.0;
|
||
}
|
||
}
|
||
$row['defective_quantity'] = 0.0;
|
||
$row['is_gift'] = (int) ($row['is_gift'] ?? 0) === 1 ? '是' : '否';
|
||
$row['combine_type'] = empty($row['src_combine_sku_id']) ? '非组合' : '组合装';
|
||
$row['shipping_warehouse_code'] = $row['send_warehouse'] ?: ($row['wms_co_id'] ?? null);
|
||
$row['product_link'] = $this->productLink(
|
||
(string) ($row['shop_site'] ?? ''), (string) ($row['shop_name'] ?? ''),
|
||
(string) ($row['shop_style_code'] ?? ''),
|
||
);
|
||
foreach (['offline_customer_code', 'offline_customer_name', 'unique_code', 'registration_time',
|
||
'confirmation_time', 'warehouse_entry_time', 'classification_order',
|
||
'receiving_warehouse_code', 'receiving_warehouse'] as $field) {
|
||
$row[$field] = null;
|
||
}
|
||
foreach ($this->expenseFields() as $field) {
|
||
$row[$field] = null;
|
||
}
|
||
unset($row['shop_site'], $row['unit_cost'], $row['sku_name'], $row['seller_income_amount'],
|
||
$row['src_combine_sku_id'], $row['send_warehouse'], $row['wms_co_id']);
|
||
return $row;
|
||
}
|
||
|
||
/** @param array<int,array<string,mixed>> $rows */
|
||
private function hydrateRefunds(array &$rows): void
|
||
{
|
||
$orderIds = array_values(array_unique(array_filter(array_column($rows, 'internal_order_no'))));
|
||
if ($orderIds === []) {
|
||
return;
|
||
}
|
||
$refunds = $this->connection->table('ops_jst_return_refund_info as a')
|
||
->join('ops_jst_return_refund_item as ri', 'ri.as_id', '=', 'a.as_id')
|
||
->whereIn('a.o_id', $orderIds)
|
||
->orderByDesc('a.as_date')->orderByDesc('a.as_id')
|
||
->get([
|
||
'a.o_id as internal_order_no', 'a.as_id as after_sales_id',
|
||
'a.outer_as_id as outside_after_sales_id', 'a.as_date as after_sales_time',
|
||
'a.type as refund_type', 'a.question_type', 'a.labels as after_sales_tags',
|
||
'a.refund as refund_amount', 'a.free_amount as platform_subsidy',
|
||
'ri.outer_oi_id as sub_order_no', 'ri.sku_id as product_code',
|
||
'ri.type as detail_return_type', 'ri.qty as application_quantity',
|
||
'ri.amount as application_amount', 'ri.r_qty as return_quantity',
|
||
'ri.shop_amount as return_amount', 'ri.defective_qty as defective_quantity',
|
||
])->groupBy('internal_order_no');
|
||
|
||
foreach ($rows as &$row) {
|
||
$matches = collect($refunds->get($row['internal_order_no'], collect()))->filter(
|
||
static function (object $refund) use ($row): bool {
|
||
return (string) ($refund->sub_order_no ?? '') !== ''
|
||
? (string) $refund->sub_order_no === (string) ($row['sub_order_no'] ?? '')
|
||
: (string) ($refund->product_code ?? '') === (string) ($row['product_code'] ?? '');
|
||
},
|
||
)->values();
|
||
$first = $matches->first();
|
||
if ($first !== null) {
|
||
$row['after_sales_no'] = $first->outside_after_sales_id ?: $first->after_sales_id;
|
||
$row['refund_type'] = $first->refund_type;
|
||
$row['question_type'] = $first->question_type;
|
||
$row['detail_return_type'] = $first->detail_return_type;
|
||
$row['after_sales_tags'] = $first->after_sales_tags;
|
||
}
|
||
$unitCost = $row['product_quantity'] == 0 ? 0 : $row['product_cost'] / $row['product_quantity'];
|
||
$preReturned = 0.0;
|
||
foreach ($matches as $refund) {
|
||
$phase = empty($row['send_time']) || (string) $refund->after_sales_time < (string) $row['send_time']
|
||
? 'pre' : 'post';
|
||
$application = (float) ($refund->application_quantity ?? 0);
|
||
$returned = (float) ($refund->return_quantity ?? 0);
|
||
$row[$phase . '_application_quantity'] += $application;
|
||
$row[$phase . '_application_amount'] += (float) ($refund->application_amount ?? 0);
|
||
$row[$phase . '_application_cost'] += $application * $unitCost;
|
||
$row[$phase . '_return_quantity'] += $returned;
|
||
$row[$phase . '_return_amount'] += (float) ($refund->return_amount ?? 0);
|
||
$row[$phase . '_return_cost'] += $returned * $unitCost;
|
||
$row[$phase . '_refund_amount'] += (float) ($refund->refund_amount ?? 0);
|
||
$row[$phase . '_refund_cost'] += ($application - $returned) * $unitCost;
|
||
$row[$phase . '_platform_subsidy'] += (float) ($refund->platform_subsidy ?? 0);
|
||
$row['defective_quantity'] += (float) ($refund->defective_quantity ?? 0);
|
||
if ($phase === 'pre') {
|
||
$preReturned += $returned;
|
||
}
|
||
}
|
||
$row['actual_shipped_quantity'] = $row['product_quantity'] - $preReturned;
|
||
$row['actual_shipped_amount'] = $row['product_amount'] - $row['pre_return_amount'];
|
||
$row['actual_shipped_cost'] = $row['actual_shipped_quantity'] * $unitCost;
|
||
}
|
||
unset($row);
|
||
}
|
||
|
||
private function productLink(string $site, string $shopName, string $itemId): ?string
|
||
{
|
||
if ($itemId === '') {
|
||
return null;
|
||
}
|
||
$platform = mb_strtolower($site . ' ' . $shopName);
|
||
if (str_contains($platform, '京东') || str_contains($platform, '京喜') || str_contains($platform, 'jd')) {
|
||
return 'https://item.jd.com/' . rawurlencode($itemId) . '.html';
|
||
}
|
||
if (str_contains($platform, '抖音')) {
|
||
return 'https://haohuo.jinritemai.com/ecommerce/trade/detail/index.html?id=' . rawurlencode($itemId);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function expenseFields(): array
|
||
{
|
||
return [
|
||
'account_500112', 'account_60031', 'account_800101', 'account_800103',
|
||
'account_800104', 'account_800105', 'account_800106', 'account_800107',
|
||
'account_800108', 'account_800109', 'account_800110', 'account_800114',
|
||
'account_8001201', 'account_8001202', 'account_800122', 'account_800125',
|
||
'account_800127', 'account_800129', 'account_800130', 'account_800132',
|
||
'account_800304', 'account_800501', 'account_800503',
|
||
];
|
||
}
|
||
|
||
private function contains(string $value): string
|
||
{
|
||
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
|
||
}
|
||
}
|