378 lines
16 KiB
PHP
378 lines
16 KiB
PHP
<?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
|
||
{
|
||
$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;
|
||
$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');
|
||
$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];
|
||
}
|
||
}
|
||
}
|