Compare commits

...

1 Commits

Author SHA1 Message Date
a9443cdfc1 供应商对账:退货明细改为「退货扣减确认」面板(逐行确认/异议 + 全部确认)
厂家端数据页的退货区域按截图样式重做:顶部汇总提示(共 N 行、合计扣减)、已确认/异议/待确认统计、整宽退货表(发货量…原条码/类型/操作)、逐行确认或异议、底部「全部确认无异议」;异议行保持暂不扣减,一键确认不覆盖异议;金额为空按预估金额参与扣减;默认展示金额最高的 15 行。
2026-10-17 09:40:27 +08:00
6 changed files with 347 additions and 19 deletions

View File

@ -6,8 +6,10 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
@ -18,6 +20,8 @@ class SupplierReconciliation extends Controller
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
) {
$this->middleware('auth');
$this->middleware('company.identify');
@ -58,4 +62,54 @@ class SupplierReconciliation extends Controller
? response()->json(['message' => $message, 'rows' => $rows])
: back()->with('success', $message);
}
public function confirmReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
}
public function disputeReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
}
public function confirmAllReturns(Request $request): RedirectResponse
{
[$supplier, $companyId] = $this->returnTarget($request);
$keys = array_values(array_map(
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
$this->pageData->returnRows($supplier, $companyId),
));
$confirmed = $this->returnConfirmations->confirmAll(
$supplier,
$keys,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
}
private function markReturn(Request $request, string $status, string $message): RedirectResponse
{
$request->validate(['key' => ['required', 'string', 'max:191']]);
[$supplier, $companyId] = $this->returnTarget($request);
$this->returnConfirmations->mark(
$supplier,
(string) $request->input('key'),
$status,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', $message);
}
/** @return array{0:string,1:int} */
private function returnTarget(Request $request): array
{
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
return [trim((string) $request->input('supplier')), (int) (company_id() ?: 1)];
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace Modules\EcommerceFinance\Services;
/**
* 退货扣减确认:厂家逐行「确认 / 异议」,或一键全部确认。
* 状态存在按表数据表里(数据集 return-confirmation:<厂家>,记录键沿用退货行的 _action_key)。
*/
class SupplierReturnConfirmation
{
public const PREFIX = 'return-confirmation:';
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_DISPUTED = 'disputed';
public function __construct(
private readonly SupplierPayableOverrideStore $store,
) {
}
/** @return array<string,string> 记录键 => 状态 */
public function statuses(string $supplier, int $companyId): array
{
$statuses = [];
foreach ($this->store->listRows(self::PREFIX . $supplier, $companyId) as $row) {
$key = (string) ($row['_action_key'] ?? '');
$status = (string) ($row['status'] ?? '');
if ($key !== '' && in_array($status, [self::STATUS_CONFIRMED, self::STATUS_DISPUTED], true)) {
$statuses[$key] = $status;
}
}
return $statuses;
}
public function mark(string $supplier, string $recordKey, string $status, int $companyId, ?int $userId): void
{
if (! in_array($status, [self::STATUS_CONFIRMED, self::STATUS_DISPUTED], true)) {
return;
}
$this->store->save(self::PREFIX . $supplier, $recordKey, [
'status' => $status,
'marked_at' => now()->toDateTimeString(),
], $companyId, $userId, 'manual');
}
/** @param list<string> $recordKeys 一键确认全部(已异议的行保持异议,不覆盖) */
public function confirmAll(string $supplier, array $recordKeys, int $companyId, ?int $userId): int
{
$statuses = $this->statuses($supplier, $companyId);
$confirmed = 0;
$this->store->transaction(function () use ($supplier, $recordKeys, $statuses, $companyId, $userId, &$confirmed): void {
foreach ($recordKeys as $key) {
if ($key === '') {
continue;
}
$status = $statuses[$key] ?? '';
if ($status === self::STATUS_DISPUTED) {
continue;
}
if ($status !== self::STATUS_CONFIRMED) {
$this->mark($supplier, $key, self::STATUS_CONFIRMED, $companyId, $userId);
}
$confirmed++;
}
});
return $confirmed;
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\View;
use App\Http\Controllers\Business\PayablesFinalActions;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
@ -20,6 +21,7 @@ class SupplierReconciliationPageData
private readonly SupplierPayableOverrideStore $store,
private readonly SupplierStatementStore $statements,
private readonly SupplierStatementMatcher $matcher,
private readonly SupplierReturnConfirmation $returnConfirmations,
) {
}
@ -149,6 +151,7 @@ class SupplierReconciliationPageData
$page = max((int) request()->query('rows_page', 1), 1);
$statementRows = $selected !== '' ? $this->statements->rows($selected, $companyId) : [];
$returnRows = $selected !== '' ? $this->returnRows($selected, $companyId) : [];
$returnPanel = $selected !== '' ? $this->returnConfirmationPanel($selected, $returnRows, $companyId) : null;
return [
'view' => 'business.pages.supplier-reconciliation.supplier-data',
@ -157,15 +160,77 @@ class SupplierReconciliationPageData
'uploads' => $selected !== '' ? $this->statements->uploads($selected, $companyId) : [],
'statementRows' => array_slice($statementRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
'statementTotal' => count($statementRows),
'returnRows' => array_slice($returnRows, ($page - 1) * self::PER_PAGE, self::PER_PAGE),
'returnTotal' => count($returnRows),
'returnPanel' => $returnPanel,
'rowsPage' => $page,
'perPage' => self::PER_PAGE,
'statementTemplateUrl' => route('business.supplier-reconciliation.template'),
'uploadUrl' => route('business.supplier-reconciliation.statement.upload'),
'returnConfirmUrl' => route('business.supplier-reconciliation.return.confirm'),
'returnDisputeUrl' => route('business.supplier-reconciliation.return.dispute'),
'returnConfirmAllUrl' => route('business.supplier-reconciliation.return.confirm-all'),
];
}
/**
* 退货扣减确认面板:共 N 行、合计扣减、异议暂不扣减;表格展示金额最高的 15 行。
*
* @param list<array<string,mixed>> $returnRows
* @return array<string,mixed>
*/
private function returnConfirmationPanel(string $supplier, array $returnRows, int $companyId): array
{
$statuses = $this->returnConfirmations->statuses($supplier, $companyId);
$total = 0.0;
$confirmedAmount = 0.0;
$disputedAmount = 0.0;
$disputedCount = 0;
$confirmedCount = 0;
$rows = [];
foreach ($returnRows as $row) {
$key = (string) ($row['_action_key'] ?? '');
$deduction = $this->returnDeduction($row);
$status = $statuses[$key] ?? '';
$total += $deduction;
if ($status === SupplierReturnConfirmation::STATUS_CONFIRMED) {
$confirmedAmount += $deduction;
$confirmedCount++;
} elseif ($status === SupplierReturnConfirmation::STATUS_DISPUTED) {
$disputedAmount += $deduction;
$disputedCount++;
}
$rows[] = ['_key' => $key, 'status' => $status, 'deduction' => $deduction] + $row;
}
usort($rows, static fn (array $a, array $b): int => $b['deduction'] <=> $a['deduction']);
return [
'supplier' => $supplier,
'rows' => array_slice($rows, 0, 15),
'total_rows' => count($returnRows),
'total_amount' => round($total, 2),
'confirmed_count' => $confirmedCount,
'confirmed_amount' => round($confirmedAmount, 2),
'disputed_count' => $disputedCount,
'disputed_amount' => round($disputedAmount, 2),
];
}
/** 扣减金额:金额为空时用预估金额(与采购方记账口径一致)。 */
private function returnDeduction(array $row): float
{
$amount = trim((string) ($row['amount'] ?? ''));
if ($amount !== '' && is_numeric(str_replace(',', '', $amount))) {
return (float) str_replace(',', '', $amount);
}
$guess = trim((string) ($row['guess_amount'] ?? ''));
return is_numeric(str_replace(',', '', $guess)) ? (float) str_replace(',', '', $guess) : 0.0;
}
/** @return array<string,mixed> */
private function detail(array $mine, string $supplier, int $companyId): array
{
@ -182,8 +247,8 @@ class SupplierReconciliationPageData
return $compared;
}
/** @return list<array<string,mixed>> 该厂家的退货明细(我方数据,只读展示给厂家) */
private function returnRows(string $supplier, int $companyId): array
/** @return list<array<string,mixed>> 该厂家的退货明细(我方数据,展示给厂家确认扣减) */
public function returnRows(string $supplier, int $companyId): array
{
$rows = $this->store->listRows(
PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation',

View File

@ -19,6 +19,26 @@
.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; }
.supplier-reconciliation-page .sr-upload { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.supplier-reconciliation-page .sr-pagination { display: flex; align-items: center; gap: 12px; color: var(--vip-content-muted, #595959); font-size: .8125rem; }
.supplier-reconciliation-page .sr-button.is-small { height: 24px; padding: 0 10px; font-size: .75rem; }
.supplier-reconciliation-page .sr-return-banner {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 12px; border: 1px solid #E4DCCB; border-radius: 8px;
background: var(--vip-content-accent-soft, #F7F0DE);
color: #5B5343; font-size: .8125rem; line-height: 1.7;
}
.supplier-reconciliation-page .sr-return-banner .material-icons-outlined { color: var(--vip-content-accent-dark, #856632); font-size: 18px; }
.supplier-reconciliation-page .sr-return-status { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--vip-content-muted, #595959); font-size: .75rem; }
.supplier-reconciliation-page .sr-return-table { min-width: 1400px; }
.supplier-reconciliation-page .sr-type-tag {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px;
border-radius: 4px; background: #EFEAE0; color: #595959; font-size: .75rem; white-space: nowrap;
}
.supplier-reconciliation-page .sr-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: 4px; font-size: .75rem; white-space: nowrap; }
.supplier-reconciliation-page .sr-tag.is-match { background: #ECF4EC; color: #3F7A46; }
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
.supplier-reconciliation-page .sr-inline-form { display: inline-flex; }
.supplier-reconciliation-page .sr-return-actions { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.supplier-reconciliation-page .sr-return-panel > .sr-return-actions { gap: 12px; }
</style>
@if (is_string(session('success')))
@ -124,37 +144,89 @@
@endif
</div>
<div class="sr-card">
<h2>{{ $selectedSupplier }} · 退货明细(我方数据,只读,共 {{ number_format($returnTotal) }} 行)</h2>
<div class="sr-card sr-return-panel">
<div class="sr-return-banner" role="note">
<span class="material-icons-outlined" aria-hidden="true">info</span>
<span>
<strong>① 退货扣减确认</strong>(共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。
核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。
</span>
</div>
<div class="sr-return-status">
<span>已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }})</span>
<span>· 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }})</span>
<span>· 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行</span>
</div>
<div class="sr-scroll">
<table>
<table class="sr-return-table">
<thead>
<tr>
<th scope="col">月份</th><th scope="col">发货时间</th><th scope="col">明细供应商</th><th scope="col">条形码</th>
<th scope="col">商品名称</th><th scope="col">发货量</th><th scope="col">单价</th><th scope="col">金额</th>
<th scope="col">仓库名称</th><th scope="col">快递单号</th>
<th scope="col">发货量</th><th scope="col">单价</th><th scope="col">金额</th>
<th scope="col">预估单价</th><th scope="col">预估金额</th><th scope="col">供应商条码</th>
<th scope="col">备注</th><th scope="col">标题</th><th scope="col">wms配货单号</th>
<th scope="col">仓库名称</th><th scope="col">快递单号</th><th scope="col">发货时间</th>
<th scope="col">原条码</th><th scope="col">类型</th><th scope="col">操作</th>
</tr>
</thead>
<tbody>
@forelse ($returnRows as $row)
@forelse ($returnPanel['rows'] as $row)
<tr>
<td>{{ $row['summary_month'] ?? '—' }}</td>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['supplier'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
<td>{{ $row['sku_name'] ?? '—' }}</td>
<td class="is-num">{{ $row['qty'] ?? '—' }}</td>
<td class="is-num">{{ $row['unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['amount'] ?? '—' }}</td>
<td class="is-num">{{ $row['guess_unit_price'] ?? '—' }}</td>
<td class="is-num">{{ $row['guess_amount'] ?? '—' }}</td>
<td>{{ $row['supplier_barcode'] ?? '—' }}</td>
<td>{{ $row['remark'] ?? '—' }}</td>
<td>{{ $row['title'] ?? '—' }}</td>
<td>{{ $row['wms_pick_no'] ?? '—' }}</td>
<td>{{ $row['warehouse'] ?? '—' }}</td>
<td>{{ $row['express_no'] ?? '—' }}</td>
<td>{{ $row['send_time'] ?? '—' }}</td>
<td>{{ $row['barcode'] ?? '—' }}</td>
<td><span class="sr-type-tag">退货</span></td>
<td>
<span class="sr-return-actions">
@if ($row['status'] === 'confirmed')
<span class="sr-tag is-match">已确认</span>
@elseif ($row['status'] === 'disputed')
<span class="sr-tag is-mismatch">异议中</span>
@else
<form method="POST" action="{{ $returnConfirmUrl }}" class="sr-inline-form">
@csrf
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<input type="hidden" name="key" value="{{ $row['_key'] }}">
<button type="submit" class="sr-button is-small is-primary">确认</button>
</form>
<form method="POST" action="{{ $returnDisputeUrl }}" class="sr-inline-form">
@csrf
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<input type="hidden" name="key" value="{{ $row['_key'] }}">
<button type="submit" class="sr-button is-small">异议</button>
</form>
@endif
</span>
</td>
</tr>
@empty
<tr><td colspan="10"><div class="sr-empty">该厂家暂无退货数据。</div></td></tr>
<tr><td colspan="15"><div class="sr-empty">该厂家暂无退货数据。</div></td></tr>
@endforelse
</tbody>
</table>
</div>
<p class="sr-hint">
列名取自《退货明细》原始表头(标记多标签、创建人、对账人、退货单备注等内部字段不展示),左右滑动查看全部。
退货共 {{ number_format($returnPanel['total_rows']) }} 行,上表展示金额最高的 {{ count($returnPanel['rows']) }} 行。
金额列为空的行以预估金额参与扣减,与采购方记账口径一致。
</p>
<div class="sr-return-actions">
<form method="POST" action="{{ $returnConfirmAllUrl }}" class="sr-inline-form">
@csrf
<input type="hidden" name="supplier" value="{{ $selectedSupplier }}">
<button type="submit" class="sr-button is-primary">全部确认无异议</button>
</form>
<span class="sr-hint">确认后退货扣减生效,明细列入结算确认函。</span>
</div>
</div>
@endif
</div>

View File

@ -395,6 +395,19 @@ Route::post('workspace/supplier-reconciliation/statement', [SupplierReconciliati
->middleware(['business.page:supplier-reconciliation,supplier-data', 'import'])
->name('business.supplier-reconciliation.statement.upload');
// 退货扣减确认:逐行确认/异议 + 一键全部确认
Route::post('workspace/supplier-reconciliation/returns/confirm', [SupplierReconciliationActions::class, 'confirmReturn'])
->middleware('business.page:supplier-reconciliation,supplier-data')
->name('business.supplier-reconciliation.return.confirm');
Route::post('workspace/supplier-reconciliation/returns/dispute', [SupplierReconciliationActions::class, 'disputeReturn'])
->middleware('business.page:supplier-reconciliation,supplier-data')
->name('business.supplier-reconciliation.return.dispute');
Route::post('workspace/supplier-reconciliation/returns/confirm-all', [SupplierReconciliationActions::class, 'confirmAllReturns'])
->middleware('business.page:supplier-reconciliation,supplier-data')
->name('business.supplier-reconciliation.return.confirm-all');
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');

View File

@ -127,8 +127,57 @@ class SupplierReconciliationTest extends FeatureTestCase
->assertOk()
->assertSeeText('厂家对账单 · 上传与实时数据')
->assertSeeText('上传记录')
->assertSeeText('退货明细')
->assertSeeText('款式十一');
->assertSeeText('退货扣减确认')
->assertSeeText('合计扣减')
->assertSeeText('全部确认无异议')
->assertSeeText('待确认')
->assertSeeText('ZSJ-1');
}
public function test_return_rows_can_be_confirmed_disputed_and_confirmed_all(): void
{
$store = app(SupplierPayableOverrideStore::class);
$companyId = (int) $this->company->id;
foreach ([
'return:1' => ['barcode' => 'CJ019316N1097V01', 'qty' => '7', 'unit_price' => '196.50', 'amount' => '1375.50'],
'return:2' => ['barcode' => 'DNZ016720N1097V01', 'qty' => '7', 'unit_price' => '182.50', 'amount' => '1277.50'],
] as $key => $row) {
$store->save('final:supplier:return-reconciliation', $key, $row + [
'summary_month' => '2026-09', 'send_time' => '2026-09-13 09:10:24', 'supplier' => '晨丰',
'warehouse' => '客退临时上架仓', 'title' => '', 'remark' => '',
], $companyId, null);
}
$this->seedStatement('晨丰', [$this->statementRow('ZSJ-1', '128.00', '2', '256.00')]);
$page = fn () => $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
'supplier' => '晨丰',
]))->assertOk();
$page()->assertSeeText('合计扣减 ¥2,653.00');
$this->loginAs()
->post(route('business.supplier-reconciliation.return.confirm'), ['supplier' => '晨丰', 'key' => 'return:1'])
->assertStatus(302)
->assertSessionHas('success', '已确认该行退货扣减。');
$page()->assertSeeText('已确认')->assertSeeText('¥1,375.50');
$this->loginAs()
->post(route('business.supplier-reconciliation.return.dispute'), ['supplier' => '晨丰', 'key' => 'return:2'])
->assertStatus(302)
->assertSessionHas('success', '已提交异议,复核通过前该行暂不扣减。');
$this->loginAs()
->post(route('business.supplier-reconciliation.return.confirm-all'), ['supplier' => '晨丰'])
->assertStatus(302)
->assertSessionHas('success', '已全部确认:1 行退货扣减生效(异议行保持暂不扣减)。');
$statuses = app(\Modules\EcommerceFinance\Services\SupplierReturnConfirmation::class)
->statuses('晨丰', $companyId);
$this->assertSame('confirmed', $statuses['return:1']);
$this->assertSame('disputed', $statuses['return:2'], '一键确认不应覆盖已提交的异议');
}
public function test_statement_template_downloads_the_bundled_file(): void