0 的行求和,退款/冲减行不计入 */ class SyncStoreReceiptOrderMatches extends Command { protected $signature = 'store-receipt:order-match:sync {month : 账单月份,格式 YYYY-MM} {--platform=taobao-alipay : 收款账户,目前支持 taobao-alipay} {--chunk=1000 : 单次向上游查询的订单号数量} {--limit=0 : 仅处理前 N 笔账单(调试用,0 表示全量)}'; protected $description = '按店铺收款账户账单匹配订单原始数据,生成「业绩明细」核对镜像'; /** 收款账户 → 账单镜像表 */ private const BILL_TABLES = [ 'taobao-alipay' => 'taobao_alipay_bill_rows', 'taobao-wechat' => 'taobao_wechat_bill_rows', 'pinduoduo' => 'pdd_settlement_bill_rows', ]; private const TARGET_TABLE = 'store_receipt_order_match_rows'; private const STATUS_MATCHED = '已匹配'; private const STATUS_AMOUNT_DIFF = '金额不一致'; private const STATUS_NO_AMOUNT = '有订单无商品金额'; private const STATUS_UNMATCHED = '订单未匹配'; public function handle(UpstreamReadOnlyQueryFactory $factory): int { $month = trim((string) $this->argument('month')); if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) { throw new InvalidArgumentException('月份格式应为 YYYY-MM,例如 2026-08。'); } $platform = (string) $this->option('platform'); if (! isset(self::BILL_TABLES[$platform])) { throw new InvalidArgumentException('暂不支持的收款账户:' . $platform); } $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $billTable = self::BILL_TABLES[$platform]; $chunkSize = max(20, (int) $this->option('chunk')); $limit = max(0, (int) $this->option('limit')); $start = Carbon::createFromFormat('Y-m-d', $month . '-01')->startOfDay(); $end = $start->copy()->addMonthNoOverflow(); $reportMonth = $start->toDateString(); $this->info(sprintf('账期 %s(%s ~ %s),账单来源 %s', $month, $start->toDateString(), $end->copy()->subDay()->toDateString(), $billTable)); $aggregate = DB::connection($connection)->table($billTable) ->selectRaw('online_order_no, SUM(income_amount) as income_amount, COUNT(*) as payment_count, MIN(occurred_at) as first_occurred_at, MAX(store_name) as store_name') ->where('bill_item', '交易收款') ->where('occurred_at', '>=', $start->toDateTimeString()) ->where('occurred_at', '<', $end->toDateTimeString()) ->whereNotNull('online_order_no') ->where('online_order_no', '<>', '') ->groupBy('online_order_no') ->orderBy('first_occurred_at') ->get(); if ($limit > 0) { $aggregate = $aggregate->take($limit); } $orders = []; foreach ($aggregate as $row) { $no = trim((string) $row->online_order_no); if ($no === '') { continue; } $orders[$no] = [ 'online_order_no' => $no, 'income_amount' => round((float) $row->income_amount, 2), 'payment_count' => (int) $row->payment_count, 'occurred_date' => substr((string) $row->first_occurred_at, 0, 10), 'store_name' => (string) ($row->store_name ?? ''), ]; } if ($orders === []) { $this->warn('该账期没有「交易收款」记录,未写入任何数据。'); return self::SUCCESS; } $this->info('账单侧订单数:' . count($orders)); $upstream = $factory->forSource('operations_replica'); DB::connection($connection)->table(self::TARGET_TABLE) ->where('report_month', $reportMonth) ->delete(); $target = DB::connection($connection)->table(self::TARGET_TABLE); $dwsFrom = $start->copy()->subDays(150)->toDateString(); $dwsTo = $end->copy()->addDays(7)->toDateString(); $now = now(); $buffer = []; $imported = 0; $stats = []; $chunks = array_chunk(array_keys($orders), $chunkSize); $totalChunks = count($chunks); foreach ($chunks as $index => $chunk) { $in = implode(',', array_map(static fn (string $no): string => "'" . str_replace("'", "''", $no) . "'", $chunk)); $viewRows = []; try { foreach ($upstream->select( "SELECT so_id, sku_ids, qty, wareHoursName FROM rpt.Jst_SendData_View_extension WHERE so_id IN ({$in})" ) as $row) { $viewRows[(string) $row['so_id']][] = $row; } } catch (Throwable $exception) { $this->error('发货明细查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage()); return self::FAILURE; } $salesRows = []; try { foreach ($upstream->select( "SELECT sourceno, skuoid, sales_amount, goods_count FROM rpt.dws_sn_sales_full_info_di WHERE sourceno IN ({$in}) AND stat_date >= '{$dwsFrom}' AND stat_date < '{$dwsTo}'" ) as $row) { $salesRows[(string) $row['sourceno']][] = $row; } } catch (Throwable $exception) { $this->error('销售明细查询失败(第 ' . ($index + 1) . ' 批):' . $exception->getMessage()); return self::FAILURE; } foreach ($chunk as $no) { foreach ($this->buildRows($orders[$no], $viewRows[$no] ?? [], $salesRows[$no] ?? [], $reportMonth, $now) as $row) { $buffer[] = $row; $stats[$row['match_status']] = ($stats[$row['match_status']] ?? 0) + 1; } } if (count($buffer) >= 500) { $target->insertOrIgnore($buffer); $imported += count($buffer); $buffer = []; } if (($index + 1) % 25 === 0 || $index + 1 === $totalChunks) { $this->info(sprintf('进度 %d/%d 批,已写入 %d 行', $index + 1, $totalChunks, $imported + count($buffer))); } } if ($buffer !== []) { $target->insertOrIgnore($buffer); $imported += count($buffer); } $this->info('写入完成,共 ' . $imported . ' 行(订单 ' . count($orders) . ' 笔)。'); foreach ($stats as $status => $count) { $this->line(' ' . $status . ':' . $count . ' 行'); } return self::SUCCESS; } /** * 单笔账单展开为明细行:发货明细的每个商品行一行,金额取销售全量同条码的有效行。 * * @param array $order * @param array> $viewRows * @param array> $salesRows * @return array> */ private function buildRows(array $order, array $viewRows, array $salesRows, string $reportMonth, Carbon $now): array { $income = (float) $order['income_amount']; $positive = array_values(array_filter( $salesRows, static fn (array $row): bool => (int) ($row['goods_count'] ?? 0) > 0 )); $goodsAmount = $positive === [] ? null : round(array_sum(array_map(static fn (array $row): float => (float) $row['sales_amount'], $positive)), 2); if ($goodsAmount !== null && abs($goodsAmount - $income) < 0.005) { $status = self::STATUS_MATCHED; } elseif ($viewRows !== [] || $salesRows !== []) { $status = $goodsAmount === null ? self::STATUS_NO_AMOUNT : self::STATUS_AMOUNT_DIFF; } else { $status = self::STATUS_UNMATCHED; } $base = [ 'report_month' => $reportMonth, 'occurred_date' => $order['occurred_date'], 'store_name' => $order['store_name'], 'online_order_no' => $order['online_order_no'], 'payment_count' => $order['payment_count'], 'income_amount' => $income, 'match_status' => $status, 'imported_at' => $now, ]; if ($viewRows === []) { // 没有发货明细时,用销售全量的商品行兜底(仓库留空)。 if ($positive === []) { return [$base + [ 'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|none'), 'warehouse' => null, 'product_code' => null, 'quantity' => null, 'product_amount' => null, ]]; } $rows = []; $seen = []; foreach ($positive as $row) { $code = mb_strtoupper(trim((string) $row['skuoid'])); if ($code === '' || isset($seen[$code])) { continue; } $seen[$code] = true; $rows[] = $base + [ 'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|' . $code), 'warehouse' => null, 'product_code' => $code, 'quantity' => (float) $row['goods_count'], 'product_amount' => round((float) $row['sales_amount'], 2), ]; } return $rows !== [] ? $rows : [$base + [ 'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|none'), 'warehouse' => null, 'product_code' => null, 'quantity' => null, 'product_amount' => null, ]]; } $amountByCode = []; foreach ($positive as $row) { $code = mb_strtoupper(trim((string) $row['skuoid'])); if ($code === '' || isset($amountByCode[$code])) { continue; } $amountByCode[$code] = round((float) $row['sales_amount'], 2); } $rows = []; $seen = []; foreach ($viewRows as $view) { $code = mb_strtoupper(trim((string) $view['sku_ids'])); $warehouse = trim((string) ($view['wareHoursName'] ?? '')); $key = $code . '|' . $warehouse; if ($key === '|' || isset($seen[$key])) { continue; } $seen[$key] = true; $rows[] = $base + [ 'row_key' => md5($reportMonth . '|' . $order['online_order_no'] . '|' . $key), 'warehouse' => $warehouse !== '' ? $warehouse : null, 'product_code' => $code !== '' ? $code : null, 'quantity' => is_numeric($view['qty'] ?? null) ? (float) $view['qty'] : null, 'product_amount' => $amountByCode[$code] ?? null, ]; } return $rows; } }