CaiWuXiTong/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php

386 lines
17 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\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
/**
* 发货明细只读仓库。读取扁平表 jst_oms_shipping(对齐工作平台发货数据 47 列),
* 供“发货数据”页面展示与后续快递运费核算使用。
*/
class ShippingOrderRepository
{
private readonly ConnectionInterface $db;
public function __construct()
{
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
try {
// 合并取"最后一行"值用 GROUP_CONCAT,放宽默认 1024 长度避免截断
$this->db->statement('SET SESSION group_concat_max_len = 4194304');
} catch (\Throwable) {
}
}
/**
* @param array<string,string> $filters
* @return array{data:array,total:int}
*/
public function paginate(array $filters, int $page, int $perPage, string $mode = 'merged'): array
{
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
if ($mode === 'detail') {
$q = $this->filteredQuery($filters);
$total = (clone $q)->count();
$rows = (clone $q)
->orderByDesc('ship_time')
->orderByDesc('source_id')
->orderBy('sku_source_id')
->forPage($page, $perPage)
->get()->map(fn ($r) => $this->detailRow((array) $r))->all();
return ['data' => $rows, 'total' => $total];
}
// 合并:按包裹(source_id)归并
$base = $this->filteredQuery($filters);
$total = (clone $base)->distinct()->count('source_id');
$pageKeys = (clone $base)->select('source_id')->selectRaw('MAX(ship_time) as ship_time')
->groupBy('source_id')->orderByDesc('ship_time')->orderByDesc('source_id')
->forPage($page, $perPage)->pluck('source_id')->all();
if ($pageKeys === []) {
return ['data' => [], 'total' => $total];
}
$rows = (clone $base)
->whereIn('source_id', $pageKeys)
->groupBy('source_id')
->selectRaw($this->mergedSelectRaw())
->orderByDesc('ship_time')
->orderByDesc('source_id')
->get()->map(fn ($r) => (array) $r)->all();
return ['data' => $rows, 'total' => $total];
}
/** 仅批量读取本月配件及其交易单号对应的三个月首发候选;复用发货数据合并口径。 */
public function accessoryReshipBatches(string $monthStart, string $nextMonth, string $windowStart): \Generator
{
foreach ($this->accessoryFreightBatches($monthStart, $nextMonth) as $accessories) {
$trades = array_values(array_unique(array_filter(array_column($accessories, 'trade_no'), static fn ($trade) => trim((string) $trade) !== '')));
$originals = $trades === [] ? [] : $this->db->table('jst_oms_shipping')
->where('warehouse_name', '<>', \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE)
->where('ship_time', '>=', $windowStart)->where('ship_time', '<', $nextMonth)
->whereIn('trade_no', $trades)->whereNotNull('express_no')->where('express_no', '<>', '')
->select(['trade_no', 'barcode', 'express_no', 'express_name1', 'express_name2', 'ship_time', 'warehouse_name'])
->get()->map(fn ($row) => (array) $row)->all();
yield compact('accessories', 'originals');
}
}
/** 常州配件按发货包裹合并,运费只累计一次;两个扣款明细共用同一口径。 */
public function accessoryFreightBatches(string $monthStart, string $nextMonth): \Generator
{
$last = null;
while (true) {
$query = $this->db->table('jst_oms_shipping')
->where('warehouse_name', \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE)
->where('ship_time', '>=', $monthStart)->where('ship_time', '<', $nextMonth);
if ($last !== null) $query->where('source_id', '>', $last);
// 指定条码顺序,保证“第一个条码”在重复匹配时稳定。
$select = 'source_id, MAX(express_no) as express_no, MAX(trade_no) as trade_no, MAX(express_name1) as express_name1, MAX(express_name2) as express_name2, MAX(ship_time) as ship_time, SUM(freight) as freight, GROUP_CONCAT(DISTINCT barcode ORDER BY id SEPARATOR \',\' ) as barcode';
$accessories = $query->groupBy('source_id')->selectRaw($select)->orderBy('source_id')->limit(1000)->get()->map(fn ($row) => (array) $row)->all();
if ($accessories === []) return;
yield $accessories;
$last = $accessories[array_key_last($accessories)]['source_id'];
}
}
/** 明细口径:无尺寸(0)的长宽高/体积显示为空白,对齐工作平台导出明细(合并保持 0)。 */
private function detailRow(array $r): array
{
foreach (['length_cm', 'width_cm', 'height_cm', 'volume_cm3'] as $c) {
if (array_key_exists($c, $r) && $r[$c] !== null && $r[$c] !== '' && (float) $r[$c] == 0.0) {
$r[$c] = null;
}
}
return $r;
}
/**
* 包裹内"最后一行"(按明细 id 顺序)的值。工作平台合并的商品名称/供应商取的是
* 条码串最后一个商品那行(样本 6213/6216、575/575),不是 MIN。
*/
private function lastOf(string $expr): string
{
// SEPARATOR 只能是字符串常量;用名称里不会出现的 '#~#' 作分隔
return "NULLIF(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE({$expr}, '') ORDER BY `id` SEPARATOR '#~#'), '#~#', -1), '')";
}
/** 合并聚合 SELECT:条码/商品ID 逗号拼接、数量/重量/体积/金额/运费求和、长宽高取最大、其余取值。 */
private function mergedSelectRaw(): string
{
$sum = ['qty', 'freight', 'online_weight', 'actual_weight', 'volume_cm3', 'weight_kg',
'settle_weight_kg', 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3', 'amount', 'settle_freight'];
$concat = ['item_id', 'barcode'];
$min = ['product_name', 'supplier_name', 'product_code'];
$all = ['express_no', 'trade_no', 'item_id', 'barcode', 'product_name', 'qty', 'pkg_count',
'receiver_state', 'buyer_nick', 'receiver_name', 'shop_name', 'express_name1', 'warehouse_name',
'ship_time', 'freight', 'online_weight', 'actual_weight', 'length_cm', 'width_cm', 'height_cm',
'volume_cm3', 'receiver_city', 'receiver_district', 'supplier_name', 'receiver_address',
'receiver_mobile', 'receiver_phone', 'express_name2', 'platform', 'buyer_memo', 'seller_memo',
'package_no', 'amount', 'product_code', 'download_time', 'pay_time', 'freight_rate', 'freight_formula',
'weight_kg', 'settle_weight_kg', 'settle_volume_cm3', 'recon_weight_kg', 'recon_volume_cm3',
'finished_barcode', 'finished_name', 'finished_supplier', 'settle_freight', 'front_no'];
// 组合件拆件后明细存部件值,合并取整行值(m_*),普通行 m_* 为空则回落原列
$mcol = ['product_name' => 'm_name', 'volume_cm3' => 'm_volume_cm3',
'length_cm' => 'm_length_cm', 'width_cm' => 'm_width_cm', 'height_cm' => 'm_height_cm'];
$parts = ['MAX(id) as id', 'source_id'];
foreach ($all as $c) {
if ($c === 'product_name') {
$parts[] = $this->lastOf("COALESCE(`m_name`, `product_name`)") . " as `{$c}`";
} elseif (isset($mcol[$c])) {
$fn = $c === 'volume_cm3' ? 'SUM' : 'MAX';
$parts[] = "{$fn}(COALESCE(`{$mcol[$c]}`, `{$c}`)) as `{$c}`";
} elseif (in_array($c, $sum, true)) {
$parts[] = "SUM(`{$c}`) as `{$c}`";
} elseif (in_array($c, $concat, true)) {
$parts[] = "GROUP_CONCAT(DISTINCT `{$c}` SEPARATOR ',') as `{$c}`";
} elseif (in_array($c, $min, true)) {
$parts[] = $this->lastOf("`{$c}`") . " as `{$c}`";
} else {
$parts[] = "MAX(`{$c}`) as `{$c}`";
}
}
return implode(', ', $parts);
}
/**
* @param array<string,string> $filters
* @return \Generator<int,array<string,mixed>>
*/
public function exportRows(array $filters, int $batchSize = 1000, string $mode = 'merged'): \Generator
{
$batchSize = min(max($batchSize, 1), 5000);
$last = null;
if ($mode === 'detail') {
while (true) {
$query = $this->filteredQuery($filters);
if ($last !== null) {
$query->where('id', '>', $last);
}
$rows = $query->orderBy('id')->limit($batchSize)->get()->all();
if ($rows === []) {
return;
}
foreach ($rows as $row) {
yield $this->detailRow((array) $row);
}
$last = (int) $rows[array_key_last($rows)]->id;
if (count($rows) < $batchSize) {
return;
}
}
}
// 合并导出:按 source_id 键集分页 + 归并
while (true) {
$query = $this->filteredQuery($filters);
if ($last !== null) {
$query->where('source_id', '>', $last);
}
$keys = (clone $query)->select('source_id')->distinct()
->orderBy('source_id')->limit($batchSize)->pluck('source_id')->all();
if ($keys === []) {
return;
}
$rows = $query->whereIn('source_id', $keys)->groupBy('source_id')->selectRaw($this->mergedSelectRaw())
->orderBy('source_id')->limit($batchSize)->get()->all();
if ($rows === []) {
return;
}
foreach ($rows as $row) {
yield (array) $row;
}
$last = (int) $rows[array_key_last($rows)]->source_id;
if (count($rows) < $batchSize) {
return;
}
}
}
/** @param array<string,string> $filters */
private function filteredQuery(array $filters): Builder
{
$query = $this->db->table('jst_oms_shipping');
if (($filters['express_no'] ?? '') !== '') {
$query->where('express_no', '=', trim((string) $filters['express_no']));
}
if (($filters['sourceno'] ?? '') !== '') {
$query->where('trade_no', '=', trim((string) $filters['sourceno']));
}
// 条形码:单值关键词
if (($filters['barcode'] ?? '') !== '') {
$query->whereRaw("barcode LIKE ? ESCAPE '!'", [$this->contains((string) $filters['barcode'])]);
}
// 供应商/店铺/仓库:多选,精确匹配所选的任意一个
foreach (['supplier_name', 'shop_name', 'warehouse_name'] as $column) {
$values = $this->multi($filters[$column] ?? null);
if ($values !== []) {
$query->whereIn($column, $values);
}
}
// 快递公司:多选,匹配快递名称1/2 任意一个
$couriers = $this->multi($filters['express_company'] ?? null);
if ($couriers !== []) {
$query->where(function (Builder $group) use ($couriers): void {
$group->whereIn('express_name1', $couriers)
->orWhereIn('express_name2', $couriers);
});
}
if (($filters['ship_from'] ?? '') !== '') {
$query->where('ship_time', '>=', $filters['ship_from'] . ' 00:00:00');
}
if (($filters['ship_to'] ?? '') !== '') {
$query->where('ship_time', '<', date('Y-m-d 00:00:00', strtotime($filters['ship_to'] . ' +1 day')));
}
return $query;
}
private function contains(string $value): string
{
return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%';
}
/**
* 归一多选筛选值为字符串数组(兼容单个字符串)。
* @return array<int,string>
*/
private function multi(mixed $value): array
{
if (is_string($value)) {
$value = $value === '' ? [] : [$value];
}
if (! is_array($value)) {
return [];
}
$out = [];
foreach ($value as $item) {
$item = trim((string) $item);
if ($item !== '' && ! in_array($item, $out, true)) {
$out[] = $item;
}
}
return array_slice($out, 0, 50);
}
/**
* 取某字段去重候选值(供筛选框自动补全)。
* @return array<int,string>
*/
public function distinctValues(string $field, string $keyword, int $limit = 20): array
{
$map = [
'supplier_name' => ['supplier_name'],
'shop_name' => ['shop_name'],
'warehouse_name' => ['warehouse_name'],
'express_company' => ['express_name1', 'express_name2'],
];
if (! isset($map[$field])) {
return [];
}
$limit = min(max($limit, 1), 500);
$like = $this->contains($keyword);
$values = [];
foreach ($map[$field] as $col) {
$rows = $this->db->table('jst_oms_shipping')
->selectRaw($col . ' as v')
->whereRaw($col . " LIKE ? ESCAPE '!'", [$like])
->whereNotNull($col)
->where($col, '<>', '')
->groupBy($col)
->orderBy($col)
->limit($limit)
->pluck('v');
foreach ($rows as $v) {
$v = trim((string) $v);
// 过滤空值与占位脏值(如自动发货仓 send_warehouse 为 "0")
if ($v !== '' && $v !== '0') {
$values[$v] = true;
}
}
}
$values = array_keys($values);
sort($values, SORT_FLAG_CASE | SORT_STRING);
return array_slice($values, 0, $limit);
}
/** 报价录入的近期发货名称候选;只读固定数量的新明细,不在每次输入时扫描整张发货表。 */
public function recentCourierNames(string $keyword, int $limit = 20): array
{
$key = 'shipping:recent-couriers:' . hash('sha256', $this->db->getName() . '|' . $this->db->getDatabaseName());
$names = Cache::remember($key, now()->addMinutes(5), function (): array {
$names = [];
foreach ($this->db->table('jst_oms_shipping')->orderByDesc('id')->limit(10000)->get(['express_name1', 'express_name2']) as $row) {
foreach ([$row->express_name1, $row->express_name2] as $value) {
$name = trim((string) $value);
if ($name !== '' && $name !== '0') {
$names[$name] = true;
}
}
}
$names = array_keys($names);
sort($names, SORT_STRING);
return $names;
});
return array_slice(array_values(array_filter($names, static fn (string $name): bool => mb_stripos($name, $keyword) !== false)), 0, min(max($limit, 1), 20));
}
/** 同步状态(供页面显示同步中/最近完成)。 */
public function syncStatus(): array
{
try {
$row = $this->db->table('jst_sync_state')->where('dataset_key', 'jst.sctrade')->first();
if (! $row) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$freightPending = $this->db->table('jst_sync_state')
->where('dataset_key', 'like', 'jst.sctrade.freight.%')->where('status', '!=', 'succeeded')->count();
$itemsPending = $this->db->table('jst_sync_state')
->where('dataset_key', 'like', 'jst.sctrade.items.%')->where('status', '!=', 'succeeded')->count();
$code = (string) ($row->status ?? 'unavailable');
return [
'code' => ($freightPending || $itemsPending) && $code === 'succeeded' ? 'partial' : $code,
'last_succeeded_at' => $row->last_succeeded_at ?? null,
'freight_pending' => (bool) $freightPending,
'warning' => implode(' ', array_filter([
$code === 'partial' ? '部分包裹尚需与上游源键核对,原有镜像记录已保留。' : null,
$freightPending ? "{$freightPending} 个日期窗口的运费和结算重量待补,空白不代表 0。" : null,
$itemsPending ? "{$itemsPending} 个日期窗口的商品或金额待核对。" : null,
])),
];
} catch (\Throwable) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
}
}