feat: 接入公司支付宝账单与台账手动编辑 #797

Merged
yutu merged 1 commits from feature/92451-alipay-ledger-edit into main 2026-10-30 20:03:05 +08:00
18 changed files with 2464 additions and 10 deletions

View File

@ -0,0 +1,58 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\CompanyAlipayBillImporter;
use Throwable;
class ImportCompanyAlipayBills extends Command
{
protected $signature = 'finance-ledger:import-company-alipay
{path : 支付宝交易记录 CSV/XLSX/XLS 文件}
{--date= : 账单日期 YYYY-MM-DD,默认前一天}
{--account= : 支付宝登录账号,用于匹配账户总表}
{--account-short= : 账户总表简称;账号重复时必填}
{--dry-run : 只校验,不写入财务台账}';
protected $description = '导入公司非店铺支付宝前一天交易到财务台账动账明细';
public function handle(CompanyAlipayBillImporter $importer): int
{
$date = trim((string) ($this->option('date') ?: now()->subDay()->toDateString()));
$account = trim((string) $this->option('account'));
if ($account === '') {
$this->error('必须使用 --account 指定支付宝登录账号。');
return self::FAILURE;
}
try {
$result = $importer->import(
(string) $this->argument('path'),
$account,
$date,
$this->option('account-short') ?: null,
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
$this->error('支付宝账单未导入,保留原有动账明细。' . $exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['校验模式', $result['dry_run'] ? '仅校验' : '正式导入'],
['账单日期', $result['balance_date']],
['账户简称', $result['account_short']],
['有效笔数', $result['rows']],
['收入合计', number_format((float) $result['income'], 2)],
['支出合计', number_format((float) $result['expense'], 2)],
['替换原记录', $result['replaced']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
]);
$this->info($result['dry_run'] ? '支付宝账单校验完成。' : '支付宝账单已接入财务台账动账明细。');
return self::SUCCESS;
}
}

View File

@ -15,8 +15,10 @@ use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\FinanceLedgerAccountCreator;
use Modules\EcommerceFinance\Services\FinanceLedgerAccountEditor;
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Modules\EcommerceFinance\Services\FinanceLedgerFlowEditor;
use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk;
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk;
@ -1072,6 +1074,147 @@ class FinanceLedger extends Controller
], $inserted ? 201 : 200, ['Cache-Control' => 'private, no-store']);
}
public function editAccount(int $id): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户。');
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$account = $connection->table('fin_accounts')->where('id', $id)->first();
abort_if($account === null, 404);
$account->status = FinanceLedgerAccountStatus::normalize(
$connection->table('fin_account_statuses')->where('account_short', $account->account_short)->value('status'),
);
$overrideType = $connection->table('fin_account_type_overrides')
->where('account_short', $account->account_short)
->value('account_type');
if ($overrideType !== null) {
$account->account_type = (string) $overrideType;
}
return response()->json([
'ok' => true,
'account' => $account,
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateAccount(
Request $request,
int $id,
FinanceLedgerAccountEditor $editor,
): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户。');
$data = $request->validate([
'company_name' => ['required', 'string', 'max:100'],
'account_short' => ['required', 'string', 'max:100'],
'account_type' => ['nullable', 'string', 'max:50'],
'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))],
'region' => ['nullable', 'string', 'max:50'],
'account_no' => ['nullable', 'string', 'max:50'],
'bank_name' => ['nullable', 'string', 'max:200'],
'bank_city_branch' => ['nullable', 'string', 'max:100'],
'company_plan' => ['nullable', 'string', 'max:50'],
'account_should_keep' => ['nullable', 'string', Rule::in(FinanceLedgerAccountShouldKeep::OPTIONS)],
'single_limit' => ['nullable', 'string', 'max:50'],
'daily_limit' => ['nullable', 'string', 'max:50'],
'monthly_limit' => ['nullable', 'string', 'max:50'],
'yearly_limit' => ['nullable', 'string', 'max:50'],
'clearing_days' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
'raise_target' => ['nullable', 'string', 'max:50'],
'raise_status' => ['nullable', 'string', 'max:50'],
'raise_actions' => ['nullable', 'string', 'max:500'],
'done_actions' => ['nullable', 'string', 'max:500'],
'next_action' => ['nullable', 'string', 'max:200'],
'remark' => ['nullable', 'string', 'max:500'],
'owner' => ['nullable', 'string', 'max:50'],
]);
$accountType = trim((string) ($data['account_type'] ?? ''));
if ($accountType !== '') {
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$allowedTypes = FinanceLedgerAccountType::options(
$connection->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all(),
$connection->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->pluck('account_type')->all(),
);
if (! in_array($accountType, $allowedTypes, true)) {
throw ValidationException::withMessages([
'account_type' => '请选择现有账户类型。',
]);
}
}
$account = $editor->update($id, $data);
$account['status'] = FinanceLedgerAccountStatus::normalize((string) $data['status']);
return response()->json([
'ok' => true,
'account' => $account,
'message' => '账户信息已保存。',
], 200, ['Cache-Control' => 'private, no-store']);
}
public function editDailyFlow(Request $request, FinanceLedgerFlowEditor $editor): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改每日资金流水。');
$data = $request->validate([
'flow_date' => ['required', 'date_format:Y-m-d'],
'account_short' => ['required', 'string', 'max:100'],
]);
$flow = $editor->existingFlow((string) $data['flow_date'], trim((string) $data['account_short']));
abort_if($flow === null, 404);
return response()->json([
'ok' => true,
'flow' => $flow,
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateDailyFlow(Request $request, FinanceLedgerFlowEditor $editor): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改每日资金流水。');
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
$data = $request->validate([
'flow_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:' . $openingDate, 'before_or_equal:today'],
'account_short' => ['required', 'string', 'max:100'],
'opening_balance' => ['required', 'numeric', 'between:-9999999999999999.99,9999999999999999.99'],
'inflow' => ['required', 'numeric', 'min:0', 'max:9999999999999999.99'],
'outflow' => ['required', 'numeric', 'min:0', 'max:9999999999999999.99'],
'closing_balance' => ['required', 'numeric', 'between:-9999999999999999.99,9999999999999999.99'],
'remark' => ['nullable', 'string', 'max:200'],
]);
$flow = $editor->update($data);
return response()->json([
'ok' => true,
'flow' => $flow,
'message' => '每日资金流水已保存并重算。',
], 200, ['Cache-Control' => 'private, no-store']);
}
public function resetDailyFlow(Request $request, FinanceLedgerFlowEditor $editor): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改每日资金流水。');
$data = $request->validate([
'flow_date' => ['required', 'date_format:Y-m-d'],
'account_short' => ['required', 'string', 'max:100'],
]);
$flow = $editor->reset((string) $data['flow_date'], trim((string) $data['account_short']));
return response()->json([
'ok' => true,
'flow' => $flow,
'message' => '已恢复自动计算。',
], 200, ['Cache-Control' => 'private, no-store']);
}
public function updateTransactionAccount(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改动账账户关联。');

View File

@ -0,0 +1,81 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('fin_daily_flows') && ! $schema->hasColumn('fin_daily_flows', 'manual_override')) {
$schema->table('fin_daily_flows', function (Blueprint $table): void {
$table->boolean('manual_override')->default(false)->after('remark');
$table->string('updated_by', 100)->nullable()->after('manual_override');
});
}
if (! $schema->hasTable('fin_daily_flow_overrides')) {
$schema->create('fin_daily_flow_overrides', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->date('flow_date');
$table->string('account_short', 100);
$table->decimal('opening_balance', 18, 2)->default(0);
$table->decimal('inflow', 18, 2)->default(0);
$table->decimal('outflow', 18, 2)->default(0);
$table->decimal('closing_balance', 18, 2)->default(0);
$table->string('remark', 200)->nullable();
$table->string('updated_by', 100)->nullable();
$table->timestamps();
$table->unique(
['flow_date', 'account_short'],
'fin_flow_override_date_account_unique',
);
$table->index(['account_short'], 'fin_flow_override_account_index');
});
}
if (! $schema->hasTable('fin_ledger_edit_events')) {
$schema->create('fin_ledger_edit_events', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('entity_type', 20);
$table->string('entity_key', 200);
$table->json('changes');
$table->unsignedBigInteger('user_id')->nullable();
$table->string('user_name', 100)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(
['entity_type', 'entity_key', 'created_at'],
'fin_edit_events_entity_index',
);
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
$schema->dropIfExists('fin_ledger_edit_events');
$schema->dropIfExists('fin_daily_flow_overrides');
if ($schema->hasTable('fin_daily_flows') && $schema->hasColumn('fin_daily_flows', 'updated_by')) {
$schema->table('fin_daily_flows', function (Blueprint $table): void {
$table->dropColumn('updated_by');
});
}
if ($schema->hasTable('fin_daily_flows') && $schema->hasColumn('fin_daily_flows', 'manual_override')) {
$schema->table('fin_daily_flows', function (Blueprint $table): void {
$table->dropColumn('manual_override');
});
}
}
};

View File

@ -9,6 +9,7 @@
"courier-quotes:import-billing-rules",
"dewu-bills:import-authoritative",
"business-mirror:import-douyin-authoritative",
"finance-ledger:import-company-alipay",
"finance-ledger:import-base",
"finance-ledger:import-snapshot",
"internal-flow:import",

View File

@ -0,0 +1,429 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
class CompanyAlipayBillImporter
{
public const SOURCE = 'alipay-company';
public const PROCESS_TYPE = '公司支付宝';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{
* dry_run:bool,balance_date:string,account_short:string,rows:int,
* income:float,expense:float,replaced:int,recalculated:?array
* }
*/
public function import(
string $path,
string $accountNo,
string $balanceDate,
?string $accountShort = null,
bool $dryRun = false,
): array {
$date = $this->date($balanceDate);
$accountNo = $this->text($accountNo);
if ($accountNo === '') {
throw new InvalidArgumentException('支付宝账号不能为空。');
}
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('支付宝账单文件不存在或不可读取。');
}
$account = $this->account($accountNo, $accountShort);
$rows = $this->rows($path, $accountNo, $account->account_short, $date);
if ($rows === []) {
throw new InvalidArgumentException("支付宝账单中没有 {$date} 的有效交易,已保留原有动账明细。");
}
$income = round(array_sum(array_column($rows, 'income')), 2);
$expense = round(array_sum(array_column($rows, 'expense')), 2);
$replaced = $this->connection->table('fin_transactions')
->where('source', self::SOURCE)
->where('source_value', $accountNo)
->whereDate('txn_date', $date)
->count();
$result = [
'dry_run' => $dryRun,
'balance_date' => $date,
'account_short' => (string) $account->account_short,
'rows' => count($rows),
'income' => $income,
'expense' => $expense,
'replaced' => $replaced,
'recalculated' => null,
];
if ($dryRun) {
return $result;
}
$recalculated = $this->connection->transaction(function () use (
$rows,
$date,
$accountNo,
$account,
$income,
$expense,
): array {
$this->connection->table('fin_transactions')
->where('source', self::SOURCE)
->where('source_value', $accountNo)
->whereDate('txn_date', $date)
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table('fin_transactions')->insert($chunk);
}
$now = now();
$this->connection->table('fin_account_map')->updateOrInsert(
['source' => self::SOURCE, 'source_value' => $accountNo],
[
'account_short' => (string) $account->account_short,
'txn_count' => count($rows),
'income_sum' => $income,
'expense_sum' => $expense,
'updated_by' => '系统自动同步',
'updated_at' => $now,
'created_at' => $now,
],
);
return app(FinanceLedgerCalculator::class)->recalculate();
});
$result['recalculated'] = $recalculated;
return $result;
}
private function account(string $accountNo, ?string $accountShort): object
{
$query = $this->connection->table('fin_accounts')->where('account_no', $accountNo);
$accountShort = $this->text($accountShort);
if ($accountShort !== '') {
$query->where('account_short', $accountShort);
}
$accounts = $query->get(['id', 'account_short', 'account_no', 'account_type']);
if ($accounts->isEmpty()) {
throw new InvalidArgumentException('账户总表中没有账号为 ' . $accountNo . ' 的支付宝账户。');
}
if ($accounts->count() > 1) {
throw new InvalidArgumentException('支付宝账号对应多个账户,请同时指定账户简称。');
}
return $accounts->first();
}
/** @return list<array<string,mixed>> */
private function rows(string $path, string $accountNo, string $accountShort, string $date): array
{
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
[$headers, $dataRows] = match ($extension) {
'csv' => $this->csvRows($path),
'xlsx', 'xls' => $this->workbookRows($path),
default => throw new InvalidArgumentException('支付宝账单仅支持 CSV、XLSX 或 XLS 文件。'),
};
$fields = $this->fields($headers);
$rows = [];
$seen = [];
foreach ($dataRows as $values) {
$value = static function (string $field) use ($values, $fields): string {
$index = $fields[$field] ?? null;
return $index === null ? '' : trim((string) ($values[$index] ?? ''));
};
$time = $this->dateTime($value('time'));
if ($time === null || substr($time, 0, 10) !== $date) {
continue;
}
$status = $value('status');
if ($status !== '' && preg_match('/关闭|失败|等待付款|未支付|取消/u', $status) === 1) {
continue;
}
$rawAmount = $this->amountText($value('amount'));
$direction = $value('direction');
[$income, $expense] = $this->amounts($rawAmount, $direction);
if ($income === 0.0 && $expense === 0.0) {
continue;
}
$recordCode = $value('record_code');
if ($recordCode === '') {
$recordCode = hash('sha256', implode("\0", [
$time, $direction, $rawAmount, $value('counterparty'), $value('description'), $value('merchant_order_no'),
]));
}
$uniqueKey = $recordCode . "\0" . $time . "\0" . number_format($income, 2, '.', '') . "\0" . number_format($expense, 2, '.', '');
if (isset($seen[$uniqueKey])) {
continue;
}
$seen[$uniqueKey] = true;
$counterparty = $value('counterparty');
$description = $value('description');
$paymentMethod = $value('payment_method');
$merchantOrderNo = $value('merchant_order_no');
$remark = $value('remark');
$detail = implode(' · ', array_values(array_filter([
$counterparty,
$description,
$remark,
], static fn (string $text): bool => $text !== '')));
if ($detail === '') {
$detail = '支付宝交易 ' . $recordCode;
}
$now = now();
$rows[] = [
'txn_date' => $date,
'account_short' => $accountShort,
'detail' => mb_substr($detail, 0, 4000),
'income' => $income,
'expense' => $expense,
'approval_no' => mb_substr($recordCode, 0, 100),
'source' => self::SOURCE,
'source_value' => $accountNo,
'process_type' => self::PROCESS_TYPE,
'row_key' => hash('sha256', implode("\0", [
self::SOURCE, $accountNo, $recordCode, $time, $merchantOrderNo,
])),
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'created_by' => '系统自动同步',
'updated_by' => '系统自动同步',
];
}
return $rows;
}
/**
* @return array{0:list<string>,1:list<list<string>>}
*/
private function csvRows(string $path): array
{
[$headers, $headerRow] = $this->csvHeader($path);
return [$headers, (function () use ($path, $headerRow): \Generator {
$stream = $this->openCsvStream($path);
$foundHeader = false;
try {
while (($row = fgetcsv($stream)) !== false) {
$values = array_map(static fn (mixed $value): string => trim((string) $value), $row);
if (! $foundHeader) {
$foundHeader = $values === $headerRow;
continue;
}
yield $values;
}
} finally {
fclose($stream);
}
})()];
}
/**
* @return array{0:list<string>,1:list<string>}
*/
private function csvHeader(string $path): array
{
$stream = $this->openCsvStream($path);
try {
while (($row = fgetcsv($stream)) !== false) {
$values = array_map(static fn (mixed $value): string => trim((string) $value), $row);
$headers = array_map(fn (string $value): string => $this->header($value), $values);
if ($this->hasAny($headers, ['交易时间', '发生时间', '交易创建时间', '创建时间', '付款时间'])
&& $this->hasAny($headers, ['金额', '金额元', '金额(元)', '金额(元)', '交易金额'])) {
return [$headers, $values];
}
}
} finally {
fclose($stream);
}
throw new InvalidArgumentException('未找到支付宝交易时间与金额表头。');
}
/**
* @return resource
*/
private function openCsvStream(string $path)
{
$stream = fopen($path, 'rb');
if ($stream === false) {
throw new RuntimeException('无法读取支付宝 CSV 文件。');
}
$sample = fread($stream, 8192);
if ($sample === false || trim($sample) === '') {
fclose($stream);
throw new InvalidArgumentException('支付宝 CSV 文件为空。');
}
$hasBom = str_starts_with($sample, "\xEF\xBB\xBF");
if (! mb_check_encoding($hasBom ? substr($sample, 3) : $sample, 'UTF-8')) {
stream_filter_append($stream, 'convert.iconv.GB18030/UTF-8', STREAM_FILTER_READ);
}
rewind($stream);
if ($hasBom) {
fseek($stream, 3);
}
return $stream;
}
/**
* @return array{0:list<string>,1:list<list<string>>}
*/
private function workbookRows(string $path): array
{
$spreadsheet = IOFactory::load($path);
$matrix = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
$spreadsheet->disconnectWorksheets();
return $this->headerAndRows(array_map(
static fn (array $row): array => array_map(static fn (mixed $value): string => trim((string) $value), $row),
$matrix,
));
}
/**
* @param list<list<string>> $matrix
* @return array{0:list<string>,1:list<list<string>>}
*/
private function headerAndRows(array $matrix): array
{
foreach ($matrix as $index => $row) {
$headers = array_map(fn (string $value): string => $this->header($value), $row);
$hasTime = $this->hasAny($headers, ['交易时间', '发生时间', '交易创建时间', '创建时间', '付款时间']);
$hasAmount = $this->hasAny($headers, ['金额', '金额元', '金额(元)', '金额(元)', '交易金额']);
if ($hasTime && $hasAmount) {
return [$headers, array_values(array_slice($matrix, $index + 1))];
}
}
throw new InvalidArgumentException('未找到支付宝交易时间与金额表头。');
}
/** @param list<string> $headers @return array<string,int> */
private function fields(array $headers): array
{
$aliases = [
'time' => ['交易时间', '发生时间', '交易创建时间', '创建时间', '付款时间'],
'record_code' => ['交易号', '交易订单号', '支付宝交易号', '流水号'],
'direction' => ['收/支', '收支', '收支方向', '收入支出', '收入支出类型'],
'amount' => ['金额', '金额元', '金额(元)', '金额(元)', '交易金额'],
'counterparty' => ['交易对方', '对方账号', '交易对象'],
'description' => ['商品说明', '商品名称', '商品'],
'status' => ['交易状态', '状态'],
'payment_method' => ['收付款方式', '付款方式'],
'merchant_order_no' => ['商家订单号', '商户订单号'],
'remark' => ['备注'],
];
$fields = [];
foreach ($aliases as $field => $names) {
foreach ($names as $name) {
$index = array_search($name, $headers, true);
if ($index !== false) {
$fields[$field] = $index;
break;
}
}
}
if (! isset($fields['time'], $fields['record_code'], $fields['direction'], $fields['amount'], $fields['status'])) {
throw new InvalidArgumentException('支付宝账单缺少交易时间、交易号、收支方向、金额或交易状态列。');
}
return $fields;
}
/** @return array{0:float,1:float} */
private function amounts(string $amount, string $direction): array
{
$negative = str_starts_with($amount, '-');
$value = round(abs((float) str_replace([',', ',', '¥', '¥'], '', $amount)), 2);
$direction = mb_strtolower($this->text($direction));
if (str_contains($direction, '不计') || str_contains($direction, '中性')) {
return [0.0, 0.0];
}
if (str_contains($direction, '支') || str_contains($direction, '出') || $negative) {
return [0.0, $value];
}
return [$value, 0.0];
}
private function amountText(string $value): string
{
return trim(str_replace([' ', ','], '', $value));
}
private function dateTime(string $value): ?string
{
$value = trim($value);
if (is_numeric($value)) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)
->format('Y-m-d H:i:s');
} catch (\Throwable) {
return null;
}
}
if (preg_match('/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/', $value, $matches) !== 1) {
return null;
}
[$year, $month, $day, $hour, $minute] = array_map('intval', array_slice($matches, 1, 5));
$second = isset($matches[6]) && $matches[6] !== '' ? (int) $matches[6] : 0;
if (! checkdate($month, $day, $year) || $hour > 23 || $minute > 59 || $second > 59) {
return null;
}
return sprintf('%04d-%02d-%02d %02d:%02d:%02d', $year, $month, $day, $hour, $minute, $second);
}
/** @param list<string> $headers @param list<string> $names */
private function hasAny(array $headers, array $names): bool
{
return array_intersect($headers, $names) !== [];
}
private function header(string $value): string
{
return preg_replace('/[\s ]+/u', '', trim($value)) ?? trim($value);
}
private function date(string $value): string
{
$date = CarbonImmutable::createFromFormat('!Y-m-d', trim($value));
if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== trim($value)) {
throw new InvalidArgumentException('账单日期必须使用 YYYY-MM-DD 格式。');
}
return $date->format('Y-m-d');
}
private function text(mixed $value): string
{
return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value);
}
}

View File

@ -0,0 +1,205 @@
<?php
namespace Modules\EcommerceFinance\Services;
use App\Utilities\FinanceLedgerAccountStatus;
use App\Utilities\FinanceLedgerAccountType;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
class FinanceLedgerAccountEditor
{
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly FinanceLedgerCalculator $calculator,
) {
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function update(int $id, array $data): array
{
return $this->connection->transaction(function () use ($id, $data): array {
$account = $this->connection->table('fin_accounts')->where('id', $id)->lockForUpdate()->first();
if ($account === null) {
abort(404);
}
$oldShort = trim((string) $account->account_short);
$accountShort = $this->text($data['account_short'] ?? '');
$companyName = $this->text($data['company_name'] ?? '');
if ($accountShort === '' || $companyName === '') {
throw ValidationException::withMessages([
'account_short' => '账户简称和公司名称不能为空。',
]);
}
$duplicate = $this->connection->table('fin_accounts')
->where('account_short', $accountShort)
->where('id', '<>', $id)
->exists();
if ($duplicate) {
throw ValidationException::withMessages([
'account_short' => '账户简称已存在,请改用其他简称。',
]);
}
$accountType = $this->nullableText($data['account_type'] ?? null);
$accountNo = $this->nullableText($data['account_no'] ?? null);
$status = FinanceLedgerAccountStatus::normalize((string) ($data['status'] ?? ''));
$accountLast4 = FinanceLedgerAccountType::last4(
(string) $accountType,
(string) $accountNo,
(string) ($account->account_last4 ?? ''),
);
$values = [
'company_name' => $companyName,
'account_short' => $accountShort,
'region' => $this->nullableText($data['region'] ?? null),
'account_no' => $accountNo,
'bank_name' => $this->nullableText($data['bank_name'] ?? null),
'bank_city_branch' => $this->nullableText($data['bank_city_branch'] ?? null),
'account_type' => $accountType,
'company_plan' => $this->nullableText($data['company_plan'] ?? null),
'account_should_keep' => $this->nullableText($data['account_should_keep'] ?? null),
'account_last4' => $accountLast4,
'single_limit' => $this->nullableText($data['single_limit'] ?? null),
'daily_limit' => $this->nullableText($data['daily_limit'] ?? null),
'monthly_limit' => $this->nullableText($data['monthly_limit'] ?? null),
'yearly_limit' => $this->nullableText($data['yearly_limit'] ?? null),
'clearing_days' => $this->nullableNumber($data['clearing_days'] ?? null),
'raise_target' => $this->nullableText($data['raise_target'] ?? null),
'raise_status' => $this->nullableText($data['raise_status'] ?? null),
'raise_actions' => $this->nullableText($data['raise_actions'] ?? null),
'done_actions' => $this->nullableText($data['done_actions'] ?? null),
'next_action' => $this->nullableText($data['next_action'] ?? null),
'remark' => $this->nullableText($data['remark'] ?? null),
'owner' => $this->nullableText($data['owner'] ?? null),
'updated_at' => now(),
];
$before = (array) $account;
if ($oldShort !== $accountShort) {
$this->renameReferences($oldShort, $accountShort);
}
$this->connection->table('fin_accounts')->where('id', $id)->update($values);
$this->upsertStatus($accountShort, $status);
$this->connection->table('fin_account_type_overrides')
->where('account_short', $accountShort)
->delete();
$this->connection->table('fin_daily_flows')
->where('account_short', $accountShort)
->update([
'company_name' => $companyName,
'updated_at' => now(),
]);
$this->calculator->refreshAccountAmounts($accountShort);
$after = (array) $this->connection->table('fin_accounts')->where('id', $id)->first();
$this->connection->table('fin_ledger_edit_events')->insert([
'entity_type' => 'account',
'entity_key' => (string) $id,
'changes' => json_encode([
'before' => $before,
'after' => $after,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'user_id' => user()?->id,
'user_name' => (string) (user()?->name ?? ''),
'created_at' => now(),
]);
return $after;
});
}
private function upsertStatus(string $accountShort, string $status): void
{
$values = [
'status' => $status,
'updated_by' => (string) (user()?->name ?? ''),
'updated_at' => now(),
];
if ($this->connection->table('fin_account_statuses')->where('account_short', $accountShort)->exists()) {
$this->connection->table('fin_account_statuses')->where('account_short', $accountShort)->update($values);
return;
}
$this->connection->table('fin_account_statuses')->insert($values + [
'account_short' => $accountShort,
'created_at' => now(),
]);
}
private function renameReferences(string $oldShort, string $newShort): void
{
$now = now();
foreach ([
'fin_account_opening' => ['account_short' => [], 'updated_at' => $now],
'fin_daily_flows' => ['account_short' => [], 'updated_at' => $now],
'fin_transactions' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now],
'fin_account_map' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now],
'fin_account_statuses' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now],
'fin_account_type_overrides' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now],
'fin_account_todos' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now],
'fin_account_keep_todos' => ['account_short' => [], 'updated_by' => (string) (user()?->name ?? ''), 'updated_at' => $now],
'fin_shops' => ['account_short' => [], 'updated_at' => $now],
] as $table => $extra) {
if (! Schema::connection($this->connection->getName())->hasTable($table)
|| ! Schema::connection($this->connection->getName())->hasColumn($table, 'account_short')) {
continue;
}
$this->connection->table($table)->where('account_short', $oldShort)->update([
'account_short' => $newShort,
...$extra,
]);
}
if (Schema::connection($this->connection->getName())->hasTable('fin_daily_flow_overrides')) {
$this->connection->table('fin_daily_flow_overrides')
->where('account_short', $oldShort)
->update([
'account_short' => $newShort,
'updated_at' => $now,
]);
}
$this->connection->table('fin_account_map_events')
->where('old_account_short', $oldShort)
->update(['old_account_short' => $newShort]);
$this->connection->table('fin_account_map_events')
->where('new_account_short', $oldShort)
->update(['new_account_short' => $newShort]);
}
private function text(mixed $value): string
{
return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value);
}
private function nullableText(mixed $value): ?string
{
$text = $this->text($value);
return $text !== '' ? $text : null;
}
private function nullableNumber(mixed $value): ?float
{
if ($value === null || trim((string) $value) === '') {
return null;
}
return round((float) $value, 2);
}
}

View File

@ -50,6 +50,15 @@ class FinanceLedgerCalculator
->orderBy('txn_date')
->orderBy('id')
->get(['txn_date', 'account_short', 'source', 'source_value', 'income', 'expense']);
$schema = $connection->getSchemaBuilder();
$manualOverrides = $schema->hasTable('fin_daily_flow_overrides')
? $connection->table('fin_daily_flow_overrides')->get()->keyBy(
static fn (object $row): string => substr((string) $row->flow_date, 0, 10)
. "\0"
. (string) $row->account_short,
)
: collect();
$hasManualColumns = $schema->hasColumn('fin_daily_flows', 'manual_override');
$dailyByAccount = [];
$balances = [];
@ -100,11 +109,65 @@ class FinanceLedgerCalculator
'created_at' => $now,
'updated_at' => $now,
];
if ($hasManualColumns) {
$openingFlow['manual_override'] = false;
$openingFlow['updated_by'] = null;
}
$flows[] = $openingFlow;
$openingIndex = array_key_last($flows);
$dates = array_values(array_unique([
...array_column($rows, 'flow_date'),
...$manualOverrides
->filter(static fn (object $row): bool => (string) $row->account_short === $account)
->map(static fn (object $row): string => substr((string) $row->flow_date, 0, 10))
->values()
->all(),
]));
sort($dates);
$rowsByDate = [];
foreach ($rows as $row) {
if ($row['flow_date'] < $start) {
$rowsByDate[(string) $row['flow_date']] = $row;
}
foreach ($dates as $date) {
if ($date < $start) {
continue;
}
$override = $manualOverrides->get($date . "\0" . $account);
if ($override !== null) {
$openingBalance = round((float) $override->opening_balance, 2);
$inflow = round((float) $override->inflow, 2);
$outflow = round((float) $override->outflow, 2);
$balance = round((float) $override->closing_balance, 2);
$flow = [
'flow_date' => $date,
'company_name' => $company,
'account_short' => $account,
'opening_balance' => $openingBalance,
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $balance,
'remark' => $override->remark !== null ? (string) $override->remark : '手动调整',
'created_at' => $now,
'updated_at' => $now,
];
if ($hasManualColumns) {
$flow['manual_override'] = true;
$flow['updated_by'] = $override->updated_by !== null ? (string) $override->updated_by : null;
}
if ($date === $start) {
$flows[$openingIndex] = $flow;
} else {
$flows[] = $flow;
}
continue;
}
$row = $rowsByDate[$date] ?? null;
if ($row === null) {
continue;
}
$inflow = round((float) $row['inflow'], 2);
@ -113,7 +176,7 @@ class FinanceLedgerCalculator
continue;
}
if ($row['flow_date'] === $start) {
if ($date === $start) {
// 期初日发生动账时合并到同一行,避免唯一键冲突。
$balance = round($balance + $inflow - $outflow, 2);
$flows[$openingIndex]['inflow'] = $inflow;
@ -124,8 +187,8 @@ class FinanceLedgerCalculator
}
$balance = round($balance + $inflow - $outflow, 2);
$flows[] = [
'flow_date' => $row['flow_date'],
$flow = [
'flow_date' => $date,
'company_name' => $company,
'account_short' => $account,
'opening_balance' => round($balance - $inflow + $outflow, 2),
@ -136,6 +199,11 @@ class FinanceLedgerCalculator
'created_at' => $now,
'updated_at' => $now,
];
if ($hasManualColumns) {
$flow['manual_override'] = false;
$flow['updated_by'] = null;
}
$flows[] = $flow;
}
$latest[$account] = $balance;

View File

@ -0,0 +1,207 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Validation\ValidationException;
class FinanceLedgerFlowEditor
{
private readonly ConnectionInterface $connection;
public function __construct(
DatabaseManager $databases,
private readonly FinanceLedgerCalculator $calculator,
) {
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @param array<string,mixed> $data
* @return array<string,mixed>
*/
public function update(array $data): array
{
$date = $this->date($data['flow_date'] ?? null);
$accountShort = $this->text($data['account_short'] ?? '');
$openingCents = $this->cents($data['opening_balance'] ?? 0);
$inflowCents = $this->cents($data['inflow'] ?? 0);
$outflowCents = $this->cents($data['outflow'] ?? 0);
$closingCents = $this->cents($data['closing_balance'] ?? 0);
$opening = $openingCents / 100;
$inflow = $inflowCents / 100;
$outflow = $outflowCents / 100;
$closing = $closingCents / 100;
$remark = $this->nullableText($data['remark'] ?? null);
if ($openingCents + $inflowCents - $outflowCents !== $closingCents) {
throw ValidationException::withMessages([
'closing_balance' => '期末余额必须等于期初余额 + 当日入账 - 当日出账。',
]);
}
$this->connection->transaction(function () use (
$date,
$accountShort,
$opening,
$inflow,
$outflow,
$closing,
$remark,
): void {
$account = $this->connection->table('fin_accounts')
->where('account_short', $accountShort)
->lockForUpdate()
->first();
if ($account === null) {
throw ValidationException::withMessages([
'account_short' => '账户简称不存在,请从账户总表中选择。',
]);
}
$before = $this->existingFlow($date, $accountShort);
$values = [
'opening_balance' => $opening,
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $closing,
'remark' => $remark,
'updated_by' => (string) (user()?->name ?? ''),
'updated_at' => now(),
];
$existing = $this->connection->table('fin_daily_flow_overrides')
->where('flow_date', $date)
->where('account_short', $accountShort)
->first();
if ($existing === null) {
$this->connection->table('fin_daily_flow_overrides')->insert($values + [
'flow_date' => $date,
'account_short' => $accountShort,
'created_at' => now(),
]);
} else {
$this->connection->table('fin_daily_flow_overrides')
->where('id', $existing->id)
->update($values);
}
$this->connection->table('fin_ledger_edit_events')->insert([
'entity_type' => 'daily_flow',
'entity_key' => $date . '|' . $accountShort,
'changes' => json_encode([
'before' => $before,
'after' => [
'flow_date' => $date,
'account_short' => $accountShort,
'opening_balance' => $opening,
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $closing,
'remark' => $remark,
],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'user_id' => user()?->id,
'user_name' => (string) (user()?->name ?? ''),
'created_at' => now(),
]);
$this->calculator->recalculate();
});
return $this->existingFlow($date, $accountShort) ?? [];
}
/**
* @return array<string,mixed>|null
*/
public function reset(string $flowDate, string $accountShort): ?array
{
$date = $this->date($flowDate);
$accountShort = $this->text($accountShort);
$this->connection->transaction(function () use ($date, $accountShort): void {
$before = $this->existingFlow($date, $accountShort);
$deleted = $this->connection->table('fin_daily_flow_overrides')
->where('flow_date', $date)
->where('account_short', $accountShort)
->delete();
if ($deleted > 0) {
$this->connection->table('fin_ledger_edit_events')->insert([
'entity_type' => 'daily_flow_reset',
'entity_key' => $date . '|' . $accountShort,
'changes' => json_encode([
'before' => $before,
'after' => null,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'user_id' => user()?->id,
'user_name' => (string) (user()?->name ?? ''),
'created_at' => now(),
]);
}
$this->calculator->recalculate();
});
return $this->existingFlow($date, $accountShort);
}
/** @return array<string,mixed>|null */
public function existingFlow(string $date, string $accountShort): ?array
{
$row = $this->connection->table('fin_daily_flows')
->whereDate('flow_date', $date)
->where('account_short', $accountShort)
->first();
if ($row === null) {
return null;
}
return [
'id' => (int) $row->id,
'flow_date' => substr((string) $row->flow_date, 0, 10),
'company_name' => $row->company_name,
'account_short' => $row->account_short,
'opening_balance' => round((float) $row->opening_balance, 2),
'inflow' => round((float) $row->inflow, 2),
'outflow' => round((float) $row->outflow, 2),
'closing_balance' => round((float) $row->closing_balance, 2),
'remark' => $row->remark,
'manual_override' => (bool) ($row->manual_override ?? false),
'updated_by' => $row->updated_by ?? null,
];
}
private function date(mixed $value): string
{
$text = trim((string) $value);
$date = CarbonImmutable::createFromFormat('!Y-m-d', $text);
if (! $date instanceof CarbonImmutable || $date->format('Y-m-d') !== $text) {
throw ValidationException::withMessages([
'flow_date' => '流水日期必须使用 YYYY-MM-DD 格式。',
]);
}
return $text;
}
private function cents(mixed $value): int
{
return (int) round((float) $value * 100);
}
private function text(mixed $value): string
{
return preg_replace('/\s+/u', ' ', trim((string) $value)) ?? trim((string) $value);
}
private function nullableText(mixed $value): ?string
{
$text = $this->text($value);
return $text !== '' ? $text : null;
}
}

View File

@ -61,7 +61,7 @@
$date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : '';
$ledgerPageUrl = route('business.pages.show', ['section' => 'financial-operations', 'page' => 'ledger']);
$tabUrl = static fn (string $tab): string => $ledgerPageUrl . '?' . http_build_query(['tab' => $tab]);
$sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入'];
$sourceLabels = ['dingtalk' => '钉钉审批', 'alipay-company' => '公司支付宝', 'shop' => '店铺提现', 'shop_bill' => '店铺账单提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单', 'manual' => '手工录入'];
$shopBalanceDate = $ledgerShopBalanceDate ?? null;
$shopBalanceDates = $ledgerShopBalanceDates ?? [];
$manualTransactionAccountOptions = $ledgerManualTransactionAccountOptions ?? [];
@ -242,6 +242,9 @@
.ledger-company-form__message[hidden] { display:none !important; }
.ledger-company-form__footer { display:flex; align-items:center; justify-content:flex-end; gap:.5rem; padding:.8rem 1.1rem 1rem; border-top:1px solid #e8e3da; }
.ledger-company-form__footer .ledger-btn:disabled { opacity:.58; cursor:wait; }
.ledger-edit-dialog { width:min(68rem, calc(100vw - 2rem)); }
.ledger-edit-dialog .ledger-company-form__body { grid-template-columns:repeat(4, minmax(0, 1fr)); }
.ledger-edit-dialog .ledger-edit-field--wide { grid-column:span 2; }
#ledger-panel-accounts .ledger-account-editor { display:none; }
#ledger-panel-accounts.is-account-edit-mode .ledger-account-readonly { display:none; }
#ledger-panel-accounts.is-account-edit-mode .ledger-account-editor { display:block; }
@ -452,8 +455,15 @@
.ledger-source-tag--receiving { background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); }
.ledger-balance-alert { display:inline-flex; align-items:center; gap:.2rem; color:var(--vip-warning, #935F07); font-size:.72rem; font-weight:600; white-space:nowrap; }
.ledger-balance-alert .material-icons-outlined { font-size:.95rem; }
.ledger-row-edit { display:inline-grid; place-items:center; width:1.55rem; height:1.55rem; margin-left:.3rem; padding:0; border:1px solid var(--vip-border, #D8D2C7); border-radius:.3rem; background:#fff; color:var(--vip-content-accent-dark, #856632); cursor:pointer; vertical-align:middle; }
.ledger-row-edit:hover, .ledger-row-edit:focus-visible { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-soft, #F7F0DE); outline:none; }
.ledger-row-edit .material-icons-outlined { font-size:.95rem; }
.ledger-manual-badge { display:inline-flex; margin-left:.25rem; padding:.08rem .28rem; border-radius:.25rem; background:#fff8eb; color:var(--vip-warning, #935F07); font-size:.58rem; font-weight:700; vertical-align:middle; }
.ledger-flow-equation { grid-column:1 / -1; margin:0; padding:.55rem .65rem; border-radius:.4rem; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-muted, #595959); font-size:.66rem; line-height:1.5; }
.ledger-flow-edit-actions { display:flex; align-items:center; gap:.5rem; margin-right:auto; }
.ledger-flow-edit-actions .ledger-btn { color:var(--vip-warning, #935F07); }
.ledger-mono { font-variant-numeric:tabular-nums; }
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } }
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } .ledger-edit-dialog .ledger-company-form__body { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) { .ledger-company-form__body { grid-template-columns:repeat(2, minmax(0, 1fr)); } .ledger-company-field--wide { grid-column:span 2; } }
@media (max-width: 520px) { .ledger-company-form__body { grid-template-columns:minmax(0, 1fr); } .ledger-company-field--wide { grid-column:auto; } }
</style>
@ -653,6 +663,86 @@
@endforeach
</datalist>
</dialog>
<dialog class="ledger-company-dialog ledger-edit-dialog" data-ledger-account-dialog aria-labelledby="ledger-account-edit-title">
<form class="ledger-company-form" method="POST" data-ledger-account-form>
@csrf
<header class="ledger-company-form__header">
<div>
<h3 class="ledger-company-form__title" id="ledger-account-edit-title">编辑账户</h3>
<p class="ledger-company-form__help">可修改账户资料、限额和执行信息。当前余额、可用金额由动账明细和账户状态自动计算,不在此处直接覆盖。</p>
</div>
<button class="ledger-company-form__close" type="button" aria-label="关闭账户编辑" data-ledger-account-close>
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</header>
<div class="ledger-company-form__body">
<div class="ledger-company-form__section"><span>基础资料</span><span>账户简称修改后,期初、动账、流水和映射会同步更新。</span></div>
<div class="ledger-company-field"><label for="ledger-account-company-name">公司名称 <span aria-hidden="true">*</span></label><input id="ledger-account-company-name" name="company_name" type="text" list="ledger-company-options" maxlength="100" required></div>
<div class="ledger-company-field"><label for="ledger-account-short-edit">账户简称 <span aria-hidden="true">*</span></label><input id="ledger-account-short-edit" name="account_short" type="text" maxlength="100" required></div>
<div class="ledger-company-field"><label for="ledger-account-type-edit">账户类型</label><select id="ledger-account-type-edit" name="account_type"><option value="">未填写</option>@foreach ($accountTypeOptions as $accountType)<option value="{{ $accountType }}">{{ $accountType }}</option>@endforeach</select></div>
<div class="ledger-company-field"><label for="ledger-account-status-edit">账户状态</label><select id="ledger-account-status-edit" name="status" required>@foreach ($accountStatusOptions as $statusCode => $statusLabel)<option value="{{ $statusCode }}">{{ $statusLabel }}</option>@endforeach</select></div>
<div class="ledger-company-field"><label for="ledger-account-region-edit">地区</label><input id="ledger-account-region-edit" name="region" type="text" maxlength="50"></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-account-no-edit">账号</label><input id="ledger-account-no-edit" name="account_no" type="text" maxlength="50"></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-account-bank-name-edit">开户行</label><input id="ledger-account-bank-name-edit" name="bank_name" type="text" maxlength="200"></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-account-bank-branch-edit">开户城市/网点</label><input id="ledger-account-bank-branch-edit" name="bank_city_branch" type="text" maxlength="100"></div>
<div class="ledger-company-field"><label for="ledger-account-plan-edit">公司规划</label><input id="ledger-account-plan-edit" name="company_plan" type="text" list="ledger-company-plan-options" maxlength="50"></div>
<div class="ledger-company-field"><label for="ledger-account-keep-edit">账户是否保留</label><select id="ledger-account-keep-edit" name="account_should_keep"><option value="">未填写</option>@foreach ($accountKeepOptions as $keepOption)<option value="{{ $keepOption }}">{{ $keepOption }}</option>@endforeach</select></div>
<div class="ledger-company-field"><label for="ledger-account-owner-edit">责任人</label><input id="ledger-account-owner-edit" name="owner" type="text" maxlength="50"></div>
<div class="ledger-company-field"><label for="ledger-account-current-balance-edit">当前余额(元)</label><input id="ledger-account-current-balance-edit" type="text" data-ledger-account-current readonly></div>
<div class="ledger-company-form__section"><span>限额与出清</span><span>限额沿用原表字段,可填写数值或范围。</span></div>
<div class="ledger-company-field"><label for="ledger-account-single-limit">单笔限额(元)</label><input id="ledger-account-single-limit" name="single_limit" type="text" maxlength="50"></div>
<div class="ledger-company-field"><label for="ledger-account-daily-limit">日限额(元)</label><input id="ledger-account-daily-limit" name="daily_limit" type="text" maxlength="50"></div>
<div class="ledger-company-field"><label for="ledger-account-monthly-limit">月限额(元)</label><input id="ledger-account-monthly-limit" name="monthly_limit" type="text" maxlength="50"></div>
<div class="ledger-company-field"><label for="ledger-account-yearly-limit">年限额(元)</label><input id="ledger-account-yearly-limit" name="yearly_limit" type="text" maxlength="50"></div>
<div class="ledger-company-field"><label for="ledger-account-clearing-days">预计出清天数</label><input id="ledger-account-clearing-days" name="clearing_days" type="number" min="0" step="0.01"></div>
<div class="ledger-company-form__section"><span>提额与备注</span><span>用于账户管理和风险跟进。</span></div>
<div class="ledger-company-field"><label for="ledger-account-raise-target">提额目标(元)</label><input id="ledger-account-raise-target" name="raise_target" type="text" maxlength="50"></div>
<div class="ledger-company-field"><label for="ledger-account-raise-status">提额状态</label><input id="ledger-account-raise-status" name="raise_status" type="text" maxlength="50"></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-account-raise-actions">提额动作</label><input id="ledger-account-raise-actions" name="raise_actions" type="text" maxlength="500"></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-account-done-actions">已做动作</label><input id="ledger-account-done-actions" name="done_actions" type="text" maxlength="500"></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-account-next-action">下一步</label><input id="ledger-account-next-action" name="next_action" type="text" maxlength="200"></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-account-remark-edit">备注</label><input id="ledger-account-remark-edit" name="remark" type="text" maxlength="500"></div>
<p class="ledger-company-form__message" role="alert" data-ledger-account-error hidden></p>
</div>
<footer class="ledger-company-form__footer">
<button class="ledger-btn" type="button" data-ledger-account-cancel>取消</button>
<button class="ledger-btn primary" type="submit" data-ledger-account-save><span class="material-icons-outlined" aria-hidden="true">check</span>保存账户</button>
</footer>
</form>
</dialog>
<dialog class="ledger-company-dialog ledger-edit-dialog" data-ledger-flow-dialog aria-labelledby="ledger-flow-edit-title">
<form class="ledger-company-form" method="POST" data-ledger-flow-form>
@csrf
<input type="hidden" name="flow_date" data-ledger-flow-date-value>
<input type="hidden" name="account_short" data-ledger-flow-account-value>
<header class="ledger-company-form__header">
<div>
<h3 class="ledger-company-form__title" id="ledger-flow-edit-title">编辑每日资金流水</h3>
<p class="ledger-company-form__help">保存后按手填数据重算该账户后续流水和当前余额;恢复自动计算会删除本日手动覆盖。</p>
</div>
<button class="ledger-company-form__close" type="button" aria-label="关闭流水编辑" data-ledger-flow-close><span class="material-icons-outlined" aria-hidden="true">close</span></button>
</header>
<div class="ledger-company-form__body">
<div class="ledger-company-field"><label for="ledger-flow-date-edit">日期</label><input id="ledger-flow-date-edit" type="text" data-ledger-flow-date readonly></div>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-flow-account-edit">账户简称</label><input id="ledger-flow-account-edit" type="text" data-ledger-flow-account readonly></div>
<div class="ledger-company-field"><label>当前口径</label><input type="text" data-ledger-flow-mode readonly></div>
<div class="ledger-company-field"><label for="ledger-flow-opening">期初余额(元)</label><input id="ledger-flow-opening" name="opening_balance" type="number" step="0.01" required data-ledger-flow-opening></div>
<div class="ledger-company-field"><label for="ledger-flow-inflow">当日入账(元)</label><input id="ledger-flow-inflow" name="inflow" type="number" min="0" step="0.01" required data-ledger-flow-inflow></div>
<div class="ledger-company-field"><label for="ledger-flow-outflow">当日出账(元)</label><input id="ledger-flow-outflow" name="outflow" type="number" min="0" step="0.01" required data-ledger-flow-outflow></div>
<div class="ledger-company-field"><label for="ledger-flow-closing">期末余额(元)</label><input id="ledger-flow-closing" name="closing_balance" type="number" step="0.01" required readonly data-ledger-flow-closing></div>
<p class="ledger-flow-equation">期末余额按“期初余额 + 当日入账 - 当日出账”自动计算,保存时会再次校验。</p>
<div class="ledger-company-field ledger-edit-field--wide"><label for="ledger-flow-remark">备注</label><input id="ledger-flow-remark" name="remark" type="text" maxlength="200" data-ledger-flow-remark></div>
<p class="ledger-company-form__message" role="alert" data-ledger-flow-error hidden></p>
</div>
<footer class="ledger-company-form__footer">
<div class="ledger-flow-edit-actions"><button class="ledger-btn" type="button" data-ledger-flow-reset hidden>恢复自动计算</button></div>
<button class="ledger-btn" type="button" data-ledger-flow-cancel>取消</button>
<button class="ledger-btn primary" type="submit" data-ledger-flow-save><span class="material-icons-outlined" aria-hidden="true">check</span>保存流水</button>
</footer>
</form>
</dialog>
@endif
<div class="ledger-summary">
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>
@ -725,7 +815,12 @@
@forelse ($ledgerAccounts as $row)
<tr>
<td>{{ $row->company_name }}</td>
<td>{{ $row->account_short }}</td>
<td>
{{ $row->account_short }}
@if ($canEditAccounts)
<button class="ledger-row-edit" type="button" title="编辑账户 {{ $row->account_short }}" aria-label="编辑账户 {{ $row->account_short }}" data-ledger-account-edit data-edit-url="{{ route('business.financial-ledger.accounts.edit', ['id' => $row->id]) }}" data-save-url="{{ route('business.financial-ledger.accounts.update', ['id' => $row->id]) }}"><span class="material-icons-outlined" aria-hidden="true">edit</span></button>
@endif
</td>
<td>
@if ($canEditAccountType)
<span class="ledger-account-readonly" data-account-edit-display data-account-type-display>{{ $row->account_type ?: '未填写' }}</span>
@ -860,7 +955,7 @@
])
@endforeach
</tr></thead>
<tbody>@forelse ($ledgerFlows as $row)<tr><td>{{ $date($row->flow_date) }}</td><td>{{ $row->company_name }}</td><td>{{ $row->account_short }}</td><td class="num">{{ $money($row->opening_balance) }}</td><td class="num">{{ $row->inflow > 0 ? $money($row->inflow) : '' }}</td><td class="num">{{ $row->outflow > 0 ? $money($row->outflow) : '' }}</td><td class="num">{{ $money($row->closing_balance) }}</td><td>{{ $row->remark }}</td></tr>@empty<tr><td colspan="8"><div class="ledger-empty">当前筛选没有流水数据;期初日会为每个期初账户生成一行。</div></td></tr>@endforelse</tbody>
<tbody>@forelse ($ledgerFlows as $row)<tr><td>{{ $date($row->flow_date) }}</td><td>{{ $row->company_name }}</td><td>{{ $row->account_short }}@if ($row->manual_override ?? false)<span class="ledger-manual-badge" title="由 {{ $row->updated_by ?: '财务人员' }} 手动修改">手动</span>@endif @if ($canEditAccounts)<button class="ledger-row-edit" type="button" title="编辑 {{ $date($row->flow_date) }} {{ $row->account_short }} 流水" aria-label="编辑 {{ $date($row->flow_date) }} {{ $row->account_short }} 流水" data-ledger-flow-edit data-edit-url="{{ route('business.financial-ledger.flows.edit', ['flow_date' => $date($row->flow_date), 'account_short' => $row->account_short]) }}" data-save-url="{{ route('business.financial-ledger.flows.update') }}" data-reset-url="{{ route('business.financial-ledger.flows.reset') }}" data-flow-date="{{ $date($row->flow_date) }}" data-flow-account-short="{{ $row->account_short }}"><span class="material-icons-outlined" aria-hidden="true">edit</span></button>@endif</td><td class="num">{{ $money($row->opening_balance) }}</td><td class="num">{{ $row->inflow > 0 ? $money($row->inflow) : '' }}</td><td class="num">{{ $row->outflow > 0 ? $money($row->outflow) : '' }}</td><td class="num">{{ $money($row->closing_balance) }}</td><td>{{ $row->remark }}</td></tr>@empty<tr><td colspan="8"><div class="ledger-empty">当前筛选没有流水数据;期初日会为每个期初账户生成一行。</div></td></tr>@endforelse</tbody>
</table>
@include('business.pages.financial-ledger.partials.pagination', [
'rows' => $ledgerFlows,
@ -1695,6 +1790,243 @@
accountCompanyForm.removeAttribute('aria-busy');
}
});
const accountEditDialog = page.querySelector('[data-ledger-account-dialog]');
const accountEditForm = page.querySelector('[data-ledger-account-form]');
const accountEditError = page.querySelector('[data-ledger-account-error]');
const accountEditSave = page.querySelector('[data-ledger-account-save]');
const closeAccountEditDialog = () => {
if (accountEditDialog?.open) accountEditDialog.close();
};
const accountEditField = (name) => accountEditForm?.elements.namedItem(name) || null;
page.querySelectorAll('[data-ledger-account-edit]').forEach((button) => {
button.addEventListener('click', async () => {
if (!accountEditDialog || !accountEditForm) return;
button.disabled = true;
try {
const response = await fetch(button.dataset.editUrl, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || '账户资料读取失败');
const account = payload.account || {};
accountEditForm.action = button.dataset.saveUrl;
accountEditForm.reset();
[
'company_name', 'account_short', 'account_type', 'status', 'region',
'account_no', 'bank_name', 'bank_city_branch', 'company_plan',
'account_should_keep', 'single_limit', 'daily_limit', 'monthly_limit',
'yearly_limit', 'clearing_days', 'raise_target', 'raise_status',
'raise_actions', 'done_actions', 'next_action', 'remark', 'owner',
].forEach((name) => {
const field = accountEditField(name);
if (field) field.value = account[name] ?? '';
});
accountEditField('status').value = account.status || 'normal';
const balanceField = page.querySelector('[data-ledger-account-current]');
if (balanceField) {
balanceField.value = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(account.current_balance || 0));
}
if (accountEditError) {
accountEditError.hidden = true;
accountEditError.textContent = '';
}
if (typeof accountEditDialog.showModal === 'function') {
accountEditDialog.showModal();
} else {
accountEditDialog.setAttribute('open', '');
}
accountEditField('company_name')?.focus();
} catch (error) {
notify(error.message || '账户资料读取失败', true);
} finally {
button.disabled = false;
}
});
});
page.querySelector('[data-ledger-account-close]')?.addEventListener('click', closeAccountEditDialog);
page.querySelector('[data-ledger-account-cancel]')?.addEventListener('click', closeAccountEditDialog);
accountEditDialog?.addEventListener('click', (event) => {
if (event.target === accountEditDialog) closeAccountEditDialog();
});
accountEditForm?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!accountEditForm.reportValidity()) return;
if (accountEditSave) accountEditSave.disabled = true;
accountEditForm.setAttribute('aria-busy', 'true');
if (accountEditError) {
accountEditError.hidden = true;
accountEditError.textContent = '';
}
try {
const response = await fetch(accountEditForm.action, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(accountEditForm),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const firstError = Object.values(payload.errors || {})[0]?.[0];
throw new Error(firstError || payload.message || '账户保存失败');
}
notify(payload.message || '账户信息已保存');
closeAccountEditDialog();
window.location.reload();
} catch (error) {
if (accountEditError) {
accountEditError.textContent = error.message || '账户保存失败';
accountEditError.hidden = false;
}
notify(error.message || '账户保存失败', true);
} finally {
if (accountEditSave) accountEditSave.disabled = false;
accountEditForm.removeAttribute('aria-busy');
}
});
const flowEditDialog = page.querySelector('[data-ledger-flow-dialog]');
const flowEditForm = page.querySelector('[data-ledger-flow-form]');
const flowEditError = page.querySelector('[data-ledger-flow-error]');
const flowEditSave = page.querySelector('[data-ledger-flow-save]');
const flowEditReset = page.querySelector('[data-ledger-flow-reset]');
const flowOpening = page.querySelector('[data-ledger-flow-opening]');
const flowInflow = page.querySelector('[data-ledger-flow-inflow]');
const flowOutflow = page.querySelector('[data-ledger-flow-outflow]');
const flowClosing = page.querySelector('[data-ledger-flow-closing]');
let flowResetUrl = '';
const updateFlowClosing = () => {
if (!flowClosing) return;
const opening = Number(flowOpening?.value || 0);
const inflow = Number(flowInflow?.value || 0);
const outflow = Number(flowOutflow?.value || 0);
flowClosing.value = (opening + inflow - outflow).toFixed(2);
};
[flowOpening, flowInflow, flowOutflow].forEach((input) => input?.addEventListener('input', updateFlowClosing));
const closeFlowEditDialog = () => {
if (flowEditDialog?.open) flowEditDialog.close();
};
page.querySelectorAll('[data-ledger-flow-edit]').forEach((button) => {
button.addEventListener('click', async () => {
if (!flowEditDialog || !flowEditForm) return;
button.disabled = true;
try {
const response = await fetch(button.dataset.editUrl, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || '流水读取失败');
const flow = payload.flow || {};
flowEditForm.action = button.dataset.saveUrl;
flowResetUrl = button.dataset.resetUrl;
flowEditForm.querySelector('[data-ledger-flow-date-value]').value = flow.flow_date || '';
flowEditForm.querySelector('[data-ledger-flow-account-value]').value = flow.account_short || '';
page.querySelector('[data-ledger-flow-date]').value = flow.flow_date || '';
page.querySelector('[data-ledger-flow-account]').value = flow.account_short || '';
page.querySelector('[data-ledger-flow-mode]').value = flow.manual_override ? '手动覆盖' : '自动计算';
flowOpening.value = Number(flow.opening_balance || 0).toFixed(2);
flowInflow.value = Number(flow.inflow || 0).toFixed(2);
flowOutflow.value = Number(flow.outflow || 0).toFixed(2);
page.querySelector('[data-ledger-flow-remark]').value = flow.remark || '';
updateFlowClosing();
if (flowEditReset) flowEditReset.hidden = !flow.manual_override;
if (flowEditError) {
flowEditError.hidden = true;
flowEditError.textContent = '';
}
if (typeof flowEditDialog.showModal === 'function') {
flowEditDialog.showModal();
} else {
flowEditDialog.setAttribute('open', '');
}
flowOpening.focus();
} catch (error) {
notify(error.message || '流水读取失败', true);
} finally {
button.disabled = false;
}
});
});
page.querySelector('[data-ledger-flow-close]')?.addEventListener('click', closeFlowEditDialog);
page.querySelector('[data-ledger-flow-cancel]')?.addEventListener('click', closeFlowEditDialog);
flowEditDialog?.addEventListener('click', (event) => {
if (event.target === flowEditDialog) closeFlowEditDialog();
});
flowEditForm?.addEventListener('submit', async (event) => {
event.preventDefault();
updateFlowClosing();
if (!flowEditForm.reportValidity()) return;
if (flowEditSave) flowEditSave.disabled = true;
flowEditForm.setAttribute('aria-busy', 'true');
if (flowEditError) {
flowEditError.hidden = true;
flowEditError.textContent = '';
}
try {
const response = await fetch(flowEditForm.action, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: new FormData(flowEditForm),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const firstError = Object.values(payload.errors || {})[0]?.[0];
throw new Error(firstError || payload.message || '流水保存失败');
}
notify(payload.message || '每日资金流水已保存');
closeFlowEditDialog();
window.location.reload();
} catch (error) {
if (flowEditError) {
flowEditError.textContent = error.message || '流水保存失败';
flowEditError.hidden = false;
}
notify(error.message || '流水保存失败', true);
} finally {
if (flowEditSave) flowEditSave.disabled = false;
flowEditForm.removeAttribute('aria-busy');
}
});
flowEditReset?.addEventListener('click', async () => {
if (!flowResetUrl || !flowEditForm) return;
flowEditReset.disabled = true;
try {
const response = await fetch(flowResetUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-CSRF-TOKEN': flowEditForm.querySelector('input[name="_token"]')?.value || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: new URLSearchParams({
flow_date: flowEditForm.querySelector('[data-ledger-flow-date-value]').value,
account_short: flowEditForm.querySelector('[data-ledger-flow-account-value]').value,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.message || '恢复自动计算失败');
notify(payload.message || '已恢复自动计算');
closeFlowEditDialog();
window.location.reload();
} catch (error) {
if (flowEditError) {
flowEditError.textContent = error.message || '恢复自动计算失败';
flowEditError.hidden = false;
}
notify(error.message || '恢复自动计算失败', true);
} finally {
flowEditReset.disabled = false;
}
});
const accountColumnPicker = page.querySelector('[data-ledger-column-picker]');
const accountColumnPanel = accountColumnPicker?.querySelector('[data-ledger-column-panel]');
const accountColumnOptions = accountColumnPicker?.querySelector('[data-ledger-column-options]');

View File

@ -741,6 +741,28 @@ Route::post('workspace/financial-ledger/transactions/{id}/account', [\App\Http\C
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.transactions.account.update');
Route::get('workspace/financial-ledger/accounts/{id}/edit', [\App\Http\Controllers\Business\FinanceLedger::class, 'editAccount'])
->whereNumber('id')
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.edit');
Route::post('workspace/financial-ledger/accounts/{id}', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccount'])
->whereNumber('id')
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.update');
Route::get('workspace/financial-ledger/flows/edit', [\App\Http\Controllers\Business\FinanceLedger::class, 'editDailyFlow'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.flows.edit');
Route::post('workspace/financial-ledger/flows/update', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateDailyFlow'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.flows.update');
Route::post('workspace/financial-ledger/flows/reset', [\App\Http\Controllers\Business\FinanceLedger::class, 'resetDailyFlow'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.flows.reset');
Route::post('workspace/financial-ledger/accounts/status', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountStatus'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.status');

View File

@ -0,0 +1,30 @@
# 公司非店铺支付宝每日账单
每天 09:45 自动读取公司支付宝账户前一天的交易记录,并导入财务台账“动账明细”。
## 首次登录
```powershell
node scripts\company-alipay-direct\open-login.mjs
```
脚本会打开独立 Edge 登录窗口并预填账号。图形验证码、短信或扫码确认必须由本人完成;登录成功后保留该浏览器配置目录,后续定时任务复用登录会话。
## 手动运行
```powershell
pwsh -File scripts\company-alipay-direct\run-alipay-daily.ps1
# 指定历史日期,只导出不导入
pwsh -File scripts\company-alipay-direct\run-alipay-daily.ps1 -Date 2026-10-29 -DryRun
```
## 安装每日任务
```powershell
pwsh -File scripts\company-alipay-direct\install-daily-task.ps1
```
账号、密码、账户简称和浏览器登录态仅保存在 `runtime/private/`,不会进入 Git。私有文件需包含 `账号:`、`密码:` 和 `支付宝账户简称:` 三项。任一登录失效、验证页面出现、空数据或导入失败都会保留上一版数据并记录日志。
账单导入生产时统一通过服务器受限批处理入口执行,使用独立批处理容器、上游同步锁和资源上限;支付宝登录与账单导出仍只在本机运行。

View File

@ -0,0 +1,208 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CDP, evaluate, goto, launchEdge, openPage, sleep } from '../erp-daily-bills/cdp.mjs';
const BASE = path.dirname(fileURLToPath(import.meta.url));
const PROFILE = path.resolve(BASE, '../../runtime/private/alipay-profile');
const SECRET_FILE = process.env.ALIPAY_SECRET_FILE
|| path.resolve(BASE, '../../runtime/private/敏感信息.md');
const PORT = 9335;
const arg = (name, fallback = null) => {
const hit = process.argv.find((item) => item.startsWith(`--${name}=`));
return hit ? hit.slice(name.length + 3) : fallback;
};
const fmt = (date) => [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
const yesterday = new Date(Date.now() - 86400000);
const targetDate = arg('date', fmt(yesterday));
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) throw new Error('日期必须使用 YYYY-MM-DD 格式');
const outputDir = path.resolve(arg('out', path.join(BASE, '../../runtime/private/alipay-exports', targetDate)));
fs.mkdirSync(outputDir, { recursive: true });
const outputFile = path.join(outputDir, `alipay-company-${targetDate}.csv`);
const stateFile = path.join(outputDir, 'last-export.json');
const secret = fs.readFileSync(SECRET_FILE, 'utf8');
const account = /账号:([^\r\n]+)/.exec(secret)?.[1]?.trim();
const password = /密码:([^\r\n]+)/.exec(secret)?.[1]?.trim();
const accountShort = /支付宝账户简称:([^\r\n]+)/.exec(secret)?.[1]?.trim();
if (!account || !password || !accountShort) throw new Error('私有文件缺少支付宝账号、密码或账户简称');
const dotDate = targetDate.replaceAll('-', '.');
const query = new URLSearchParams({
dateRange: 'customDate',
beginDate: dotDate,
endDate: dotDate,
beginTime: '00:00',
endTime: '24:00',
dateType: 'createDate',
fundFlow: 'all',
tradeType: 'ALL',
status: 'all',
});
const targetUrl = `https://consumeprod.alipay.com/record/advanced.htm?${query}`;
const { child, version } = await launchEdge({
port: PORT,
profileDir: PROFILE,
headless: !process.argv.includes('--interactive'),
});
const cdp = await CDP.connect(version.webSocketDebuggerUrl);
const { sessionId } = await openPage(cdp, 'about:blank');
async function pageInfo() {
return evaluate(cdp, sessionId, `({
url: location.href,
title: document.title,
text: document.body?.innerText || '',
hasUser: !!document.querySelector('#J-input-user'),
hasPassword: !!document.querySelector('input[type="password"]')
})`);
}
async function maybeLogin() {
await goto(cdp, sessionId, targetUrl);
await sleep(1600);
let state = await pageInfo();
if (!/auth\.alipay\.com\/login/.test(state.url) && !state.hasUser && !state.hasPassword) return state;
await evaluate(cdp, sessionId, `(() => {
const el = [...document.querySelectorAll('*')].find((node) => (node.innerText || '').trim() === '账密登录');
el && el.click();
return true;
})()`);
await sleep(900);
await evaluate(cdp, sessionId, `(() => {
const setValue = (el, value) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter ? setter.call(el, value) : (el.value = value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
};
const user = document.querySelector('#J-input-user');
const pass = document.querySelector('input[type="password"]');
if (!user || !pass) return false;
setValue(user, ${JSON.stringify(account)});
setValue(pass, ${JSON.stringify(password)});
const button = [...document.querySelectorAll('button')].find((node) => (node.innerText || '').replace(/\\s+/g, '') === '登录');
button && button.click();
return true;
})()`);
await sleep(3500);
state = await pageInfo();
if (/请在下图依次点击|验证码|短信验证|扫码验证/.test(state.text)) {
throw new Error('支付宝登录或账单下载需要人工验证,请先运行 open-login.mjs 完成验证');
}
if (state.hasUser && state.hasPassword) throw new Error('支付宝登录失败,请先运行 open-login.mjs 检查登录状态');
return state;
}
async function waitForRecords() {
for (let attempt = 0; attempt < 20; attempt++) {
const ready = await evaluate(cdp, sessionId, `({
rows: document.querySelectorAll('#tradeRecordsIndex tr.J-item').length,
noData: /暂无数据|没有找到符合条件的记录/.test(document.body?.innerText || ''),
begin: document.querySelector('#beginDate')?.value || '',
end: document.querySelector('#endDate')?.value || ''
})`);
if (ready.begin === dotDate && ready.end === dotDate && (ready.rows > 0 || ready.noData)) return ready;
await sleep(800);
}
throw new Error('支付宝交易记录加载超时');
}
function parseRows(rows) {
return rows.map((row) => {
const cells = row.cells.map((cell) => (cell.innerText || '').replace(/\s+/g, ' ').trim());
const timeMatch = /^(\d{4})[./-](\d{1,2})[./-](\d{1,2})\s+(\d{1,2}):(\d{2})/.exec(cells[0] || '');
const ids = /订单号[::]\s*([^|]+)\|\s*交易号[::]\s*(\d+)/.exec(cells[3] || '') || [];
const amountText = cells[5] || '';
const numeric = Number(amountText.replace(/[^\d.-]/g, ''));
return {
time: timeMatch
? `${timeMatch[1]}-${timeMatch[2].padStart(2, '0')}-${timeMatch[3].padStart(2, '0')} ${timeMatch[4].padStart(2, '0')}:${timeMatch[5]}`
: '',
name: cells[2] || '',
merchantOrderNo: (ids[1] || '').trim(),
recordCode: (ids[2] || '').trim(),
counterparty: cells[4] || '',
direction: amountText.trim().startsWith('-') ? '支出' : '收入',
amount: Math.abs(numeric).toFixed(2),
status: cells[7] || '',
};
}).filter((row) => row.time && row.recordCode && Number(row.amount) > 0);
}
try {
await maybeLogin();
await goto(cdp, sessionId, targetUrl);
await waitForRecords();
const collected = [];
const seenPages = new Set();
for (let page = 1; page <= 200; page++) {
const rows = await evaluate(cdp, sessionId, `[...document.querySelectorAll('#tradeRecordsIndex tr.J-item')].map((row) => ({
cells: [...row.cells].map((cell) => ({ innerText: cell.innerText || '' }))
}))`);
const pageRows = parseRows(rows);
const fingerprint = pageRows.map((row) => row.recordCode).join(',') + '|' + pageRows.length;
if (seenPages.has(fingerprint)) break;
seenPages.add(fingerprint);
collected.push(...pageRows);
const next = await evaluate(cdp, sessionId, `(() => {
const visible = (el) => !!(el.offsetWidth || el.offsetHeight);
const link = [...document.querySelectorAll('a')].find((el) => visible(el)
&& (el.innerText || '').replace(/\\s+/g, '').includes('下一页')
&& !/(disabled|fn-hide)/.test(el.className || ''));
if (!link) return false;
link.click();
return true;
})()`);
if (!next) break;
await sleep(1500);
}
const unique = [...new Map(collected.map((row) => [row.recordCode, row])).values()];
if (unique.length === 0) throw new Error(`支付宝 ${targetDate} 没有可导入交易`);
const header = ['交易时间', '交易对方', '商品说明', '收/支', '金额(元)', '交易状态', '交易号', '商家订单号', '备注'];
const csv = [header, ...unique.map((row) => [
row.time,
row.counterparty,
row.name,
row.direction,
row.amount,
row.status,
row.recordCode,
row.merchantOrderNo,
'',
])].map((line) => line.map((value) => `"${String(value).replaceAll('"', '""')}"`).join(',')).join('\r\n');
fs.writeFileSync(outputFile, `\uFEFF${csv}\r\n`, 'utf8');
const income = unique.filter((row) => row.direction === '收入').reduce((sum, row) => sum + Number(row.amount), 0);
const expense = unique.filter((row) => row.direction === '支出').reduce((sum, row) => sum + Number(row.amount), 0);
const state = {
ok: true,
date: targetDate,
rows: unique.length,
income: Number(income.toFixed(2)),
expense: Number(expense.toFixed(2)),
file: outputFile,
cutoffTime: new Date().toISOString(),
};
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf8');
console.log(JSON.stringify({ ...state, accountShort }));
} catch (error) {
fs.writeFileSync(stateFile, JSON.stringify({
ok: false,
date: targetDate,
error: String(error?.message || error),
cutoffTime: new Date().toISOString(),
}, null, 2), 'utf8');
throw error;
} finally {
cdp.close();
child.kill();
}

View File

@ -0,0 +1,75 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[ValidatePattern('^([01]\d|2[0-3]):[0-5]\d$')]
[string] $Time = '09:45',
[string] $TaskName = 'CaiWuXiTong-CompanyAlipayDaily'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$runner = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot 'run-alipay-daily.ps1')).Path
$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$arguments = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-WindowStyle', 'Hidden',
'-File', ('"' + $runner + '"')
) -join ' '
if (-not $PSCmdlet.ShouldProcess($TaskName, "register daily Alipay export at $Time")) {
return
}
$service = New-Object -ComObject 'Schedule.Service'
$rootFolder = $null
$definition = $null
try {
$service.Connect()
$rootFolder = $service.GetFolder('\')
$definition = $service.NewTask(0)
$definition.RegistrationInfo.Description = 'CaiWuXiTong 公司非店铺支付宝前一日账单同步'
$definition.Principal.UserId = $currentUser
$definition.Principal.LogonType = 3
$definition.Principal.RunLevel = 0
$definition.Settings.Enabled = $true
$definition.Settings.StartWhenAvailable = $true
$definition.Settings.DisallowStartIfOnBatteries = $false
$definition.Settings.StopIfGoingOnBatteries = $false
$definition.Settings.ExecutionTimeLimit = 'PT30M'
$definition.Settings.MultipleInstances = 2
$definition.Settings.RestartCount = 2
$definition.Settings.RestartInterval = 'PT10M'
$trigger = $definition.Triggers.Create(2)
$trigger.StartBoundary = (Get-Date).Date.AddDays(1).ToString('yyyy-MM-dd') + 'T' + $Time + ':00'
$trigger.DaysInterval = 1
$trigger.Enabled = $true
$action = $definition.Actions.Create(0)
$action.Path = $windowsPowerShell
$action.Arguments = $arguments
$action.WorkingDirectory = $PSScriptRoot
$registered = $rootFolder.RegisterTaskDefinition(
$TaskName,
$definition,
6,
$null,
$null,
3,
$null
)
Write-Host "已安装每日支付宝账单任务:$TaskName,执行时间每天 $Time。"
[void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($registered)
} finally {
foreach ($item in @($definition, $rootFolder, $service)) {
if ($null -ne $item -and [System.Runtime.InteropServices.Marshal]::IsComObject($item)) {
[void] [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($item)
}
}
}

View File

@ -0,0 +1,62 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CDP, evaluate, goto, launchEdge, openPage, sleep } from '../erp-daily-bills/cdp.mjs';
const BASE = path.dirname(fileURLToPath(import.meta.url));
const PROFILE = path.resolve(BASE, '../../runtime/private/alipay-profile');
const SECRET_FILE = process.env.ALIPAY_SECRET_FILE
|| path.resolve(BASE, '../../runtime/private/敏感信息.md');
const PORT = 9335;
const text = fs.readFileSync(SECRET_FILE, 'utf8');
const account = /账号:([^\r\n]+)/.exec(text)?.[1]?.trim();
const password = /密码:([^\r\n]+)/.exec(text)?.[1]?.trim();
if (!account || !password) throw new Error('私有文件缺少支付宝账号或密码');
const { child, version } = await launchEdge({
port: PORT,
profileDir: PROFILE,
headless: false,
detached: true,
});
child.unref();
const cdp = await CDP.connect(version.webSocketDebuggerUrl);
const { sessionId } = await openPage(cdp, 'about:blank');
await goto(cdp, sessionId, 'https://auth.alipay.com/login/index.htm?goto=https%3A%2F%2Fconsumeprod.alipay.com%2Frecord%2Fadvanced.htm');
await sleep(1800);
let loginInputs = await evaluate(cdp, sessionId, `({
user: !!document.querySelector('#J-input-user'),
password: !!document.querySelector('input[type="password"]')
})`);
if (!loginInputs.user || !loginInputs.password) {
await evaluate(cdp, sessionId, `(() => {
const el = [...document.querySelectorAll('*')].find((node) => (node.innerText || '').trim() === '账密登录');
el && el.click();
return true;
})()`);
await sleep(800);
loginInputs = await evaluate(cdp, sessionId, `({
user: !!document.querySelector('#J-input-user'),
password: !!document.querySelector('input[type="password"]')
})`);
}
if (loginInputs.user && loginInputs.password) {
await evaluate(cdp, sessionId, `(() => {
const setValue = (el, value) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter ? setter.call(el, value) : (el.value = value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
};
document.querySelector('#J-input-user') && setValue(document.querySelector('#J-input-user'), ${JSON.stringify(account)});
const password = document.querySelector('input[type="password"]');
password && setValue(password, ${JSON.stringify(password)});
return true;
})()`);
}
console.log('支付宝登录窗口已打开。账号已预填,请完成图形验证码或短信验证;登录成功后关闭窗口即可。');
cdp.close();
process.exit(0);

View File

@ -0,0 +1,99 @@
[CmdletBinding()]
param(
[ValidatePattern('^\d{4}-\d{2}-\d{2}$')]
[string] $Date,
[string] $Server = '218.93.190.75',
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string] $SshUser = 'root',
[string] $InputFile,
[switch] $DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$secretFile = Join-Path $root 'runtime\private\敏感信息.md'
$exportScript = Join-Path $PSScriptRoot 'export-daily.mjs'
$logDir = Join-Path $root 'runtime\private\alipay-exports\logs'
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$logFile = Join-Path $logDir ((Get-Date -Format 'yyyyMMdd') + '.log')
function Write-Log {
param([string] $Message)
$line = '{0} {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8
}
if (-not (Test-Path -LiteralPath $secretFile -PathType Leaf)) {
throw '缺少 runtime/private/敏感信息.md'
}
$secret = Get-Content -LiteralPath $secretFile -Raw
$account = [regex]::Match($secret, '账号:([^\r\n]+)').Groups[1].Value.Trim()
$accountShort = [regex]::Match($secret, '支付宝账户简称:([^\r\n]+)').Groups[1].Value.Trim()
if ($account -notmatch '^\d{6,30}$') {
throw '私有文件中的支付宝账号格式无效'
}
if ([string]::IsNullOrWhiteSpace($accountShort)) {
throw '私有文件中的支付宝账户简称不能为空'
}
if ([string]::IsNullOrWhiteSpace($Date)) {
$Date = (Get-Date).Date.AddDays(-1).ToString('yyyy-MM-dd')
}
$outputDir = Join-Path $root ('runtime\private\alipay-exports\' + $Date)
$localFile = Join-Path $outputDir ('alipay-company-' + $Date + '.csv')
if (-not [string]::IsNullOrWhiteSpace($InputFile)) {
$localFile = (Resolve-Path -LiteralPath $InputFile -ErrorAction Stop).Path
} else {
$node = (Get-Command node.exe -ErrorAction Stop).Source
Write-Log ('开始导出支付宝账单:' + $Date)
$exportOutput = (& $node $exportScript ('--date=' + $Date) ('--out=' + $outputDir) 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $localFile -PathType Leaf)) {
Write-Log ('支付宝导出失败:' + ($exportOutput.Trim() -replace "`r?`n", ' | '))
throw '支付宝账单导出失败;未写入财务台账'
}
$state = Get-Content -LiteralPath (Join-Path $outputDir 'last-export.json') -Raw | ConvertFrom-Json
if (-not $state.ok -or [int] $state.rows -le 0) {
Write-Log '支付宝账单为空;未写入财务台账'
throw '支付宝账单为空;未写入财务台账'
}
Write-Log ('支付宝导出完成:{0} 笔,收入 {1},支出 {2}' -f $state.rows, $state.income, $state.expense)
}
if ($DryRun) {
Write-Log 'DryRun:执行远程校验,不写入财务台账'
}
. (Join-Path $root 'scripts\deploy-auth.ps1')
$remoteFile = '/opt/caiwuxitong/runtime/private/alipay-imports/' + $Date + '/alipay-company.csv'
$auth = Start-CaiwuDeploymentAuth -Server $Server -SshUser $SshUser
try {
$target = "${SshUser}@${Server}"
$options = $auth.Options
& ssh @options $target ("install -d -m 700 '" + [IO.Path]::GetDirectoryName($remoteFile).Replace('\', '/') + "'")
if ($LASTEXITCODE -ne 0) { throw '创建支付宝账单远程目录失败' }
& scp @options $localFile ("${target}:" + $remoteFile)
if ($LASTEXITCODE -ne 0) { throw '上传支付宝账单失败' }
$dryRunFlag = if ($DryRun) { ' --dry-run' } else { '' }
$escapedShort = $accountShort.Replace("'", "'\''")
$command = @(
'set -eu',
("sh /opt/caiwuxitong/run-finance-batch.sh finance-ledger:import-company-alipay " +
"'$remoteFile' --date='$Date' --account='$account' --account-short='" +
$escapedShort + "'" + $dryRunFlag)
) -join ' && '
$importOutput = (& ssh @options $target $command 2>&1 | Out-String)
if ($LASTEXITCODE -ne 0) {
Write-Log ('支付宝导入失败:' + ($importOutput.Trim() -replace "`r?`n", ' | '))
throw '支付宝账单导入失败'
}
Write-Log ('支付宝导入完成:' + ($importOutput.Trim() -replace "`r?`n", ' | '))
} finally {
Stop-CaiwuDeploymentAuth $auth
}

View File

@ -4,7 +4,13 @@ import fs from 'node:fs';
const EDGE = 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe';
export async function launchEdge({ port = 9333, profileDir, headless = true, downloadDir } = {}) {
export async function launchEdge({
port = 9333,
profileDir,
headless = true,
downloadDir,
detached = false,
} = {}) {
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
@ -15,7 +21,7 @@ export async function launchEdge({ port = 9333, profileDir, headless = true, dow
];
if (headless) args.push('--headless=new');
if (downloadDir) args.push('--disable-popup-blocking');
const child = spawn(EDGE, args, { detached: false, stdio: 'ignore' });
const child = spawn(EDGE, args, { detached, stdio: 'ignore' });
let version = null;
for (let i = 0; i < 60; i++) {
try {

View File

@ -0,0 +1,277 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerManualEditTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail(),
);
$connection = DB::connection('business_mirror');
$accountId = $connection->table('fin_accounts')->insertGetId([
'company_name' => '手动编辑测试公司',
'account_short' => '手动编辑测试账户',
'account_type' => '公户',
'account_no' => '6222000000008899',
'current_balance' => 100,
'daily_available' => 100,
]);
$this->accountId = (int) $accountId;
$connection->table('fin_account_opening')->insert([
'account_short' => '手动编辑测试账户',
'opening_date' => '2026-10-17',
'opening_balance' => 100,
]);
$connection->table('fin_transactions')->insert([
[
'txn_date' => '2026-10-18',
'account_short' => '手动编辑测试账户',
'detail' => '自动入账',
'income' => 25,
'expense' => 0,
'source' => 'manual',
'process_type' => '手工录入',
'row_key' => hash('sha256', 'manual-edit-income'),
],
[
'txn_date' => '2026-10-19',
'account_short' => '手动编辑测试账户',
'detail' => '自动出账',
'income' => 0,
'expense' => 30,
'source' => 'manual',
'process_type' => '手工录入',
'row_key' => hash('sha256', 'manual-edit-expense'),
],
]);
app(FinanceLedgerCalculator::class)->recalculate();
}
private int $accountId;
public function test_account_and_daily_flow_edit_controls_are_visible(): void
{
$html = $this->loginAs()->get($this->pageUrl())->assertOk()->getContent();
$this->assertStringContainsString('data-ledger-account-edit', $html);
$this->assertStringContainsString('data-ledger-account-dialog', $html);
$this->assertStringContainsString('data-ledger-flow-edit', $html);
$this->assertStringContainsString('data-ledger-flow-dialog', $html);
}
public function test_account_editor_updates_fields_renames_references_and_writes_audit(): void
{
$this->loginAs()->get($this->accountEditUrl())
->assertOk()
->assertJsonPath('account.id', $this->accountId)
->assertJsonPath('account.account_short', '手动编辑测试账户');
$this->loginAs()->postJson($this->accountUpdateUrl(), [
'company_name' => '手动编辑测试公司',
'account_short' => '手动编辑测试账户新',
'account_type' => '公户',
'status' => 'limited',
'region' => '苏州',
'account_no' => '6222000000008899',
'bank_name' => '测试银行',
'bank_city_branch' => '苏州分行',
'company_plan' => '待注销',
'account_should_keep' => '否',
'single_limit' => '100000',
'daily_limit' => '200000',
'monthly_limit' => '300000',
'yearly_limit' => '400000',
'clearing_days' => 2.5,
'raise_target' => '500000',
'raise_status' => '处理中',
'raise_actions' => '提升限额',
'done_actions' => '提交资料',
'next_action' => '等待审核',
'remark' => '账户手动修改',
'owner' => '财务负责人',
])->assertOk()->assertJsonPath('account.account_short', '手动编辑测试账户新');
$connection = DB::connection('business_mirror');
$this->assertDatabaseHas('fin_accounts', [
'id' => $this->accountId,
'account_short' => '手动编辑测试账户新',
'region' => '苏州',
'bank_name' => '测试银行',
'owner' => '财务负责人',
], 'business_mirror');
foreach (['fin_account_opening', 'fin_daily_flows', 'fin_transactions', 'fin_shops'] as $table) {
$this->assertSame(
0,
$connection->table($table)->where('account_short', '手动编辑测试账户')->count(),
$table . ' 仍保留旧账户简称',
);
}
$this->assertDatabaseHas('fin_account_statuses', [
'account_short' => '手动编辑测试账户新',
'status' => 'limited',
], 'business_mirror');
$this->assertDatabaseHas('fin_ledger_edit_events', [
'entity_type' => 'account',
'entity_key' => (string) $this->accountId,
'user_name' => 'Test User',
], 'business_mirror');
}
public function test_daily_flow_override_cascades_and_can_reset_to_automatic(): void
{
$this->loginAs()->get($this->flowEditUrl('2026-10-18'))
->assertOk()
->assertJsonPath('flow.opening_balance', 100)
->assertJsonPath('flow.inflow', 25)
->assertJsonPath('flow.closing_balance', 125);
$this->loginAs()->postJson($this->flowUpdateUrl(), [
'flow_date' => '2026-10-18',
'account_short' => '手动编辑测试账户',
'opening_balance' => 100,
'inflow' => 50,
'outflow' => 10,
'closing_balance' => 140,
'remark' => '银行流水手工校正',
])->assertOk()
->assertJsonPath('flow.manual_override', true)
->assertJsonPath('flow.closing_balance', 140);
$connection = DB::connection('business_mirror');
$this->assertDatabaseHas('fin_daily_flows', [
'flow_date' => '2026-10-18',
'account_short' => '手动编辑测试账户',
'inflow' => 50,
'outflow' => 10,
'closing_balance' => 140,
'manual_override' => 1,
'updated_by' => 'Test User',
], 'business_mirror');
$this->assertDatabaseHas('fin_daily_flows', [
'flow_date' => '2026-10-19',
'account_short' => '手动编辑测试账户',
'opening_balance' => 140,
'outflow' => 30,
'closing_balance' => 110,
], 'business_mirror');
$this->assertSame(110.0, (float) $connection->table('fin_accounts')
->where('id', $this->accountId)
->value('current_balance'));
$this->loginAs()->postJson($this->flowResetUrl(), [
'flow_date' => '2026-10-18',
'account_short' => '手动编辑测试账户',
])->assertOk()->assertJsonPath('flow.manual_override', false);
$this->assertDatabaseMissing('fin_daily_flow_overrides', [
'flow_date' => '2026-10-18',
'account_short' => '手动编辑测试账户',
], 'business_mirror');
$this->assertDatabaseHas('fin_daily_flows', [
'flow_date' => '2026-10-19',
'account_short' => '手动编辑测试账户',
'opening_balance' => 125,
'closing_balance' => 95,
], 'business_mirror');
}
public function test_manual_edit_validates_equation_duplicate_short_and_permission(): void
{
$this->withExceptionHandling()->loginAs()->postJson($this->flowUpdateUrl(), [
'flow_date' => '2026-10-18',
'account_short' => '手动编辑测试账户',
'opening_balance' => 100,
'inflow' => 10,
'outflow' => 5,
'closing_balance' => 120,
])->assertUnprocessable()->assertJsonValidationErrors('closing_balance');
$connection = DB::connection('business_mirror');
$connection->table('fin_accounts')->insert([
'company_name' => '重名公司',
'account_short' => '已存在账户简称',
]);
$this->withExceptionHandling()->loginAs()->postJson($this->accountUpdateUrl(), [
'company_name' => '手动编辑测试公司',
'account_short' => '已存在账户简称',
'status' => 'normal',
])->assertUnprocessable()->assertJsonValidationErrors('account_short');
$viewer = user_model_class()::create([
'name' => '台账只读用户',
'email' => 'ledger-viewer-' . uniqid() . '@example.com',
'password' => 'local-test-password',
'locale' => 'zh-CN',
'enabled' => 1,
'landing_page' => 'dashboard',
'created_from' => 'core::test',
]);
$viewer->companies()->attach($this->company->id);
$viewer->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$viewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->withExceptionHandling()->loginAs($viewer)->postJson($this->flowUpdateUrl(), [
'flow_date' => '2026-10-18',
'account_short' => '手动编辑测试账户',
'opening_balance' => 100,
'inflow' => 10,
'outflow' => 5,
'closing_balance' => 105,
])->assertForbidden();
}
private function pageUrl(): string
{
return route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-operations',
'page' => 'ledger',
]) . '?tab=flows';
}
private function accountEditUrl(): string
{
return route('business.financial-ledger.accounts.edit', [
'company_id' => $this->company->id,
'id' => $this->accountId,
]);
}
private function accountUpdateUrl(): string
{
return route('business.financial-ledger.accounts.update', [
'company_id' => $this->company->id,
'id' => $this->accountId,
]);
}
private function flowEditUrl(string $date): string
{
return route('business.financial-ledger.flows.edit', [
'company_id' => $this->company->id,
'flow_date' => $date,
'account_short' => '手动编辑测试账户',
]);
}
private function flowUpdateUrl(): string
{
return route('business.financial-ledger.flows.update', ['company_id' => $this->company->id]);
}
private function flowResetUrl(): string
{
return route('business.financial-ledger.flows.reset', ['company_id' => $this->company->id]);
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\CompanyAlipayBillImporter;
use Tests\Feature\FeatureTestCase;
class ImportCompanyAlipayBillsTest extends FeatureTestCase
{
public function test_imports_successful_company_alipay_rows_and_replaces_the_same_day(): void
{
$connection = DB::connection('business_mirror');
$connection->table('fin_accounts')->insert([
'company_name' => '测试公司',
'account_short' => '测试公司支付宝',
'account_no' => '18800000000',
'account_type' => '支付宝',
'current_balance' => 1000,
]);
$connection->table('fin_account_opening')->insert([
'account_short' => '测试公司支付宝',
'opening_date' => '2026-10-01',
'opening_balance' => 1000,
]);
$file = $this->csvFile([
['交易时间', '交易分类', '交易对方', '商品说明', '收/支', '金额', '交易状态', '交易订单号', '商家订单号', '备注'],
['2026-10-29 09:10:00', '转账', '甲公司', '货款', '收入', '100.00', '交易成功', 'ALI-1', 'M-1', '收款'],
['2026-10-29 10:20:00', '消费', '乙公司', '服务费', '支出', '20.00', '交易成功', 'ALI-2', 'M-2', '付款'],
['2026-10-29 11:30:00', '其他', '丙公司', '关闭订单', '收入', '999.00', '交易关闭', 'ALI-3', 'M-3', ''],
['2026-10-28 11:30:00', '其他', '丁公司', '前一日', '收入', '300.00', '交易成功', 'ALI-4', 'M-4', ''],
]);
$this->artisan('finance-ledger:import-company-alipay', [
'path' => $file,
'--date' => '2026-10-29',
'--account' => '18800000000',
'--account-short' => '测试公司支付宝',
])->assertExitCode(0);
$this->assertDatabaseCount('fin_transactions', 2, 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'txn_date' => '2026-10-29',
'account_short' => '测试公司支付宝',
'approval_no' => 'ALI-1',
'source' => CompanyAlipayBillImporter::SOURCE,
'source_value' => '18800000000',
'income' => 100,
], 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'approval_no' => 'ALI-2',
'expense' => 20,
], 'business_mirror');
$this->assertDatabaseMissing('fin_transactions', ['approval_no' => 'ALI-3'], 'business_mirror');
$this->assertDatabaseMissing('fin_transactions', ['approval_no' => 'ALI-4'], 'business_mirror');
$this->assertDatabaseHas('fin_account_map', [
'source' => CompanyAlipayBillImporter::SOURCE,
'source_value' => '18800000000',
'account_short' => '测试公司支付宝',
'txn_count' => 2,
'income_sum' => 100,
'expense_sum' => 20,
], 'business_mirror');
$this->assertDatabaseHas('fin_daily_flows', [
'flow_date' => '2026-10-29',
'account_short' => '测试公司支付宝',
'opening_balance' => 1000,
'inflow' => 100,
'outflow' => 20,
'closing_balance' => 1080,
], 'business_mirror');
$replacement = $this->csvFile([
['交易时间', '交易对方', '商品说明', '收/支', '金额(元)', '交易状态', '交易号'],
['2026-10-29 10:20:00', '乙公司', '服务费', '支出', '50.00', '交易成功', 'ALI-2'],
]);
$this->artisan('finance-ledger:import-company-alipay', [
'path' => $replacement,
'--date' => '2026-10-29',
'--account' => '18800000000',
])->assertExitCode(0);
$this->assertDatabaseCount('fin_transactions', 1, 'business_mirror');
$this->assertDatabaseHas('fin_transactions', [
'approval_no' => 'ALI-2',
'expense' => 50,
], 'business_mirror');
$this->assertDatabaseHas('fin_daily_flows', [
'flow_date' => '2026-10-29',
'account_short' => '测试公司支付宝',
'outflow' => 50,
'closing_balance' => 950,
], 'business_mirror');
}
public function test_empty_or_unknown_account_keeps_existing_rows(): void
{
DB::connection('business_mirror')->table('fin_accounts')->insert([
'company_name' => '测试公司',
'account_short' => '测试公司支付宝',
'account_no' => '18800000000',
'account_type' => '支付宝',
]);
DB::connection('business_mirror')->table('fin_transactions')->insert([
'txn_date' => '2026-10-29',
'account_short' => '测试公司支付宝',
'detail' => '原有记录',
'income' => 1,
'expense' => 0,
'source' => CompanyAlipayBillImporter::SOURCE,
'source_value' => '18800000000',
'process_type' => CompanyAlipayBillImporter::PROCESS_TYPE,
'row_key' => hash('sha256', 'existing-alipay-row'),
]);
$empty = $this->csvFile([
['交易时间', '交易对方', '商品说明', '收/支', '金额(元)', '交易状态', '交易号'],
['2026-10-28 10:00:00', '甲公司', '非目标日期', '收入', '10.00', '交易成功', 'OLD-1'],
]);
$this->withExceptionHandling()->artisan('finance-ledger:import-company-alipay', [
'path' => $empty,
'--date' => '2026-10-29',
'--account' => '18800000000',
])->assertExitCode(1);
$this->assertDatabaseHas('fin_transactions', ['detail' => '原有记录'], 'business_mirror');
$this->withExceptionHandling()->artisan('finance-ledger:import-company-alipay', [
'path' => $empty,
'--date' => '2026-10-29',
'--account' => '99999999999',
])->assertExitCode(1);
$this->assertDatabaseHas('fin_transactions', ['detail' => '原有记录'], 'business_mirror');
}
/** @param list<list<string>> $rows */
private function csvFile(array $rows): string
{
$path = tempnam(sys_get_temp_dir(), 'company-alipay-') . '.csv';
$stream = fopen($path, 'wb');
if ($stream === false) {
throw new \RuntimeException('无法创建测试 CSV。');
}
foreach ($rows as $row) {
fputcsv($stream, $row);
}
fclose($stream);
return $path;
}
}