diff --git a/app/Business/TaobaoAlipayBillSpreadsheetImporter.php b/app/Business/TaobaoAlipayBillSpreadsheetImporter.php new file mode 100644 index 00000000..c6fad7a1 --- /dev/null +++ b/app/Business/TaobaoAlipayBillSpreadsheetImporter.php @@ -0,0 +1,149 @@ +getSheetByName(self::SHEET_NAME); + + if (! $sheet) { + throw new InvalidArgumentException('未找到工作表“' . self::SHEET_NAME . '”。'); + } + + $headers = $sheet->rangeToArray('A1:Y1', null, false, false)[0]; + $headers = array_map(fn ($value) => trim((string) $value), $headers); + + if ($headers !== self::HEADERS) { + throw new InvalidArgumentException('表头与淘系支付宝账户模板不一致。'); + } + + $inserted = 0; + $updated = 0; + $skipped = 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]; + + if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) { + $skipped++; + continue; + } + + $data = array_combine(self::FIELDS, $values); + $data = $this->normalizeRow($data, $rowNumber); + $record = AlipayBill::query() + ->where('company_id', $companyId) + ->where('record_code', $data['record_code']) + ->first(); + + $record ??= new AlipayBill(); + $wasExisting = $record->exists; + $record->timestamps = false; + $record->fill(array_merge($data, [ + 'company_id' => $companyId, + 'platform' => 'taobao', + 'created_from' => 'attachment-import', + ])); + $record->created_at = $data['created_at']; + $record->updated_at = $data['updated_at']; + $record->save(); + + $wasExisting ? $updated++ : $inserted++; + } + }); + + $spreadsheet->disconnectWorksheets(); + + return compact('inserted', 'updated', 'skipped'); + } + + private function normalizeRow(array $data, int $rowNumber): array + { + foreach ($data as $field => $value) { + $data[$field] = is_string($value) ? trim($value) : $value; + } + + if ($data['record_code'] === null || $data['record_code'] === '') { + throw new InvalidArgumentException("第 {$rowNumber} 行缺少记录编码。"); + } + + foreach (['occurred_at', 'created_at', 'updated_at'] as $field) { + $data[$field] = $this->normalizeDateTime($data[$field], $rowNumber); + } + + foreach (['income', 'expense', 'balance'] as $field) { + $data[$field] = $this->normalizeDecimal($data[$field], $rowNumber); + } + + foreach (self::REQUIRED_TEXT_FIELDS as $field) { + $data[$field] = $data[$field] ?? ''; + } + + return $data; + } + + private function normalizeDateTime($value, int $rowNumber): ?string + { + if ($value === null || $value === '') { + return null; + } + + try { + return is_numeric($value) + ? ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s') + : Carbon::parse((string) $value)->format('Y-m-d H:i:s'); + } catch (\Throwable) { + throw new InvalidArgumentException("第 {$rowNumber} 行包含无效日期时间。"); + } + } + + private function normalizeDecimal($value, int $rowNumber): string + { + $value = str_replace([',', ',', '¥', '¥'], '', (string) ($value ?? '0')); + + if (! is_numeric($value)) { + throw new InvalidArgumentException("第 {$rowNumber} 行包含无效金额。"); + } + + return number_format((float) $value, 6, '.', ''); + } +} diff --git a/app/Console/Commands/ImportTaobaoAlipayBills.php b/app/Console/Commands/ImportTaobaoAlipayBills.php new file mode 100644 index 00000000..57dae215 --- /dev/null +++ b/app/Console/Commands/ImportTaobaoAlipayBills.php @@ -0,0 +1,36 @@ +import( + (string) $this->argument('file'), + max(1, (int) $this->option('company')), + ); + } catch (Throwable $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } + + $this->components->info( + "导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,跳过 {$result['skipped']} 条。" + ); + + return self::SUCCESS; + } +} diff --git a/deploy/local-init.sh b/deploy/local-init.sh index fe32e159..a01ff624 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -205,7 +205,7 @@ if [ -n "$build_reason" ]; then build_attempt=1 while [ "$build_attempt" -le 2 ]; do local_image_fingerprint="$(calculate_local_image_fingerprint)" - echo "==> [1/5] 构建本地开发镜像:$build_reason。" + echo "==> [1/5] 构建本地开发镜像:${build_reason}。" docker compose -f "$compose_file" build \ --build-arg "LOCAL_BUILD_FINGERPRINT=$local_image_fingerprint" \ app diff --git a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php index 5df6b2f7..92382aff 100644 --- a/modules/EcommerceFinance/Http/Controllers/AlipayBills.php +++ b/modules/EcommerceFinance/Http/Controllers/AlipayBills.php @@ -73,6 +73,15 @@ class AlipayBills extends Controller $perPage = min(max((int) $request->get('limit', 10), 1), 100); $rows = $query->paginate($perPage); + $rows->getCollection()->transform(function (AlipayBill $bill) { + $data = $bill->toArray(); + + foreach (['occurred_at', 'created_at', 'updated_at'] as $field) { + $data[$field] = $bill->{$field}?->format('Y-m-d H:i:s'); + } + + return $data; + }); // 汇总:基于当前筛选结果(分页前) $summaryRow = (clone $query)->selectRaw( diff --git a/resources/assets/js/components/business/TaobaoAlipayBills.vue b/resources/assets/js/components/business/TaobaoAlipayBills.vue index 72ea15f0..1190d90d 100644 --- a/resources/assets/js/components/business/TaobaoAlipayBills.vue +++ b/resources/assets/js/components/business/TaobaoAlipayBills.vue @@ -85,7 +85,7 @@ - + @@ -94,9 +94,6 @@ - - - @@ -126,9 +123,6 @@ - - - @@ -156,7 +150,7 @@ - +
基础信息基础信息 账户与交易 金额(元) 业务关联
记录编码 ⇅ 原始店铺财务备注订单号金额 自定义备注 账单项目 支付宝账号
{{ r.record_code }} {{ r.store_name }}{{ r.finance_remark || '—' }}{{ r.order_no || '—' }}{{ r.amount === null || r.amount === undefined ? '—' : money(r.amount) }} {{ r.custom_remark || '—' }} {{ r.bill_item }} {{ r.alipay_account }}{{ r.modifier || '—' }}
没有符合条件的账单记录没有符合条件的账单记录
diff --git a/resources/views/business/pages/show.blade.php b/resources/views/business/pages/show.blade.php index c3bed1be..782790e6 100644 --- a/resources/views/business/pages/show.blade.php +++ b/resources/views/business/pages/show.blade.php @@ -714,7 +714,13 @@ @endunless @else -
+
($page['slug'] ?? null) === 'store-receiving-bills', + ]) + aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}" + >
该页面暂未配置数据表,敬请期待。
diff --git a/tests/Feature/Business/PagesTest.php b/tests/Feature/Business/PagesTest.php index 368a9968..8f62ceb0 100644 --- a/tests/Feature/Business/PagesTest.php +++ b/tests/Feature/Business/PagesTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Business; use App\Models\Auth\Permission; +use Modules\EcommerceFinance\Models\AlipayBill; use Tests\Feature\FeatureTestCase; class PagesTest extends FeatureTestCase @@ -13,6 +14,23 @@ class PagesTest extends FeatureTestCase Permission::where('name', 'read-admin-panel')->firstOrFail() ); + AlipayBill::create([ + 'company_id' => $this->company->id, + 'record_code' => 'time-format-test', + 'store_name' => '测试店铺', + 'bill_item' => '交易收款', + 'business_type' => '交易付款', + 'alipay_account' => '2088000000000000', + 'counterparty_account' => '测试账号', + 'account_order_no' => 'account-order-test', + 'business_serial_no' => 'business-serial-test', + 'goods_name' => '测试商品', + 'income' => 1, + 'expense' => 0, + 'balance' => 1, + 'occurred_at' => '2026-07-15 23:59:12', + ]); + $this->loginAs() ->withHeader('X-Requested-With', 'XMLHttpRequest') ->get(route('ecommerce-finance.alipay-bills.index')) @@ -22,7 +40,25 @@ class PagesTest extends FeatureTestCase 'data', 'total', 'summary' => ['income', 'expense', 'count', 'balance'], - ]); + ]) + ->assertJsonPath('data.0.occurred_at', '2026-07-15 23:59:12'); + } + + public function testTaobaoAlipayBillsPageProvidesTheVueMountPoint() + { + $this->user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'billing-transactions', + 'page' => 'store-receiving-bills', + 'tab' => 'taobao-alipay', + ])) + ->assertOk() + ->assertSee('class="biz-card mt-6 business-blank-page"', false) + ->assertSee('public/js/business/pages.min.js', false); } public function testSupplierPayablesPageRendersItsSpecificContent() diff --git a/tests/Feature/Console/ImportTaobaoAlipayBillsTest.php b/tests/Feature/Console/ImportTaobaoAlipayBillsTest.php new file mode 100644 index 00000000..ddac2684 --- /dev/null +++ b/tests/Feature/Console/ImportTaobaoAlipayBillsTest.php @@ -0,0 +1,90 @@ +files as $file) { + @unlink($file); + } + + parent::tearDown(); + } + + public function testItImportsExactIdentifiersAndIsIdempotent(): void + { + $file = $this->workbook(); + + $this->artisan('business:import-taobao-alipay-bills', ['file' => $file, '--company' => 1]) + ->expectsOutputToContain('新增 1 条,更新 0 条') + ->assertSuccessful(); + + $bill = AlipayBill::query()->where('company_id', 1)->firstOrFail(); + $this->assertSame('2000000001141269665', $bill->record_code); + $this->assertSame('2026071323001146201449637707', $bill->business_serial_no); + $this->assertSame('2026-07-15 23:59:12', $bill->occurred_at->format('Y-m-d H:i:s')); + $this->assertSame('100.250000', $bill->income); + $this->assertSame('attachment-import', $bill->created_from); + $this->assertSame('2026-07-16 06:31:02', $bill->created_at->format('Y-m-d H:i:s')); + $this->assertSame('2026-07-16 06:35:03', $bill->updated_at->format('Y-m-d H:i:s')); + + $this->artisan('business:import-taobao-alipay-bills', ['file' => $file, '--company' => 1]) + ->expectsOutputToContain('新增 0 条,更新 1 条') + ->assertSuccessful(); + + $this->assertSame(1, AlipayBill::query()->where('company_id', 1)->count()); + } + + public function testItRejectsAnUnexpectedHeader(): void + { + $file = $this->workbook('错误表头'); + + $this->artisan('business:import-taobao-alipay-bills', ['file' => $file, '--company' => 1]) + ->expectsOutputToContain('表头与淘系支付宝账户模板不一致') + ->assertFailed(); + + $this->assertSame(0, AlipayBill::query()->count()); + } + + public function testItRejectsAnUnexpectedSheetName(): void + { + $file = $this->workbook(null, '其他工作表'); + + $this->artisan('business:import-taobao-alipay-bills', ['file' => $file, '--company' => 1]) + ->expectsOutputToContain('未找到工作表“淘系支付宝账户”') + ->assertFailed(); + } + + private function workbook(?string $firstHeader = null, string $sheetName = '淘系支付宝账户'): string + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle($sheetName); + $headers = \App\Business\TaobaoAlipayBillSpreadsheetImporter::HEADERS; + $headers[0] = $firstHeader ?? $headers[0]; + $sheet->fromArray($headers, null, 'A1'); + $sheet->fromArray([ + '2000000001141269665', '淘宝_测试店铺', '', '交易收款', '2088480341979438', + '1570808064092446200', '2026071323001146201449637707', 'T200P3312548005778060954', + '', '', 'buyer@example.test', '2026-07-15 23:59:12', '100.25', '0.00', + '456.78', '交易付款', '', '12345678901234567890', '淘宝_测试店铺', '测试业务', + '支付宝', 'BIZ12345678901234567890', '2026/7/16 6:31:02', '2026/7/16 6:35:03', '测试人', + ], null, 'A2'); + + $file = tempnam(sys_get_temp_dir(), 'alipay-import-') . '.xlsx'; + (new Xlsx($spreadsheet))->save($file); + $spreadsheet->disconnectWorksheets(); + $this->files[] = $file; + + return $file; + } +} diff --git a/tests/Unit/View/TaobaoAlipayBillsTableTest.php b/tests/Unit/View/TaobaoAlipayBillsTableTest.php new file mode 100644 index 00000000..e267676c --- /dev/null +++ b/tests/Unit/View/TaobaoAlipayBillsTableTest.php @@ -0,0 +1,23 @@ +assertIsString($source); + $this->assertStringContainsString('基础信息', $source); + $this->assertStringContainsString('assertStringNotContainsString('财务备注', $source); + $this->assertStringNotContainsString('订单号', $source); + $this->assertStringNotContainsString('金额', $source); + } +}