CaiWuXiTong/app/Http/Controllers/Business/CourierAgreementSignatures.php

690 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Business;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Business\CourierSigningSubject;
use App\Services\Esign\EsignClient;
use App\Support\CourierAgreementFileArchive;
use App\Support\CourierAgreementCredentials;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteConfirmation;
use App\Support\CourierQuoteSignatureService;
use App\Support\SupplierCooperationTemplates;
use App\Utilities\SystemAccess;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Rule;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class CourierAgreementSignatures extends Controller
{
private const COOPERATION_SECONDARY_ESIGN_ACCOUNT = '18052733960';
public function __construct(
private readonly CourierAgreementSignatureService $service,
private readonly CourierQuoteSignatureService $quoteSignatureService,
private readonly CourierQuoteConfirmation $quoteConfirmation,
private readonly CourierAgreementFileArchive $agreementFileArchive,
) {
}
public function store(Request $request): RedirectResponse
{
$this->ensureCanManage();
$request->merge(['partner_type' => $request->input('partner_type', 'courier')]);
$request->validate(['partner_type' => ['required', 'in:courier,supplier']]);
if ($request->input('partner_type') === 'supplier') {
if ($request->input('document_type') === 'cooperation-contract') {
$request->validate(['supplier_contract_template' => ['required', Rule::in(array_keys(SupplierCooperationTemplates::TEMPLATES))]]);
}
$request->validate(['supplier_id' => ['required', 'integer', 'min:1'],
'document_type' => ['required', 'in:account-proof,account-change,entity-change,authorization,cooperation-contract']]);
$supplier = app(\Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository::class)
->signingSupplier((int) $request->input('supplier_id'));
if (! $supplier) throw ValidationException::withMessages(['supplier_id' => '供应商资料暂不可用,请重新选择供应商。']);
$request->merge(['courier_name' => trim((string) $supplier->scmname)]);
if (! $request->filled('esign_account')) $request->merge(['esign_account' => trim((string) $supplier->accountbankno)]);
}
abort_unless(EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$requestedType = (string) $request->input('document_type');
if ($requestedType === 'quote-confirmation') {
abort_unless(
Schema::hasTable('business_courier_quote_versions') && Schema::hasTable('business_courier_quote_signatures'),
503,
'报价签署数据表尚未完成迁移,请先执行数据库迁移。',
);
} else {
abort_unless(Schema::hasTable('business_courier_agreement_signatures'), 503, '协议签署数据表尚未完成迁移,请先执行数据库迁移。');
}
if ($request->input('document_type') === 'cooperation-contract' && $request->input('partner_type') === 'courier') {
// 合作合同统一由我方固定 E 签宝账号完成第二签署方签署。
$request->merge([
'secondary_esign_account' => self::COOPERATION_SECONDARY_ESIGN_ACCOUNT,
]);
}
if (in_array($requestedType, ['account-proof', 'account-change', 'entity-change', 'cooperation-contract', 'authorization', 'shared-contract'], true)
&& ! $request->filled('counterparty_signer_type')) {
// Existing integrations omit this field and historically meant an
// enterprise signer, so keep that behavior as the compatibility default.
$request->merge(['counterparty_signer_type' => 'organization']);
}
$data = $request->validate($this->rules(), [
'secondary_operator_name.required_if' => '请填写我方 E 签宝账号绑定的实名认证姓名。',
]);
$type = $data['document_type'];
if ($type === 'authorization' && count($data['authorization_items'] ?? []) === 0) {
throw ValidationException::withMessages(['authorization_items' => '授权委托书至少选择一项授权事项。']);
}
if ($type === 'shared-contract') {
$data['contract_partner_name'] = trim((string) $data['contract_partner_name']);
if ($data['counterparty_signer_type'] === 'person') {
$data['operator_name'] = $data['contract_partner_name'];
} elseif (blank($data['operator_name'] ?? null)) {
throw ValidationException::withMessages(['operator_name' => '请填写物流商归属企业经办人的 E 签宝实名认证姓名。']);
}
$sharedCouriers = collect($data['shared_courier_names'] ?? [])
->map(static fn ($name): string => trim((string) $name))
->filter()
->unique()
->values()
->all();
if ($sharedCouriers === [] && filled($data['shared_courier_name'] ?? null)) {
$sharedCouriers = [trim((string) $data['shared_courier_name'])];
}
if ($sharedCouriers === []) {
throw ValidationException::withMessages(['shared_courier_names' => '请至少填写一家共用合同的快递公司。']);
}
$data['shared_courier_names'] = $sharedCouriers;
// Keep the first name for old records and integrations that still read the scalar field.
$data['shared_courier_name'] = $sharedCouriers[0];
$data['shared_note'] = trim((string) ($data['shared_note'] ?? '')) ?: null;
}
if (in_array($type, ['account-proof', 'account-change'], true)) {
$data['counterparty_signer_type'] = $data['counterparty_signer_type'] ?? 'organization';
$data['operator_name'] = trim((string) ($data['operator_name'] ?? '')) ?: null;
if ($data['counterparty_signer_type'] === 'organization' && blank($data['operator_name'])) {
throw ValidationException::withMessages(['operator_name' => '企业签署主体必须填写 E 签宝实名认证姓名。']);
}
if ($data['counterparty_signer_type'] === 'person') {
// A personal signer uses the same real name for the signer and
// the E-sign identity; the UI should not require it twice.
$data['operator_name'] = trim((string) $data['counterparty_organization_name']);
}
}
if ($type === 'entity-change') {
$formerCompanyDeregistered = filter_var(
$data['entity_change_old_company_deregistered'] ?? false,
FILTER_VALIDATE_BOOLEAN,
);
$data['entity_change_old_company_deregistered'] = $formerCompanyDeregistered;
$data['counterparty_signer_type'] = $formerCompanyDeregistered ? 'person' : 'organization';
if (blank($data['operator_name'] ?? null)) {
throw ValidationException::withMessages(['operator_name' => $formerCompanyDeregistered
? '请填写原公司法人的 E 签宝实名认证姓名。'
: '请填写变更前主体签署人的 E 签宝实名认证姓名。']);
}
if (blank($data['secondary_esign_account'] ?? null)) {
throw ValidationException::withMessages(['secondary_esign_account' => '请填写变更后主体的 E 签宝账号。']);
}
if (blank($data['secondary_operator_name'] ?? null)) {
throw ValidationException::withMessages(['secondary_operator_name' => '请填写变更后主体签署人的 E 签宝实名认证姓名。']);
}
}
if ($type === 'cooperation-contract') {
$data['counterparty_signer_type'] = $data['counterparty_signer_type'] ?? 'organization';
if ($data['counterparty_signer_type'] === 'person') {
$data['operator_name'] = trim((string) $data['cooperation_party_b_name']);
} elseif (blank($data['operator_name'] ?? null)) {
$party = $data['partner_type'] === 'supplier' ? '供应商' : '快递方';
throw ValidationException::withMessages(['operator_name' => "请填写{$party}企业经办人的 E 签宝实名认证姓名。"]);
}
if (blank($data['secondary_operator_name'] ?? null)) {
throw ValidationException::withMessages(['secondary_operator_name' => '请填写我方 E 签宝账号绑定的实名认证姓名。']);
}
$companyNames = [trim($data['cooperation_party_a_name'])];
if ($data['counterparty_signer_type'] === 'organization') {
$companyNames[] = trim($data['cooperation_party_b_name']);
}
foreach (['operator_name' => $data['partner_type'] === 'supplier' ? '供应商' : '快递方', 'secondary_operator_name' => '我方'] as $field => $party) {
$data[$field] = trim($data[$field]);
if (in_array($data[$field], $companyNames, true)) {
throw ValidationException::withMessages([
$field => "{$party}实名认证姓名应填写账号绑定的个人姓名,不能填写甲乙方公司名称。",
]);
}
}
}
if ($type === 'authorization') {
$data['counterparty_signer_type'] = $data['counterparty_signer_type'] ?? 'organization';
$data['secondary_signer_type'] = $data['secondary_signer_type'] ?? 'organization';
if ($data['counterparty_signer_type'] === 'person') {
$data['operator_name'] = trim((string) $data['principal_name']);
} elseif (blank($data['operator_name'] ?? null)) {
throw ValidationException::withMessages(['operator_name' => '请填写委托方企业经办人的 E 签宝实名认证姓名。']);
}
if ($data['secondary_signer_type'] === 'person') {
$data['secondary_operator_name'] = trim((string) $data['trustee_name']);
} elseif (blank($data['secondary_operator_name'] ?? null)) {
throw ValidationException::withMessages(['secondary_operator_name' => '请填写受托方企业经办人的 E 签宝实名认证姓名。']);
}
}
$this->validateEsignAccount($data['esign_account'], 'esign_account');
if (! empty($data['secondary_esign_account'])) {
$this->validateEsignAccount($data['secondary_esign_account'], 'secondary_esign_account');
}
if ($type === 'quote-confirmation') {
return $this->sendQuoteConfirmation($data);
}
// signing_subject identifies our initiating company. The E-sign
// organization must be the counterparty that receives this request.
$data['organization_name'] = $this->counterpartyOrganizationName($data);
$data['operator_name'] = trim((string) ($data['operator_name'] ?? '')) ?: null;
$data['operator_account'] = $data['esign_account'];
$data['payload'] = $this->payload($data);
$data['payload']['credential_required'] = $request->boolean('credential_required');
$data['payload']['credential_signer'] = (int) ($data['credential_signer'] ?? ($type === 'entity-change' ? 1 : 0));
if ($request->boolean('credential_required') && $type === 'entity-change'
&& $data['payload']['credential_signer'] === 0 && $data['counterparty_signer_type'] === 'person') {
throw ValidationException::withMessages(['credential_signer' => '营业执照需要企业盖章,请选择变更后主体签署证件。']);
}
$signature = $this->service->send($data, user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', $signature->status === CourierAgreementSignature::STATUS_SIGNING
? "{$signature->documentLabel()}已推送到 E 签宝,等待对方签署。"
: "{$signature->documentLabel()}签署任务已受理。")
->with('agreement_signature_id', $signature->id);
}
/**
* Create a signing subject for the current tenant.
*
* The subject is deliberately kept separate from the global companies table:
* one tenant may need to sign agreements using more than one legal entity.
*/
public function storeSubject(Request $request): JsonResponse
{
$this->ensureCanManage();
if ($response = $this->subjectsTableUnavailable()) {
return $response;
}
$data = $this->subjectData($request);
$subject = DB::transaction(function () use ($data): CourierSigningSubject {
$this->ensureSubjectNameAvailable($data['name']);
$isFirst = ! CourierSigningSubject::query()->exists();
return CourierSigningSubject::create([
'company_id' => company_id(),
'name' => $data['name'],
'unified_social_credit_code' => $data['unified_social_credit_code'],
'is_default' => $isFirst,
'created_by' => user_id(),
'updated_by' => user_id(),
]);
});
return $this->subjectResponse($subject, '公司主体已新增。');
}
public function updateSubject(Request $request, CourierSigningSubject $subject): JsonResponse
{
$this->ensureCanManage();
$this->ensureSubjectBelongsToCurrentCompany($subject);
if ($response = $this->subjectsTableUnavailable()) {
return $response;
}
$data = $this->subjectData($request);
$this->ensureSubjectNameAvailable($data['name'], $subject);
$subject->update([
'name' => $data['name'],
'unified_social_credit_code' => $data['unified_social_credit_code'],
'updated_by' => user_id(),
]);
return $this->subjectResponse($subject->fresh(), '公司主体已更新。');
}
public function setDefaultSubject(CourierSigningSubject $subject): JsonResponse
{
$this->ensureCanManage();
$this->ensureSubjectBelongsToCurrentCompany($subject);
if ($response = $this->subjectsTableUnavailable()) {
return $response;
}
DB::transaction(function () use ($subject): void {
CourierSigningSubject::query()
->where('is_default', true)
->update([
'is_default' => false,
'updated_by' => user_id(),
'updated_at' => now(),
]);
$subject->forceFill([
'is_default' => true,
'updated_by' => user_id(),
])->save();
});
return $this->subjectResponse($subject->fresh(), '默认签署主体已切换。');
}
public function destroySubject(Request $request, CourierSigningSubject $subject): JsonResponse
{
$this->ensureCanManage();
$this->ensureSubjectBelongsToCurrentCompany($subject);
if ($response = $this->subjectsTableUnavailable()) {
return $response;
}
$request->validate(['delete_confirmed' => ['required', 'accepted']]);
DB::transaction(function () use ($subject): void {
$subject = CourierSigningSubject::query()->disableCache()->lockForUpdate()->findOrFail($subject->id);
$wasDefault = $subject->is_default;
$subject->forceFill(['is_default' => false, 'updated_by' => user_id()])->save();
$subject->delete();
if ($wasDefault) {
$nextDefault = CourierSigningSubject::query()->disableCache()->orderBy('id')->lockForUpdate()->first();
$nextDefault?->forceFill(['is_default' => true, 'updated_by' => user_id()])->save();
}
});
return $this->subjectResponse(null, '公司主体已删除,历史协议的主体快照保留。');
}
public function synchronize(CourierAgreementSignature $agreementSignature): RedirectResponse
{
$this->ensureCanManage();
$signature = $this->service->synchronize($agreementSignature, user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', "{$signature->documentLabel()}状态已同步:{$signature->statusLabel()}。");
}
public function archive(Request $request, CourierAgreementSignature $agreementSignature): RedirectResponse
{
$this->ensureCanManage();
abort_unless($agreementSignature->status === CourierAgreementSignature::STATUS_COMPLETED && $agreementSignature->remote_status === '2', 422, '只有签署完成的协议可以人工归档。');
$data = $request->validate([
'archive_reason_code' => ['required', 'in:completed,confirmed,backfill,other'],
'archive_note' => ['nullable', 'string', 'max:200'],
]);
$reasonLabel = match ($data['archive_reason_code']) {
'completed' => '签署完成',
'confirmed' => '财务已确认',
'backfill' => '历史补录',
'other' => '其他',
};
$note = trim((string) ($data['archive_note'] ?? ''));
if ($data['archive_reason_code'] === 'other' && $note === '') {
throw ValidationException::withMessages(['archive_note' => '选择“其他”时请填写归档说明。']);
}
$reason = $note === '' ? $reasonLabel : $reasonLabel . ':' . $note;
$signature = $this->service->archive($agreementSignature, null, user_id(), $reason);
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', "{$signature->documentLabel()}已人工确认并归档。");
}
public function destroy(Request $request, CourierAgreementSignature $agreementSignature): JsonResponse|RedirectResponse
{
return $this->deleteTask($request, $agreementSignature);
}
public function destroyQuote(Request $request, CourierQuoteSignature $quoteSignature): JsonResponse|RedirectResponse
{
return $this->deleteTask($request, $quoteSignature);
}
private function deleteTask(Request $request, CourierAgreementSignature|CourierQuoteSignature $task): JsonResponse|RedirectResponse
{
$this->ensureCanManage();
abort_unless($task->company_id === (int) company_id(), 404);
$request->validate(['delete_confirmed' => ['required', 'accepted']]);
DB::transaction(function () use ($task): void {
$signature = $task::query()->disableCache()->lockForUpdate()->findOrFail($task->id);
// Soft-delete only the local task, retaining its files, remote flow and audit records.
$signature->updated_by = user_id();
$signature->save();
$signature->delete();
});
$message = '签署任务已删除。';
return $request->expectsJson()
? response()->json(['message' => $message])
: redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', $message);
}
public function download(CourierAgreementSignature $agreementSignature, string $kind): StreamedResponse|RedirectResponse
{
$this->ensureCanManage();
abort_unless($agreementSignature->company_id === (int) company_id(), 404);
if (preg_match('/^credential-(license|identity|bank)-(source|signed)$/', $kind, $match)) {
$document = $agreementSignature->credentialDocuments()[$match[1]] ?? null;
abort_unless($document, 404);
$path = $document[$match[2] . '_path'] ?? null;
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '证件推送单不存在。');
return \Storage::disk('uploads')->response($path,
$agreementSignature->business_no . '-' . $document['name'] . ($match[2] === 'signed' ? '-已签署' : '-原文') . '.pdf',
['Content-Type' => 'application/pdf'], request()->boolean('preview') ? 'inline' : 'attachment');
}
try {
[$path, $suffix] = match ($kind) {
'source' => [$this->agreementFileArchive->sourcePath($agreementSignature), '签署原文'],
'signed' => [$agreementSignature->signed_pdf_path, '已签署协议'],
'certificate' => [$agreementSignature->certificate_path, '签署完成证书'],
default => abort(404),
};
} catch (RuntimeException $exception) {
return redirect()
->route('business.pages.show', [
'section' => 'courier-payables',
'page' => 'agreement-signing',
'agreement_view' => 'tasks',
])
->with('error', $exception->getMessage());
}
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '归档文件不存在。');
return \Storage::disk('uploads')->download($path, $agreementSignature->business_no . '-' . $suffix . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function synchronizeQuote(CourierQuoteSignature $quoteSignature): RedirectResponse
{
$this->ensureCanManage();
$signature = $this->quoteSignatureService->synchronize($quoteSignature, user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', "报价确认单状态已同步:{$signature->statusLabel()}。");
}
public function downloadQuote(CourierQuoteSignature $quoteSignature, string $kind): StreamedResponse
{
$this->ensureCanManage();
[$path, $suffix] = match ($kind) {
'source' => [$quoteSignature->source_pdf_path, '报价确认单原文'],
'signed' => [$quoteSignature->signed_pdf_path, '已签署报价确认单'],
'certificate' => [$quoteSignature->certificate_path, '签署完成证书'],
default => abort(404),
};
abort_if(blank($path) || ! \Storage::disk('uploads')->exists($path), 404, '归档文件不存在。');
$versionCode = $quoteSignature->version?->version_code ?: (string) $quoteSignature->quote_version_id;
return \Storage::disk('uploads')->download($path, $versionCode . '-' . $suffix . '.pdf', ['Content-Type' => 'application/pdf']);
}
public function previewQuote(CourierQuoteVersion $quoteVersion): JsonResponse
{
$this->ensureCanManage();
abort_unless($quoteVersion->status === 'approved', 422, '只有已审核报价版本可以生成报价确认单。');
return response()->json($this->quoteConfirmation->data($quoteVersion));
}
private function rules(): array
{
$date = ['required', 'date', 'after_or_equal:today', 'before_or_equal:' . now()->addDays(90)->toDateString()];
$supplierContract = request('partner_type') === 'supplier' && request('document_type') === 'cooperation-contract';
$supplierFields = [];
foreach (SupplierCooperationTemplates::FIELDS as $key => $field) {
$supplierFields['supplier_contract_fields.' . $key] = [
Rule::requiredIf($supplierContract && ($field['required'] ?? false)), 'nullable', 'string',
'max:' . ($key === 'supply_price' ? 1000 : 255),
];
if (($field['type'] ?? '') === 'email') {
$supplierFields['supplier_contract_fields.' . $key][] = 'email';
}
}
return [
'supplier_contract_template' => [Rule::requiredIf($supplierContract), 'nullable', Rule::in(array_keys(SupplierCooperationTemplates::TEMPLATES)), Rule::prohibitedIf(! $supplierContract)],
'supplier_contract_fields' => ['nullable', 'array:' . implode(',', array_keys(SupplierCooperationTemplates::FIELDS))],
'partner_type' => ['required', 'in:courier,supplier'],
'supplier_id' => ['required_if:partner_type,supplier', 'nullable', 'integer', 'min:1'],
'idempotency_key' => ['required', 'string', 'max:80', 'regex:/^[A-Za-z0-9_-]+$/'],
'courier_name' => ['required', 'string', 'max:255'],
'document_type' => ['required', 'in:' . implode(',', array_keys(CourierAgreementSignature::DOCUMENTS))],
'quote_version_id' => ['required_if:document_type,quote-confirmation', 'nullable', 'integer', 'min:1'],
'signing_subject' => ['required', 'string', 'min:2', 'max:255'],
'company_full_name' => ['required', 'string', 'min:2', 'max:255'],
'signature_date' => ['required', 'date'],
'signing_deadline' => $date,
'esign_account' => ['required', 'string', 'max:255'],
'secondary_esign_account' => ['required_if:document_type,cooperation-contract,authorization', 'nullable', 'string', 'max:255'],
'secondary_operator_name' => ['nullable', 'string', 'min:2', 'max:100'],
'operator_name' => ['nullable', 'string', 'min:2', 'max:100'],
'counterparty_organization_name' => ['required_if:document_type,account-proof,account-change', 'nullable', 'string', 'min:2', 'max:255'],
'counterparty_signer_type' => ['required_if:document_type,account-proof,account-change,entity-change,cooperation-contract,authorization,shared-contract', 'nullable', 'in:organization,person'],
'secondary_signer_type' => ['nullable', 'in:organization,person'],
'entity_change_old_company_deregistered' => ['nullable', 'boolean'],
'archive_mode' => ['required', 'in:auto,manual'],
'account_proof_effective_date' => ['required_if:document_type,account-proof', 'date'],
'account_proof_account_name' => ['required_if:document_type,account-proof', 'string', 'max:255'],
'account_proof_bank_name' => ['required_if:document_type,account-proof', 'string', 'max:255'],
'account_proof_bank_account' => ['required_if:document_type,account-proof', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'],
'account_change_date' => ['required_if:document_type,account-change', 'date'],
'old_account_name' => ['required_if:document_type,account-change', 'string', 'max:255'],
'old_account_number' => ['required_if:document_type,account-change', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'],
'old_account_stop_date' => ['required_if:document_type,account-change', 'date'],
'new_account_effective_date' => ['required_if:document_type,account-change', 'date'],
'new_account_name' => ['required_if:document_type,account-change', 'string', 'max:255'],
'new_bank_name' => ['required_if:document_type,account-change', 'string', 'max:255'],
'new_bank_account' => ['required_if:document_type,account-change', 'string', 'max:64', 'regex:/^[0-9A-Za-z-]+$/'],
'former_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'],
'entity_change_date' => ['required_if:document_type,entity-change', 'date'],
'changed_company_name' => ['required_if:document_type,entity-change', 'string', 'max:255'],
'changed_company_start_date' => ['required_if:document_type,entity-change', 'date'],
'contract_partner_name' => ['required_if:document_type,shared-contract', 'string', 'min:2', 'max:255'],
'shared_courier_names' => ['nullable', 'array', 'min:1'],
'shared_courier_names.*' => ['required', 'string', 'max:255'],
'shared_courier_name' => ['nullable', 'string', 'max:255'],
'shared_note' => ['nullable', 'string', 'max:2000'],
'cooperation_party_a_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'],
'cooperation_party_a_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
'cooperation_party_b_name' => ['required_if:document_type,cooperation-contract', 'string', 'max:255'],
'cooperation_party_b_phone' => ['required_if:document_type,cooperation-contract', 'string', 'max:64'],
'cooperation_start_date' => ['required_if:document_type,cooperation-contract', 'date'],
'cooperation_end_date' => ['required_if:document_type,cooperation-contract', 'date', 'after_or_equal:cooperation_start_date'],
'principal_name' => ['required_if:document_type,authorization', 'string', 'max:100'],
'principal_id_number' => ['required_if:document_type,authorization', 'string', 'max:64'],
'trustee_name' => ['required_if:document_type,authorization', 'string', 'max:100'],
'trustee_id_number' => ['required_if:document_type,authorization', 'string', 'max:64'],
'authorization_items' => ['nullable', 'array'],
'authorization_items.*' => ['in:1,2,3,4'],
'bill_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
'loan_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
'payment_counterparty' => ['required_if:document_type,authorization', 'string', 'max:255'],
'authorization_start_date' => ['required_if:document_type,authorization', 'date'],
'authorization_end_date' => ['required_if:document_type,authorization', 'date', 'after_or_equal:authorization_start_date'],
] + $supplierFields + CourierAgreementCredentials::rules((string) request('document_type'), request()->boolean('credential_required'));
}
private function payload(array $data): array
{
$allowed = array_keys($this->rules());
$payload = array_intersect_key($data, array_flip($allowed));
unset($payload['esign_account'], $payload['idempotency_key'], $payload['operator_name'], $payload['credential_images']);
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
if (($data['document_type'] ?? null) === 'entity-change') {
$payload['former_legal_representative'] = trim((string) ($data['operator_name'] ?? ''));
$payload['entity_proof_date'] = $data['signature_date'] ?? null;
}
if (($data['partner_type'] ?? 'courier') === 'supplier' && ($data['document_type'] ?? '') === 'cooperation-contract') {
$payload['supplier_contract_signer_name'] = $data['operator_name'];
}
return $payload;
}
private function sendQuoteConfirmation(array $data): RedirectResponse
{
$version = CourierQuoteVersion::query()
->where('status', 'approved')
->findOrFail((int) $data['quote_version_id']);
$this->ensureQuoteVersionMatchesCourier($version, $data['courier_name']);
$signature = $this->quoteSignatureService->prepareVersion($version, user_id(), $data['signature_date']);
$account = trim((string) $data['esign_account']);
$signature = $this->quoteSignatureService->send($signature, [
'organization_name' => $version->carrier_name,
'operator_name' => trim((string) ($data['operator_name'] ?? '')) ?: $version->carrier_name,
'operator_account' => $account,
'notice_type' => filter_var($account, FILTER_VALIDATE_EMAIL) !== false ? '2' : '1',
'expires_at' => \Illuminate\Support\Carbon::parse($data['signing_deadline'])->endOfDay(),
], user_id());
return redirect()
->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing', 'agreement_view' => 'tasks'])
->with('success', "报价确认单 {$version->version_code} 已推送到 E 签宝,等待快递方签署并自动回传归档。")
->with('quote_signature_id', $signature->id);
}
private function ensureQuoteVersionMatchesCourier(CourierQuoteVersion $version, string $courier): void
{
abort_unless(
trim($version->carrier_name) === trim($courier),
422,
'所选报价版本与快递公司不一致,请重新选择。',
);
}
private function counterpartyOrganizationName(array $data): string
{
return match ($data['document_type']) {
'account-proof', 'account-change' => trim((string) $data['counterparty_organization_name']),
'shared-contract' => trim((string) $data['contract_partner_name']),
'entity-change' => trim((string) ($data['former_company_name'] ?? $data['courier_name'])),
'authorization' => trim((string) ($data['principal_name'] ?? $data['courier_name'])),
default => trim((string) $data['courier_name']),
};
}
private function ensureCanManage(): void
{
abort_unless(
in_array(SystemAccess::roleName(user()), [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN], true),
403,
'当前账号没有协议签署管理权限。',
);
}
private function subjectData(Request $request): array
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'unified_social_credit_code' => ['nullable', 'string', 'max:32', 'regex:/^[0-9A-Za-z]{18}$/'],
]);
$data['name'] = trim($data['name']);
if (mb_strlen($data['name']) < 2) {
throw ValidationException::withMessages(['name' => '公司名称至少填写 2 个字符。']);
}
$data['unified_social_credit_code'] = trim((string) ($data['unified_social_credit_code'] ?? '')) ?: null;
return $data;
}
private function ensureSubjectNameAvailable(string $name, ?CourierSigningSubject $except = null): void
{
$query = CourierSigningSubject::query()->where('name', $name);
if ($except) {
$query->where($query->getModel()->getQualifiedKeyName(), '<>', $except->getKey());
}
if ($query->exists()) {
throw ValidationException::withMessages(['name' => '当前公司下已有同名签署主体。']);
}
}
private function subjectResponse(?CourierSigningSubject $subject, string $message): JsonResponse
{
return response()->json([
'message' => $message,
'subject' => $subject ? $this->serializeSubject($subject) : null,
'subjects' => CourierSigningSubject::query()
->orderByDesc('is_default')
->orderBy('name')
->get()
->map(fn (CourierSigningSubject $item): array => $this->serializeSubject($item))
->values(),
]);
}
private function serializeSubject(CourierSigningSubject $subject): array
{
return [
'id' => $subject->id,
'name' => $subject->name,
'unified_social_credit_code' => $subject->unified_social_credit_code,
'is_default' => (bool) $subject->is_default,
];
}
private function subjectsTableUnavailable(): ?JsonResponse
{
if (Schema::hasTable('business_courier_signing_subjects')) {
return null;
}
return response()->json([
'message' => '公司主体数据表尚未完成迁移,请先执行数据库迁移。',
], 503);
}
private function ensureSubjectBelongsToCurrentCompany(CourierSigningSubject $subject): void
{
abort_unless((int) $subject->company_id === (int) company_id(), 404, '签署主体不存在。');
}
private function validateEsignAccount(string $account, string $field): void
{
$valid = preg_match('/^1\d{10}$/', $account) === 1
|| filter_var($account, FILTER_VALIDATE_EMAIL) !== false;
if (! $valid) {
throw ValidationException::withMessages([$field => 'E签宝账号必须是 11 位手机号或有效邮箱。']);
}
}
}