fix: 供应商对账识别未定价行 (#1067)
Co-authored-by: 苏木 <594326199@qq.com> Co-committed-by: 苏木 <594326199@qq.com>
This commit is contained in:
parent
c32dbe57b2
commit
4e76f76b71
@ -130,7 +130,7 @@ class SupplierReconciliation extends Controller
|
||||
$request->validate([
|
||||
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
|
||||
'reconciler' => ['nullable', 'string', 'max:191'],
|
||||
'comparison_filter' => ['nullable', 'in:,supplier_over,supplier_under,mismatch,match'],
|
||||
'comparison_filter' => ['nullable', 'in:,unpriced,supplier_over,supplier_under,mismatch,match'],
|
||||
]);
|
||||
[$supplier, $companyId] = $this->returnTarget($request);
|
||||
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
|
||||
@ -232,7 +232,7 @@ class SupplierReconciliation extends Controller
|
||||
'date' => ['nullable', 'string', 'max:32'],
|
||||
'barcode' => ['nullable', 'string', 'max:191'],
|
||||
'difference_type' => ['nullable', 'string', 'max:32'],
|
||||
'comparison_filter' => ['nullable', 'in:,supplier_over,supplier_under,mismatch,match'],
|
||||
'comparison_filter' => ['nullable', 'in:,unpriced,supplier_over,supplier_under,mismatch,match'],
|
||||
'selected_rows' => ['nullable', 'array', 'max:600'],
|
||||
'selected_rows.*' => ['string', 'max:2048'],
|
||||
]);
|
||||
@ -245,7 +245,10 @@ class SupplierReconciliation extends Controller
|
||||
}
|
||||
$differences = array_values(array_filter(
|
||||
$detail['rows'],
|
||||
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
|
||||
static fn (array $row): bool => ! in_array($row['type'] ?? '', [
|
||||
SupplierStatementMatcher::TYPE_MATCH,
|
||||
SupplierStatementMatcher::TYPE_UNPRICED,
|
||||
], true),
|
||||
));
|
||||
|
||||
if ($request->input('scope') === 'row') {
|
||||
|
||||
@ -10,7 +10,7 @@ class SupplierComparisonCsvExporter
|
||||
{
|
||||
private const HEADERS = [
|
||||
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
|
||||
'我方数量', '我方单价', '我方金额',
|
||||
'我方数量', '我方单价', '我方金额', '我方预估单价', '我方预估金额',
|
||||
'厂家数量', '厂家单价', '厂家金额',
|
||||
'数量差异', '金额差异', '结算口径', '结算金额',
|
||||
];
|
||||
@ -48,6 +48,8 @@ class SupplierComparisonCsvExporter
|
||||
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
|
||||
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
|
||||
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
|
||||
'未定价', (int) ($summary['unpriced'] ?? 0),
|
||||
'未定价预估金额', $this->money($summary['unpricedGuessAmount'] ?? 0),
|
||||
]);
|
||||
$this->write($csv, [
|
||||
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
|
||||
@ -69,11 +71,15 @@ class SupplierComparisonCsvExporter
|
||||
$this->number($row['mineQty'] ?? 0),
|
||||
$this->money($row['mineUnitPrice'] ?? 0),
|
||||
$this->money($row['mineAmount'] ?? 0),
|
||||
$this->money($row['mineGuessUnitPrice'] ?? 0),
|
||||
$this->money($row['mineGuessAmount'] ?? 0),
|
||||
$this->number($row['theirsQty'] ?? 0),
|
||||
$this->money($row['theirsUnitPrice'] ?? 0),
|
||||
$this->money($row['theirsAmount'] ?? 0),
|
||||
$this->quantityDifference($row['qtyDifference'] ?? 0),
|
||||
$this->amountDifference($row['amountDifference'] ?? 0),
|
||||
($row['type'] ?? '') === SupplierStatementMatcher::TYPE_UNPRICED
|
||||
? '未定价'
|
||||
: $this->amountDifference($row['amountDifference'] ?? 0),
|
||||
$row['note'] ?? '',
|
||||
$this->money($row['settleAmount'] ?? 0),
|
||||
]);
|
||||
|
||||
@ -25,6 +25,8 @@ class SupplierStatementMatcher
|
||||
|
||||
public const TYPE_SUPPLIER_MISSING = '厂家少';
|
||||
|
||||
public const TYPE_UNPRICED = '未定价';
|
||||
|
||||
/** 厂家条码后缀:供应商编号 + 版本,如 N1097V01、N1269V01。 */
|
||||
private const SUPPLIER_SUFFIX = '/[A-Z]{1,3}\d{3,6}V\d{1,3}$/i';
|
||||
|
||||
@ -170,6 +172,8 @@ class SupplierStatementMatcher
|
||||
$date = $this->normalizeDate($row[$mineSide ? 'date' : 'createdAt'] ?? '');
|
||||
$expressNo = trim((string) $this->firstValue($row, ['expressNo', 'express_no']));
|
||||
$unitPrice = (float) ($row['unitPrice'] ?? 0);
|
||||
$guessUnitPrice = (float) ($row['guessUnitPrice'] ?? $row['guess_unit_price'] ?? 0);
|
||||
$guessAmount = (float) ($row['guessAmount'] ?? $row['guess_amount'] ?? 0);
|
||||
|
||||
$grouped[$key] ??= [
|
||||
'purchaseNo' => '',
|
||||
@ -185,6 +189,10 @@ class SupplierStatementMatcher
|
||||
'expressNos' => [],
|
||||
'unitPrice' => 0.0,
|
||||
'unitPrices' => [],
|
||||
'guessUnitPrice' => 0.0,
|
||||
'guessUnitPrices' => [],
|
||||
'guessAmount' => 0.0,
|
||||
'unpricedQty' => 0.0,
|
||||
'qty' => 0.0,
|
||||
'amount' => 0.0,
|
||||
'sourceRows' => 0,
|
||||
@ -202,6 +210,9 @@ class SupplierStatementMatcher
|
||||
if ($unitPrice > 0) {
|
||||
$grouped[$key]['unitPrices'][number_format($unitPrice, 4, '.', '')] = true;
|
||||
}
|
||||
if ($guessUnitPrice > 0) {
|
||||
$grouped[$key]['guessUnitPrices'][number_format($guessUnitPrice, 4, '.', '')] = true;
|
||||
}
|
||||
if ($grouped[$key]['barcode'] === '' && $barcode !== '') {
|
||||
$grouped[$key]['barcode'] = $barcode;
|
||||
}
|
||||
@ -211,6 +222,10 @@ class SupplierStatementMatcher
|
||||
|
||||
$grouped[$key]['qty'] += (float) ($row['qty'] ?? 0);
|
||||
$grouped[$key]['amount'] += (float) ($row['amount'] ?? 0);
|
||||
$grouped[$key]['guessAmount'] += $guessAmount;
|
||||
if ($mineSide && $unitPrice <= 0 && (float) ($row['amount'] ?? 0) <= 0) {
|
||||
$grouped[$key]['unpricedQty'] += (float) ($row['qty'] ?? 0);
|
||||
}
|
||||
$grouped[$key]['sourceRows']++;
|
||||
}
|
||||
|
||||
@ -294,6 +309,9 @@ class SupplierStatementMatcher
|
||||
'supplierExtra' => 0,
|
||||
'mismatch' => 0,
|
||||
'supplierMissing' => 0,
|
||||
'unpriced' => 0,
|
||||
'unpricedQty' => 0.0,
|
||||
'unpricedGuessAmount' => 0.0,
|
||||
'mineAmount' => 0.0,
|
||||
'theirsAmount' => 0.0,
|
||||
'supplierOverQty' => 0.0,
|
||||
@ -313,6 +331,8 @@ class SupplierStatementMatcher
|
||||
$summary['theirsAmount'] = round((float) $summary['theirsAmount'], 2);
|
||||
$summary['supplierOverQty'] = round((float) $summary['supplierOverQty'], 4);
|
||||
$summary['supplierOverAmount'] = round((float) $summary['supplierOverAmount'], 2);
|
||||
$summary['unpricedQty'] = round((float) $summary['unpricedQty'], 4);
|
||||
$summary['unpricedGuessAmount'] = round((float) $summary['unpricedGuessAmount'], 2);
|
||||
$summary['settleQty'] = round((float) $summary['settleQty'], 4);
|
||||
$summary['settleAmount'] = round((float) $summary['settleAmount'], 2);
|
||||
$summary['differenceAmount'] = round(
|
||||
@ -370,11 +390,19 @@ class SupplierStatementMatcher
|
||||
$unitPrice = $mineUnitPrice > 0 ? $mineUnitPrice : $theirsUnitPrice;
|
||||
$mineQty = (float) ($mineRow['qty'] ?? 0);
|
||||
$mineAmount = (float) ($mineRow['amount'] ?? 0);
|
||||
$mineGuessUnitPrice = (float) ($mineRow['guessUnitPrice'] ?? 0);
|
||||
$mineGuessAmount = (float) ($mineRow['guessAmount'] ?? 0);
|
||||
$mineUnpricedQty = (float) ($mineRow['unpricedQty'] ?? 0);
|
||||
$theirsQty = (float) ($theirsRow['qty'] ?? 0);
|
||||
$theirsAmount = (float) ($theirsRow['amount'] ?? 0);
|
||||
$qtyDifference = $theirsQty - $mineQty;
|
||||
$amountDifference = $theirsAmount - $mineAmount;
|
||||
$quantityDifference = $this->describeDifference($qtyDifference, $amountDifference);
|
||||
$mineIsUnpriced = $mineRow !== null
|
||||
&& $mineUnpricedQty >= $mineQty - 0.005
|
||||
&& $mineUnitPrice <= 0
|
||||
&& $mineAmount <= 0;
|
||||
$theirsIsPriced = $theirsUnitPrice > 0 || $theirsAmount > 0;
|
||||
$dateDifferenceDays = $this->dateDifferenceDays($date, $theirsDate);
|
||||
$matchNote = $matchedLayer === null
|
||||
? ''
|
||||
@ -401,6 +429,20 @@ class SupplierStatementMatcher
|
||||
. ',金额 ¥' . number_format($mineAmount, 2)
|
||||
. ';四层匹配后仍未找到厂家对应记录,按厂家数据结算(不付款)';
|
||||
$summary['supplierMissing']++;
|
||||
} elseif ($mineIsUnpriced && $theirsIsPriced && abs($qtyDifference) < 0.005) {
|
||||
$type = self::TYPE_UNPRICED;
|
||||
$settleQty = $mineQty;
|
||||
$settle = 0.0;
|
||||
$note = $matchNote
|
||||
. '采购单号、商品编码、数量一致,我方尚未定价'
|
||||
. ($mineGuessUnitPrice > 0
|
||||
? '(预估单价 ¥' . number_format($mineGuessUnitPrice, 2)
|
||||
. ',预估金额 ¥' . number_format($mineGuessAmount, 2) . ')'
|
||||
: '')
|
||||
. ';不计厂家多算,待定价后结算';
|
||||
$summary['unpriced']++;
|
||||
$summary['unpricedQty'] += $mineQty;
|
||||
$summary['unpricedGuessAmount'] += $mineGuessAmount;
|
||||
} elseif (abs($mineAmount - $theirsAmount) < 0.005 && abs($mineQty - $theirsQty) < 0.005) {
|
||||
$type = self::TYPE_MATCH;
|
||||
$settleQty = $mineQty;
|
||||
@ -422,8 +464,10 @@ class SupplierStatementMatcher
|
||||
|
||||
$summary['mineAmount'] += $mineAmount;
|
||||
$summary['theirsAmount'] += $theirsAmount;
|
||||
$summary['supplierOverQty'] += max($qtyDifference, 0);
|
||||
$summary['supplierOverAmount'] += max($amountDifference, 0);
|
||||
if ($type !== self::TYPE_UNPRICED) {
|
||||
$summary['supplierOverQty'] += max($qtyDifference, 0);
|
||||
$summary['supplierOverAmount'] += max($amountDifference, 0);
|
||||
}
|
||||
$summary['settleQty'] += $settleQty;
|
||||
$summary['settleAmount'] += $settle;
|
||||
|
||||
@ -443,6 +487,8 @@ class SupplierStatementMatcher
|
||||
'expressNo' => $expressNo,
|
||||
'unitPrice' => $unitPrice,
|
||||
'mineUnitPrice' => $mineUnitPrice,
|
||||
'mineGuessUnitPrice' => $mineGuessUnitPrice,
|
||||
'mineGuessAmount' => $mineGuessAmount,
|
||||
'theirsUnitPrice' => $theirsUnitPrice,
|
||||
'mineQty' => $mineQty,
|
||||
'mineAmount' => $mineAmount,
|
||||
@ -451,7 +497,9 @@ class SupplierStatementMatcher
|
||||
'qtyDifference' => round($qtyDifference, 4),
|
||||
'amountDifference' => round($amountDifference, 2),
|
||||
'supplierOverQty' => round(max($qtyDifference, 0), 4),
|
||||
'supplierOverAmount' => round(max($amountDifference, 0), 2),
|
||||
'supplierOverAmount' => $type === self::TYPE_UNPRICED
|
||||
? 0.0
|
||||
: round(max($amountDifference, 0), 2),
|
||||
'type' => $type,
|
||||
'settleQty' => $settleQty,
|
||||
'settleAmount' => $settle,
|
||||
@ -527,6 +575,13 @@ class SupplierStatementMatcher
|
||||
$group['unitPrice'] = round((float) $group['amount'] / (float) $group['qty'], 4);
|
||||
}
|
||||
|
||||
$guessPrices = array_keys((array) ($group['guessUnitPrices'] ?? []));
|
||||
if (count($guessPrices) === 1) {
|
||||
$group['guessUnitPrice'] = (float) $guessPrices[0];
|
||||
} elseif ((float) $group['qty'] != 0.0) {
|
||||
$group['guessUnitPrice'] = round((float) $group['guessAmount'] / (float) $group['qty'], 4);
|
||||
}
|
||||
|
||||
return $group;
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ use Modules\EcommerceFinance\Services\SupplierStatementStore;
|
||||
|
||||
/**
|
||||
* 「供应商对账」页面数据:
|
||||
* - 财务端数据:按供应商汇总 + 行级差异(采购单号 + 日期 + 条码匹配,四类结果带结算口径)
|
||||
* - 财务端数据:按供应商与商品编码汇总,区分一致、未定价、厂家多、厂家少和数量/金额差异
|
||||
* - 厂家端数据:厂家上传的对账单(实时)与只读的退货明细
|
||||
*/
|
||||
class SupplierReconciliationPageData
|
||||
@ -21,6 +21,7 @@ class SupplierReconciliationPageData
|
||||
/** 财务端比对结果筛选。 */
|
||||
private const COMPARISON_FILTERS = [
|
||||
'',
|
||||
'unpriced',
|
||||
'supplier_over',
|
||||
'supplier_under',
|
||||
'mismatch',
|
||||
@ -599,6 +600,9 @@ class SupplierReconciliationPageData
|
||||
'theirsAmount' => 0.0,
|
||||
'supplierOverQty' => 0.0,
|
||||
'supplierOverAmount' => 0.0,
|
||||
'unpriced' => 0,
|
||||
'unpricedQty' => 0.0,
|
||||
'unpricedGuessAmount' => 0.0,
|
||||
'settleQty' => 0.0,
|
||||
'settleAmount' => 0.0,
|
||||
'differenceAmount' => 0.0,
|
||||
@ -650,6 +654,7 @@ class SupplierReconciliationPageData
|
||||
$allRows = array_values((array) ($detail['rows'] ?? []));
|
||||
$counts = [
|
||||
'all' => count($allRows),
|
||||
'unpriced' => 0,
|
||||
'supplier_over' => 0,
|
||||
'supplier_under' => 0,
|
||||
'mismatch' => 0,
|
||||
@ -658,10 +663,13 @@ class SupplierReconciliationPageData
|
||||
$differenceCount = 0;
|
||||
|
||||
foreach ($allRows as $row) {
|
||||
if ((string) ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH) {
|
||||
if (! in_array((string) ($row['type'] ?? ''), [
|
||||
SupplierStatementMatcher::TYPE_MATCH,
|
||||
SupplierStatementMatcher::TYPE_UNPRICED,
|
||||
], true)) {
|
||||
$differenceCount++;
|
||||
}
|
||||
foreach (['supplier_over', 'supplier_under', 'mismatch', 'match'] as $filter) {
|
||||
foreach (['unpriced', 'supplier_over', 'supplier_under', 'mismatch', 'match'] as $filter) {
|
||||
if ($this->comparisonRowMatchesFilter($row, $filter)) {
|
||||
$counts[$filter]++;
|
||||
}
|
||||
@ -674,7 +682,10 @@ class SupplierReconciliationPageData
|
||||
));
|
||||
$filteredDifferenceCount = count(array_filter(
|
||||
$rows,
|
||||
static fn (array $row): bool => (string) ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
|
||||
static fn (array $row): bool => ! in_array((string) ($row['type'] ?? ''), [
|
||||
SupplierStatementMatcher::TYPE_MATCH,
|
||||
SupplierStatementMatcher::TYPE_UNPRICED,
|
||||
], true),
|
||||
));
|
||||
|
||||
$detail['all_total_rows'] = count($allRows);
|
||||
@ -695,10 +706,17 @@ class SupplierReconciliationPageData
|
||||
$amountDifference = (float) ($row['amountDifference'] ?? 0);
|
||||
|
||||
return match ($comparisonFilter) {
|
||||
'unpriced' => $type === SupplierStatementMatcher::TYPE_UNPRICED,
|
||||
'supplier_over' => $type === SupplierStatementMatcher::TYPE_SUPPLIER_EXTRA
|
||||
|| ($type !== SupplierStatementMatcher::TYPE_MATCH && $amountDifference > 0.005),
|
||||
|| (
|
||||
! in_array($type, [SupplierStatementMatcher::TYPE_MATCH, SupplierStatementMatcher::TYPE_UNPRICED], true)
|
||||
&& $amountDifference > 0.005
|
||||
),
|
||||
'supplier_under' => $type === SupplierStatementMatcher::TYPE_SUPPLIER_MISSING
|
||||
|| ($type !== SupplierStatementMatcher::TYPE_MATCH && $amountDifference < -0.005),
|
||||
|| (
|
||||
! in_array($type, [SupplierStatementMatcher::TYPE_MATCH, SupplierStatementMatcher::TYPE_UNPRICED], true)
|
||||
&& $amountDifference < -0.005
|
||||
),
|
||||
'mismatch' => $type === SupplierStatementMatcher::TYPE_MISMATCH,
|
||||
'match' => $type === SupplierStatementMatcher::TYPE_MATCH,
|
||||
default => true,
|
||||
@ -717,7 +735,7 @@ class SupplierReconciliationPageData
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{all:int,supplier_over:int,supplier_under:int,mismatch:int,match:int} $counts
|
||||
* @param array{all:int,unpriced:int,supplier_over:int,supplier_under:int,mismatch:int,match:int} $counts
|
||||
* @return list<array{value:string,label:string,count:int,description:string}>
|
||||
*/
|
||||
private function comparisonFilterOptions(array $counts): array
|
||||
@ -727,7 +745,13 @@ class SupplierReconciliationPageData
|
||||
'value' => '',
|
||||
'label' => '全部结果',
|
||||
'count' => (int) ($counts['all'] ?? 0),
|
||||
'description' => '显示双方一致和全部差异行',
|
||||
'description' => '显示双方一致、未定价和全部差异行',
|
||||
],
|
||||
[
|
||||
'value' => 'unpriced',
|
||||
'label' => '未定价',
|
||||
'count' => (int) ($counts['unpriced'] ?? 0),
|
||||
'description' => '采购单号、商品编码、数量一致,但我方尚无实际单价和金额',
|
||||
],
|
||||
[
|
||||
'value' => 'supplier_over',
|
||||
|
||||
@ -45,10 +45,12 @@
|
||||
.supplier-reconciliation-page .sr-tag.is-extra { background: #FDF1E2; color: #B4650A; }
|
||||
.supplier-reconciliation-page .sr-tag.is-mismatch { background: #FBEBE9; color: #A6392F; }
|
||||
.supplier-reconciliation-page .sr-tag.is-missing { background: #EAF2F9; color: #2F6FA8; }
|
||||
.supplier-reconciliation-page .sr-tag.is-unpriced { background: #FFF4D8; color: #866211; }
|
||||
.supplier-reconciliation-page .sr-tag.is-logistics { background: #F7F0DE; color: #856632; }
|
||||
.supplier-reconciliation-page .sr-tag.is-no-bookkeeping { background: #EFEAE0; color: #595959; }
|
||||
.supplier-reconciliation-page tr.is-extra td,
|
||||
.supplier-reconciliation-page tr.is-mismatch td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page tr.is-mismatch td,
|
||||
.supplier-reconciliation-page tr.is-unpriced td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page tr.is-logistics td { background: #FFFDF7; }
|
||||
.supplier-reconciliation-page .sr-comparison-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 9px 10px; border: 1px solid #E4DCCB; border-radius: 8px; background: var(--vip-content-accent-soft, #F7F0DE); }
|
||||
.supplier-reconciliation-page .sr-filter-tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
@ -74,6 +76,8 @@
|
||||
.supplier-reconciliation-page .sr-push-preview-head span { color: var(--vip-content-muted, #595959); font-size: .75rem; }
|
||||
.supplier-reconciliation-page .sr-message-preview { min-height: 42px; display: flex; align-items: center; padding: 9px 12px; border: 1px solid #DED3BD; border-left: 4px solid var(--vip-content-accent-dark, #856632); border-radius: 7px; background: #fff; color: var(--vip-content-ink, #171717); font-size: .875rem; font-weight: 600; line-height: 1.6; font-variant-numeric: tabular-nums; }
|
||||
.supplier-reconciliation-page .sr-push-scope { color: var(--vip-content-muted, #595959); font-size: .75rem; line-height: 1.55; }
|
||||
.supplier-reconciliation-page .sr-unpriced-note { color: #866211; font-size: .75rem; line-height: 1.55; }
|
||||
.supplier-reconciliation-page .sr-estimated { color: #866211; font-size: .7rem; white-space: nowrap; }
|
||||
.supplier-reconciliation-page .sr-push-controls { min-width: 0; display: grid; gap: 8px; align-content: center; }
|
||||
.supplier-reconciliation-page .sr-push-basis-field { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 9px; }
|
||||
.supplier-reconciliation-page .sr-push-basis-field > span { color: #5B5343; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
||||
@ -191,7 +195,9 @@
|
||||
页面“双方原始合计”直接按筛选后的原始行汇总,不受匹配分组影响;匹配过程不会删除或改写我方终版货款对账原始行。<br>
|
||||
数量、金额一致 → 正常结算;厂家多(我方没有)→ 不结算、退回修改;
|
||||
数量或金额不一致 → 明确列出双方数量、单价、金额及厂家多算部分,取金额低的一方结算;
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款)。厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
|
||||
厂家少(我方有)→ 按厂家数据结算(不付款);
|
||||
采购单号、商品编码、数量一致但仅有我方预估价 → 标记“未定价”,不计厂家多算,待定价后结算。
|
||||
厂家未填采购单号时,仅在同日期+商品编码能唯一匹配我方一条数据时兜底。<br>
|
||||
厂家登录:在「厂家登录账号管理」新增供应商并勾选可登录,供应商名称必须与对账数据中的厂家名称完全一致;
|
||||
厂家登录后只能查看自己的对账单上传记录、全部退货明细及导出数据。
|
||||
</div>
|
||||
@ -488,7 +494,7 @@
|
||||
@endphp
|
||||
<div class="sr-comparison-tools">
|
||||
<nav class="sr-filter-tabs" aria-label="比对结果筛选">
|
||||
<span class="sr-filter-caption">差异筛选</span>
|
||||
<span class="sr-filter-caption">结果筛选</span>
|
||||
@foreach ($comparisonFilters as $filterOption)
|
||||
<a href="{{ $comparisonFilterUrl($filterOption['value']) }}"
|
||||
class="sr-filter-tab {{ $comparisonFilter === $filterOption['value'] ? 'is-active' : '' }}"
|
||||
@ -518,6 +524,9 @@
|
||||
: '当前账期';
|
||||
$settlementQty = (float) ($detail['summary']['settleQty'] ?? 0);
|
||||
$settlementAmount = (float) ($detail['summary']['settleAmount'] ?? 0);
|
||||
$unpricedCount = (int) ($detail['summary']['unpriced'] ?? 0);
|
||||
$unpricedQty = (float) ($detail['summary']['unpricedQty'] ?? 0);
|
||||
$unpricedGuessAmount = (float) ($detail['summary']['unpricedGuessAmount'] ?? 0);
|
||||
$mineQty = (float) ($detail['sourceTotals']['mineQty'] ?? 0);
|
||||
$mineAmount = (float) ($detail['sourceTotals']['mineAmount'] ?? 0);
|
||||
$theirsQty = (float) ($detail['sourceTotals']['theirsQty'] ?? 0);
|
||||
@ -533,7 +542,12 @@
|
||||
<span>实际发送以这里显示的内容为准</span>
|
||||
</div>
|
||||
<div id="summary-push-preview" class="sr-message-preview" aria-live="polite">账期:{{ $displayMonthLabel }},件数{{ $formatPushQty($pushPreviewQty) }}件,货款{{ number_format($pushPreviewAmount, 2, '.', '') }}元</div>
|
||||
<div class="sr-push-scope">汇总范围:当前账期 + 当前对账人;下方“差异筛选”只改变表格显示,不改变整期推送范围。</div>
|
||||
<div class="sr-push-scope">汇总范围:当前账期 + 当前对账人;下方“结果筛选”只改变表格显示,不改变整期推送范围。</div>
|
||||
@if ($unpricedCount > 0)
|
||||
<div class="sr-unpriced-note">
|
||||
另有 {{ number_format($unpricedCount) }} 条未定价,共 {{ $formatPushQty($unpricedQty) }} 件,预估金额 {{ number_format($unpricedGuessAmount, 2) }} 元;未计价金额不计入推送货款,待定价后结算。
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sr-push-controls">
|
||||
<label class="sr-push-basis-field" for="summary-push-basis">
|
||||
@ -582,6 +596,7 @@
|
||||
<div class="sr-summary-cell"><span>厂家多(退回修改)</span><b>{{ $detail['summary']['supplierExtra'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>数量/金额不一致(按低结算)</span><b>{{ $detail['summary']['mismatch'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>厂家少(按厂家结算)</span><b>{{ $detail['summary']['supplierMissing'] }}</b></div>
|
||||
<div class="sr-summary-cell"><span>未定价(预估 ¥{{ number_format($detail['summary']['unpricedGuessAmount'], 2) }})</span><b>{{ $detail['summary']['unpriced'] }}</b></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="sr-summary-group" aria-label="双方原始合计">
|
||||
@ -633,12 +648,13 @@
|
||||
'厂家多' => 'is-extra',
|
||||
'数量/金额不一致' => 'is-mismatch',
|
||||
'厂家少' => 'is-missing',
|
||||
'未定价' => 'is-unpriced',
|
||||
default => 'is-match',
|
||||
};
|
||||
@endphp
|
||||
<tr class="{{ $tagClass }}">
|
||||
<td class="sr-select-cell">
|
||||
@if ($row['type'] !== '一致')
|
||||
@if (! in_array($row['type'], ['一致', '未定价'], true))
|
||||
<input class="js-difference-checkbox"
|
||||
form="push-selected-form"
|
||||
type="checkbox"
|
||||
@ -672,8 +688,20 @@
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ $row['mineQty'] !== 0.0 ? number_format($row['mineQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}</td>
|
||||
<td class="is-num">
|
||||
@if ($row['type'] === '未定价' && (float) ($row['mineGuessUnitPrice'] ?? 0) > 0)
|
||||
<span class="sr-estimated">预估 {{ number_format($row['mineGuessUnitPrice'], 2) }}</span>
|
||||
@else
|
||||
{{ $row['mineUnitPrice'] !== 0.0 ? number_format($row['mineUnitPrice'], 2) : '—' }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">
|
||||
@if ($row['type'] === '未定价' && (float) ($row['mineGuessAmount'] ?? 0) > 0)
|
||||
<span class="sr-estimated">预估 {{ number_format($row['mineGuessAmount'], 2) }}</span>
|
||||
@else
|
||||
{{ $row['mineAmount'] !== 0.0 ? number_format($row['mineAmount'], 2) : '—' }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">{{ $row['theirsQty'] !== 0.0 ? number_format($row['theirsQty'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsUnitPrice'] !== 0.0 ? number_format($row['theirsUnitPrice'], 2) : '—' }}</td>
|
||||
<td class="is-num">{{ $row['theirsAmount'] !== 0.0 ? number_format($row['theirsAmount'], 2) : '—' }}</td>
|
||||
@ -685,7 +713,9 @@
|
||||
@endif
|
||||
</td>
|
||||
<td class="is-num">
|
||||
@if (abs($row['amountDifference']) < 0.005)
|
||||
@if ($row['type'] === '未定价')
|
||||
未定价
|
||||
@elseif (abs($row['amountDifference']) < 0.005)
|
||||
一致
|
||||
@else
|
||||
厂家{{ $row['amountDifference'] > 0 ? '多' : '少' }} ¥{{ number_format(abs($row['amountDifference']), 2) }}
|
||||
@ -694,9 +724,11 @@
|
||||
<td class="sr-comparison-note">
|
||||
<span class="sr-note-text" title="{{ $row['note'] }}">{{ $row['note'] }}</span>
|
||||
</td>
|
||||
<td class="is-num">{{ number_format($row['settleAmount'], 2) }}</td>
|
||||
<td class="is-num">{{ $row['type'] === '未定价' ? '待定价' : number_format($row['settleAmount'], 2) }}</td>
|
||||
<td>
|
||||
@if ($row['type'] !== '一致')
|
||||
@if ($row['type'] === '未定价')
|
||||
<span class="sr-muted">待定价</span>
|
||||
@elseif ($row['type'] !== '一致')
|
||||
<form method="POST"
|
||||
action="{{ route('business.supplier-reconciliation.push-differences') }}"
|
||||
onsubmit="return confirm('确认将这条差异推送到该厂家钉钉群?')">
|
||||
|
||||
@ -195,6 +195,53 @@ class SupplierReconciliationDingTalkTest extends FeatureTestCase
|
||||
});
|
||||
}
|
||||
|
||||
public function test_unpriced_rows_are_not_pushed_as_supplier_differences(): void
|
||||
{
|
||||
app(SupplierDingTalkNotifier::class)->save('未定价厂家', self::WEBHOOK, 'SEC-test-123', false);
|
||||
app(SupplierPayableOverrideStore::class)->save(
|
||||
'final:supplier:payment-reconciliation',
|
||||
'mine:unpriced',
|
||||
[
|
||||
'summary_month' => '2026-10',
|
||||
'purchaseNo' => '948180',
|
||||
'date' => '2026-10-14',
|
||||
'supplier' => '未定价厂家',
|
||||
'skuCode' => 'SZ014916',
|
||||
'productCode' => 'SZ014916N1109V01',
|
||||
'qty' => '20',
|
||||
'unitPrice' => null,
|
||||
'amount' => null,
|
||||
'guessUnitPrice' => '195',
|
||||
'guessAmount' => '3900',
|
||||
'reconciler' => '陈亲园',
|
||||
],
|
||||
(int) $this->company->id,
|
||||
null,
|
||||
);
|
||||
app(SupplierStatementStore::class)->save('未定价厂家', [
|
||||
array_merge($this->statementRow('SZ014916N1109V01', '186.00', '20', '3720.00'), [
|
||||
'purchaseNo' => '948180',
|
||||
'createdAt' => '2026-10-08',
|
||||
'supplier' => '未定价厂家',
|
||||
]),
|
||||
], 'unpriced.csv', (int) $this->company->id, null);
|
||||
Http::fake();
|
||||
|
||||
$this->loginAs()
|
||||
->post(route('business.supplier-reconciliation.push-differences'), [
|
||||
'supplier' => '未定价厂家',
|
||||
'month' => '2026-10',
|
||||
'reconciler' => '陈亲园',
|
||||
'scope' => 'all',
|
||||
])
|
||||
->assertStatus(302)
|
||||
->assertSessionHasErrors([
|
||||
'dingtalk_push' => '当前筛选没有可推送的差异。',
|
||||
]);
|
||||
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_push_reconciliation_summary_rejects_unknown_basis_without_http_call(): void
|
||||
{
|
||||
$this->withExceptionHandling();
|
||||
|
||||
@ -466,6 +466,110 @@ class SupplierReconciliationTest extends FeatureTestCase
|
||||
$this->assertSame(-30.0, $rows['NET-B-N1065V01']['amountDifference']);
|
||||
}
|
||||
|
||||
public function test_finance_comparison_marks_missing_mine_price_as_unpriced(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
$store = app(SupplierPayableOverrideStore::class);
|
||||
$store->save(
|
||||
'final:supplier:payment-reconciliation',
|
||||
'unpriced:948180',
|
||||
[
|
||||
'summary_month' => '2026-10',
|
||||
'purchaseNo' => '948180',
|
||||
'date' => '2026-10-14',
|
||||
'supplier' => '木缘居',
|
||||
'skuCode' => 'SZ014916',
|
||||
'productCode' => 'SZ014916N1109V01',
|
||||
'qty' => '20',
|
||||
'unitPrice' => null,
|
||||
'amount' => null,
|
||||
'guessUnitPrice' => '195',
|
||||
'guessAmount' => '3900',
|
||||
'reconciler' => '陈亲园',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
$store->save(
|
||||
'final:supplier:payment-reconciliation',
|
||||
'priced:948180',
|
||||
[
|
||||
'summary_month' => '2026-10',
|
||||
'purchaseNo' => '948181',
|
||||
'date' => '2026-10-14',
|
||||
'supplier' => '木缘居',
|
||||
'skuCode' => 'SZ014917',
|
||||
'productCode' => 'SZ014917N1109V01',
|
||||
'qty' => '10',
|
||||
'unitPrice' => '100',
|
||||
'amount' => '1000',
|
||||
'guessUnitPrice' => '100',
|
||||
'guessAmount' => '1000',
|
||||
'reconciler' => '陈亲园',
|
||||
],
|
||||
$companyId,
|
||||
null,
|
||||
);
|
||||
$this->seedStatement('木缘居', [
|
||||
array_merge($this->statementRow('SZ014916N1109V01', '186.00', '20', '3720.00'), [
|
||||
'purchaseNo' => '948180',
|
||||
'createdAt' => '2026-10-08',
|
||||
'supplier' => '木缘居',
|
||||
]),
|
||||
array_merge($this->statementRow('SZ014917N1109V01', '100.00', '10', '1000.00'), [
|
||||
'purchaseNo' => '948181',
|
||||
'createdAt' => '2026-10-14',
|
||||
'supplier' => '木缘居',
|
||||
]),
|
||||
]);
|
||||
|
||||
$detail = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
|
||||
'木缘居',
|
||||
$companyId,
|
||||
['month' => '2026-10', 'reconciler' => '陈亲园'],
|
||||
);
|
||||
$unpriced = collect($detail['rows'])->firstWhere('type', SupplierStatementMatcher::TYPE_UNPRICED);
|
||||
|
||||
$this->assertSame(1, $detail['summary']['match']);
|
||||
$this->assertSame(1, $detail['summary']['unpriced']);
|
||||
$this->assertSame(0, $detail['summary']['supplierExtra']);
|
||||
$this->assertSame(0, $detail['summary']['supplierMissing']);
|
||||
$this->assertSame(0, $detail['summary']['mismatch']);
|
||||
$this->assertSame(0.0, $detail['summary']['supplierOverAmount']);
|
||||
$this->assertSame(0.0, $detail['summary']['differenceAmount']);
|
||||
$this->assertSame(1, $detail['comparison_counts']['unpriced']);
|
||||
$this->assertSame(0, $detail['difference_count']);
|
||||
$this->assertSame(195.0, $unpriced['mineGuessUnitPrice']);
|
||||
$this->assertSame(3900.0, $unpriced['mineGuessAmount']);
|
||||
|
||||
$filtered = app(SupplierReconciliationPageData::class)->financeDetailForFilters(
|
||||
'木缘居',
|
||||
$companyId,
|
||||
[
|
||||
'month' => '2026-10',
|
||||
'reconciler' => '陈亲园',
|
||||
'comparison_filter' => 'unpriced',
|
||||
],
|
||||
);
|
||||
$this->assertSame(1, $filtered['total_rows']);
|
||||
$this->assertSame(1, $filtered['comparison_counts']['unpriced']);
|
||||
$this->assertSame(0, $filtered['difference_count']);
|
||||
|
||||
$this->loginAs()->get(route('business.pages.show', [
|
||||
'section' => 'supplier-reconciliation',
|
||||
'page' => 'finance-data',
|
||||
'supplier' => '木缘居',
|
||||
'month' => '2026-10',
|
||||
'reconciler' => '陈亲园',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSeeText('未定价')
|
||||
->assertSeeText('预估 195.00')
|
||||
->assertSeeText('预估 3,900.00')
|
||||
->assertSeeText('待定价')
|
||||
->assertSeeText('未计价金额不计入推送货款,待定价后结算');
|
||||
}
|
||||
|
||||
public function test_finance_comparison_matches_crossed_purchase_numbers_by_barcode_and_quantity(): void
|
||||
{
|
||||
$companyId = (int) $this->company->id;
|
||||
|
||||
@ -125,6 +125,46 @@ class SupplierStatementMatcherTest extends TestCase
|
||||
$this->assertStringContainsString('采购单号+商品编码匹配', $result['rows'][0]['note']);
|
||||
}
|
||||
|
||||
public function test_matching_quantity_with_missing_actual_mine_price_is_unpriced(): void
|
||||
{
|
||||
$mine = [[
|
||||
'purchaseNo' => '948180',
|
||||
'date' => '2026-10-14',
|
||||
'productCode' => 'SZ014916N1109V01',
|
||||
'unitPrice' => null,
|
||||
'qty' => '20',
|
||||
'amount' => null,
|
||||
'guessUnitPrice' => '195',
|
||||
'guessAmount' => '3900',
|
||||
]];
|
||||
$theirs = [[
|
||||
'purchaseNo' => '948180',
|
||||
'createdAt' => '2026-10-08',
|
||||
'barcode' => 'SZ014916N1109V01',
|
||||
'unitPrice' => '186',
|
||||
'qty' => '20',
|
||||
'amount' => '3720',
|
||||
]];
|
||||
|
||||
$result = app(SupplierStatementMatcher::class)->netByBarcode($mine, $theirs);
|
||||
$row = $result['rows'][0];
|
||||
|
||||
$this->assertSame(SupplierStatementMatcher::TYPE_UNPRICED, $row['type']);
|
||||
$this->assertSame(20.0, $row['mineQty']);
|
||||
$this->assertSame(20.0, $row['theirsQty']);
|
||||
$this->assertSame(0.0, $row['mineAmount']);
|
||||
$this->assertSame(3720.0, $row['theirsAmount']);
|
||||
$this->assertSame(195.0, $row['mineGuessUnitPrice']);
|
||||
$this->assertSame(3900.0, $row['mineGuessAmount']);
|
||||
$this->assertSame(1, $result['summary']['unpriced']);
|
||||
$this->assertSame(20.0, $result['summary']['unpricedQty']);
|
||||
$this->assertSame(3900.0, $result['summary']['unpricedGuessAmount']);
|
||||
$this->assertSame(0.0, $result['summary']['supplierOverAmount']);
|
||||
$this->assertSame(20.0, $result['summary']['settleQty']);
|
||||
$this->assertSame(0.0, $result['summary']['settleAmount']);
|
||||
$this->assertSame(0.0, $result['summary']['differenceAmount']);
|
||||
}
|
||||
|
||||
public function test_purchase_and_barcode_match_with_unrecognized_supplier_date(): void
|
||||
{
|
||||
$mine = [[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user