CaiWuXiTong/tests/Unit/EcommerceFinance/SupplierStatementMatcherTest.php
苏木 7aacb20058 feat: 供应商对账改为四层顺序匹配
按采购单号+条码+日期、采购单号+条码、条码+数量、快递单号+条码的顺序逐层匹配。

- 每层只处理上一轮剩余原始行
- 后三层允许日期差异
- 跨采购单号匹配时展示双方原单号
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-11-09 15:23:32 +08:00

462 lines
17 KiB
PHP

<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Tests\TestCase;
class SupplierStatementMatcherTest extends TestCase
{
public function test_purchase_and_barcode_match_with_date_difference(): void
{
$mine = [[
'purchaseNo' => '879735',
'date' => '2026-09-10',
'skuCode' => 'DNZ033609',
'productCode' => 'DNZ033609N1178V01',
'unitPrice' => '100',
'qty' => '20',
'amount' => '2000',
]];
$theirs = [[
'purchaseNo' => '879735',
'createdAt' => '2026-09-17',
'barcode' => 'DNZ033609N1097V01',
'unitPrice' => '100',
'qty' => '20',
'amount' => '2000',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(0, $result['summary']['mismatch']);
$this->assertSame(0.0, $result['summary']['differenceAmount']);
$this->assertSame(2000.0, $result['summary']['settleAmount']);
$this->assertSame(7, $result['rows'][0]['dateToleranceDays']);
$this->assertSame(2, $result['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('已忽略日期差异', $result['rows'][0]['note']);
}
public function test_purchase_and_barcode_match_has_no_seven_day_limit(): void
{
$mine = [[
'purchaseNo' => '879735',
'date' => '2026-09-10',
'skuCode' => 'DNZ033609',
'qty' => '20',
'amount' => '2000',
]];
$theirs = [[
'purchaseNo' => '879735',
'createdAt' => '2026-09-18',
'barcode' => 'DNZ033609N1097V01',
'qty' => '20',
'amount' => '2000',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(8, $result['rows'][0]['dateDifferenceDays']);
}
public function test_purchase_and_barcode_match_then_reports_quantity_and_amount_difference(): void
{
$mine = [[
'purchaseNo' => '879735',
'date' => '2026-09-10',
'skuCode' => 'DNZ033609',
'qty' => '20',
'amount' => '2000',
]];
$theirs = [[
'purchaseNo' => '879735',
'createdAt' => '2026-09-12',
'barcode' => 'DNZ033609N1097V01',
'qty' => '19',
'amount' => '1900',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(0, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(1, $result['summary']['mismatch']);
$this->assertSame(-1.0, $result['rows'][0]['qtyDifference']);
$this->assertSame(-100.0, $result['rows'][0]['amountDifference']);
$this->assertStringContainsString('采购单号+商品编码匹配', $result['rows'][0]['note']);
}
public function test_purchase_and_barcode_match_with_unrecognized_supplier_date(): void
{
$mine = [[
'purchaseNo' => '879735',
'date' => '2026-10-10',
'skuCode' => 'SJ009363',
'productCode' => 'SJ009363N160V01',
'qty' => '130',
'amount' => '12350',
]];
$theirs = [[
'purchaseNo' => '879735',
'createdAt' => '2026.1',
'barcode' => 'SJ009363N160V01',
'qty' => '130',
'amount' => '12350',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(0, $result['summary']['mismatch']);
$this->assertSame('2026-10-10', $result['rows'][0]['date']);
$this->assertSame(2, $result['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('已忽略日期差异', $result['rows'][0]['note']);
}
public function test_purchase_and_barcode_aggregate_then_report_ambiguous_quantity(): void
{
$mine = [
[
'purchaseNo' => '879735',
'date' => '2026-10-10',
'skuCode' => 'SJ009363',
'qty' => '130',
'amount' => '12350',
],
[
'purchaseNo' => '879735',
'date' => '2026-10-12',
'skuCode' => 'SJ009363',
'qty' => '130',
'amount' => '12350',
],
];
$theirs = [[
'purchaseNo' => '879735',
'createdAt' => '2026.1',
'barcode' => 'SJ009363N160V01',
'qty' => '130',
'amount' => '12350',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(0, $result['summary']['match']);
$this->assertSame(1, $result['summary']['mismatch']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(260.0, $result['rows'][0]['mineQty']);
$this->assertSame(130.0, $result['rows'][0]['theirsQty']);
}
public function test_purchase_and_barcode_match_aggregates_mine_rows_across_dates(): void
{
$mine = [
[
'purchaseNo' => '923923',
'date' => '2026-10-03',
'skuCode' => 'CBG011611',
'qty' => '36',
'amount' => '2466',
],
[
'purchaseNo' => '923923',
'date' => '2026-10-04',
'skuCode' => 'CBG011611',
'qty' => '44',
'amount' => '3014',
],
];
$theirs = [[
'purchaseNo' => '923923',
'createdAt' => '2026.1',
'barcode' => 'CBG011611N160V01',
'qty' => '80',
'amount' => '5480',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(80.0, $result['rows'][0]['mineQty']);
$this->assertSame(5480.0, $result['rows'][0]['mineAmount']);
$this->assertSame(2, $result['rows'][0]['dateCount']);
$this->assertSame(2, $result['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('已忽略日期差异', $result['rows'][0]['note']);
}
public function test_same_amount_rows_with_different_purchase_numbers_are_all_retained(): void
{
$mine = [
[
'purchaseNo' => '911299',
'date' => '2026-10-10',
'skuCode' => 'XG007322',
'productCode' => 'XG007322N160V01',
'qty' => '30',
'amount' => '4530',
],
[
'purchaseNo' => '941858',
'date' => '2026-10-10',
'skuCode' => 'XG007322',
'productCode' => 'XG007322N160V01',
'qty' => '30',
'amount' => '4530',
],
];
$theirs = [[
'purchaseNo' => '941858',
'createdAt' => '2026.1',
'barcode' => 'XG007322N160V01',
'qty' => '30',
'amount' => '4530',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(1, $result['summary']['supplierMissing']);
$this->assertSame(9060.0, $result['summary']['mineAmount']);
$this->assertSame(4530.0, $result['summary']['theirsAmount']);
$this->assertSame(4530.0, $result['summary']['differenceAmount']);
$this->assertCount(2, $result['rows']);
$this->assertSame('911299', $result['rows'][0]['purchaseNo']);
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $result['rows'][0]['type']);
$this->assertSame('941858', $result['rows'][1]['purchaseNo']);
$this->assertSame(SupplierStatementMatcher::TYPE_MATCH, $result['rows'][1]['type']);
}
public function test_regression_keeps_real_missing_and_extra_lines_without_hiding_source_rows(): void
{
$mine = [
[
'purchaseNo' => '911299',
'date' => '2026-10-10',
'skuCode' => 'XG007322',
'qty' => '30',
'amount' => '4530',
],
[
'purchaseNo' => '941858',
'date' => '2026-10-10',
'skuCode' => 'XG007322',
'qty' => '30',
'amount' => '4530',
],
[
'purchaseNo' => '923923',
'date' => '2026-10-03',
'skuCode' => 'CBG011611',
'qty' => '36',
'amount' => '2466',
],
[
'purchaseNo' => '923923',
'date' => '2026-10-04',
'skuCode' => 'CBG011611',
'qty' => '44',
'amount' => '3014',
],
[
'purchaseNo' => '937779',
'date' => '2026-10-20',
'skuCode' => 'SJ009363',
'qty' => '130',
'amount' => '12350',
],
];
$theirs = [
[
'purchaseNo' => '941858',
'createdAt' => '2026.1',
'barcode' => 'XG007322N160V01',
'qty' => '30',
'amount' => '4530',
],
[
'purchaseNo' => '923923',
'createdAt' => '2026.1',
'barcode' => 'CBG011611N160V01',
'qty' => '80',
'amount' => '5480',
],
[
'purchaseNo' => '937779',
'createdAt' => '2026.1',
'barcode' => 'SJ009363N160V01',
'qty' => '130',
'amount' => '12350',
],
[
'purchaseNo' => '922003',
'createdAt' => '2026.1',
'barcode' => 'SJ009363N160V01',
'qty' => '20',
'amount' => '0',
],
];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$differences = array_values(array_filter(
$result['rows'],
static fn (array $row): bool => $row['type'] !== SupplierStatementMatcher::TYPE_MATCH,
));
$this->assertSame(3, $result['summary']['match']);
$this->assertSame(1, $result['summary']['supplierExtra']);
$this->assertSame(1, $result['summary']['supplierMissing']);
$this->assertSame(26890.0, $result['summary']['mineAmount']);
$this->assertSame(22360.0, $result['summary']['theirsAmount']);
$this->assertSame(4530.0, $result['summary']['differenceAmount']);
$this->assertCount(2, $differences);
$byPurchaseNo = collect($differences)->keyBy('purchaseNo');
$this->assertSame(SupplierStatementMatcher::TYPE_SUPPLIER_MISSING, $byPurchaseNo['911299']['type']);
$this->assertSame(30.0, $byPurchaseNo['911299']['qtyDifference'] * -1);
$this->assertSame('SJ009363N160V01', $byPurchaseNo['922003']['barcode']);
$this->assertSame(20.0, $byPurchaseNo['922003']['qtyDifference']);
}
public function test_crossed_purchase_numbers_match_by_barcode_and_quantity(): void
{
$mine = [
[
'purchaseNo' => '958052',
'date' => '2026-10-26',
'skuCode' => 'DSG004815',
'productCode' => 'DSG004815N1019V01',
'qty' => '30',
'unitPrice' => '69',
'amount' => '2070',
],
[
'purchaseNo' => '972032',
'date' => '2026-10-26',
'skuCode' => 'DSG004816',
'productCode' => 'DSG004816N1019V01',
'qty' => '30',
'unitPrice' => '69',
'amount' => '2070',
],
];
$theirs = [
[
'purchaseNo' => '958052',
'createdAt' => '2026-10-26',
'barcode' => 'DSG004816N1019V01',
'qty' => '30',
'unitPrice' => '69',
'amount' => '2070',
],
[
'purchaseNo' => '972032',
'createdAt' => '2026-10-26',
'barcode' => 'DSG004815N1019V01',
'qty' => '30',
'unitPrice' => '69',
'amount' => '2070',
],
];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$rows = collect($result['rows'])->keyBy('minePurchaseNo');
$this->assertSame(2, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(0, $result['summary']['mismatch']);
$this->assertSame(3, $rows['958052']['matchedByLayer']);
$this->assertSame('972032', $rows['958052']['theirsPurchaseNo']);
$this->assertTrue($rows['958052']['purchaseNoDifference']);
$this->assertSame(3, $rows['972032']['matchedByLayer']);
$this->assertSame('958052', $rows['972032']['theirsPurchaseNo']);
$this->assertStringContainsString('采购单号不一致', $rows['958052']['note']);
$this->assertStringContainsString('商品编码+数量匹配', $rows['958052']['note']);
}
public function test_barcode_and_quantity_match_before_express_fallback(): void
{
$mine = [[
'expressNo' => '@sf123456',
'purchaseNo' => 'MINE-PO',
'date' => '2026-10-01',
'skuCode' => 'CZ000927',
'productCode' => 'CZ000927N1201V02',
'qty' => '7',
'amount' => '602',
]];
$theirs = [[
'expressNo' => 'SF123456',
'purchaseNo' => '',
'createdAt' => '2026-10-09',
'barcode' => 'CZ000927N1201V01',
'qty' => '7',
'amount' => '602',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(1, $result['summary']['match']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(0, $result['summary']['mismatch']);
$this->assertSame('SF123456', $result['rows'][0]['expressNo']);
$this->assertSame(3, $result['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('商品编码+数量匹配', $result['rows'][0]['note']);
}
public function test_express_match_is_used_after_earlier_layers_and_aggregates_rows(): void
{
$mine = [
[
'expressNo' => 'SF10001',
'purchaseNo' => '',
'date' => '2026-10-01',
'skuCode' => 'CZ000927',
'qty' => '2',
'amount' => '172',
],
[
'expressNo' => 'SF10001',
'purchaseNo' => '',
'date' => '2026-10-02',
'skuCode' => 'CZ000927',
'qty' => '5',
'amount' => '430',
],
];
$theirs = [[
'expressNo' => 'SF10001',
'purchaseNo' => '',
'createdAt' => '2026-10-08',
'barcode' => 'CZ000927N1201V01',
'qty' => '8',
'amount' => '688',
]];
$result = (new SupplierStatementMatcher())->compare($mine, $theirs);
$this->assertSame(0, $result['summary']['match']);
$this->assertSame(1, $result['summary']['mismatch']);
$this->assertSame(0, $result['summary']['supplierExtra']);
$this->assertSame(0, $result['summary']['supplierMissing']);
$this->assertSame(7.0, $result['rows'][0]['mineQty']);
$this->assertSame(602.0, $result['rows'][0]['mineAmount']);
$this->assertSame(1.0, $result['rows'][0]['qtyDifference']);
$this->assertSame(4, $result['rows'][0]['matchedByLayer']);
$this->assertStringContainsString('快递单号+商品编码匹配', $result['rows'][0]['note']);
}
}