CaiWuXiTong/modules/EcommerceFinance/Repositories/ShippingOrderRepository.php
limeng 2a1b663da2 feat(发货数据): 合并/明细双模式,还原工作平台 导出/导出明细
- 同步改为"一个发货单×每件商品一行"(明细底座);新增 pkg_count 列存"1/N"件数
- 体积=数量×长×宽×高;包裹级(实称重量/运费)仅落首件,合并求和不重复
- 仓储 paginate/exportRows 支持 mode:
  合并=按 source_id 归并(条码/商品ID逗号拼接、数量/重量/体积/金额/运费求和、长宽高取最大、其余取值);
  明细=每件一行
- 页面加"合并/明细"切换 + 两个导出(导出/导出明细) + 件数列
- 同步内存加固(1.5G、关本地查询日志、每批释放+gc),避免全量 OOM

实测:包裹199602/明细222311,多件包裹11667,补差0;
7件样例合并体积=各件求和、运费不重复、明细每行1/7。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-09-21 07:49:26 +00:00

272 lines
9.9 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;
/**
* 发货明细只读仓库。读取扁平表 jst_oms_shipping(对齐工作平台发货数据 47 列),
* 供“发货数据”页面展示与后续快递运费核算使用。
*/
class ShippingOrderRepository
{
private readonly ConnectionInterface $db;
public function __construct()
{
$this->db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
}
/**
* @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) => (array) $r)->all();
return ['data' => $rows, 'total' => $total];
}
// 合并:按包裹(source_id)归并
$base = $this->filteredQuery($filters);
$total = (clone $base)->distinct()->count('source_id');
$rows = (clone $base)
->groupBy('source_id')
->selectRaw($this->mergedSelectRaw())
->orderByDesc('ship_time')
->orderByDesc('source_id')
->forPage($page, $perPage)
->get()->map(fn ($r) => (array) $r)->all();
return ['data' => $rows, 'total' => $total];
}
/** 合并聚合 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'];
$parts = ['MAX(id) as id', 'source_id'];
foreach ($all as $c) {
if (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[] = "MIN(`{$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 (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);
}
$rows = $query->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 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];
}
return [
'code' => (string) ($row->status ?? 'unavailable'),
'last_succeeded_at' => $row->last_succeeded_at ?? null,
];
} catch (\Throwable) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
}
}