货款对账:差异支持按厂家钉钉机器人推送 (#378)
This commit is contained in:
parent
3fedeef59a
commit
41f6946a2b
@ -6,13 +6,16 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
|
||||
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。
|
||||
@ -25,6 +28,7 @@ class SupplierReconciliation extends Controller
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierReconciliationPageData $pageData,
|
||||
private readonly SupplierReturnCsvExporter $returnCsvExporter,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
) {
|
||||
$this->middleware('auth');
|
||||
$this->middleware('company.identify');
|
||||
@ -101,6 +105,74 @@ class SupplierReconciliation extends Controller
|
||||
return $this->returnCsvExporter->download($rows, $supplier);
|
||||
}
|
||||
|
||||
public function saveDingTalkRobot(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
|
||||
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
|
||||
'dingtalk_at_all' => ['nullable', 'boolean'],
|
||||
]);
|
||||
[$supplier] = $this->returnTarget($request);
|
||||
$this->dingTalkNotifier->save(
|
||||
$supplier,
|
||||
(string) $request->input('dingtalk_webhook', ''),
|
||||
(string) $request->input('dingtalk_secret', ''),
|
||||
$request->boolean('dingtalk_at_all'),
|
||||
);
|
||||
|
||||
return back()->with('success', "{$supplier} 的钉钉群机器人已保存。");
|
||||
}
|
||||
|
||||
public function pushDifferences(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'supplier' => ['required', 'string', 'max:191'],
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
'scope' => ['required', 'in:all,row'],
|
||||
'barcode' => ['nullable', 'string', 'max:191'],
|
||||
'difference_type' => ['nullable', 'string', 'max:32'],
|
||||
'unit_price' => ['nullable', 'numeric'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all());
|
||||
$differences = array_values(array_filter(
|
||||
$detail['rows'],
|
||||
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
|
||||
));
|
||||
|
||||
if ($request->input('scope') === 'row') {
|
||||
$barcode = trim((string) $request->input('barcode'));
|
||||
$type = trim((string) $request->input('difference_type'));
|
||||
$unitPrice = (float) $request->input('unit_price');
|
||||
$differences = array_values(array_filter(
|
||||
$differences,
|
||||
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
|
||||
&& (string) ($row['type'] ?? '') === $type
|
||||
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
|
||||
));
|
||||
}
|
||||
|
||||
if ($differences === []) {
|
||||
return back()->withErrors(['dingtalk_push' => '当前筛选没有可推送的差异。']);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->dingTalkNotifier->push($supplier, $differences, [
|
||||
'month' => (string) $request->input('month', ''),
|
||||
'reconciler' => (string) $request->input('reconciler', ''),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
return back()->withErrors(['dingtalk_push' => '推送失败:' . $exception->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
"已推送 {$result['rows']} 条差异到 {$supplier} 钉钉群({$result['messages']} 条消息)。",
|
||||
);
|
||||
}
|
||||
|
||||
private function markReturn(Request $request, string $status, string $message): RedirectResponse
|
||||
{
|
||||
$request->validate(['key' => ['required', 'string', 'max:191']]);
|
||||
|
||||
323
modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php
Normal file
323
modules/EcommerceFinance/Services/SupplierDingTalkNotifier.php
Normal file
@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 货款对账差异:按供应商保存钉钉群机器人,并推送当前比对差异。
|
||||
*/
|
||||
class SupplierDingTalkNotifier
|
||||
{
|
||||
private const SETTING_KEY = 'supplier_reconciliation.dingtalk_robots';
|
||||
|
||||
private const ROWS_PER_MESSAGE = 30;
|
||||
|
||||
private const MAX_MESSAGES = 20;
|
||||
|
||||
/** @return array{configured:bool,webhook_hint:string,at_all:bool,last_pushed_at:string,last_push_count:int} */
|
||||
public function configuration(string $supplier): array
|
||||
{
|
||||
$config = $this->configs()[$supplier] ?? null;
|
||||
$webhook = is_array($config) ? trim((string) ($config['webhook'] ?? '')) : '';
|
||||
|
||||
return [
|
||||
'configured' => $webhook !== '',
|
||||
'webhook_hint' => $this->maskWebhook($webhook),
|
||||
'at_all' => (bool) ($config['at_all'] ?? false),
|
||||
'last_pushed_at' => (string) ($config['last_pushed_at'] ?? ''),
|
||||
'last_push_count' => (int) ($config['last_push_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function save(string $supplier, string $webhook, string $secret, bool $atAll): void
|
||||
{
|
||||
$configs = $this->configs();
|
||||
$existing = is_array($configs[$supplier] ?? null) ? $configs[$supplier] : [];
|
||||
$existingWebhook = trim((string) ($existing['webhook'] ?? ''));
|
||||
$webhook = trim($webhook);
|
||||
|
||||
if ($webhook === '') {
|
||||
$webhook = $existingWebhook;
|
||||
}
|
||||
if ($webhook === '') {
|
||||
throw ValidationException::withMessages(['dingtalk_webhook' => '请填写钉钉群机器人 Webhook。']);
|
||||
}
|
||||
|
||||
$this->assertWebhook($webhook);
|
||||
|
||||
$secret = trim($secret);
|
||||
if ($secret === '' && $webhook === $existingWebhook) {
|
||||
$secret = trim((string) ($existing['secret'] ?? ''));
|
||||
}
|
||||
if (mb_strlen($secret) > 256) {
|
||||
throw ValidationException::withMessages(['dingtalk_secret' => '机器人加签密钥过长。']);
|
||||
}
|
||||
|
||||
$configs[$supplier] = [
|
||||
'webhook' => $webhook,
|
||||
'secret' => $secret,
|
||||
'at_all' => $atAll,
|
||||
'last_pushed_at' => (string) ($existing['last_pushed_at'] ?? ''),
|
||||
'last_push_count' => (int) ($existing['last_push_count'] ?? 0),
|
||||
];
|
||||
$this->persist($configs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $differences
|
||||
* @param array<string,mixed> $filters
|
||||
* @return array{messages:int,rows:int}
|
||||
*/
|
||||
public function push(string $supplier, array $differences, array $filters): array
|
||||
{
|
||||
if ($differences === []) {
|
||||
throw ValidationException::withMessages(['dingtalk_push' => '当前筛选没有可推送的差异。']);
|
||||
}
|
||||
|
||||
$configs = $this->configs();
|
||||
$config = is_array($configs[$supplier] ?? null) ? $configs[$supplier] : [];
|
||||
$webhook = trim((string) ($config['webhook'] ?? ''));
|
||||
if ($webhook === '') {
|
||||
throw ValidationException::withMessages(['dingtalk_push' => '该厂家还没有配置钉钉群机器人 Webhook。']);
|
||||
}
|
||||
$this->assertWebhook($webhook);
|
||||
|
||||
$chunks = array_chunk(array_values($differences), self::ROWS_PER_MESSAGE);
|
||||
if (count($chunks) > self::MAX_MESSAGES) {
|
||||
throw ValidationException::withMessages([
|
||||
'dingtalk_push' => '当前差异超过 ' . (self::ROWS_PER_MESSAGE * self::MAX_MESSAGES)
|
||||
. ' 条,请先缩小月份或对账人范围后再分批推送。',
|
||||
]);
|
||||
}
|
||||
|
||||
$summary = $this->summarize($differences);
|
||||
$total = max(count($chunks), 1);
|
||||
foreach ($chunks as $index => $chunk) {
|
||||
$title = $supplier . ' · 货款对账差异';
|
||||
$this->send(
|
||||
$webhook,
|
||||
trim((string) ($config['secret'] ?? '')),
|
||||
(bool) ($config['at_all'] ?? false),
|
||||
$title,
|
||||
$this->message($supplier, $chunk, $summary, $filters, $index + 1, $total, $index * self::ROWS_PER_MESSAGE),
|
||||
);
|
||||
}
|
||||
|
||||
$config['last_pushed_at'] = now()->format('Y-m-d H:i:s');
|
||||
$config['last_push_count'] = count($differences);
|
||||
$configs[$supplier] = $config;
|
||||
$this->persist($configs);
|
||||
|
||||
return ['messages' => count($chunks), 'rows' => count($differences)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array{count:int,extra:int,mismatch:int,missing:int,difference_amount:float}
|
||||
*/
|
||||
public function summarize(array $rows): array
|
||||
{
|
||||
$summary = ['count' => count($rows), 'extra' => 0, 'mismatch' => 0, 'missing' => 0, 'difference_amount' => 0.0];
|
||||
foreach ($rows as $row) {
|
||||
$type = (string) ($row['type'] ?? '');
|
||||
$mine = (float) ($row['mineAmount'] ?? 0);
|
||||
$theirs = (float) ($row['theirsAmount'] ?? 0);
|
||||
|
||||
if ($type === SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA) {
|
||||
$summary['extra']++;
|
||||
$summary['difference_amount'] += $theirs;
|
||||
} elseif ($type === SupplierStatementMatcher::TYPE_MISMATCH) {
|
||||
$summary['mismatch']++;
|
||||
$summary['difference_amount'] += abs($mine - $theirs);
|
||||
} elseif ($type === SupplierStatementMatcher::TYPE_SUPPLIER_MISSING) {
|
||||
$summary['missing']++;
|
||||
$summary['difference_amount'] += $mine;
|
||||
}
|
||||
}
|
||||
$summary['difference_amount'] = round($summary['difference_amount'], 2);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @param array{count:int,extra:int,mismatch:int,missing:int,difference_amount:float} $summary
|
||||
* @param array<string,mixed> $filters
|
||||
*/
|
||||
private function message(
|
||||
string $supplier,
|
||||
array $rows,
|
||||
array $summary,
|
||||
array $filters,
|
||||
int $part,
|
||||
int $parts,
|
||||
int $rowOffset,
|
||||
): string {
|
||||
$month = trim((string) ($filters['month'] ?? ''));
|
||||
$reconciler = trim((string) ($filters['reconciler'] ?? ''));
|
||||
$lines = [
|
||||
'### 货款对账差异通知 · ' . $supplier,
|
||||
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
|
||||
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
|
||||
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
|
||||
. ' / 金额数量不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
|
||||
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
|
||||
];
|
||||
|
||||
if ($parts > 1) {
|
||||
$lines[] = '- 本条为第 ' . $part . ' / ' . $parts . ' 条,请结合后续消息一起核对。';
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
foreach ($rows as $index => $row) {
|
||||
$mineQty = (float) ($row['mineQty'] ?? 0);
|
||||
$theirsQty = (float) ($row['theirsQty'] ?? 0);
|
||||
$mineAmount = (float) ($row['mineAmount'] ?? 0);
|
||||
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
|
||||
$barcode = trim((string) ($row['barcode'] ?? ''));
|
||||
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
|
||||
|
||||
$lines[] = sprintf(
|
||||
'%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s',
|
||||
$rowOffset + $index + 1,
|
||||
(string) ($row['type'] ?? '差异'),
|
||||
$barcode,
|
||||
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
|
||||
number_format((float) ($row['unitPrice'] ?? 0), 2),
|
||||
$this->number($mineQty),
|
||||
number_format($mineAmount, 2),
|
||||
$this->number($theirsQty),
|
||||
number_format($theirsAmount, 2),
|
||||
(string) ($row['note'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
$lines[] = '';
|
||||
$lines[] = '> 请登录厂家对账门户核对并修改对账单;如已确认,请联系财务处理。';
|
||||
$lines[] = '- [打开厂家对账门户](' . route('login') . ')';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function send(string $webhook, string $secret, bool $atAll, string $title, string $text): void
|
||||
{
|
||||
$url = $webhook;
|
||||
if ($secret !== '') {
|
||||
$timestamp = (string) (int) (microtime(true) * 1000);
|
||||
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
|
||||
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
|
||||
}
|
||||
|
||||
$response = Http::timeout(10)->post($url, [
|
||||
'msgtype' => 'markdown',
|
||||
'markdown' => ['title' => $title, 'text' => $text],
|
||||
'at' => ['atMobiles' => [], 'isAtAll' => $atAll],
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('钉钉机器人推送返回 HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
$body = (array) $response->json();
|
||||
if ((int) ($body['errcode'] ?? 0) !== 0) {
|
||||
throw new RuntimeException('钉钉机器人推送失败:' . (string) ($body['errcode'] ?? '?')
|
||||
. ' ' . (string) ($body['errmsg'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
private function assertWebhook(string $webhook): void
|
||||
{
|
||||
$parts = parse_url($webhook);
|
||||
parse_str((string) ($parts['query'] ?? ''), $query);
|
||||
$host = strtolower((string) ($parts['host'] ?? ''));
|
||||
|
||||
if (($parts['scheme'] ?? '') !== 'https'
|
||||
|| ! in_array($host, ['oapi.dingtalk.com', 'api.dingtalk.com'], true)
|
||||
|| ($parts['path'] ?? '') !== '/robot/send'
|
||||
|| trim((string) ($query['access_token'] ?? '')) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'dingtalk_webhook' => '请填写有效的钉钉群机器人 Webhook(必须以 https://oapi.dingtalk.com/robot/send?access_token= 开头)。',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function maskWebhook(string $webhook): string
|
||||
{
|
||||
if ($webhook === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($webhook);
|
||||
$host = (string) ($parts['host'] ?? '');
|
||||
|
||||
return $host !== '' ? 'https://' . $host . '/robot/send?access_token=••••••' : '已配置';
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> */
|
||||
private function configs(): array
|
||||
{
|
||||
$value = setting(self::SETTING_KEY, []);
|
||||
if (is_string($value)) {
|
||||
$value = json_decode($value, true) ?: [];
|
||||
}
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($value as $supplier => &$config) {
|
||||
if (! is_array($config)) {
|
||||
unset($value[$supplier]);
|
||||
continue;
|
||||
}
|
||||
$config['webhook'] = $this->decryptValue($config['webhook'] ?? '');
|
||||
$config['secret'] = $this->decryptValue($config['secret'] ?? '');
|
||||
}
|
||||
unset($config);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @param array<string,array<string,mixed>> $configs */
|
||||
private function persist(array $configs): void
|
||||
{
|
||||
$stored = [];
|
||||
foreach ($configs as $supplier => $config) {
|
||||
$webhook = trim((string) ($config['webhook'] ?? ''));
|
||||
$secret = trim((string) ($config['secret'] ?? ''));
|
||||
$stored[$supplier] = [
|
||||
'webhook' => $webhook !== '' ? Crypt::encryptString($webhook) : '',
|
||||
'secret' => $secret !== '' ? Crypt::encryptString($secret) : '',
|
||||
'at_all' => (bool) ($config['at_all'] ?? false),
|
||||
'last_pushed_at' => (string) ($config['last_pushed_at'] ?? ''),
|
||||
'last_push_count' => (int) ($config['last_push_count'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
setting()->set(self::SETTING_KEY, json_encode($stored, JSON_UNESCAPED_UNICODE));
|
||||
setting()->save();
|
||||
}
|
||||
|
||||
private function decryptValue(mixed $value): string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($value);
|
||||
} catch (Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
private function number(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace Modules\EcommerceFinance\View;
|
||||
|
||||
use App\Http\Controllers\Business\PayablesFinalActions;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
|
||||
@ -23,6 +24,7 @@ class SupplierReconciliationPageData
|
||||
private readonly SupplierStatementStore $statements,
|
||||
private readonly SupplierStatementMatcher $matcher,
|
||||
private readonly SupplierReturnConfirmation $returnConfirmations,
|
||||
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -111,6 +113,7 @@ class SupplierReconciliationPageData
|
||||
'reconciler' => $reconciler,
|
||||
'selectedSupplier' => $selected,
|
||||
'detail' => $detail,
|
||||
'robot' => $selected !== '' ? $this->dingTalkNotifier->configuration($selected) : null,
|
||||
'listUrl' => route('business.pages.show', array_filter([
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
@ -196,6 +199,38 @@ class SupplierReconciliationPageData
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按页面当前月份/对账人筛选重新计算财务端比对结果,供差异推送使用。
|
||||
*
|
||||
* @param array<string,mixed> $input
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function financeDetailForFilters(string $supplier, int $companyId, array $input): array
|
||||
{
|
||||
$mine = $this->store->listRows(
|
||||
PayablesFinalActions::DATASET_PREFIX . 'supplier:payment-reconciliation',
|
||||
$companyId,
|
||||
);
|
||||
|
||||
$month = $this->filterText($input['month'] ?? '');
|
||||
if ($month !== '' && preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
|
||||
$mine = array_values(array_filter(
|
||||
$mine,
|
||||
static fn (array $row): bool => substr(trim((string) ($row['summary_month'] ?? '')), 0, 7) === $month,
|
||||
));
|
||||
}
|
||||
|
||||
$reconciler = $this->filterText($input['reconciler'] ?? '');
|
||||
if ($reconciler !== '') {
|
||||
$mine = array_values(array_filter(
|
||||
$mine,
|
||||
static fn (array $row): bool => trim((string) ($row['reconciler'] ?? '')) === $reconciler,
|
||||
));
|
||||
}
|
||||
|
||||
return $this->detail($mine, $supplier, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $input
|
||||
* @return array{returnPanel:array<string,mixed>,returnWarehouses:list<string>}
|
||||
|
||||
@ -8,9 +8,13 @@
|
||||
.supplier-reconciliation-page .sr-field { display: grid; gap: 4px; }
|
||||
.supplier-reconciliation-page .sr-field span { color: var(--vip-content-muted, #595959); font-size: .75rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page select,
|
||||
.supplier-reconciliation-page input[type="search"] { height: 36px; min-width: 12rem; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; }
|
||||
.supplier-reconciliation-page input[type="search"],
|
||||
.supplier-reconciliation-page input[type="url"],
|
||||
.supplier-reconciliation-page input[type="password"] { height: 36px; min-width: 12rem; padding: 0 10px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; font-size: .8125rem; }
|
||||
.supplier-reconciliation-page .sr-button { display: inline-flex; height: 36px; align-items: center; gap: 6px; padding: 0 14px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .8125rem; font-weight: 600; text-decoration: none; cursor: pointer; }
|
||||
.supplier-reconciliation-page .sr-button.is-primary { border-color: var(--vip-content-accent-dark, #856632); background: var(--vip-content-accent-dark, #856632); color: #fff; }
|
||||
.supplier-reconciliation-page .sr-button.is-disabled { opacity: .55; cursor: not-allowed; }
|
||||
.supplier-reconciliation-page .sr-button.is-small { height: 26px; padding: 0 9px; font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-hint { margin: 0; color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.7; }
|
||||
.supplier-reconciliation-page .sr-scroll { min-width: 0; max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
||||
.supplier-reconciliation-page table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: .8125rem; }
|
||||
@ -29,6 +33,15 @@
|
||||
.supplier-reconciliation-page .sr-summary-cell { padding: 10px 12px; border: 1px solid var(--vip-border, #D8D2C7); border-radius: 8px; display: grid; gap: 4px; }
|
||||
.supplier-reconciliation-page .sr-summary-cell b { font-size: 1.125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-summary-cell span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-robot-config { padding: 10px 12px; border: 1px solid #EFEAE0; border-radius: 8px; background: #FCFAF5; }
|
||||
.supplier-reconciliation-page .sr-robot-config summary { cursor: pointer; color: #5B5343; font-size: .8125rem; font-weight: 600; }
|
||||
.supplier-reconciliation-page .sr-robot-form { display: grid; grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) auto; gap: 10px; align-items: end; margin-top: 12px; }
|
||||
.supplier-reconciliation-page .sr-robot-check { display: flex; align-items: center; gap: 6px; min-height: 36px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-robot-form input[type="url"],
|
||||
.supplier-reconciliation-page .sr-robot-form input[type="password"] { width: 100%; min-width: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.supplier-reconciliation-page .sr-robot-form { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.supplier-reconciliation-page .sr-empty { padding: 28px; border: 1px dashed var(--vip-border, #D8D2C7); border-radius: 8px; color: var(--vip-content-muted, #595959); font-size: .8125rem; text-align: center; }
|
||||
</style>
|
||||
|
||||
@ -38,6 +51,11 @@
|
||||
@if ($errors->has('import'))
|
||||
<div class="biz-alert biz-alert-error" role="alert">{{ $errors->first('import') }}</div>
|
||||
@endif
|
||||
@if ($errors->has('dingtalk_push') || $errors->has('dingtalk_webhook') || $errors->has('dingtalk_secret'))
|
||||
<div class="biz-alert biz-alert-error" role="alert">
|
||||
{{ $errors->first('dingtalk_push') ?: $errors->first('dingtalk_webhook') ?: $errors->first('dingtalk_secret') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="sr-card">
|
||||
<form method="GET" class="sr-toolbar" aria-label="供应商对账筛选">
|
||||
@ -84,6 +102,11 @@
|
||||
</div>
|
||||
|
||||
@if ($detail)
|
||||
@php
|
||||
$differenceCount = (int) $detail['summary']['supplierExtra']
|
||||
+ (int) $detail['summary']['mismatch']
|
||||
+ (int) $detail['summary']['supplierMissing'];
|
||||
@endphp
|
||||
<div class="sr-card">
|
||||
<div class="sr-toolbar">
|
||||
<h2>{{ $selectedSupplier }} · 比对明细</h2>
|
||||
@ -99,7 +122,65 @@
|
||||
<span style="flex:1 1 auto"></span>
|
||||
<a href="{{ $listUrl }}" class="sr-button">← 返回供应商列表</a>
|
||||
<a href="{{ $statementTemplateUrl }}" class="sr-button">下载对账单模板</a>
|
||||
@if ($differenceCount > 0)
|
||||
<form method="POST"
|
||||
action="{{ route('business.supplier-reconciliation.push-differences') }}"
|
||||
onsubmit="return confirm('确认将当前筛选的 {{ $differenceCount }} 条差异推送到该厂家钉钉群?')">
|
||||
@csrf
|
||||
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
|
||||
<input type="hidden" name="month" value="{{ $month }}">
|
||||
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
|
||||
<input type="hidden" name="scope" value="all">
|
||||
<button type="submit"
|
||||
class="sr-button is-primary {{ $robot['configured'] ? '' : 'is-disabled' }}"
|
||||
@disabled(! $robot['configured'])
|
||||
title="{{ $robot['configured'] ? '推送当前筛选的全部差异' : '请先在下方配置厂家钉钉群机器人' }}">
|
||||
推送全部差异({{ $differenceCount }} 条)
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
<details class="sr-robot-config" @if (! $robot['configured']) open @endif>
|
||||
<summary>
|
||||
厂家钉钉群机器人:
|
||||
{{ $robot['configured'] ? '已配置' : '未配置,配置后才能推送差异' }}
|
||||
@if ($robot['last_pushed_at'] !== '')
|
||||
· 上次推送 {{ $robot['last_pushed_at'] }}({{ $robot['last_push_count'] }} 条)
|
||||
@endif
|
||||
</summary>
|
||||
<form method="POST"
|
||||
action="{{ route('business.supplier-reconciliation.dingtalk-robot.save') }}"
|
||||
class="sr-robot-form">
|
||||
@csrf
|
||||
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
|
||||
<label class="sr-field"><span>机器人 Webhook</span>
|
||||
<input type="url"
|
||||
name="dingtalk_webhook"
|
||||
value=""
|
||||
placeholder="{{ $robot['configured'] ? $robot['webhook_hint'] . '(留空表示不更换)' : 'https://oapi.dingtalk.com/robot/send?access_token=...' }}"
|
||||
autocomplete="off">
|
||||
</label>
|
||||
<label class="sr-field"><span>加签密钥(可选)</span>
|
||||
<input type="password"
|
||||
name="dingtalk_secret"
|
||||
value=""
|
||||
placeholder="{{ $robot['configured'] ? '留空保留原密钥' : '机器人安全设置中的加签密钥' }}"
|
||||
autocomplete="new-password">
|
||||
</label>
|
||||
<div>
|
||||
<label class="sr-robot-check">
|
||||
<input type="checkbox" name="dingtalk_at_all" value="1" @checked($robot['at_all'])>
|
||||
推送时 @ 所有人(默认关闭)
|
||||
</label>
|
||||
<button type="submit" class="sr-button">保存机器人</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="sr-hint">
|
||||
在厂家钉钉群的「群设置 → 智能群助手 → 添加机器人 → 自定义」中复制 Webhook;如机器人启用加签,请同时填写加签密钥。
|
||||
如机器人使用关键词校验,请把关键词设为「货款对账」。
|
||||
Webhook 和密钥保存后仅用于本系统推送,页面不会再次展示完整内容。
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@else
|
||||
<div class="sr-card">
|
||||
@ -159,6 +240,7 @@
|
||||
<th scope="col">厂家金额</th>
|
||||
<th scope="col">结算口径</th>
|
||||
<th scope="col">结算金额</th>
|
||||
<th scope="col">推送</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -186,9 +268,33 @@
|
||||
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
|
||||
<td class="sr-muted">{{ $row['note'] }}</td>
|
||||
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
|
||||
<td>
|
||||
@if ($row['type'] !== '一致')
|
||||
<form method="POST"
|
||||
action="{{ route('business.supplier-reconciliation.push-differences') }}"
|
||||
onsubmit="return confirm('确认将这条差异推送到该厂家钉钉群?')">
|
||||
@csrf
|
||||
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
|
||||
<input type="hidden" name="month" value="{{ $month }}">
|
||||
<input type="hidden" name="reconciler" value="{{ $reconciler }}">
|
||||
<input type="hidden" name="scope" value="row">
|
||||
<input type="hidden" name="barcode" value="{{ $row['barcode'] }}">
|
||||
<input type="hidden" name="difference_type" value="{{ $row['type'] }}">
|
||||
<input type="hidden" name="unit_price" value="{{ number_format($row['unitPrice'], 4, '.', '') }}">
|
||||
<button type="submit"
|
||||
class="sr-button is-small {{ $robot['configured'] ? 'is-primary' : 'is-disabled' }}"
|
||||
@disabled(! $robot['configured'])
|
||||
title="{{ $robot['configured'] ? '只推送这条差异' : '请先配置厂家钉钉群机器人' }}">
|
||||
推送本条
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="sr-muted">—</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="9"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
<tr><td colspan="10"><div class="sr-empty">该厂家还没有上传对账单,或我方还没有这家供应商的结算数据。</div></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -412,6 +412,14 @@ Route::post('workspace/supplier-reconciliation/returns/confirm-all', [SupplierRe
|
||||
->middleware('business.page:supplier-reconciliation,supplier-data')
|
||||
->name('business.supplier-reconciliation.return.confirm-all');
|
||||
|
||||
Route::post('workspace/supplier-reconciliation/dingtalk-robot', [SupplierReconciliationActions::class, 'saveDingTalkRobot'])
|
||||
->middleware('business.page:supplier-reconciliation,finance-data')
|
||||
->name('business.supplier-reconciliation.dingtalk-robot.save');
|
||||
|
||||
Route::post('workspace/supplier-reconciliation/push-differences', [SupplierReconciliationActions::class, 'pushDifferences'])
|
||||
->middleware('business.page:supplier-reconciliation,finance-data')
|
||||
->name('business.supplier-reconciliation.push-differences');
|
||||
|
||||
Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds')
|
||||
->middleware('business.page:refunds,non-jingxi-refund-data')
|
||||
->name('business.refunds.non-jingxi-refund-data.export');
|
||||
|
||||
245
tests/Feature/Business/SupplierReconciliationDingTalkTest.php
Normal file
245
tests/Feature/Business/SupplierReconciliationDingTalkTest.php
Normal file
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Business;
|
||||
|
||||
use App\Models\Auth\Permission;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
|
||||
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
use Tests\Feature\FeatureTestCase;
|
||||
|
||||
class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
{
|
||||
private const WEBHOOK = 'https://oapi.dingtalk.com/robot/send?access_token=supplier-test-token';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->user->attachPermission(
|
||||
Permission::where('name', 'read-admin-panel')->firstOrFail()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_finance_detail_shows_push_buttons_and_robot_configuration(): void
|
||||
{
|
||||
$this->seedReconciliation();
|
||||
|
||||
$this->loginAs()->get($this->pageUrl())
|
||||
->assertOk()
|
||||
->assertSeeText('推送全部差异(3 条)')
|
||||
->assertSeeText('推送本条')
|
||||
->assertSeeText('厂家钉钉群机器人')
|
||||
->assertSeeText('未配置,配置后才能推送差异')
|
||||
->assertDontSee(self::WEBHOOK);
|
||||
}
|
||||
|
||||
public function test_robot_configuration_is_saved_and_secret_is_not_rendered(): void
|
||||
{
|
||||
$this->seedReconciliation();
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.dingtalk-robot.save'), [
|
||||
'supplier' => '晨丰',
|
||||
'dingtalk_webhook' => self::WEBHOOK,
|
||||
'dingtalk_secret' => 'SEC-test-123',
|
||||
'dingtalk_at_all' => '1',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHas('success', '晨丰 的钉钉群机器人已保存。');
|
||||
|
||||
$config = app(SupplierDingTalkNotifier::class)->configuration('晨丰');
|
||||
$this->assertTrue($config['configured']);
|
||||
$this->assertTrue($config['at_all']);
|
||||
$this->assertStringContainsString('access_token=••••••', $config['webhook_hint']);
|
||||
$this->assertStringNotContainsString('supplier-test-token', (string) setting('supplier_reconciliation.dingtalk_robots'));
|
||||
$this->assertStringNotContainsString('SEC-test-123', (string) setting('supplier_reconciliation.dingtalk_robots'));
|
||||
|
||||
$this->loginAs()->get($this->pageUrl())
|
||||
->assertOk()
|
||||
->assertSeeText('已配置')
|
||||
->assertDontSeeText('supplier-test-token')
|
||||
->assertDontSeeText('SEC-test-123');
|
||||
}
|
||||
|
||||
public function test_invalid_robot_webhook_is_rejected(): void
|
||||
{
|
||||
$this->withExceptionHandling();
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.dingtalk-robot.save'), [
|
||||
'supplier' => '晨丰',
|
||||
'dingtalk_webhook' => 'https://example.com/robot/send?access_token=unsafe',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHasErrors('dingtalk_webhook');
|
||||
}
|
||||
|
||||
public function test_push_all_differences_sends_only_current_supplier_and_filtered_rows(): void
|
||||
{
|
||||
$this->seedReconciliation();
|
||||
$this->saveRobot();
|
||||
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.push-differences'), [
|
||||
'supplier' => '晨丰',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
'scope' => 'all',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHas('success', '已推送 3 条差异到 晨丰 钉钉群(1 条消息)。');
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(function ($request): bool {
|
||||
$text = (string) ($request['markdown']['text'] ?? '');
|
||||
|
||||
return str_contains($request->url(), 'oapi.dingtalk.com/robot/send')
|
||||
&& str_contains($request->url(), 'timestamp=')
|
||||
&& str_contains($request->url(), 'sign=')
|
||||
&& str_contains($text, '货款对账差异通知 · 晨丰')
|
||||
&& str_contains($text, 'B-1')
|
||||
&& str_contains($text, 'C-1')
|
||||
&& str_contains($text, 'D-1')
|
||||
&& ! str_contains($text, '`A-1`')
|
||||
&& ! str_contains($text, '鸣飞家具');
|
||||
});
|
||||
|
||||
$config = app(SupplierDingTalkNotifier::class)->configuration('晨丰');
|
||||
$this->assertNotSame('', $config['last_pushed_at']);
|
||||
$this->assertSame(3, $config['last_push_count']);
|
||||
}
|
||||
|
||||
public function test_push_single_difference_only_sends_the_selected_row(): void
|
||||
{
|
||||
$this->seedReconciliation();
|
||||
$this->saveRobot();
|
||||
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.push-differences'), [
|
||||
'supplier' => '晨丰',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
'scope' => 'row',
|
||||
'barcode' => 'B-1',
|
||||
'difference_type' => '金额不一致',
|
||||
'unit_price' => '50',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHas('success', '已推送 1 条差异到 晨丰 钉钉群(1 条消息)。');
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
$text = (string) ($request['markdown']['text'] ?? '');
|
||||
|
||||
return str_contains($text, 'B-1')
|
||||
&& ! str_contains($text, '`C-1`')
|
||||
&& ! str_contains($text, '`D-1`');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_push_without_robot_configuration_is_rejected_without_http_call(): void
|
||||
{
|
||||
$this->seedReconciliation();
|
||||
Http::fake();
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.push-differences'), [
|
||||
'supplier' => '晨丰',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
'scope' => 'all',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHasErrors('dingtalk_push');
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_large_difference_set_is_sent_in_multiple_robot_messages(): void
|
||||
{
|
||||
$this->saveRobot();
|
||||
Http::fake(['*' => Http::response(['errcode' => 0, 'errmsg' => 'ok'])]);
|
||||
$rows = [];
|
||||
for ($index = 1; $index <= 31; $index++) {
|
||||
$rows[] = [
|
||||
'barcode' => sprintf('BULK-%02d', $index),
|
||||
'supplierBarcode' => '',
|
||||
'unitPrice' => 10,
|
||||
'mineQty' => 2,
|
||||
'theirsQty' => 1,
|
||||
'mineAmount' => 20,
|
||||
'theirsAmount' => 10,
|
||||
'type' => '金额不一致',
|
||||
'settleAmount' => 10,
|
||||
'note' => '金额/数量不一致:按金额低的一方结算',
|
||||
];
|
||||
}
|
||||
|
||||
$result = app(SupplierDingTalkNotifier::class)->push('晨丰', $rows, [
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
]);
|
||||
|
||||
$this->assertSame(31, $result['rows']);
|
||||
$this->assertSame(2, $result['messages']);
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
private function seedReconciliation(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
foreach ([
|
||||
'mine:1' => ['skuCode' => 'A-1', 'qty' => '2', 'unitPrice' => '100', 'amount' => '200'],
|
||||
'mine:2' => ['skuCode' => 'B-1', 'qty' => '2', 'unitPrice' => '50', 'amount' => '100'],
|
||||
'mine:3' => ['skuCode' => 'C-1', 'qty' => '1', 'unitPrice' => '30', 'amount' => '30'],
|
||||
] as $key => $row) {
|
||||
$store->save('final:supplier:payment-reconciliation', $key, $row + [
|
||||
'summary_month' => '2026-09',
|
||||
'supplier' => '晨丰',
|
||||
'reconciler' => '张三',
|
||||
], $companyId, null);
|
||||
}
|
||||
|
||||
app(SupplierStatementStore::class)->save('晨丰', [
|
||||
$this->statementRow('A-1', '100', '2', '200'),
|
||||
$this->statementRow('B-1', '50', '1', '50'),
|
||||
$this->statementRow('D-1', '80', '1', '80'),
|
||||
], 'statement.csv', $companyId, null);
|
||||
}
|
||||
|
||||
private function saveRobot(): void
|
||||
{
|
||||
app(SupplierDingTalkNotifier::class)->save('晨丰', self::WEBHOOK, 'SEC-test-123', false);
|
||||
}
|
||||
|
||||
private function pageUrl(): string
|
||||
{
|
||||
return route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '晨丰',
|
||||
'month' => '2026-09',
|
||||
'reconciler' => '张三',
|
||||
]);
|
||||
}
|
||||
|
||||
private function statementRow(string $barcode, string $unitPrice, string $qty, string $amount): array
|
||||
{
|
||||
return [
|
||||
'expressName' => null,
|
||||
'expressNo' => null,
|
||||
'purchaseNo' => '编号-' . $barcode,
|
||||
'createdAt' => '2026-09-30',
|
||||
'supplier' => '晨丰',
|
||||
'barcode' => $barcode,
|
||||
'skuName' => '规格甲',
|
||||
'qty' => $qty,
|
||||
'unitPrice' => $unitPrice,
|
||||
'amount' => $amount,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user