diff --git a/app/Business/TaobaoAlipayBillSpreadsheetImporter.php b/app/Business/TaobaoAlipayBillSpreadsheetImporter.php index c6fad7a1..fb8af9e2 100644 --- a/app/Business/TaobaoAlipayBillSpreadsheetImporter.php +++ b/app/Business/TaobaoAlipayBillSpreadsheetImporter.php @@ -35,7 +35,7 @@ class TaobaoAlipayBillSpreadsheetImporter 'counterparty_account', 'account_order_no', 'business_serial_no', 'goods_name', ]; - public function import(string $path, int $companyId): array + public function import(string $path, int $companyId, bool $replace = false): array { if (! is_file($path) || ! is_readable($path)) { throw new InvalidArgumentException('导入文件不存在或不可读取。'); @@ -55,21 +55,27 @@ class TaobaoAlipayBillSpreadsheetImporter throw new InvalidArgumentException('表头与淘系支付宝账户模板不一致。'); } + $rows = []; + $skipped = 0; + for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) { + $values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0]; + if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) { + $skipped++; + continue; + } + $rows[] = $this->normalizeRow(array_combine(self::FIELDS, $values), $rowNumber); + } + $inserted = 0; $updated = 0; - $skipped = 0; + $replaced = 0; - DB::transaction(function () use ($sheet, $companyId, &$inserted, &$updated, &$skipped) { - for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) { - $values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0]; + DB::transaction(function () use ($rows, $companyId, $replace, &$inserted, &$updated, &$replaced) { + if ($replace) { + $replaced = DB::table('ec_alipay_bills')->where('company_id', $companyId)->delete(); + } - if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) { - $skipped++; - continue; - } - - $data = array_combine(self::FIELDS, $values); - $data = $this->normalizeRow($data, $rowNumber); + foreach ($rows as $data) { $record = AlipayBill::query() ->where('company_id', $companyId) ->where('record_code', $data['record_code']) @@ -93,7 +99,7 @@ class TaobaoAlipayBillSpreadsheetImporter $spreadsheet->disconnectWorksheets(); - return compact('inserted', 'updated', 'skipped'); + return compact('inserted', 'updated', 'skipped', 'replaced'); } private function normalizeRow(array $data, int $rowNumber): array diff --git a/app/Console/Commands/ImportTaobaoAlipayBills.php b/app/Console/Commands/ImportTaobaoAlipayBills.php index 57dae215..3e4b228e 100644 --- a/app/Console/Commands/ImportTaobaoAlipayBills.php +++ b/app/Console/Commands/ImportTaobaoAlipayBills.php @@ -10,7 +10,8 @@ class ImportTaobaoAlipayBills extends Command { protected $signature = 'business:import-taobao-alipay-bills {file : 淘系支付宝账户 Excel 文件路径} - {--company=1 : 数据所属公司 ID}'; + {--company=1 : 数据所属公司 ID} + {--replace : 校验通过后替换该公司的全部淘系支付宝账单}'; protected $description = '从标准附件导入店铺收款账户账单的淘系支付宝账户数据'; @@ -20,6 +21,7 @@ class ImportTaobaoAlipayBills extends Command $result = $importer->import( (string) $this->argument('file'), max(1, (int) $this->option('company')), + (bool) $this->option('replace'), ); } catch (Throwable $e) { $this->components->error($e->getMessage()); @@ -28,7 +30,7 @@ class ImportTaobaoAlipayBills extends Command } $this->components->info( - "导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,跳过 {$result['skipped']} 条。" + "导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,替换旧数据 {$result['replaced']} 条,跳过 {$result['skipped']} 条。" ); return self::SUCCESS; diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 34520ed5..ab0e08c1 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -16,17 +16,17 @@ class AlipayBills extends Controller 'store_name' => '原始店铺', 'custom_remark' => '自定义备注', 'bill_item' => '账单项目', - 'alipay_account' => '平台渠道', + 'alipay_account' => '支付宝账号', 'account_order_no' => '账务订单号', 'business_serial_no' => '业务流水号', 'merchant_order_no' => '商户订单号', 'business_base_order_no' => '业务基础订单号', - 'goods_name' => '商品编码', + 'goods_name' => '商品名称', 'counterparty_account' => '对方账号', 'occurred_at' => '发生时间', 'income' => '收入', 'expense' => '支出', - 'balance' => '收支净额', + 'balance' => '余额', 'business_type' => '业务类型', 'remark' => '备注', 'online_order_no' => '线上单号', @@ -52,7 +52,7 @@ class AlipayBills extends Controller $dir = $request->get('dir') === 'asc' ? 'asc' : 'desc'; $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], - 'alipay_account' => ['nullable', 'string', 'max:128'], + 'source_channel' => ['nullable', 'string', 'max:128'], 'bill_item' => ['nullable', 'string', 'max:255'], 'business_type' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], @@ -90,7 +90,7 @@ class AlipayBills extends Controller $this->authorizeStoreReceivingBills(); $filters = $request->validate([ 'store_name' => ['nullable', 'string', 'max:255'], - 'alipay_account' => ['nullable', 'string', 'max:128'], + 'source_channel' => ['nullable', 'string', 'max:128'], 'bill_item' => ['nullable', 'string', 'max:255'], 'business_type' => ['nullable', 'string', 'max:255'], 'start' => ['nullable', 'date_format:Y-m-d'], diff --git a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php index 6b68c570..eb51b30d 100644 --- a/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/StoreReceivingAlipayBillMirrorRepository.php @@ -3,6 +3,7 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use RuntimeException; @@ -17,6 +18,7 @@ class StoreReceivingAlipayBillMirrorRepository public const DATASET = 'operations_replica.rpt.jst_originalBillInfo'; public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo'; + public const SKU_DATASET = 'operations_replica.rpt.jst_sku'; public function __construct( private readonly BusinessMirrorDatasetRegistry $datasets, @@ -58,7 +60,7 @@ class StoreReceivingAlipayBillMirrorRepository $perPage = min(max($perPage, 1), 100); $direction = $direction === 'asc' ? 'asc' : 'desc'; $sortColumn = [ - 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : 'b.Id', + 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'), 'occurred_at' => 'b.fee_time', 'income' => 'b.amount', 'expense' => 'b.amount', @@ -88,8 +90,10 @@ class StoreReceivingAlipayBillMirrorRepository $query, $sortColumn, $direction, $page, $perPage, $filters, $channel, $targetTable, ); $shopNames = $this->shopNames($billRows); + $productNames = $mapping === 'taobao' ? $this->productNames($billRows) : []; + $importedRows = $mapping === 'taobao' ? $this->importedRows($billRows) : []; $rows = collect($billRows) - ->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping)) + ->map(fn (object $row): array => $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows)) ->all(); $income = $summary['income']; $expense = $summary['expense']; @@ -146,7 +150,7 @@ class StoreReceivingAlipayBillMirrorRepository $shopNames = $this->allShopNames(); $direction = $direction === 'desc' ? 'desc' : 'asc'; $sortColumn = [ - 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : 'b.Id', + 'record_code' => in_array($mapping, ['pinduoduo', 'dewu'], true) ? 'b.auto_id' : ($mapping === 'taobao' ? 'b.p_auto_id' : 'b.Id'), 'occurred_at' => 'b.fee_time', 'income' => 'b.amount', 'expense' => 'b.amount', @@ -238,8 +242,10 @@ class StoreReceivingAlipayBillMirrorRepository } $rows = $batch->select('b.*')->orderBy($sortColumn, $direction) ->orderBy('b.Id', $direction)->limit(1000)->get(); + $productNames = $mapping === 'taobao' ? $this->productNames($rows->all()) : []; + $importedRows = $mapping === 'taobao' ? $this->importedRows($rows->all()) : []; foreach ($rows as $row) { - yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping); + yield $this->mapRowFor($row, $shopNames[(string) $row->shop_id] ?? null, $mapping, $productNames, $importedRows); } $last = $rows->last(); } while ($rows->count() === 1000); @@ -321,7 +327,7 @@ class StoreReceivingAlipayBillMirrorRepository private function dailySummarySupports(array $filters): bool { - return ($filters['alipay_account'] ?? '') === '' + return ($filters['source_channel'] ?? '') === '' && ($filters['bill_item'] ?? '') === '' && ($filters['keyword'] ?? '') === ''; } @@ -377,8 +383,8 @@ class StoreReceivingAlipayBillMirrorRepository $query->whereIn('b.shop_id', $shopIds); } } - if (($filters['alipay_account'] ?? '') !== '') { - $query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['alipay_account']) . '%'); + if (($filters['source_channel'] ?? '') !== '') { + $query->where('b.channel', 'like', '%' . $this->escapeLike((string) $filters['source_channel']) . '%'); } if (($filters['bill_item'] ?? '') !== '') { $query->where('b.fee_name', 'like', '%' . $this->escapeLike((string) $filters['bill_item']) . '%'); @@ -488,6 +494,47 @@ class StoreReceivingAlipayBillMirrorRepository ->all(); } + /** @param array $rows @return array */ + private function productNames(array $rows): array + { + $skuIds = collect($rows)->map(fn (object $row): ?string => $row->sku_id ?: $row->shop_sku_id) + ->filter()->unique()->values()->all(); + if ($skuIds === []) return []; + + try { + $dataset = $this->datasets->get(self::SKU_DATASET); + } catch (\Throwable) { + return []; + } + if (! $this->mirror->ready($dataset) || ! Schema::connection($this->mirror->connection()->getName())->hasTable('ops_jst_sku')) { + return []; + } + + return $this->mirror->connection()->table('ops_jst_sku') + ->whereIn('sku_id', $skuIds)->pluck('name', 'sku_id') + ->mapWithKeys(static fn (mixed $name, mixed $id): array => [(string) $id => (string) $name]) + ->all(); + } + + /** @param array $rows @return array */ + private function importedRows(array $rows): array + { + if (! Schema::hasTable('ec_alipay_bills')) { + return []; + } + + $recordCodes = collect($rows)->pluck('p_auto_id')->filter()->unique()->values()->all(); + if ($recordCodes === []) { + return []; + } + + return DB::table('ec_alipay_bills') + ->where('company_id', (int) (company_id() ?: 1)) + ->whereNull('deleted_at') + ->whereIn('record_code', $recordCodes) + ->get()->keyBy(fn (object $row): string => (string) $row->record_code)->all(); + } + /** @param array $rows */ public function mapDouyinRows(array $rows): array { @@ -495,44 +542,63 @@ class StoreReceivingAlipayBillMirrorRepository return array_map(fn (object $row): array => $this->mapRowFor($row, $shops[(string) $row->shop_id] ?? null, 'douyin'), $rows); } - private function mapRow(object $row, ?string $shopName): array + private function mapRow(object $row, ?string $shopName, array $productNames = [], array $importedRows = []): array { $amount = abs((float) ($row->amount ?? 0)); $income = (string) ($row->inout_type ?? '') === '1' ? $amount : 0.0; $expense = (string) ($row->inout_type ?? '') === '-1' ? $amount : 0.0; - return [ + $mapped = [ 'id' => (string) $row->Id, - 'record_code' => (string) $row->Id, + 'record_code' => (string) ($row->p_auto_id ?: $row->Id), 'store_name' => $shopName, 'custom_remark' => null, 'bill_item' => $row->fee_name, - 'alipay_account' => $row->channel, - 'account_order_no' => $row->primary_key, - 'business_serial_no' => $row->auto_id, - 'merchant_order_no' => $row->p_auto_id, - 'business_base_order_no' => $row->so_id, - 'goods_name' => $row->sku_id ?: $row->shop_sku_id, + 'alipay_account' => null, + 'account_order_no' => null, + 'business_serial_no' => null, + 'merchant_order_no' => null, + 'business_base_order_no' => null, + 'goods_name' => $productNames[(string) ($row->sku_id ?: $row->shop_sku_id)] ?? null, 'counterparty_account' => null, 'occurred_at' => $this->dateTime($row->fee_time), 'income' => $income, 'expense' => $expense, - 'balance' => round($income - $expense, 2), - 'business_type' => $this->directionName($row->inout_type), + 'balance' => null, + 'business_type' => null, 'remark' => null, 'online_order_no' => $row->so_id, 'online_real_store' => $shopName, - 'business_description' => $row->combine_type, - 'business_bill_source' => $row->channel, - 'business_order_no' => $row->p_auto_id, + 'business_description' => null, + 'business_bill_source' => null, + 'business_order_no' => null, 'created_at' => $this->dateTime($row->createTime), 'updated_at' => $this->dateTime($row->updateTime), 'modifier' => '', ]; + + $imported = $importedRows[$mapped['record_code']] ?? null; + if ($imported === null) { + return $mapped; + } + + foreach ([ + 'custom_remark', 'alipay_account', 'account_order_no', 'business_serial_no', + 'merchant_order_no', 'business_base_order_no', 'counterparty_account', 'balance', + 'business_type', 'remark', 'business_description', 'business_bill_source', + 'business_order_no', 'modifier', + ] as $field) { + $mapped[$field] = $imported->{$field} ?? $mapped[$field]; + } + if ($mapped['goods_name'] === null || $mapped['goods_name'] === '') { + $mapped['goods_name'] = $imported->goods_name ?? null; + } + + return $mapped; } /** @return array */ - private function mapRowFor(object $row, ?string $shopName, string $mapping): array + private function mapRowFor(object $row, ?string $shopName, string $mapping, array $productNames = [], array $importedRows = []): array { if ($mapping === 'pinduoduo') { return $this->mapPinduoduoRow($row, $shopName); @@ -543,7 +609,7 @@ class StoreReceivingAlipayBillMirrorRepository } if ($mapping !== 'douyin') { - return $this->mapRow($row, $shopName); + return $this->mapRow($row, $shopName, $productNames, $importedRows); } $amount = abs((float) ($row->amount ?? 0)); diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index d2663e30..98afe930 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -10,7 +10,7 @@
- + @@ -71,8 +71,8 @@ import TableColumnSettings from './TableColumnSettings.vue'; const COLUMNS = [ ['记录编码', 'record_code', 'num', '基础信息'], ['原始店铺', 'store_name', 'text', '基础信息'], ['自定义备注', 'custom_remark', 'text', '基础信息'], ['账单项目', 'bill_item', 'tag', '基础信息'], - ['平台渠道', 'alipay_account', 'num', '账户与交易'], ['账务订单号', 'account_order_no', 'num', '账户与交易'], ['业务流水号', 'business_serial_no', 'num', '账户与交易'], ['商户订单号', 'merchant_order_no', 'num', '账户与交易'], ['业务基础订单号', 'business_base_order_no', 'num', '账户与交易'], ['商品编码', 'goods_name', 'num', '账户与交易'], ['对方账号', 'counterparty_account', 'text', '账户与交易'], ['发生时间', 'occurred_at', 'date', '账户与交易'], - ['收入', 'income', 'money_in', '金额(元)'], ['支出', 'expense', 'money_out', '金额(元)'], ['收支净额', 'balance', 'money', '金额(元)'], + ['支付宝账号', 'alipay_account', 'num', '账户与交易'], ['账务订单号', 'account_order_no', 'num', '账户与交易'], ['业务流水号', 'business_serial_no', 'num', '账户与交易'], ['商户订单号', 'merchant_order_no', 'num', '账户与交易'], ['业务基础订单号', 'business_base_order_no', 'num', '账户与交易'], ['商品名称', 'goods_name', 'text', '账户与交易'], ['对方账号', 'counterparty_account', 'text', '账户与交易'], ['发生时间', 'occurred_at', 'date', '账户与交易'], + ['收入', 'income', 'money_in', '金额(元)'], ['支出', 'expense', 'money_out', '金额(元)'], ['余额', 'balance', 'money', '金额(元)'], ['业务类型', 'business_type', 'tag', '业务关联'], ['备注', 'remark', 'text', '业务关联'], ['线上单号', 'online_order_no', 'num', '业务关联'], ['线上单号对应实际店铺', 'online_real_store', 'text', '业务关联'], ['业务描述', 'business_description', 'text', '业务关联'], ['业务账单来源', 'business_bill_source', 'text', '业务关联'], ['业务订单号', 'business_order_no', 'num', '维护信息'], ['创建时间', 'created_at', 'date', '维护信息'], ['修改时间', 'updated_at', 'date', '维护信息'], ['修改人', 'modifier', 'text', '维护信息'], ].map(([label, field, kind, group]) => ({ key: field, label, field, kind, group })); @@ -81,17 +81,17 @@ const SORTABLE = ['occurred_at', 'income', 'expense', 'balance', 'record_code']; export default { name: 'TaobaoAlipayBills', components: { TableColumnSettings }, props: { apiBase: { type: String, required: true }, exportBase: { type: String, required: true }, userKey: { type: [String, Number], required: true } }, - data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; }, + data() { return { loading: false, error: '', rows: [], configuredColumns: [], summary: { income: 0, expense: 0, balance: 0, count: 0 }, total: 0, currentPage: 1, lastPage: 1, perPage: 20, filters: { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }, sort: { key: 'occurred_at', dir: 'desc' }, exportMode: 'filtered-visible' }; }, computed: { defaultColumns() { return COLUMNS; }, visibleColumns() { return this.configuredColumns.filter((column) => column.visible); }, visibleGroups() { return this.visibleColumns.reduce((groups, column) => { const last = groups[groups.length - 1]; if (last && last.label === column.group) last.span += 1; else groups.push({ label: column.group, span: 1 }); return groups; }, []); }, - stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支净额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; }, + stats() { return [{ label: '收入合计', value: this.money(this.summary.income), note: `${this.summary.count} 笔交易`, className: 'is-income' }, { label: '支出合计', value: this.money(this.summary.expense), note: '按当前筛选', className: 'is-expense' }, { label: '收支差额', value: this.money(this.summary.balance), note: '收入合计 - 支出合计', className: '' }, { label: '交易笔数', value: this.summary.count, note: '当前筛选结果', className: '' }]; }, rangeStart() { return this.total === 0 ? 0 : (this.currentPage - 1) * this.perPage + 1; }, rangeEnd() { return Math.min(this.currentPage * this.perPage, this.total); }, }, mounted() { this.fetch(); }, methods: { async fetch() { this.loading = true; this.error = ''; try { const response = await window.axios.get(this.apiBase, { params: { ...this.filters, sort: this.sort.key, dir: this.sort.dir, limit: this.perPage, page: this.currentPage } }); const body = response.data || {}; this.rows = body.data || []; this.total = body.total || 0; this.currentPage = body.current_page || 1; this.lastPage = body.last_page || 1; this.perPage = body.per_page || this.perPage; this.summary = body.summary || { income: 0, expense: 0, balance: 0, count: 0 }; } catch (error) { this.rows = []; this.total = 0; this.summary = { income: 0, expense: 0, balance: 0, count: 0 }; this.error = (error.response && error.response.data && error.response.data.message) || '淘系支付宝账单暂时无法读取,请稍后重试。'; } finally { this.loading = false; } }, - applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', alipay_account: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); }, + applyFilters() { this.currentPage = 1; this.fetch(); }, resetFilters() { this.filters = { store_name: '', source_channel: '', bill_item: '', business_type: '', start: '', end: '', keyword: '' }; this.applyFilters(); }, changePage(page) { this.currentPage = page; this.fetch(); this.$nextTick(() => { if (this.$refs.scroll) this.$refs.scroll.scrollTop = 0; }); }, maybeSort(column) { if (!this.sortableField(column.field)) return; this.sort.dir = this.sort.key === column.field && this.sort.dir === 'desc' ? 'asc' : 'desc'; this.sort.key = column.field; this.applyFilters(); }, sortableField(field) { return SORTABLE.indexOf(field) !== -1; }, sortMark(field) { return this.sort.key === field ? (this.sort.dir === 'asc' ? '↑' : '↓') : '↕'; }, rawValue(row, column) { const value = row[column.field]; return value === null || value === undefined ? '' : value; }, diff --git a/tests/Feature/Console/ImportTaobaoAlipayBillsTest.php b/tests/Feature/Console/ImportTaobaoAlipayBillsTest.php index ddac2684..65ffd9bf 100644 --- a/tests/Feature/Console/ImportTaobaoAlipayBillsTest.php +++ b/tests/Feature/Console/ImportTaobaoAlipayBillsTest.php @@ -64,6 +64,30 @@ class ImportTaobaoAlipayBillsTest extends TestCase ->assertFailed(); } + public function testReplaceValidatesFirstAndAtomicallyRemovesOldRows(): void + { + $old = AlipayBill::query()->create([ + 'company_id' => 1, 'record_code' => 'OLD-ROW', 'store_name' => '旧店铺', + 'bill_item' => '旧账单', 'business_type' => '', 'alipay_account' => '', + 'counterparty_account' => '', 'account_order_no' => '', 'business_serial_no' => '', + 'goods_name' => '', 'income' => 0, 'expense' => 0, 'balance' => 0, + 'platform' => 'taobao', 'created_from' => 'old-data', + ]); + + $this->artisan('business:import-taobao-alipay-bills', [ + 'file' => $this->workbook('错误表头'), '--company' => 1, '--replace' => true, + ])->assertFailed(); + $this->assertDatabaseHas('ec_alipay_bills', ['id' => $old->id, 'record_code' => 'OLD-ROW']); + + $this->artisan('business:import-taobao-alipay-bills', [ + 'file' => $this->workbook(), '--company' => 1, '--replace' => true, + ])->expectsOutputToContain('替换旧数据 1 条')->assertSuccessful(); + + $this->assertSame(1, AlipayBill::query()->where('company_id', 1)->count()); + $this->assertDatabaseMissing('ec_alipay_bills', ['record_code' => 'OLD-ROW']); + $this->assertDatabaseHas('ec_alipay_bills', ['record_code' => '2000000001141269665']); + } + private function workbook(?string $firstHeader = null, string $sheetName = '淘系支付宝账户'): string { $spreadsheet = new Spreadsheet(); diff --git a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php index b04b6025..f6c4c46a 100644 --- a/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/StoreReceivingAlipayBillMirrorRepositoryTest.php @@ -90,10 +90,11 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase ); $this->assertSame(2, $result['total']); - $this->assertSame('2', $result['data'][0]['record_code']); + $this->assertSame('P-2', $result['data'][0]['record_code']); $this->assertSame('天猫-经营店', $result['data'][0]['store_name']); - $this->assertSame('支出', $result['data'][0]['business_type']); - $this->assertSame('alipayCSV', $result['data'][0]['alipay_account']); + $this->assertNull($result['data'][0]['business_type']); + $this->assertNull($result['data'][0]['alipay_account']); + $this->assertNull($result['data'][0]['balance']); $this->assertSame(100.25, $result['summary']['income']); $this->assertSame(20.10, $result['summary']['expense']); $this->assertSame(80.15, $result['summary']['balance']); @@ -107,6 +108,40 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase $this->repository->paginate([], 'occurred_at', 'desc', 1, 10); } + public function test_imported_attachment_rows_enrich_matching_mirror_rows_for_the_taobao_page(): void + { + $this->markReady(); + DB::connection('business_mirror')->table('ops_jst_shop_info')->insert([ + 'Id' => 10, 'shop_id' => 7, 'shop_name' => '天猫-经营店', 'shop_site' => '淘宝天猫', + ]); + DB::connection('business_mirror')->table('ops_jst_original_bill_info')->insert( + $this->row(1, 7, '2026-08-01 09:00:00', '1', 100.25), + ); + DB::table('ec_alipay_bills')->insert([ + 'company_id' => 1, 'record_code' => 'P-1', 'store_name' => '天猫-附件店铺', + 'custom_remark' => '模板数据', 'bill_item' => '交易收款', 'alipay_account' => '20880001', + 'account_order_no' => 'ACCOUNT-1', 'business_serial_no' => 'SERIAL-1', + 'merchant_order_no' => 'MERCHANT-1', 'business_base_order_no' => 'BASE-1', + 'goods_name' => '附件商品', 'counterparty_account' => '买家', + 'occurred_at' => '2026-08-01 09:00:00', 'income' => 100.25, 'expense' => 0, 'balance' => 300.50, + 'business_type' => '交易付款', 'remark' => '备注', 'online_order_no' => 'ONLINE-1', + 'online_real_store' => '天猫-附件店铺', 'business_description' => '业务描述', + 'business_bill_source' => '支付宝', 'business_order_no' => 'BUSINESS-1', + 'modifier' => '导入人', 'platform' => 'taobao', 'created_from' => 'attachment-import', + 'created_at' => '2026-08-02 09:00:00', 'updated_at' => '2026-08-02 10:00:00', + ]); + + $result = $this->repository->paginate([], 'occurred_at', 'desc', 1, 10); + + $this->assertSame(1, $result['total']); + $this->assertSame('P-1', $result['data'][0]['record_code']); + $this->assertSame('天猫-经营店', $result['data'][0]['store_name']); + $this->assertSame('20880001', $result['data'][0]['alipay_account']); + $this->assertSame(300.50, $result['data'][0]['balance']); + $this->assertSame(100.25, $result['summary']['income']); + $this->assertSame('succeeded', $result['sync']['status']); + } + public function test_export_uses_the_same_filters_and_mapping_as_the_page(): void { $this->markReady(); @@ -119,11 +154,11 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase ]); $rows = iterator_to_array($this->repository->exportRows([ - 'business_type' => '支出', 'alipay_account' => 'alipayCSV', 'keyword' => 'SKU-2', + 'business_type' => '支出', 'source_channel' => 'alipayCSV', 'keyword' => 'SKU-2', ])); $this->assertCount(1, $rows); - $this->assertSame('2', $rows[0]['record_code']); + $this->assertSame('P-2', $rows[0]['record_code']); $this->assertSame(20.10, $rows[0]['expense']); } @@ -197,7 +232,7 @@ class StoreReceivingAlipayBillMirrorRepositoryTest extends TestCase } foreach (array_chunk($fixtures, 100) as $chunk) $table->insert($chunk); $table->insert($this->row(9999, 7, '2026-08-01 09:00:00', '1', 10, 'OtherChannel')); - foreach (['occurred_at' => 'fee_time', 'income' => 'amount', 'record_code' => 'Id'] as $sort => $column) { + foreach (['occurred_at' => 'fee_time', 'income' => 'amount', 'record_code' => 'p_auto_id'] as $sort => $column) { foreach (['asc', 'desc'] as $direction) { $expected = (clone $table)->where('channel', 'CommonBillTGCPay') ->orderBy($column, $direction)->orderBy('Id', $direction)->pluck('Id')->map('strval')->all(); diff --git a/tests/Unit/View/TaobaoAlipayBillsTableTest.php b/tests/Unit/View/TaobaoAlipayBillsTableTest.php index 62e54c09..87dcc33a 100644 --- a/tests/Unit/View/TaobaoAlipayBillsTableTest.php +++ b/tests/Unit/View/TaobaoAlipayBillsTableTest.php @@ -14,13 +14,14 @@ class TaobaoAlipayBillsTableTest extends TestCase ); $this->assertIsString($source); - $this->assertStringContainsString('基础信息', $source); - $this->assertStringContainsString('assertStringContainsString('v-if="error" class="tb-error"', $source); - $this->assertStringContainsString('e.response.data.message', $source); - $this->assertStringContainsString('', $source); - $this->assertStringContainsString('@click="exportRows">导出', $source); - $this->assertStringContainsString('收支净额', $source); + $this->assertStringContainsString('v-for="(group, index) in visibleGroups"', $source); + $this->assertStringContainsString(':colspan="visibleColumns.length"', $source); + $this->assertStringContainsString('v-if="error" class="tw-alert"', $source); + $this->assertStringContainsString('error.response.data.message', $source); + $this->assertStringContainsString('支付宝账号', $source); + $this->assertStringContainsString('@click="exportRows"', $source); + $this->assertStringContainsString('商品名称', $source); + $this->assertStringContainsString('余额', $source); $this->assertStringNotContainsString('财务备注', $source); $this->assertStringNotContainsString('订单号', $source); $this->assertStringNotContainsString('金额', $source);