店铺收款账户账单淘系支付宝附件数据 #36
149
app/Business/TaobaoAlipayBillSpreadsheetImporter.php
Normal file
149
app/Business/TaobaoAlipayBillSpreadsheetImporter.php
Normal 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, '.', '');
|
||||
}
|
||||
}
|
||||
36
app/Console/Commands/ImportTaobaoAlipayBills.php
Normal file
36
app/Console/Commands/ImportTaobaoAlipayBills.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
90
tests/Feature/Console/ImportTaobaoAlipayBillsTest.php
Normal file
90
tests/Feature/Console/ImportTaobaoAlipayBillsTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
23
tests/Unit/View/TaobaoAlipayBillsTableTest.php
Normal file
23
tests/Unit/View/TaobaoAlipayBillsTableTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user