db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local')); } /** * @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) => (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 $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 (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 $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 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]; } } }