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 $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 $filters * @return \Generator> */ 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 $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 */ 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 */ 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]; } } }