feat(供应商对账): 增加免登录送货确认链接 (#1180)
Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
This commit is contained in:
parent
c658bbed99
commit
770ddea8a9
@ -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} 钉钉群,等待厂家确认。",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 . '月';
|
||||
}
|
||||
}
|
||||
@ -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', '财务日报'),
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
. '核对无误后,请直接点击下方“厂家确认”。';
|
||||
|
||||
@ -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,
|
||||
|
||||
103
modules/EcommerceFinance/Services/SupplierSummaryShareLink.php
Normal file
103
modules/EcommerceFinance/Services/SupplierSummaryShareLink.php
Normal 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)), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
@ -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');
|
||||
|
||||
@ -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,
|
||||
|
||||
157
tests/Feature/Business/SupplierSummaryConfirmationLinkTest.php
Normal file
157
tests/Feature/Business/SupplierSummaryConfirmationLinkTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user