feat(供应商对账): 增加免登录送货确认链接 (#1180)

Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
This commit is contained in:
苏木 2026-11-15 11:36:27 +08:00 committed by sumu
parent c658bbed99
commit 770ddea8a9
12 changed files with 857 additions and 31 deletions

View File

@ -224,11 +224,15 @@ class SupplierReconciliation extends Controller
]);
}
$channel = trim((string) config('dingtalk.summary_confirmation_mode', 'link')) === 'card'
? '确认卡片'
: '确认链接';
return back()->withInput([
'push_basis' => $pushBasis,
])->with(
'success',
"已将 {$result['period']} 对账确认卡片推送到 {$supplier} 钉钉群,等待厂家确认。",
"已将 {$result['period']} 对账{$channel}推送到 {$supplier} 钉钉群,等待厂家确认。",
);
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Business;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierSummaryConfirmation;
use Modules\EcommerceFinance\Services\SupplierSummaryShareLink;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* 钉钉群推送的厂家货款确认页,无需厂家登录。
*/
class SupplierSummaryConfirmationShare extends Controller
{
public function __construct(
private readonly SupplierSummaryConfirmation $confirmations,
private readonly SupplierSummaryShareLink $shareLinks,
private readonly SupplierDingTalkNotifier $notifier,
) {
}
public function show(Request $request, string $token): Response
{
$target = $this->resolveRecord($request, $token);
$expiresAt = $this->expiresAt($request);
return response()
->view('business.pages.supplier-reconciliation.summary-confirmation-share', [
'supplier' => $target['supplier'],
'month' => $target['month'],
'monthLabel' => $this->displayMonth($target['month']),
'qty' => (float) ($target['record']['qty'] ?? 0),
'amount' => (float) ($target['record']['amount'] ?? 0),
'status' => (string) ($target['record']['status'] ?? ''),
'confirmedAt' => (string) ($target['record']['confirmed_at'] ?? ''),
'expiresAt' => $expiresAt,
'confirmUrl' => $this->shareLinks->signedRoute(
'signed.supplier-summary-confirmations.confirm',
$token,
$target['companyId'],
$expiresAt,
),
])
->withHeaders([
'Cache-Control' => 'private, no-store, max-age=0',
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Referrer-Policy' => 'no-referrer',
]);
}
public function confirm(Request $request, string $token): RedirectResponse
{
$target = $this->resolveRecord($request, $token);
if (($target['record']['status'] ?? '') === SupplierSummaryConfirmation::STATUS_CONFIRMED) {
return $this->redirectToPage($request, $token, $target)
->with('success', '本期货款已经确认,无需重复操作。');
}
$result = $this->confirmations->confirm(
$target['request_id'],
$target['companyId'],
null,
'',
'manufacturer_link',
);
if ($result['newly_confirmed']) {
try {
$this->notifier->sendSummaryConfirmationReceipt($result['record']);
} catch (Throwable $exception) {
report($exception);
}
}
return $this->redirectToPage($request, $token, $target)
->with(
'success',
$result['newly_confirmed']
? '已确认本期货款,财务系统已实时更新。'
: '本期货款已经确认,无需重复操作。',
);
}
/**
* @return array{
* supplier:string,
* month:string,
* push_basis:string,
* request_id:string,
* companyId:int,
* record:array<string,mixed>
* }
*/
private function resolveRecord(Request $request, string $token): array
{
$companyId = (int) ($request->route('company_id') ?: company_id() ?: 1);
$payload = $this->shareLinks->resolveToken($token, $companyId);
abort_if($payload === null, 404, '该货款确认链接无效或已过期。');
$record = $this->confirmations->find($payload['request_id'], $companyId);
abort_if($record === null, 404, '该货款确认记录不存在,请联系财务重新发送。');
abort_unless(
trim((string) ($record['supplier'] ?? '')) === $payload['supplier']
&& trim((string) ($record['month'] ?? '')) === $payload['month']
&& trim((string) ($record['push_basis'] ?? '')) === $payload['push_basis'],
404,
'该货款确认记录与链接不一致。',
);
return [
...$payload,
'companyId' => $companyId,
'record' => $record,
];
}
/** @param array<string,mixed> $target */
private function redirectToPage(Request $request, string $token, array $target): RedirectResponse
{
$pageUrl = $this->shareLinks->signedRoute(
'signed.supplier-summary-confirmations.show',
$token,
$target['companyId'],
$this->expiresAt($request),
);
return redirect()->to($pageUrl);
}
private function expiresAt(Request $request): CarbonImmutable
{
$expires = (int) $request->query('expires', 0);
return $expires > 0
? CarbonImmutable::createFromTimestamp($expires)
: now()->addDays(SupplierSummaryShareLink::TTL_DAYS);
}
private function displayMonth(string $month): string
{
[$year, $monthNumber] = explode('-', $month, 2);
return (int) $year . '年' . (int) $monthNumber . '月';
}
}

View File

@ -60,6 +60,9 @@ return [
'robot_code' => env('DINGTALK_CARD_ROBOT_CODE'),
],
// card:钉钉互动卡片内直接确认;link:厂家打开签名确认页后点击确认。
'summary_confirmation_mode' => env('DINGTALK_SUMMARY_CONFIRMATION_MODE', 'link'),
'worklog' => [
'enabled' => filter_var(env('DINGTALK_WORKLOG_ENABLED', true), FILTER_VALIDATE_BOOL),
'template_name' => env('DINGTALK_WORKLOG_TEMPLATE_NAME', '财务日报'),

View File

@ -22,6 +22,8 @@ class SupplierDingTalkNotifier
public function __construct(
private readonly SupplierReturnShareLink $returnShareLinks,
private readonly SupplierSummaryAICard $summaryAICard,
private readonly SupplierSummaryConfirmation $summaryConfirmations,
private readonly SupplierSummaryShareLink $summaryShareLinks,
) {
}
@ -222,7 +224,7 @@ class SupplierDingTalkNotifier
/**
* 推送当前账期的原始件数和货款汇总。
*
* @return array{messages:int,qty:float,amount:float,period:string}
* @return array{messages:int,qty:float,amount:float,period:string,request_id?:string,confirmation_url?:string}
*/
public function pushReconciliationSummary(
string $supplier,
@ -249,7 +251,8 @@ class SupplierDingTalkNotifier
}
$this->assertWebhook($webhook);
if (config('dingtalk.card.enabled', true)) {
$mode = trim((string) config('dingtalk.summary_confirmation_mode', 'link'));
if ($mode === 'card' && config('dingtalk.card.enabled', true)) {
$result = $this->summaryAICard->push(
$supplier,
$month,
@ -260,6 +263,7 @@ class SupplierDingTalkNotifier
$userId,
trim((string) ($config['card_conversation_id'] ?? '')),
trim((string) ($config['card_robot_code'] ?? '')),
trim((string) ($config['card_robot_name'] ?? '')),
);
$config['last_pushed_at'] = $this->platformTimestamp();
$config['last_push_count'] = 1;
@ -270,16 +274,51 @@ class SupplierDingTalkNotifier
}
$period = $this->displayMonth($month);
$this->send(
$webhook,
trim((string) ($config['secret'] ?? '')),
(bool) ($config['at_all'] ?? false),
$supplier . ' · 货款对账汇总 · ' . $period,
'账期:' . $period
. ',件数' . $this->number($qty)
. '件,货款' . number_format($amount, 2, '.', '')
. '元',
$resolvedCompanyId = $companyId > 0 ? $companyId : (int) (company_id() ?: 1);
$resolvedBasis = in_array($pushBasis, ['settlement', 'mine'], true) ? $pushBasis : 'settlement';
$requestId = 'supplier-summary-l' . $resolvedCompanyId . '-' . bin2hex(random_bytes(16));
$this->summaryConfirmations->create(
$requestId,
$supplier,
$month,
$resolvedBasis,
$qty,
$amount,
$resolvedCompanyId,
$userId,
);
$confirmationUrl = $this->summaryShareLinks->pageUrl(
$resolvedCompanyId,
$supplier,
$month,
$resolvedBasis,
$requestId,
);
try {
$this->send(
$webhook,
trim((string) ($config['secret'] ?? '')),
(bool) ($config['at_all'] ?? false),
$supplier . ' · 货款对账确认 · ' . $period,
implode("\n", [
'### 供应商货款对账确认 · ' . $supplier,
'- 账期:**' . $period . '**',
'- 件数:**' . $this->number($qty) . '** 件',
'- 货款:**¥' . number_format($amount, 2) . '**',
'',
'#### [打开货款确认页](' . $confirmationUrl . ')',
'- 打开后核对数据,点击“确认本期货款”。',
'- 确认结果会实时回传财务系统。',
'',
'> 无需登录;链接仅可查看本厂家本次账期,有效期 '
. SupplierSummaryShareLink::TTL_DAYS . ' 天。',
]),
);
$this->summaryConfirmations->markSent($requestId, $resolvedCompanyId);
} catch (Throwable $exception) {
$this->summaryConfirmations->markFailed($requestId, $resolvedCompanyId, $exception->getMessage());
throw $exception;
}
$config['last_pushed_at'] = $this->platformTimestamp();
$config['last_push_count'] = 1;
@ -291,9 +330,48 @@ class SupplierDingTalkNotifier
'qty' => round($qty, 4),
'amount' => round($amount, 2),
'period' => $period,
'request_id' => $requestId,
'confirmation_url' => $confirmationUrl,
];
}
/** @param array<string,mixed> $record */
public function sendSummaryConfirmationReceipt(array $record): bool
{
$supplier = trim((string) ($record['supplier'] ?? ''));
if ($supplier === '') {
return false;
}
$configs = $this->configs();
$config = is_array($configs[$supplier] ?? null) ? $configs[$supplier] : [];
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
return false;
}
$this->assertWebhook($webhook);
$month = trim((string) ($record['month'] ?? ''));
$period = preg_match('/^\d{4}-\d{2}$/', $month) === 1 ? $this->displayMonth($month) : $month;
$this->send(
$webhook,
trim((string) ($config['secret'] ?? '')),
(bool) ($config['at_all'] ?? false),
$supplier . ' · 货款确认回执 · ' . $period,
implode("\n", [
'### 供应商货款确认回执 · ' . $supplier,
'- 账期:**' . $period . '**',
'- 本次确认:**' . $this->number((float) ($record['qty'] ?? 0)) . '** 件,**¥'
. number_format((float) ($record['amount'] ?? 0), 2) . '**',
'- 确认方式:厂家通过货款确认页点击确认',
'- 确认时间:' . (string) ($record['confirmed_at'] ?? $this->platformTimestamp()),
'- 系统状态:**已实时回传财务系统**',
]),
);
return true;
}
/**
* @param list<array<string,mixed>> $returns
* @return array{messages:int,rows:int}

View File

@ -83,7 +83,6 @@ class SupplierSummaryAICard
$period,
$qty,
$amount,
$pushBasis,
);
$this->confirmations->markSent($outTrackId, $companyId);
} catch (\Throwable $exception) {
@ -294,13 +293,10 @@ class SupplierSummaryAICard
string $period,
float $qty,
float $amount,
string $pushBasis,
): void {
$basisLabel = $pushBasis === 'mine' ? '我方原始数据' : '结算口径';
$summary = "### 货款对账确认\n\n"
. '- 厂家:**' . $this->markdown($supplier) . "**\n"
. '- 账期:**' . $period . "**\n"
. '- 口径:' . $basisLabel . "\n"
. '- 件数:**' . $this->number($qty) . "** 件\n"
. '- 货款:**¥' . number_format($amount, 2) . "**\n\n"
. '核对无误后,请直接点击下方“厂家确认”。';

View File

@ -73,8 +73,9 @@ class SupplierSummaryConfirmation
public function confirm(
string $outTrackId,
int $companyId,
string $dingTalkUserId,
?string $dingTalkUserId,
string $corpId = '',
string $source = 'dingtalk_card',
): array {
$record = $this->find($outTrackId, $companyId);
if ($record === null) {
@ -85,13 +86,19 @@ class SupplierSummaryConfirmation
return ['record' => $record, 'newly_confirmed' => false];
}
$confirmed = array_merge($record, [
$changes = [
'status' => self::STATUS_CONFIRMED,
'confirmed_at' => $this->timestamp(),
'confirmed_by_dingtalk_user_id' => $dingTalkUserId,
'dingtalk_corp_id' => $corpId,
'confirmed_source' => 'dingtalk_card',
]);
'confirmed_source' => $source,
];
if ($dingTalkUserId !== null && trim($dingTalkUserId) !== '') {
$changes['confirmed_by_dingtalk_user_id'] = trim($dingTalkUserId);
}
if ($corpId !== '') {
$changes['dingtalk_corp_id'] = $corpId;
}
$confirmed = array_merge($record, $changes);
unset($confirmed['_action_key']);
$this->store->save(
self::DATASET,

View File

@ -0,0 +1,103 @@
<?php
namespace Modules\EcommerceFinance\Services;
use DateTimeInterface;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\URL;
use Throwable;
/**
* 厂家月度货款确认页的临时免登录链接。
*
* 令牌承载公司与确认记录,URL 再做 Laravel 签名绑定。厂家只能打开本次
* 推送对应的账期,不暴露其他厂家、其他月份或财务端内部页面。
*/
class SupplierSummaryShareLink
{
public const TTL_DAYS = 30;
public function pageUrl(
int $companyId,
string $supplier,
string $month,
string $pushBasis,
string $requestId,
): string {
return $this->signedRoute(
'signed.supplier-summary-confirmations.show',
$this->token($companyId, $supplier, $month, $pushBasis, $requestId),
$companyId,
now()->addDays(self::TTL_DAYS),
);
}
public function signedRoute(
string $routeName,
string $token,
int $companyId,
?DateTimeInterface $expires = null,
): string {
return URL::temporarySignedRoute($routeName, $expires ?? now()->addDays(self::TTL_DAYS), [
'company_id' => $companyId,
'token' => $token,
]);
}
/** @return array{supplier:string,month:string,push_basis:string,request_id:string}|null */
public function resolveToken(string $token, int $companyId): ?array
{
try {
$padding = (4 - strlen($token) % 4) % 4;
$encoded = strtr($token . str_repeat('=', $padding), '-_', '+/');
$encrypted = base64_decode($encoded, true);
if ($encrypted === false) {
return null;
}
$payload = json_decode(Crypt::decryptString($encrypted), true, flags: JSON_THROW_ON_ERROR);
} catch (Throwable) {
return null;
}
$supplier = is_array($payload) ? trim((string) ($payload['supplier'] ?? '')) : '';
$month = is_array($payload) ? trim((string) ($payload['month'] ?? '')) : '';
$pushBasis = is_array($payload) ? trim((string) ($payload['push_basis'] ?? '')) : '';
$requestId = is_array($payload) ? trim((string) ($payload['request_id'] ?? '')) : '';
$payloadCompanyId = is_array($payload) ? (int) ($payload['company_id'] ?? 0) : 0;
if (
$supplier === ''
|| $payloadCompanyId !== $companyId
|| preg_match('/^\d{4}-\d{2}$/', $month) !== 1
|| ! in_array($pushBasis, ['settlement', 'mine'], true)
|| preg_match('/^supplier-summary-l\d+-[a-f0-9]{16,128}$/', $requestId) !== 1
) {
return null;
}
return [
'supplier' => $supplier,
'month' => $month,
'push_basis' => $pushBasis,
'request_id' => $requestId,
];
}
private function token(
int $companyId,
string $supplier,
string $month,
string $pushBasis,
string $requestId,
): string {
$payload = json_encode([
'company_id' => $companyId,
'supplier' => $supplier,
'month' => $month,
'push_basis' => $pushBasis,
'request_id' => $requestId,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode(Crypt::encryptString($payload)), '+/', '-_'), '=');
}
}

View File

@ -1,4 +1,7 @@
<div class="supplier-reconciliation-page">
@php
$summaryConfirmationMode = config('dingtalk.summary_confirmation_mode', 'link') === 'card' ? 'card' : 'link';
@endphp
<style>
.supplier-reconciliation-page { display: grid; gap: 12px; }
.supplier-reconciliation-page .sr-card { min-width: 0; max-width: 100%; padding: 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: var(--vip-surface, #fff); display: grid; gap: 10px; }
@ -258,9 +261,9 @@
<input type="hidden" name="month" value="{{ $month }}">
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
<button type="submit"
class="sr-button is-primary {{ $month !== '' && $robot['configured'] && $robot['card_group_configured'] ? '' : 'is-disabled' }}"
@disabled($month === '' || ! $robot['configured'] || ! $robot['card_group_configured'])
title="{{ $month === '' ? '请先选择具体账期' : (! $robot['configured'] ? '请先配置厂家钉钉群机器人' : (! $robot['card_group_configured'] ? '请先配置互动卡片群会话 ID' : '推送当前账期对账确认卡片')) }}">
class="sr-button is-primary {{ $month !== '' && $robot['configured'] && ($summaryConfirmationMode !== 'card' || $robot['card_group_configured']) ? '' : 'is-disabled' }}"
@disabled($month === '' || ! $robot['configured'] || ($summaryConfirmationMode === 'card' && ! $robot['card_group_configured']))
title="{{ $month === '' ? '请先选择具体账期' : (! $robot['configured'] ? '请先配置厂家钉钉群机器人' : ($summaryConfirmationMode === 'card' && ! $robot['card_group_configured'] ? '请先配置互动卡片群会话 ID' : ($summaryConfirmationMode === 'card' ? '推送当前账期对账确认卡片' : '推送当前账期网页确认链接'))) }}">
推送到对账群
</button>
</form>
@ -305,7 +308,7 @@
<summary>
厂家钉钉群机器人:
{{ $robot['configured'] ? '已配置' : '未配置,配置后才能推送差异(点击展开)' }}
· {{ $robot['card_group_configured'] ? '互动确认已配置' : '互动确认待配置' }}
· {{ $summaryConfirmationMode === 'link' ? '网页确认已启用' : ($robot['card_group_configured'] ? '互动确认已配置' : '互动确认待配置') }}
@if ($robot['last_pushed_at'] !== '')
· 上次推送 {{ $robot['last_pushed_at'] }}({{ $robot['last_push_count'] }} 条)
@endif
@ -338,7 +341,7 @@
</div>
<div class="sr-robot-card-heading">
<b>厂家确认按钮</b>
<span>互动卡片使用企业内部应用机器人发送,点击按钮直接回传,不打开网页。</span>
<span>{{ $summaryConfirmationMode === 'link' ? '当前使用签名确认网页,厂家点击后提交并实时回传财务系统。' : '当前使用钉钉互动卡片,厂家点击按钮直接回传,不打开网页。' }}</span>
</div>
<label class="sr-field"><span>互动卡片群会话 ID</span>
<input type="text"
@ -580,9 +583,9 @@
<div class="sr-push-preview">
<div class="sr-push-preview-head">
<b>推送内容预览</b>
<span>实际发送以这里显示的内容为准</span>
<span>实际发送以这里显示的内容为准 · {{ $summaryConfirmationMode === 'link' ? '厂家网页确认' : '钉钉卡片确认' }}</span>
</div>
<div id="summary-push-preview" class="sr-message-preview" aria-live="polite">账期:{{ $displayMonthLabel }},件数{{ $formatPushQty($pushPreviewQty) }}件,货款{{ number_format($pushPreviewAmount, 2, '.', '') }}元</div>
<div id="summary-push-preview" class="sr-message-preview" aria-live="polite">账期:{{ $displayMonthLabel }},件数{{ $formatPushQty($pushPreviewQty) }}件,货款{{ number_format($pushPreviewAmount, 2, '.', '') }}元 · {{ $summaryConfirmationMode === 'link' ? '附货款确认链接' : '互动卡片确认' }}</div>
<div class="sr-push-scope">汇总范围:当前账期 + 当前对账人;下方“结果筛选”只改变表格显示,不改变整期推送范围。</div>
@if ($unpricedCount > 0)
<div class="sr-unpriced-note">
@ -1019,6 +1022,7 @@
const confirmationState = document.getElementById('summary-confirmation-state');
const period = @json($displayMonthLabel);
const supplier = @json($selectedSupplier);
const confirmationMode = @json($summaryConfirmationMode);
if (!form || !basis || !preview || !basisNote) {
return;
@ -1033,7 +1037,9 @@
const qty = Number(selected?.dataset.qty || 0);
const amount = Number(selected?.dataset.amount || 0);
preview.textContent = `账期:${period},件数${formatQty(qty)}件,货款${formatAmount(amount)}元`;
preview.textContent = `账期:${period},件数${formatQty(qty)}件,货款${formatAmount(amount)}元 · ${
confirmationMode === 'link' ? '附货款确认链接' : '互动卡片确认'
}`;
basisNote.textContent = selected?.dataset.note || '';
basisValues.forEach((value) => {
value.classList.toggle('is-active', value.dataset.basisValue === basis.value);

View File

@ -0,0 +1,312 @@
<x-layouts.signed>
<x-slot name="title">货款对账确认 · {{ $supplier }} · {{ $monthLabel }}</x-slot>
<x-slot name="content">
@php($isConfirmed = $status === \Modules\EcommerceFinance\Services\SupplierSummaryConfirmation::STATUS_CONFIRMED)
<style>
body > .content-layout { margin-top: 0 !important; }
body > .content-layout > #header { display: none; }
.summary-confirm-page {
--confirm-gold: #856632;
--confirm-gold-muted: #B9954F;
--confirm-gold-soft: #F7F0DE;
--confirm-ink: #171717;
--confirm-muted: #595959;
--confirm-border: #D8D2C7;
--confirm-success: #53793E;
width: min(100% - 24px, 720px);
margin: 18px auto 32px;
color: var(--confirm-ink);
}
.summary-confirm-page * { box-sizing: border-box; }
.summary-confirm-shell {
overflow: hidden;
border: 1px solid var(--confirm-border);
border-radius: 16px;
background: #FFFFFF;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
}
.summary-confirm-accent {
height: 4px;
background: linear-gradient(90deg, var(--confirm-gold), var(--confirm-gold-muted), var(--confirm-gold-soft));
}
.summary-confirm-body {
display: grid;
gap: 20px;
padding: 28px;
}
.summary-confirm-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.summary-confirm-eyebrow {
margin: 0 0 7px;
color: var(--confirm-gold);
font-size: .75rem;
font-weight: 600;
letter-spacing: .08em;
}
.summary-confirm-title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
}
.summary-confirm-subtitle {
margin: 8px 0 0;
color: var(--confirm-muted);
font-size: .8125rem;
line-height: 1.6;
}
.summary-confirm-status {
display: inline-flex;
min-height: 28px;
align-items: center;
gap: 6px;
padding: 0 10px;
border: 1px solid #E9D8B7;
border-radius: 999px;
background: #FFF8EA;
color: #935F07;
font-size: .75rem;
font-weight: 600;
white-space: nowrap;
}
.summary-confirm-status.is-confirmed {
border-color: #C8DDC5;
background: #EFF7EE;
color: var(--confirm-success);
}
.summary-confirm-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.summary-confirm-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.summary-confirm-metric {
display: grid;
gap: 7px;
padding: 16px;
border: 1px solid #E6E0D5;
border-radius: 10px;
background: #FCFBF7;
}
.summary-confirm-metric span {
color: var(--confirm-muted);
font-size: .75rem;
}
.summary-confirm-metric strong {
color: var(--confirm-ink);
font-size: 1.35rem;
font-weight: 700;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.summary-confirm-facts {
display: grid;
gap: 9px;
padding: 14px 0;
border-top: 1px solid #EEE8DD;
border-bottom: 1px solid #EEE8DD;
}
.summary-confirm-fact {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
font-size: .8125rem;
line-height: 1.5;
}
.summary-confirm-fact span { color: var(--confirm-muted); }
.summary-confirm-fact strong { text-align: right; font-weight: 600; }
.summary-confirm-note {
margin: 0;
color: var(--confirm-muted);
font-size: .75rem;
line-height: 1.7;
}
.summary-confirm-button {
display: inline-flex;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: center;
padding: 0 18px;
border: 1px solid var(--confirm-gold);
border-radius: 8px;
background: var(--confirm-gold);
color: #fff;
font: inherit;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
}
.summary-confirm-button:hover { background: #6A5129; border-color: #6A5129; }
.summary-confirm-button:focus-visible { outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.20); }
.summary-confirm-submit {
display: grid;
gap: 10px;
}
.summary-confirm-confirmed {
display: grid;
gap: 8px;
padding: 18px;
border: 1px solid #C8DDC5;
border-radius: 10px;
background: #F3F8F2;
}
.summary-confirm-confirmed strong {
color: var(--confirm-success);
font-size: 1rem;
}
.summary-confirm-confirmed span {
color: #4F6545;
font-size: .8125rem;
line-height: 1.6;
}
.summary-confirm-alert {
padding: 13px 14px;
border: 1px solid #C8DDC5;
border-radius: 8px;
background: #EFF7EE;
color: #3F6634;
font-size: .8125rem;
line-height: 1.55;
}
.summary-confirm-alert.is-error {
border-color: #E2B7B2;
background: #FFF2F0;
color: #8C2F26;
}
.summary-confirm-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 5px 14px;
margin-top: 12px;
padding: 0 4px;
color: var(--confirm-muted);
font-size: .6875rem;
line-height: 1.55;
}
@media (max-width: 560px) {
.summary-confirm-page { width: min(100% - 16px, 720px); margin-top: 10px; }
.summary-confirm-body { padding: 20px 16px; gap: 17px; }
.summary-confirm-title { font-size: 1.125rem; }
.summary-confirm-metrics { grid-template-columns: minmax(0, 1fr); }
.summary-confirm-head { display: grid; }
.summary-confirm-status { width: fit-content; }
}
</style>
<main class="summary-confirm-page">
<div class="summary-confirm-shell">
<div class="summary-confirm-accent"></div>
<div class="summary-confirm-body">
@if (is_string(session('success')) && session('success') !== '')
<div class="summary-confirm-alert" role="status">{{ session('success') }}</div>
@endif
@if ($errors->any())
<div class="summary-confirm-alert is-error" role="alert">{{ $errors->first() }}</div>
@endif
<header class="summary-confirm-head">
<div>
<p class="summary-confirm-eyebrow">供应商货款对账</p>
<h1 class="summary-confirm-title">{{ $supplier }} · {{ $monthLabel }}</h1>
<p class="summary-confirm-subtitle">请核对本期货款,确认无误后提交。</p>
</div>
<span class="summary-confirm-status {{ $isConfirmed ? 'is-confirmed' : '' }}">
<span class="summary-confirm-status-dot" aria-hidden="true"></span>
{{ $isConfirmed ? '厂家已确认' : '待厂家确认' }}
</span>
</header>
<section class="summary-confirm-metrics" aria-label="本期货款汇总">
<div class="summary-confirm-metric">
<span>本期件数</span>
<strong>{{ rtrim(rtrim(number_format($qty, 2, '.', ''), '0'), '.') }} 件</strong>
</div>
<div class="summary-confirm-metric">
<span>应付货款</span>
<strong>¥{{ number_format($amount, 2) }}</strong>
</div>
</section>
<section class="summary-confirm-facts" aria-label="确认信息">
<div class="summary-confirm-fact"><span>供应商</span><strong>{{ $supplier }}</strong></div>
<div class="summary-confirm-fact"><span>账期</span><strong>{{ $monthLabel }}</strong></div>
</section>
@if ($isConfirmed)
<section class="summary-confirm-confirmed" aria-live="polite">
<strong>本期货款已确认</strong>
<span>财务系统已实时收到确认结果,无需重复操作。</span>
@if ($confirmedAt !== '')
<span>确认时间:{{ $confirmedAt }}</span>
@endif
</section>
@else
<form method="POST" action="{{ $confirmUrl }}" class="summary-confirm-submit"
onsubmit="return confirm('请确认本期件数和货款均无误。提交后将立即回传财务系统,确定继续吗?')">
@csrf
<button type="submit" class="summary-confirm-button">确认本期货款</button>
<p class="summary-confirm-note">
点击即代表厂家确认本页数据无误。如数量或金额有差异,请先联系财务,不要点击确认。
</p>
</form>
@endif
</div>
</div>
<footer class="summary-confirm-footer">
<span>无需登录,仅可查看本厂家本次账期。</span>
<span>链接有效至 {{ $expiresAt->format('Y-m-d H:i') }}</span>
</footer>
</main>
</x-slot>
</x-layouts.signed>
@push('head_end')
<meta name="robots" content="noindex,nofollow,noarchive">
<meta name="referrer" content="no-referrer">
@endpush

View File

@ -35,3 +35,11 @@ Route::get('supplier-returns/{token}/excel', 'Business\SupplierReturnShare@downl
Route::get('supplier-returns/{token}/csv', 'Business\SupplierReturnShare@downloadCsv')
->middleware('throttle:20,1')
->name('signed.supplier-returns.csv');
Route::get('supplier-summary-confirmations/{token}', 'Business\SupplierSummaryConfirmationShare@show')
->middleware('throttle:60,1')
->name('signed.supplier-summary-confirmations.show');
Route::post('supplier-summary-confirmations/{token}/confirm', 'Business\SupplierSummaryConfirmationShare@confirm')
->middleware('throttle:10,1')
->name('signed.supplier-summary-confirmations.confirm');

View File

@ -392,7 +392,7 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
$components = (array) (json_decode((string) ($messages[2] ?? '[]'), true)['updateComponents']['components'] ?? []);
$summary = collect($components)->firstWhere('id', 'summary');
return str_contains((string) ($summary['content'] ?? ''), '口径:我方原始数据')
return ! str_contains((string) ($summary['content'] ?? ''), '口径')
&& str_contains((string) ($summary['content'] ?? ''), '件数:**5** 件')
&& str_contains((string) ($summary['content'] ?? ''), '货款:**¥330.00**');
});
@ -402,6 +402,7 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
{
$this->seedReconciliation();
$this->saveRobot();
config(['dingtalk.summary_confirmation_mode' => 'card']);
Http::fake();
$this->loginAs()
@ -1478,6 +1479,7 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
'dingtalk.client_id' => 'card-test-client',
'dingtalk.client_secret' => 'card-test-secret',
'dingtalk.card.enabled' => true,
'dingtalk.summary_confirmation_mode' => 'card',
'dingtalk.card.callback_url' => 'https://caiwu.example.test/webhooks/dingtalk/card/supplier-confirmation',
'dingtalk.card.callback_route_key' => 'supplier-summary-test-route',
'dingtalk.card.robot_code' => null,

View File

@ -0,0 +1,157 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Routing\Exceptions\InvalidSignatureException;
use Illuminate\Support\Facades\Http;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierSummaryConfirmation;
use Modules\EcommerceFinance\Services\SupplierSummaryShareLink;
use Tests\Feature\FeatureTestCase;
class SupplierSummaryConfirmationLinkTest extends FeatureTestCase
{
private const WEBHOOK = 'https://oapi.dingtalk.com/robot/send?access_token=summary-link-test-token';
protected function setUp(): void
{
parent::setUp();
$this->company->makeCurrent(true);
$this->user->attachPermission(
Permission::where('name', 'read-admin-panel')->firstOrFail()
);
config([
'dingtalk.summary_confirmation_mode' => 'link',
'app.platform_timezone' => 'Asia/Shanghai',
]);
}
public function test_summary_push_creates_a_signed_manufacturer_confirmation_page_and_records_confirmation(): void
{
$companyId = (int) $this->company->id;
app(SupplierDingTalkNotifier::class)->save(
'常州富来居',
self::WEBHOOK,
'SEC-summary-link-test',
false,
);
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
$result = app(SupplierDingTalkNotifier::class)->pushReconciliationSummary(
'常州富来居',
'2026-10',
855,
103420.50,
$companyId,
'settlement',
(int) $this->user->id,
);
$pageUrl = (string) $result['confirmation_url'];
$sentRequest = collect(Http::recorded())->first()[0] ?? null;
$this->assertNotNull($sentRequest);
$sentText = (string) ($sentRequest['markdown']['text'] ?? '');
$this->assertStringContainsString('oapi.dingtalk.com/robot/send', $sentRequest->url());
$this->assertStringContainsString('件数:**855** 件', $sentText);
$this->assertStringContainsString('货款:**¥103,420.50**', $sentText);
$this->assertStringNotContainsString('确认口径', $sentText);
$this->assertStringContainsString('[打开货款确认页](' . $pageUrl . ')', $sentText);
$this->assertStringContainsString('无需登录', $sentText);
$page = $this->get($pageUrl)
->assertOk()
->assertHeader('X-Robots-Tag', 'noindex, nofollow, noarchive')
->assertHeader('Referrer-Policy', 'no-referrer')
->assertSeeText('常州富来居 · 2026年10月')
->assertSeeText('855 件')
->assertSeeText('¥103,420.50')
->assertDontSeeText('确认口径')
->assertDontSeeText('按结算口径')
->assertSeeText('待厂家确认')
->assertSeeText('确认本期货款')
->assertSeeText('无需登录');
$html = (string) $page->getContent();
preg_match('/action="([^"]*supplier-summary-confirmations[^"]*\/confirm[^"]*)"/', $html, $matches);
$this->assertNotEmpty($matches);
$confirmUrl = html_entity_decode($matches[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
$redirect = $this->post($confirmUrl)
->assertStatus(302)
->assertSessionHas('success', '已确认本期货款,财务系统已实时更新。');
$record = app(SupplierSummaryConfirmation::class)->find($result['request_id'], $companyId);
$this->assertSame(SupplierSummaryConfirmation::STATUS_CONFIRMED, $record['status'] ?? null);
$this->assertSame('manufacturer_link', $record['confirmed_source'] ?? null);
$this->assertNotEmpty($record['confirmed_at'] ?? null);
$this->assertArrayNotHasKey('confirmed_by_dingtalk_user_id', $record);
$receiptRequest = collect(Http::recorded())->last()[0] ?? null;
$this->assertNotNull($receiptRequest);
$receiptText = (string) ($receiptRequest['markdown']['text'] ?? '');
$this->assertStringContainsString('供应商货款确认回执 · 常州富来居', $receiptText);
$this->assertStringContainsString('855** 件', $receiptText);
$this->assertStringContainsString('¥103,420.50', $receiptText);
$this->assertStringContainsString('厂家通过货款确认页点击确认', $receiptText);
$this->get($redirect->headers->get('Location'))
->assertOk()
->assertSeeText('本期货款已确认')
->assertSeeText('厂家已确认')
->assertSeeText('财务系统已实时收到确认结果')
->assertDontSee('<button type="submit" class="summary-confirm-button">', false);
$this->loginAs()->getJson(route('business.supplier-reconciliation.summary-confirmation.status', [
'supplier' => '常州富来居',
'month' => '2026-10',
'push_basis' => 'settlement',
]))->assertOk()->assertJson([
'status' => 'confirmed',
'label' => '厂家已确认',
]);
}
public function test_summary_confirmation_page_rejects_a_tampered_token(): void
{
$companyId = (int) $this->company->id;
$requestId = $this->recordPendingSummary($companyId, '晨丰');
$url = app(SupplierSummaryShareLink::class)->pageUrl(
$companyId,
'晨丰',
'2026-10',
'mine',
$requestId,
);
$tampered = preg_replace_callback(
'/supplier-summary-confirmations\/([^?\/]+)/',
static fn (array $matches): string => 'supplier-summary-confirmations/'
. ($matches[1][0] === 'A' ? 'B' : 'A') . substr($matches[1], 1),
$url,
1,
);
$this->assertNotSame($url, $tampered);
$this->expectException(InvalidSignatureException::class);
$this->get($tampered);
}
private function recordPendingSummary(int $companyId, string $supplier): string
{
$requestId = 'supplier-summary-l' . $companyId . '-' . str_repeat('a', 32);
app(SupplierSummaryConfirmation::class)->create(
$requestId,
$supplier,
'2026-10',
'mine',
12,
360.50,
$companyId,
null,
);
app(SupplierSummaryConfirmation::class)->markSent($requestId, $companyId);
return $requestId;
}
}