connection = $databases->connection( (string) config('upstream_mirrors.connection', 'business_mirror'), ); } /** @return array{lifecycles:array,period_changes:array,bills:array,daily:array} */ public function sync( ?int $batchSize = null, ?int $maxBatches = null, ?int $dailyBatchSize = null, ?int $dailyMaxBatches = null, ): array { $this->assertSchemaReady(); $lifecycleBatch = min(max((int) config('upstream_mirrors.store_report.lifecycle_batch_size', 1000), 100), 5000); $lifecycleMax = max((int) config('upstream_mirrors.store_report.lifecycle_max_batches_per_run', 5), 1); $billBatch = min(max($batchSize ?? (int) config('upstream_mirrors.store_report.batch_size', 5000), 100), 10000); $billMax = max($maxBatches ?? (int) config('upstream_mirrors.store_report.max_batches_per_run', 4), 1); $lifecycles = $this->syncDataset( StoreReportRepository::LIFECYCLES_DATASET, 'store_report_lifecycle_rows', $lifecycleBatch, $lifecycleMax, fn (int $cursor, int $limit): array => $this->upstream->lifecycleBatch($cursor, $limit), fn (array $rows, string $generation): array => $this->mapLifecycles($rows, $generation), ); $periodChanges = $this->syncShopPeriodChanges(); $bills = $this->syncDataset( StoreReportRepository::BILLS_DATASET, 'store_report_bill_rows', $billBatch, $billMax, fn (int $cursor, int $limit): array => $this->upstream->billBatch($cursor, $limit), fn (array $rows, string $generation): array => $this->mapBills($rows, $generation), ); return [ 'lifecycles' => $lifecycles, 'period_changes' => $periodChanges, 'bills' => $bills, 'daily' => $this->syncDailySummary($dailyBatchSize, $dailyMaxBatches), 'paid_sales' => $this->syncPaidSales(), ]; } /** * 把爬虫首次抓到的新主体写入店铺主体期间变更记录。 * * 爬虫主体名称先按税号归一到工商主体资料,避免同一主体的名称笔误被当成变更; * 只有与当前期间最后一个主体不同才新增记录,重复执行不会重复插入, * 记录下来的首次抓到日期会固定新主体的生效时间,后续爬虫更新不再前移。 * * @return array{status:string,checked:int,created:int,reason?:string} */ private function syncShopPeriodChanges(): array { // 控制台没有网页公司上下文;先定位首家公司的期初设置,避免回退到代码文件旧值。 try { \App\Models\Common\Company::query()->orderBy('id')->first()?->makeCurrent(); } catch (Throwable) { // 读取失败时期间构建器会回退到配置文件,不阻断同步。 } $schema = Schema::connection($this->connection->getName()); if (! $schema->hasTable('ops_crawl_shop_company')) { return ['status' => 'skipped', 'checked' => 0, 'created' => 0, 'reason' => 'crawl_mirror_not_ready']; } if (! $schema->hasTable(StoreReportPeriodBuilder::CHANGES_TABLE)) { return ['status' => 'skipped', 'checked' => 0, 'created' => 0, 'reason' => 'change_table_missing']; } $ranked = $this->connection->table('ops_crawl_shop_company') ->selectRaw('id, shop_name, company_name, tax_no, crawl_date, row_number() over (partition by shop_name order by crawl_date desc, id desc) AS row_rank'); $latest = $this->connection->table($ranked, 'crawler_latest') ->where('row_rank', 1) ->get(['shop_name', 'company_name', 'tax_no', 'crawl_date']); if ($latest->isEmpty()) { return ['status' => 'succeeded', 'checked' => 0, 'created' => 0]; } $masterNames = []; if ($schema->hasTable('ops_fys_company_info')) { $rankedCompanies = $this->connection->table('ops_fys_company_info') ->selectRaw('unifiedSocialCreditCode, businessLicenseName, row_number() over (partition by unifiedSocialCreditCode order by Id desc) AS row_rank'); foreach ($this->connection->table($rankedCompanies, 'company_latest') ->where('row_rank', 1) ->get(['unifiedSocialCreditCode', 'businessLicenseName']) as $row) { $code = trim((string) ($row->unifiedSocialCreditCode ?? '')); $name = trim((string) ($row->businessLicenseName ?? '')); if ($code !== '' && $name !== '') { $masterNames[$code] = $name; } } } $changes = []; foreach ($latest as $row) { $name = trim((string) ($row->shop_name ?? '')); $taxNo = trim((string) ($row->tax_no ?? '')); $company = $masterNames[$taxNo] ?? trim((string) ($row->company_name ?? '')); $observed = substr(trim((string) ($row->crawl_date ?? '')), 0, 10); if ($name === '' || $company === '' || $observed === '') { continue; } $changes[$name] = ['company' => $company, 'tax_no' => $taxNo, 'observed_on' => $observed]; } if ($changes === []) { return ['status' => 'succeeded', 'checked' => 0, 'created' => 0]; } $periods = app(StoreReportPeriodBuilder::class)->build(array_map( static fn (string $name): array => ['erp_store_name' => $name], array_keys($changes), )); $created = 0; foreach ($changes as $name => $change) { $list = $periods[$name] ?? []; $last = $list === [] ? null : $list[count($list) - 1]; if ($this->sameCompany($last['company_name'] ?? null, $change['company'])) { continue; } $created += (int) $this->connection->table(StoreReportPeriodBuilder::CHANGES_TABLE)->insertOrIgnore([ 'erp_store_name' => $name, 'company_name' => $change['company'], 'unified_social_credit_code' => $change['tax_no'] === '' ? null : $change['tax_no'], 'observed_on' => $change['observed_on'], 'source' => 'crawl_shop_company', 'business_key' => hash('sha256', implode('|', [$name, $change['company'], $change['observed_on']])), 'created_at' => now(), 'updated_at' => now(), ]); } return ['status' => 'succeeded', 'checked' => count($changes), 'created' => $created]; } /** * @param callable(int,int):array> $fetch * @param callable(array>,string):array{upserts:array>,deletes:array} $map * @return array{status:string,mode:string,batches:int,source_rows:int,stored_rows:int,cursor_id:int} */ private function syncDataset( string $dataset, string $table, int $batchSize, int $maxBatches, callable $fetch, callable $map, ): array { $state = $this->state($dataset); $initial = empty($state->active_generation); $mode = $initial ? 'full' : 'incremental'; $resume = in_array((string) $state->status, ['running', 'failed'], true) && (string) ($state->run_mode ?? '') === $mode; $generation = $initial ? ($resume && ! empty($state->pending_generation) ? (string) $state->pending_generation : (string) Str::uuid()) : (string) $state->active_generation; $cursor = $resume ? (int) ($state->cursor_id ?? 0) : ($initial ? 0 : max((int) ($state->cursor_id ?? 0) - max((int) config('upstream_mirrors.store_report.overlap_rows', 50000), 0), 0)); $storedRows = $initial && ! $resume ? 0 : (int) $state->row_count; $sourceRows = 0; $batches = 0; $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([ 'status' => 'running', 'run_mode' => $mode, 'pending_generation' => $initial ? $generation : null, 'cursor_id' => $cursor, 'last_attempted_at' => now(), 'last_error_code' => null, 'updated_at' => now(), ]); try { while ($batches < $maxBatches) { $rows = $fetch($cursor, $batchSize); if ($rows === []) { return $this->complete($dataset, $generation, $mode, $cursor, $storedRows, $batches, $sourceRows); } $nextCursor = max(array_map(static fn (array $row): int => (int) ($row['source_id'] ?? 0), $rows)); if ($nextCursor <= $cursor) { throw new RuntimeException('store_report_cursor_did_not_advance'); } $mapped = $map($rows, $generation); $storedRows = $this->storeBatch( $dataset, $table, $generation, $nextCursor, $mapped, $storedRows, ); $cursor = $nextCursor; $sourceRows += count($rows); $batches++; if (count($rows) < $batchSize) { return $this->complete($dataset, $generation, $mode, $cursor, $storedRows, $batches, $sourceRows); } } return $this->result('running', $mode, $batches, $sourceRows, $storedRows, $cursor); } catch (Throwable $exception) { $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([ 'status' => 'failed', 'last_error_code' => class_basename($exception), 'updated_at' => now(), ]); throw $exception; } } private function state(string $dataset): object { $now = now(); $this->connection->table('store_report_import_states')->insertOrIgnore([ 'dataset_key' => $dataset, 'status' => 'pending', 'row_count' => 0, 'source_files_json' => json_encode(['jiansun.dbo.jst_originalBillInfo', 'jiansun.dbo.fys_shopInfo_flow']), 'created_at' => $now, 'updated_at' => $now, ]); return $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->first(); } /** @param array{upserts:array>,deletes:array} $mapped */ private function storeBatch( string $dataset, string $table, string $generation, int $cursor, array $mapped, int $storedRows, ): int { return $this->connection->transaction(function () use ( $dataset, $table, $generation, $cursor, $mapped, $storedRows, ): int { $upsertIds = []; foreach ($mapped['upserts'] as $row) { $upsertIds[(int) $row['source_id']] = true; } $deleteIds = array_fill_keys(array_map('intval', $mapped['deletes']), true); $touchedIds = array_keys($upsertIds + $deleteIds); $existingIds = []; foreach (array_chunk($touchedIds, 1000) as $chunk) { foreach ($this->connection->table($table) ->where('generation', $generation) ->whereIn('source_id', $chunk) ->pluck('source_id') as $sourceId) { $existingIds[(int) $sourceId] = true; } } $inserted = count(array_diff_key($upsertIds, $existingIds)); $deleted = count(array_diff_key(array_intersect_key($deleteIds, $existingIds), $upsertIds)); $nextStoredRows = max($storedRows + $inserted - $deleted, 0); $dailyDeltas = $table === 'store_report_bill_rows' && $this->dailySummaryReady($generation) ? $this->dailyDeltas($generation, $mapped) : []; if ($mapped['deletes'] !== []) { $this->connection->table($table) ->where('generation', $generation) ->whereIn('source_id', $mapped['deletes']) ->delete(); } if ($mapped['upserts'] !== []) { $updates = array_values(array_diff(array_keys($mapped['upserts'][0]), ['generation', 'source_id'])); // MySQL/MariaDB 对单条预处理语句的占位符数量有限,不能把 // 数千行、十余列一次塞进同一条 upsert。 $columnsPerRow = max(count($mapped['upserts'][0]), 1); $chunkSize = max(1, min(2000, intdiv(50000, $columnsPerRow))); foreach (array_chunk($mapped['upserts'], $chunkSize) as $chunk) { $this->connection->table($table)->upsert( $chunk, ['generation', 'source_id'], $updates, ); } } if ($dailyDeltas !== []) { $this->applyDailyDeltas($generation, $dailyDeltas); } $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update([ 'cursor_id' => $cursor, 'row_count' => $nextStoredRows, 'updated_at' => now(), ]); return $nextStoredRows; }); } /** @return array{status:string,mode:string,batches:int,source_rows:int,stored_rows:int,cursor_id:int} */ private function complete( string $dataset, string $generation, string $mode, int $cursor, int $storedRows, int $batches, int $sourceRows, ): array { $now = now(); $values = [ 'status' => 'succeeded', 'run_mode' => null, 'cursor_id' => $cursor, 'last_succeeded_at' => $now, 'last_error_code' => null, 'row_count' => $storedRows, 'updated_at' => $now, ]; if ($mode === 'full') { $values += [ 'active_generation' => $generation, 'pending_generation' => null, 'initial_completed_at' => $now, ]; } $this->connection->table('store_report_import_states')->where('dataset_key', $dataset)->update($values); return $this->result('succeeded', $mode, $batches, $sourceRows, $storedRows, $cursor); } /** @return array{status:string,mode:string,batches:int,source_rows:int,stored_rows:int,cursor_id:int} */ private function syncDailySummary(?int $batchSize, ?int $maxBatches): array { $billState = $this->state(StoreReportRepository::BILLS_DATASET); $generation = (string) ($billState->active_generation ?? ''); if ($generation === '') { return $this->result('pending', 'full', 0, 0, 0, 0); } $batchSize = min(max($batchSize ?? (int) config('upstream_mirrors.store_report.daily_batch_size', 50000), 1000), 100000); $maxBatches = max($maxBatches ?? (int) config('upstream_mirrors.store_report.daily_max_batches_per_run', 10), 1); $state = $this->state(StoreReportRepository::DAILY_DATASET); if ((string) ($state->active_generation ?? '') === $generation && ! empty($state->initial_completed_at)) { return $this->result('succeeded', 'incremental', 0, 0, (int) $state->row_count, (int) $state->cursor_id); } $resume = in_array((string) $state->status, ['running', 'failed'], true) && (string) ($state->pending_generation ?? '') === $generation; $cursor = $resume ? (int) ($state->cursor_id ?? 0) : 0; if (! $resume) { $this->connection->table('store_report_daily_rows')->where('generation', $generation)->delete(); } $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::DAILY_DATASET) ->update([ 'status' => 'running', 'run_mode' => 'full', 'pending_generation' => $generation, 'cursor_id' => $cursor, 'last_attempted_at' => now(), 'last_error_code' => null, 'updated_at' => now(), ]); $batches = 0; $sourceRows = 0; try { while ($batches < $maxBatches) { $rows = $this->connection->table('store_report_bill_rows') ->where('generation', $generation)->where('source_id', '>', $cursor) ->orderBy('source_id')->limit($batchSize) ->get(['source_id', 'store_name', 'occurred_at', 'income', 'expense']) ->map(static fn (object $row): array => (array) $row)->all(); if ($rows === []) { return $this->completeDailySummary($generation, $cursor, $batches, $sourceRows); } $cursor = max(array_map(static fn (array $row): int => (int) $row['source_id'], $rows)); $deltas = $this->aggregateDailyRows($rows, 1.0); $this->connection->transaction(function () use ($generation, $cursor, $deltas): void { $this->applyDailyDeltas($generation, $deltas); $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::DAILY_DATASET) ->update(['cursor_id' => $cursor, 'updated_at' => now()]); }); $sourceRows += count($rows); $batches++; if (count($rows) < $batchSize) { return $this->completeDailySummary($generation, $cursor, $batches, $sourceRows); } } } catch (Throwable $exception) { $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::DAILY_DATASET) ->update(['status' => 'failed', 'last_error_code' => class_basename($exception), 'updated_at' => now()]); throw $exception; } $stored = (int) $this->connection->table('store_report_daily_rows')->where('generation', $generation)->count(); return $this->result('running', 'full', $batches, $sourceRows, $stored, $cursor); } /** @return array{status:string,mode:string,batches:int,source_rows:int,stored_rows:int,cursor_id:int} */ private function completeDailySummary(string $generation, int $cursor, int $batches, int $sourceRows): array { $rows = (int) $this->connection->table('store_report_daily_rows')->where('generation', $generation)->count(); $now = now(); $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::DAILY_DATASET) ->update([ 'status' => 'succeeded', 'run_mode' => null, 'active_generation' => $generation, 'pending_generation' => null, 'cursor_id' => $cursor, 'row_count' => $rows, 'initial_completed_at' => $now, 'last_succeeded_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]); return $this->result('succeeded', 'full', $batches, $sourceRows, $rows, $cursor); } private function dailySummaryReady(string $generation): bool { $state = $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::DAILY_DATASET)->first(); return $state !== null && (string) ($state->active_generation ?? '') === $generation && ! empty($state->initial_completed_at); } /** * 店铺维度报表的「销量」:把业绩明细的 * 「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」 * 按「店铺名称 + 发生日期」预汇总。 * * 业绩明细由 store-receipt:order-match:sync 按「账期 + 平台」整体重写,这里用 * 「行数 + 最近写入时间 + 金额合计」的指纹判断是否变化:没变化就复用当前版本, * 变化时全量重建(源表 60 万行左右,汇总后每店铺每天一行)。 * * @return array{status:string,source_rows:int,stored_rows:int,changed:bool} */ public function syncPaidSales(bool $force = false): array { $result = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('store-report:paid-sales', 600) ->get(fn () => $this->syncPaidSalesUnlocked($force)); return $result === false ? ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => 0, 'changed' => false] : $result; } private function syncPaidSalesUnlocked(bool $force): array { if (! Schema::connection($this->connection->getName()) ->hasTable(StoreReportRepository::PAID_SALES_SOURCE_TABLE)) { return ['status' => 'skipped', 'source_rows' => 0, 'stored_rows' => 0, 'changed' => false]; } $state = $this->state(StoreReportRepository::PAID_SALES_DATASET); $changes = app(StoreReportPaidSalesChanges::class); try { if (! $force && $changes->available() && ! empty($state->active_generation) && ! empty($state->initial_completed_at) && str_starts_with((string) ($state->source_fingerprint ?? ''), StoreReportPaidSalesChanges::VERSION)) { return $this->syncPaidSalesDates($state, $changes); } return $this->connection->transaction(function () use ($state, $force, $changes): array { $pending = $changes->available() ? $changes->pending() : []; $fingerprint = $changes->available() ? StoreReportPaidSalesChanges::VERSION . Str::uuid() : $this->paidSalesFingerprint(); $active = trim((string) ($state->active_generation ?? '')); if (! $force && $active !== '' && ! empty($state->initial_completed_at) && (string) ($state->source_fingerprint ?? '') === $fingerprint) { return [ 'status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => (int) ($state->row_count ?? 0), 'changed' => false, ]; } $now = now(); $generation = (string) Str::uuid(); $this->connection->statement( 'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE . ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)' . ' SELECT ?, store_name, occurred_date,' . ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)' . ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)' . ' + COALESCE(refund_amount, 0)), COUNT(*), ?' . ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE . " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL" . ' GROUP BY store_name, occurred_date', [$generation, $now->format('Y-m-d H:i:s')], ); $stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE) ->where('generation', $generation)->count(); $sourceRows = (int) ($this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->count()); $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET) ->update([ 'status' => 'succeeded', 'run_mode' => 'full', 'active_generation' => $generation, 'pending_generation' => null, 'row_count' => $stored, 'source_fingerprint' => $fingerprint, 'initial_completed_at' => $state->initial_completed_at ?? $now, 'last_attempted_at' => $now, 'last_succeeded_at' => $now, 'last_error_code' => null, 'updated_at' => $now, ]); // 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。 $this->connection->table(StoreReportRepository::PAID_SALES_TABLE) ->where('generation', '<>', $generation)->delete(); if ($changes->available()) $changes->acknowledge($pending); return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true]; }); } catch (Throwable $exception) { $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET) ->update([ 'status' => 'failed', 'last_attempted_at' => now(), 'last_error_code' => class_basename($exception), 'updated_at' => now(), ]); return [ 'status' => 'failed', 'source_rows' => 0, 'stored_rows' => (int) ($state->row_count ?? 0), 'changed' => false, ]; } } private function syncPaidSalesDates(object $state, StoreReportPaidSalesChanges $changes): array { $pending = $changes->pending(); if ($pending === []) { return ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => (int) $state->row_count, 'changed' => false]; } $dates = array_column($pending, 'occurred_on'); $generation = (string) $state->active_generation; return $this->connection->transaction(function () use ($changes, $pending, $dates, $generation): array { $this->connection->table(StoreReportRepository::PAID_SALES_TABLE) ->where('generation', $generation)->whereIn('occurred_on', $dates)->delete(); $this->connection->statement( 'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE . ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)' . ' SELECT ?, store_name, occurred_date,' . ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)' . ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)' . ' + COALESCE(refund_amount, 0)), COUNT(*), ?' . ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE . " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IN (" . implode(',', array_fill(0, count($dates), '?')) . ') GROUP BY store_name, occurred_date', [$generation, now()->format('Y-m-d H:i:s'), ...$dates], ); $stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)->where('generation', $generation)->count(); $sourceRows = (int) $this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->whereIn('occurred_date', $dates)->count(); $this->connection->table('store_report_import_states') ->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)->update([ 'status' => 'succeeded', 'run_mode' => 'incremental', 'row_count' => $stored, 'source_fingerprint' => StoreReportPaidSalesChanges::VERSION . Str::uuid(), 'last_attempted_at' => now(), 'last_succeeded_at' => now(), 'last_error_code' => null, 'updated_at' => now(), ]); $changes->acknowledge($pending); return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true]; }); } /** 业绩明细是否变化的指纹:行数 + 最近写入时间 + 销量口径四项金额合计。 */ private function paidSalesFingerprint(): string { $row = $this->connection->selectOne( 'SELECT COUNT(*) AS rows_total, MAX(imported_at) AS last_imported_at,' . ' COALESCE(SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)' . ' + COALESCE(offset_amount, 0) + COALESCE(default_compensation_amount, 0)' . ' + COALESCE(refund_amount, 0)), 0) AS sales_total' . ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE, ); return hash('sha256', implode('|', [ (string) ($row->rows_total ?? 0), (string) ($row->last_imported_at ?? ''), (string) ($row->sales_total ?? '0'), ])); } /** @param array{upserts:array>,deletes:array} $mapped */ private function dailyDeltas(string $generation, array $mapped): array { $ids = array_values(array_unique(array_merge( $mapped['deletes'], array_map(static fn (array $row): int => (int) $row['source_id'], $mapped['upserts']), ))); $deltas = []; foreach (array_chunk($ids, 1000) as $chunk) { $old = $this->connection->table('store_report_bill_rows') ->where('generation', $generation)->whereIn('source_id', $chunk) ->get(['store_name', 'occurred_at', 'income', 'expense']) ->map(static fn (object $row): array => (array) $row)->all(); $deltas = $this->mergeDailyDeltas($deltas, $this->aggregateDailyRows($old, -1.0)); } return $this->mergeDailyDeltas($deltas, $this->aggregateDailyRows($mapped['upserts'], 1.0)); } private function aggregateDailyRows(array $rows, float $sign): array { $result = []; foreach ($rows as $row) { $store = trim((string) ($row['store_name'] ?? '')); $date = substr((string) ($row['occurred_at'] ?? ''), 0, 10); if ($store === '' || strlen($date) !== 10) continue; $key = $store . "\0" . $date; $result[$key] ??= ['store_name' => $store, 'occurred_on' => $date, 'income' => 0.0, 'expense' => 0.0]; $result[$key]['income'] += $sign * (float) ($row['income'] ?? 0); $result[$key]['expense'] += $sign * (float) ($row['expense'] ?? 0); } return $result; } private function mergeDailyDeltas(array $left, array $right): array { foreach ($right as $key => $row) { $left[$key] ??= ['store_name' => $row['store_name'], 'occurred_on' => $row['occurred_on'], 'income' => 0.0, 'expense' => 0.0]; $left[$key]['income'] += $row['income']; $left[$key]['expense'] += $row['expense']; } return $left; } private function applyDailyDeltas(string $generation, array $deltas): void { if ($deltas === []) return; if ($this->connection->getDriverName() === 'sqlite') { foreach ($deltas as $row) { $current = $this->connection->table('store_report_daily_rows') ->where('generation', $generation)->where('store_name', $row['store_name']) ->where('occurred_on', $row['occurred_on'])->first(); $values = ['income' => (float) ($current->income ?? 0) + $row['income'], 'expense' => (float) ($current->expense ?? 0) + $row['expense'], 'updated_at' => now()]; $current === null ? $this->connection->table('store_report_daily_rows')->insert($values + [ 'generation' => $generation, 'store_name' => $row['store_name'], 'occurred_on' => $row['occurred_on'], ]) : $this->connection->table('store_report_daily_rows')->where('id', $current->id)->update($values); } return; } foreach (array_chunk(array_values($deltas), 500) as $chunk) { $bindings = []; $values = []; foreach ($chunk as $row) { $values[] = '(?,?,?,?,?,?)'; array_push($bindings, $generation, $row['store_name'], $row['occurred_on'], $row['income'], $row['expense'], now()->format('Y-m-d H:i:s')); } $this->connection->statement( 'INSERT INTO store_report_daily_rows (generation,store_name,occurred_on,income,expense,updated_at) VALUES ' . implode(',', $values) . ' ON DUPLICATE KEY UPDATE income=income+VALUES(income), expense=expense+VALUES(expense), updated_at=VALUES(updated_at)', $bindings, ); } } /** @return array{status:string,mode:string,batches:int,source_rows:int,stored_rows:int,cursor_id:int} */ private function result(string $status, string $mode, int $batches, int $sourceRows, int $storedRows, int $cursor): array { return [ 'status' => $status, 'mode' => $mode, 'batches' => $batches, 'source_rows' => $sourceRows, 'stored_rows' => $storedRows, 'cursor_id' => $cursor, ]; } /** @return array{upserts:array>,deletes:array} */ private function mapBills(array $rows, string $generation): array { $upserts = []; $deletes = []; foreach ($rows as $row) { $sourceId = (int) ($row['source_id'] ?? 0); $storeName = $this->text($row['shop_name'] ?? null, 191); $occurredAt = $this->dateTime($row['fee_time'] ?? null); $direction = (string) ($row['inout_type'] ?? ''); if ($sourceId < 1 || (int) ($row['del_flag'] ?? 0) !== 0 || $storeName === null || $occurredAt === null || ! in_array($direction, ['1', '-1'], true)) { if ($sourceId > 0) { $deletes[] = $sourceId; } continue; } $amount = abs((float) ($row['amount'] ?? 0)); $platform = $this->text($row['channel'] ?? null, 32) ?? 'unknown'; $business = [$sourceId, $platform, $storeName, $occurredAt, $direction, $amount]; $upserts[] = [ 'generation' => $generation, 'source_id' => $sourceId, 'shop_id' => (int) ($row['shop_id'] ?? 0) ?: null, 'platform' => $platform, 'platform_label' => $this->platformLabel($platform), 'store_name' => $storeName, 'occurred_at' => $occurredAt, 'income' => $direction === '1' ? $amount : 0, 'expense' => $direction === '-1' ? $amount : 0, 'bill_item' => $this->text($row['fee_name'] ?? null, 255), 'record_code' => (string) $sourceId, 'source_file' => 'jiansun.dbo.jst_originalBillInfo', 'row_hash' => hash('sha256', json_encode($business, JSON_UNESCAPED_UNICODE)), 'source_updated_at' => $this->dateTime($row['source_updated_at'] ?? null), 'imported_at' => now(), ]; } return ['upserts' => $upserts, 'deletes' => $deletes]; } /** @return array{upserts:array>,deletes:array} */ private function mapLifecycles(array $rows, string $generation): array { $upserts = []; $deletes = []; foreach ($rows as $row) { $sourceId = (int) ($row['source_id'] ?? 0); $storeName = $this->text($row['erp_store_name'] ?? null, 191); $creditCode = $this->text($row['unified_social_credit_code'] ?? null, 64); $effectiveAt = $this->dateTime($row['effective_at'] ?? null); if ($sourceId < 1 || $storeName === null || $creditCode === null || $effectiveAt === null) { if ($sourceId > 0) { $deletes[] = $sourceId; } continue; } $business = [$sourceId, $storeName, $creditCode, $effectiveAt]; $upserts[] = [ 'generation' => $generation, 'source_id' => $sourceId, 'business_key' => hash('sha256', (string) $sourceId), 'erp_store_name' => $storeName, 'company_name' => $this->text($row['company_name'] ?? null, 191), 'unified_social_credit_code' => $creditCode, 'effective_at' => $effectiveAt, 'expires_at' => null, 'source_file' => 'jiansun.dbo.fys_shopInfo_flow', 'row_hash' => hash('sha256', json_encode($business, JSON_UNESCAPED_UNICODE)), 'imported_at' => now(), ]; } return ['upserts' => $upserts, 'deletes' => $deletes]; } private function platformLabel(string $channel): string { return match ($channel) { 'alipayCSV' => '淘系', 'TouTiaoFXGZJ' => '抖音', 'PinduoduoImport' => '拼多多', 'Jos' => '京东', default => $channel, }; } private function text(mixed $value, int $length): ?string { $value = trim((string) ($value ?? '')); if ($value === '') { return null; } if (! mb_check_encoding($value, 'UTF-8')) { $value = mb_convert_encoding($value, 'UTF-8', 'GB18030'); } return mb_substr($value, 0, $length); } private function sameCompany(mixed $left, mixed $right): bool { $left = mb_strtolower(trim((string) ($left ?? ''))); $right = mb_strtolower(trim((string) ($right ?? ''))); return $left !== '' && $left === $right; } private function dateTime(mixed $value): ?string { $value = trim((string) ($value ?? '')); return $value === '' ? null : str_replace('T', ' ', substr($value, 0, 26)); } private function assertSchemaReady(): void { $schema = Schema::connection($this->connection->getName()); foreach (['store_report_import_states', 'store_report_bill_rows', 'store_report_lifecycle_rows', 'store_report_daily_rows'] as $table) { if (! $schema->hasTable($table)) { throw new RuntimeException('store_report_mirror_schema_missing:' . $table); } } foreach (['run_mode', 'cursor_id', 'initial_completed_at'] as $column) { if (! $schema->hasColumn('store_report_import_states', $column)) { throw new RuntimeException('store_report_mirror_upgrade_missing:' . $column); } } } }