店铺收款账户账单淘系支付宝附件数据 #36

Merged
chenxi merged 1 commits from feature/chenxi-store-receiving-alipay-import into main 2026-08-13 00:42:08 +08:00
9 changed files with 354 additions and 11 deletions

View File

@ -0,0 +1,149 @@
<?php
namespace App\Business;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\EcommerceFinance\Models\AlipayBill;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
class TaobaoAlipayBillSpreadsheetImporter
{
public const SHEET_NAME = '淘系支付宝账户';
public const HEADERS = [
'记录编码', '原始店铺', '自定义备注', '账单项目', '支付宝账号',
'账务订单号', '业务流水号', '商户订单号', '业务基础订单号', '商品名称',
'对方账号', '发生时间', '收入', '支出', '余额', '业务类型', '备注',
'线上单号', '线上单号对应实际店铺', '业务描述', '业务账单来源',
'业务订单号', '创建时间', '修改时间', '修改人',
];
private const FIELDS = [
'record_code', 'store_name', 'custom_remark', 'bill_item', 'alipay_account',
'account_order_no', 'business_serial_no', 'merchant_order_no',
'business_base_order_no', 'goods_name', 'counterparty_account', 'occurred_at',
'income', 'expense', 'balance', 'business_type', 'remark', 'online_order_no',
'online_real_store', 'business_description', 'business_bill_source',
'business_order_no', 'created_at', 'updated_at', 'modifier',
];
private const REQUIRED_TEXT_FIELDS = [
'store_name', 'bill_item', 'business_type', 'alipay_account',
'counterparty_account', 'account_order_no', 'business_serial_no', 'goods_name',
];
public function import(string $path, int $companyId): array
{
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('导入文件不存在或不可读取。');
}
$spreadsheet = IOFactory::load($path);
$sheet = $spreadsheet->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, '.', '');
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Business\TaobaoAlipayBillSpreadsheetImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportTaobaoAlipayBills extends Command
{
protected $signature = 'business:import-taobao-alipay-bills
{file : 淘系支付宝账户 Excel 文件路径}
{--company=1 : 数据所属公司 ID}';
protected $description = '从标准附件导入店铺收款账户账单的淘系支付宝账户数据';
public function handle(TaobaoAlipayBillSpreadsheetImporter $importer): int
{
try {
$result = $importer->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;
}
}

View File

@ -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

View File

@ -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(

View File

@ -85,7 +85,7 @@
<table class="tb-table" v-if="!loading">
<thead>
<tr class="tb-group">
<th colspan="7">基础信息</th>
<th colspan="4">基础信息</th>
<th colspan="8">账户与交易</th>
<th colspan="3">金额(元)</th>
<th colspan="6">业务关联</th>
@ -94,9 +94,6 @@
<tr class="tb-col">
<th @click="toggleSort('record_code')">记录编码 <span class="tb-sort">⇅</span></th>
<th>原始店铺</th>
<th>财务备注</th>
<th>订单号</th>
<th class="tb-num">金额</th>
<th>自定义备注</th>
<th>账单项目</th>
<th>支付宝账号</th>
@ -126,9 +123,6 @@
<tr v-for="r in rows" :key="r.id">
<td class="tb-mono">{{ r.record_code }}</td>
<td>{{ r.store_name }}</td>
<td class="tb-muted">{{ r.finance_remark || '—' }}</td>
<td class="tb-mono">{{ r.order_no || '—' }}</td>
<td class="tb-num">{{ r.amount === null || r.amount === undefined ? '—' : money(r.amount) }}</td>
<td class="tb-muted">{{ r.custom_remark || '—' }}</td>
<td><span class="tb-tag">{{ r.bill_item }}</span></td>
<td class="tb-mono">{{ r.alipay_account }}</td>
@ -156,7 +150,7 @@
<td class="tb-muted">{{ r.modifier || '—' }}</td>
</tr>
<tr v-if="!rows.length" class="tb-empty-row">
<td :colspan="28" class="tb-empty">没有符合条件的账单记录</td>
<td :colspan="25" class="tb-empty">没有符合条件的账单记录</td>
</tr>
</tbody>
</table>

View File

@ -714,7 +714,13 @@
</script>
@endunless
@else
<section class="biz-card mt-6" aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}">
<section
@class([
'biz-card mt-6',
'business-blank-page' => ($page['slug'] ?? null) === 'store-receiving-bills',
])
aria-label="{{ $activeTab['title'] ?? ($page['title'] ?? $section['title']) }}"
>
<div class="biz-empty">
<span class="material-icons-outlined" aria-hidden="true">description</span>
<div>该页面暂未配置数据表,敬请期待。</div>

View File

@ -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()

View File

@ -0,0 +1,90 @@
<?php
namespace Tests\Feature\Console;
use Modules\EcommerceFinance\Models\AlipayBill;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\TestCase;
class ImportTaobaoAlipayBillsTest extends TestCase
{
private array $files = [];
protected function tearDown(): void
{
foreach ($this->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;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Tests\Unit\View;
use PHPUnit\Framework\TestCase;
class TaobaoAlipayBillsTableTest extends TestCase
{
public function testTableMatchesTheTwentyFiveAttachmentColumns(): void
{
$root = dirname(__DIR__, 3);
$source = file_get_contents(
$root . '/resources/assets/js/components/business/TaobaoAlipayBills.vue'
);
$this->assertIsString($source);
$this->assertStringContainsString('<th colspan="4">基础信息</th>', $source);
$this->assertStringContainsString('<td :colspan="25"', $source);
$this->assertStringNotContainsString('<th>财务备注</th>', $source);
$this->assertStringNotContainsString('<th>订单号</th>', $source);
$this->assertStringNotContainsString('<th class="tb-num">金额</th>', $source);
}
}