From a9443cdfc15e10b7b124c79412143269b6939d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8F=E6=9C=A8?= <594326199@qq.com> Date: Sat, 17 Oct 2026 09:40:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=EF=BC=9A=E9=80=80=E8=B4=A7=E6=98=8E=E7=BB=86=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E3=80=8C=E9=80=80=E8=B4=A7=E6=89=A3=E5=87=8F=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E3=80=8D=E9=9D=A2=E6=9D=BF=EF=BC=88=E9=80=90=E8=A1=8C=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4/=E5=BC=82=E8=AE=AE=20+=20=E5=85=A8=E9=83=A8=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 厂家端数据页的退货区域按截图样式重做:顶部汇总提示(共 N 行、合计扣减)、已确认/异议/待确认统计、整宽退货表(发货量…原条码/类型/操作)、逐行确认或异议、底部「全部确认无异议」;异议行保持暂不扣减,一键确认不覆盖异议;金额为空按预估金额参与扣减;默认展示金额最高的 15 行。 --- .../Business/SupplierReconciliation.php | 54 ++++++++++ .../Services/SupplierReturnConfirmation.php | 75 ++++++++++++++ .../View/SupplierReconciliationPageData.php | 73 +++++++++++++- .../supplier-data.blade.php | 98 ++++++++++++++++--- routes/admin.php | 13 +++ .../Business/SupplierReconciliationTest.php | 53 +++++++++- 6 files changed, 347 insertions(+), 19 deletions(-) create mode 100644 modules/EcommerceFinance/Services/SupplierReturnConfirmation.php diff --git a/app/Http/Controllers/Business/SupplierReconciliation.php b/app/Http/Controllers/Business/SupplierReconciliation.php index ca401a94..c4754279 100644 --- a/app/Http/Controllers/Business/SupplierReconciliation.php +++ b/app/Http/Controllers/Business/SupplierReconciliation.php @@ -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)]; + } } diff --git a/modules/EcommerceFinance/Services/SupplierReturnConfirmation.php b/modules/EcommerceFinance/Services/SupplierReturnConfirmation.php new file mode 100644 index 00000000..9df9e73f --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierReturnConfirmation.php @@ -0,0 +1,75 @@ +,记录键沿用退货行的 _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 记录键 => 状态 */ + 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 $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; + } +} diff --git a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php index f1b87e62..08ad4d0d 100644 --- a/modules/EcommerceFinance/View/SupplierReconciliationPageData.php +++ b/modules/EcommerceFinance/View/SupplierReconciliationPageData.php @@ -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> $returnRows + * @return array + */ + 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 */ private function detail(array $mine, string $supplier, int $companyId): array { @@ -182,8 +247,8 @@ class SupplierReconciliationPageData return $compared; } - /** @return list> 该厂家的退货明细(我方数据,只读展示给厂家) */ - private function returnRows(string $supplier, int $companyId): array + /** @return list> 该厂家的退货明细(我方数据,展示给厂家确认扣减) */ + public function returnRows(string $supplier, int $companyId): array { $rows = $this->store->listRows( PayablesFinalActions::DATASET_PREFIX . 'supplier:return-reconciliation', diff --git a/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php index b3c92384..e65fbb4a 100644 --- a/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php +++ b/resources/views/business/pages/supplier-reconciliation/supplier-data.blade.php @@ -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; } @if (is_string(session('success'))) @@ -124,37 +144,89 @@ @endif -
-

{{ $selectedSupplier }} · 退货明细(我方数据,只读,共 {{ number_format($returnTotal) }} 行)

+
+
+ + + ① 退货扣减确认(共 {{ number_format($returnPanel['total_rows']) }} 行,合计扣减 ¥{{ number_format($returnPanel['total_amount'], 2) }})。 + 核对无误请点「全部确认无异议」;个别有问题的行,在该行单独点「异议」,复核通过前该行暂不扣减。 + +
+
+ 已确认 {{ $returnPanel['confirmed_count'] }} 行(¥{{ number_format($returnPanel['confirmed_amount'], 2) }}) + · 异议 {{ $returnPanel['disputed_count'] }} 行(暂不扣减 ¥{{ number_format($returnPanel['disputed_amount'], 2) }}) + · 待确认 {{ max($returnPanel['total_rows'] - $returnPanel['confirmed_count'] - $returnPanel['disputed_count'], 0) }} 行 +
- +
- - - + + + + + - @forelse ($returnRows as $row) + @forelse ($returnPanel['rows'] as $row) - - - - - + + + + + + + + + + @empty - + @endforelse
月份发货时间明细供应商条形码商品名称发货量单价金额仓库名称快递单号发货量单价金额预估单价预估金额供应商条码备注标题wms配货单号仓库名称快递单号发货时间原条码类型操作
{{ $row['summary_month'] ?? '—' }}{{ $row['send_time'] ?? '—' }}{{ $row['supplier'] ?? '—' }}{{ $row['barcode'] ?? '—' }}{{ $row['sku_name'] ?? '—' }} {{ $row['qty'] ?? '—' }} {{ $row['unit_price'] ?? '—' }} {{ $row['amount'] ?? '—' }}{{ $row['guess_unit_price'] ?? '—' }}{{ $row['guess_amount'] ?? '—' }}{{ $row['supplier_barcode'] ?? '—' }}{{ $row['remark'] ?? '—' }}{{ $row['title'] ?? '—' }}{{ $row['wms_pick_no'] ?? '—' }} {{ $row['warehouse'] ?? '—' }} {{ $row['express_no'] ?? '—' }}{{ $row['send_time'] ?? '—' }}{{ $row['barcode'] ?? '—' }}退货 + + @if ($row['status'] === 'confirmed') + 已确认 + @elseif ($row['status'] === 'disputed') + 异议中 + @else +
+ @csrf + + + +
+
+ @csrf + + + +
+ @endif +
+
该厂家暂无退货数据。
该厂家暂无退货数据。
+

+ 列名取自《退货明细》原始表头(标记多标签、创建人、对账人、退货单备注等内部字段不展示),左右滑动查看全部。 + 退货共 {{ number_format($returnPanel['total_rows']) }} 行,上表展示金额最高的 {{ count($returnPanel['rows']) }} 行。 + 金额列为空的行以预估金额参与扣减,与采购方记账口径一致。 +

+
+
+ @csrf + + +
+ 确认后退货扣减生效,明细列入结算确认函。 +
@endif
diff --git a/routes/admin.php b/routes/admin.php index 79681b7f..44c1f4bf 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Business/SupplierReconciliationTest.php b/tests/Feature/Business/SupplierReconciliationTest.php index bd0580c9..d3556204 100644 --- a/tests/Feature/Business/SupplierReconciliationTest.php +++ b/tests/Feature/Business/SupplierReconciliationTest.php @@ -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 -- 2.47.2