[ 'label' => '淘系支付宝', 'table' => 'taobao_alipay_bill_rows', 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', 'real_store' => 'online_real_store', ], 'taobao-wechat' => [ 'label' => '淘系微信', 'table' => 'taobao_wechat_bill_rows', 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', ], 'pinduoduo' => [ 'label' => '拼多多', 'table' => 'pdd_settlement_bill_rows', 'order' => 'online_order_no', 'time' => 'occurred_at', 'amount' => 'income_amount', ], 'dewu' => [ 'label' => '得物', 'table' => 'dewu_settlement_bill_rows', 'order' => 'online_order_no', 'time' => 'bill_date', 'amount' => 'income_amount', 'real_store' => 'online_real_store', ], 'jd-transactions' => [ 'label' => '京东账单', 'table' => 'jd_daily_bill_rows', 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', 'store_exclude' => '京喜', 'periods' => 'jd_daily_bill_periods', ], 'jingxi' => [ 'label' => '京喜账单', 'table' => 'jd_daily_bill_rows', 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', 'store_include' => '京喜', 'periods' => 'jd_daily_bill_periods', ], 'douyin' => [ 'label' => '抖音', 'table' => 'douyin_authoritative_bills', 'order' => 'order_no', 'time' => 'occurred_at', 'amount' => 'income', 'periods' => 'douyin_authoritative_periods', ], ]; /** 页签内多平台按店铺名前缀拆分;单平台页签无需拆分。 */ private const TAB_PLATFORM_GROUPS = [ 'taobao-alipay' => [ ['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true], ['platform' => '天猫', 'prefixes' => ['天猫']], ['platform' => '阿里巴巴', 'prefixes' => ['阿里巴巴', '1688', '新佰广']], ], 'taobao-wechat' => [ ['platform' => '淘宝', 'prefixes' => ['淘宝'], 'include_default' => true], ['platform' => '天猫', 'prefixes' => ['天猫']], ], 'pinduoduo' => [['platform' => '拼多多']], 'dewu' => [['platform' => '得物']], 'jd-transactions' => [['platform' => '京东']], 'jingxi' => [['platform' => '京喜']], 'douyin' => [['platform' => '抖音']], ]; private const STORE_PREFIXES = [ '唯品会JX', '唯品会MP', '阿里巴巴', '1688', '新佰广', '拼多多', '天猫', '淘宝', '抖音', '得物', '京喜', '京东', ]; /** * @return array{ * month:string,remark:string,generated_at:string,mapping_sha256:string,mapping_rows:int, * source_stats:array, * amounts:array,total:float * } */ public function build(string $month, string $mappingPath): array { if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) { throw new RuntimeException('月份格式必须是 YYYY-MM。'); } if (! is_file($mappingPath)) { throw new RuntimeException('科目映射表不存在:' . $mappingPath); } $start = CarbonImmutable::parse($month . '-01')->startOfMonth(); $end = $start->addMonth(); $reportMonth = $start->toDateString(); $itemsByPlatform = $this->readMappingItems($mappingPath); $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); $amounts = []; $sourceStats = []; foreach (self::SOURCES as $tab => $source) { $groups = $this->groupsForSource($tab, $itemsByPlatform); if ($groups === []) { continue; } $table = (string) $source['table']; $orderColumn = (string) $source['order']; $timeColumn = (string) $source['time']; $amountColumn = (string) $source['amount']; $realStoreColumn = (string) ($source['real_store'] ?? ''); $expenseColumn = ''; if (Schema::connection($connection)->hasColumn($table, 'expense_amount')) { $expenseColumn = 'expense_amount'; } elseif (Schema::connection($connection)->hasColumn($table, 'expense')) { $expenseColumn = 'expense'; } $select = array_values(array_unique(array_filter([ 'store_name', 'bill_item', $orderColumn, $timeColumn, $amountColumn, $expenseColumn, $realStoreColumn, ]))); $query = DB::connection($connection)->table($table) ->select($select) ->where($timeColumn, '>=', $start->toDateTimeString()) ->where($timeColumn, '<', $end->toDateTimeString()); if (isset($source['store_include'])) { $query->where('store_name', 'like', '%' . $source['store_include'] . '%'); } if (isset($source['store_exclude'])) { $query->where('store_name', 'not like', '%' . $source['store_exclude'] . '%'); } $periodsTable = (string) ($source['periods'] ?? ''); if ($periodsTable !== '') { $generations = DB::connection($connection)->table($periodsTable) ->where('period_start', '<', $end->toDateString()) ->where('period_end', '>', $start->toDateString()) ->pluck('active_generation') ->filter() ->unique() ->values() ->all(); if ($generations === []) { continue; } $query->whereIn('generation', $generations); } $query->where(function ($outer) use ($groups): void { foreach ($groups as $group) { $outer->orWhere(function ($sub) use ($group): void { if ($group['prefixes'] !== []) { $sub->where(function ($scope) use ($group): void { $scope->where(function ($own) use ($group): void { foreach ($group['prefixes'] as $prefix) { $own->orWhere('store_name', 'like', $prefix . '%'); } }); if ($group['include_default']) { $scope->orWhere(function ($other): void { foreach (self::STORE_PREFIXES as $prefix) { $other->where('store_name', 'not like', $prefix . '%'); } }); } }); } $sub->whereIn('bill_item', $group['items']); }); } }); $rows = 0; $amountTotal = 0.0; foreach ($query->orderBy($timeColumn)->cursor() as $row) { $rows++; $store = $realStoreColumn !== '' ? trim((string) ($row->{$realStoreColumn} ?? '')) : ''; if ($store === '') { $store = trim((string) ($row->store_name ?? '')); } $orderNo = trim((string) ($row->{$orderColumn} ?? '')); $amount = (float) ($row->{$amountColumn} ?? 0) - ($expenseColumn !== '' ? (float) ($row->{$expenseColumn} ?? 0) : 0.0); $key = $this->rowKey( $reportMonth, (string) $source['label'], $store, $orderNo, (string) ($row->bill_item ?? ''), ); $amounts[$key] = round(($amounts[$key] ?? 0.0) + $amount, 2); $amountTotal += $amount; } $sourceStats[(string) $source['label']] = [ 'rows' => $rows, 'amount' => round($amountTotal, 2), 'groups' => count($groups), ]; } ksort($amounts); return [ 'month' => $month, 'remark' => self::REMARK, 'generated_at' => now()->toIso8601String(), 'mapping_sha256' => hash_file('sha256', $mappingPath), 'mapping_rows' => array_sum(array_map('count', $itemsByPlatform)), 'source_stats' => $sourceStats, 'amounts' => $amounts, 'total' => round(array_sum($amounts), 2), ]; } /** @return array> */ private function readMappingItems(string $mappingPath): array { $sheet = IOFactory::load($mappingPath)->getActiveSheet(); $rows = $sheet->toArray(null, true, true, false); if ($rows === []) { throw new RuntimeException('科目映射表没有数据。'); } $headers = array_map(static fn ($value): string => trim((string) $value), array_shift($rows)); $platformIndex = array_search('平台', $headers, true); $itemIndex = array_search('账单项目', $headers, true); $remarkIndex = array_search('财务备注', $headers, true); if ($platformIndex === false || $itemIndex === false || $remarkIndex === false) { throw new RuntimeException('科目映射表缺少「平台」「账单项目」或「财务备注」列。'); } $items = []; foreach ($rows as $row) { if (trim((string) ($row[$remarkIndex] ?? '')) !== self::REMARK) { continue; } $platform = trim((string) ($row[$platformIndex] ?? '')); $item = trim((string) ($row[$itemIndex] ?? '')); if ($platform !== '' && $item !== '') { $items[$platform][$item] = true; } } $result = []; foreach ($items as $platform => $values) { $result[$platform] = array_keys($values); sort($result[$platform], SORT_NATURAL | SORT_FLAG_CASE); } return $result; } /** * @param array> $itemsByPlatform * @return list,include_default:bool,items:list}> */ private function groupsForSource(string $tab, array $itemsByPlatform): array { $groups = []; foreach (self::TAB_PLATFORM_GROUPS[$tab] ?? [] as $definition) { $platform = (string) $definition['platform']; $items = $itemsByPlatform[$platform] ?? []; if ($items === []) { continue; } if ($tab === 'taobao-wechat') { $items = $this->withWxVariants($items); } $groups[] = [ 'prefixes' => array_values((array) ($definition['prefixes'] ?? [])), 'include_default' => (bool) ($definition['include_default'] ?? false), 'items' => $items, ]; } return $groups; } /** @param list $items @return list */ private function withWxVariants(array $items): array { $variants = []; foreach ($items as $item) { $variants[$item] = true; if (stripos($item, 'wx') === 0) { $without = ltrim(mb_substr($item, 2)); if ($without !== '') { $variants[$without] = true; } } else { $variants['wx' . $item] = true; } } $list = array_keys($variants); sort($list, SORT_NATURAL | SORT_FLAG_CASE); return $list; } private function rowKey(string $month, string $platform, string $storeName, string $orderNo, string $billItem): string { $shopKey = $this->normalizeShop($storeName); $identity = $this->orderIdentity($orderNo); if ($identity === '') { return md5($month . '|' . $platform . '|' . $shopKey . '|promo|' . $billItem); } return md5($month . '|' . $platform . '|' . $shopKey . '|' . $identity); } private function normalizeShop(string $value): string { $value = str_replace(["\u{3000}", '_'], [' ', '-'], trim($value)); $value = preg_replace('/\s+/u', '', $value) ?? ''; return strtoupper($value); } private function orderIdentity(string $value): string { $value = str_replace("\u{3000}", ' ', trim($value)); if ($value === '') { return ''; } $value = preg_replace('/[\x{FF0D}\x{2014}\x{2013}\x{2010}]/u', '-', $value) ?? $value; $value = preg_replace('/\s+/u', '', $value) ?? ''; return strtoupper($value); } }