feat: 增加厂家记账多轮金额核算草稿
This commit is contained in:
parent
6bff50f32a
commit
4a97b8f099
33
docs/厂家记账金额规则.md
Normal file
33
docs/厂家记账金额规则.md
Normal file
@ -0,0 +1,33 @@
|
||||
# 厂家记账金额规则
|
||||
|
||||
本轮为只读计算,保留工单原金额及原备注,不回写上游工单。页面、扣款筛选、合计、CSV 导出共用同一计算结果。
|
||||
|
||||
## 判定顺序
|
||||
|
||||
1. 对“补偿款项”“记账全款”(兼容“记账全额”),原金额大于0且小于50元,先判断原金额÷商品买家实付合计是否小于40%。命中后金额为0,不扣款,追加备注 `小于50且小于0.4,记账改0`。
|
||||
2. 未命中的,再用工单 `actual_paid_amount`(页面“实付金额”)作分母,独立判断同样的严格边界。任一轮实付缺失、无效或为0时,只跳过该轮;另一轮仍可正常判断。多商品买家实付缺一项时,不把部分金额当作完整合计。
|
||||
3. 保留厂家记账配置、代发仓发错货限制和多供应商待确认规则。只有已判定可扣款的剩余工单进入核价,不用核价推翻“不扣款”或“待确认”。
|
||||
4. “补偿款项”“记账全款”按原单商品的实际发货数量乘普通商品资料当前成本单价,再加实际包裹运费,与原金额取较小值。成本为当前 `cost_price`,不代表历史月份成本。
|
||||
5. “运费损耗”按实际运费记账,不受原记账金额限制;原记账金额为0也需要匹配。匹配结果确为0时不扣款。
|
||||
|
||||
金额等于50元或比例等于40%均不满足小额免记账。金额计算使用十进制运算,在最终金额处四舍五入到分。
|
||||
|
||||
## 发货匹配
|
||||
|
||||
- 查找时间为工单发起月份及前五个月,右端为下月1日,不含该日。例如10月工单读取5月1日至11月1日前的发货记录。
|
||||
- 有物流单号时精确匹配该单号,同时核对工单提供的线上订单号;无物流单号时按线上订单号精确匹配全部相关包裹。
|
||||
- 不以同商品平均运费替代原单运费,不借同订单其他快递单的运费;多个工单单号只匹配一部分时标为待确认。
|
||||
- 工单列出的商品编码须全部在原单发货明细中匹配。仅累计对应商品数量和成本,包裹运费按实际明细累计一次。
|
||||
- 缺发货、有效数量、成本价或运费时标为待确认,保留原金额,不计入扣款合计。明确的0运费有效,空运费不能当0。
|
||||
- 新增“原记账金额、匹配商品成本、匹配实际运费、匹配发货数量、匹配快递单号、发货匹配期间”供核对与导出。
|
||||
|
||||
## 当前接入状态与发布前条件
|
||||
|
||||
当前核价仓库复用财务系统现有发货数据表 `jst_oms_shipping`,定点批量查询物流单号/线上订单号,商品成本复用普通商品资料镜像。每批单号最多150个,发货按主键每500行读取,不扫描六个月全表。
|
||||
|
||||
**此版本仍为草稿,尚不能作为工作平台历史数据接入完成的交付。** 用户要求工作平台原始发货数据;现有财务系统发货记录的历史覆盖不足,不能把它当作完整六个月数据。发布前必须核实工作平台 `fys_omsDataDayDetail` 等正式来源、运费字段口径、5/6月历史覆盖及代表性原单结果,再完成对应镜像接入和验收;不得以部分现有数据试算代替该条件。
|
||||
|
||||
## 验证范围
|
||||
|
||||
- 相关单元和页面测试覆盖小额两轮严格边界、多商品缺值、成本封顶、实际运费、原金额为0的运费损耗、原单/多包裹/跨年窗口、缺项待确认、既有仓库与厂家配置、页面/筛选/CSV 一致性。
|
||||
- 生产只读试算、耗时和视觉证据保存于忽略的私有目录,不在仓库保存真实财务明细、日志或截图。试算不等于上线,也不等于历史来源已验证。
|
||||
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Repositories;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
|
||||
use RuntimeException;
|
||||
|
||||
/** 厂家记账只读核价:复用发货数据页面和普通商品资料的数据库来源。 */
|
||||
class SupplierBookkeepingValuationRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GenericBusinessMirrorRepository $mirror,
|
||||
private readonly BusinessMirrorDatasetRegistry $datasets,
|
||||
) {}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function shipmentsFor(array $rows): array
|
||||
{
|
||||
if ($rows === []) return [];
|
||||
$db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
|
||||
if (!Schema::connection($db->getName())->hasTable('jst_oms_shipping')) {
|
||||
throw new RuntimeException('发货数据尚未就绪');
|
||||
}
|
||||
$from = null;
|
||||
$to = null;
|
||||
$tracking = [];
|
||||
$orders = [];
|
||||
foreach ($rows as $row) {
|
||||
[$start, $end] = self::window($row);
|
||||
$from = $from === null || $start < $from ? $start : $from;
|
||||
$to = $to === null || $end > $to ? $end : $to;
|
||||
$numbers = self::numbers($row['tracking_no'] ?? '');
|
||||
if ($numbers !== []) {
|
||||
array_push($tracking, ...$numbers);
|
||||
} else {
|
||||
array_push($orders, ...self::numbers($row['online_order_no'] ?? ''));
|
||||
}
|
||||
}
|
||||
$found = [];
|
||||
// 使用现有快递单号/交易单号索引分批定点读取,不扫描六个月全量发货数据。
|
||||
foreach (['express_no' => $tracking, 'trade_no' => $orders] as $column => $values) {
|
||||
foreach (array_chunk(array_values(array_unique($values)), 150) as $chunk) {
|
||||
$query = $db->table('jst_oms_shipping')->whereIn($column, $chunk)
|
||||
->where('ship_time', '>=', $from)->where('ship_time', '<', $to)
|
||||
->select(['id', 'source_id', 'express_no', 'trade_no', 'barcode', 'qty', 'freight', 'ship_time'])
|
||||
->orderBy('id');
|
||||
foreach ($query->lazyById(500) as $record) {
|
||||
$found[$record->id] = (array) $record;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values($found);
|
||||
}
|
||||
|
||||
/** @return array<string,?string> 规范化 SKU => 当前商品资料成本单价 */
|
||||
public function costsFor(array $skus): array
|
||||
{
|
||||
if ($skus === []) return [];
|
||||
$dataset = $this->datasets->get(ProductCatalogMirrorRepository::DATASET_KEY);
|
||||
if (!$this->mirror->ready($dataset)) {
|
||||
throw new RuntimeException('商品资料尚未完成首次同步');
|
||||
}
|
||||
$db = $this->mirror->connection();
|
||||
$table = $dataset->targetTable();
|
||||
$values = [];
|
||||
foreach ($skus as $sku) {
|
||||
$normalized = self::sku($sku);
|
||||
foreach ([$normalized, mb_strtolower($normalized), str_replace('+', '_X002B_', $normalized)] as $value) {
|
||||
if ($value !== '') $values[$value] = true;
|
||||
}
|
||||
}
|
||||
$result = [];
|
||||
$latest = [];
|
||||
$exclusions = Schema::connection($db->getName())->hasTable('product_catalog_exclusions');
|
||||
foreach (array_chunk(array_keys($values), 150) as $chunk) {
|
||||
$query = $db->table($table)->where('sku_type', 'normal')->whereIn('sku_id', $chunk);
|
||||
if ($exclusions) {
|
||||
$query->whereNotExists(static function ($q) use ($table): void {
|
||||
$q->selectRaw('1')->from('product_catalog_exclusions AS e')
|
||||
->whereColumn('e.mirror_key', $table . '._mirror_key')
|
||||
->whereRaw("e.sku_id_normalized = REPLACE(UPPER(TRIM({$table}.sku_id)), '_X002B_', '+')");
|
||||
});
|
||||
}
|
||||
foreach ($query->orderByDesc('autoid')->get(['autoid', 'sku_id', 'cost_price']) as $record) {
|
||||
$key = self::sku($record->sku_id);
|
||||
if (!isset($latest[$key]) || (int) $record->autoid > $latest[$key]) {
|
||||
$latest[$key] = (int) $record->autoid;
|
||||
$result[$key] = $record->cost_price === null ? null : (string) $record->cost_price;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** 记账所在月份及前五个月,右端为下月1日(不含)。 */
|
||||
public static function window(array $row): array
|
||||
{
|
||||
$month = Carbon::parse((string) $row['started_at'])->startOfMonth();
|
||||
return [$month->copy()->subMonthsNoOverflow(5)->format('Y-m-d H:i:s'), $month->addMonth()->format('Y-m-d H:i:s')];
|
||||
}
|
||||
|
||||
public static function numbers(mixed $value): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map('trim', preg_split('/[\r\n,,、;;]+/u', (string) $value) ?: []), static fn ($v) => $v !== '')));
|
||||
}
|
||||
|
||||
public static function sku(mixed $value): string
|
||||
{
|
||||
return str_replace('_X002B_', '+', mb_strtoupper(trim((string) $value)));
|
||||
}
|
||||
}
|
||||
@ -167,7 +167,7 @@ class SupplierBookkeepingWorkOrderRepository
|
||||
throw new RuntimeException('business_mirror_dataset_not_ready');
|
||||
}
|
||||
|
||||
$key = 'supplier-bookkeeping:rows:v1:' . sha1((string) $status['last_succeeded_at']);
|
||||
$key = 'supplier-bookkeeping:rows:v2:' . sha1((string) $status['last_succeeded_at']);
|
||||
$cached = Cache::get($key);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
@ -256,7 +256,8 @@ class SupplierBookkeepingWorkOrderRepository
|
||||
$items = $this->items($fields['itemInfo']['value'] ?? null, $fields['itemInfo']['raw'] ?? null);
|
||||
foreach (self::ITEM_FIELDS as $key => $candidates) {
|
||||
$values = array_map(fn (array $item): string => $this->firstOf($item, $candidates), $items);
|
||||
$row[$key] = implode("\n", array_filter($values, static fn (string $v): bool => $v !== ''));
|
||||
// 保留买家实付中的空商品行,防止不完整金额被误当作整单实付。
|
||||
$row[$key] = implode("\n", $key === 'buyer_paid_amount' ? $values : array_filter($values, static fn (string $v): bool => $v !== ''));
|
||||
}
|
||||
if ($row['supplier'] === '') {
|
||||
$row['supplier'] = $row['header_supplier'];
|
||||
|
||||
@ -34,6 +34,8 @@ class SupplierBookkeepingDeductionRule
|
||||
public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓';
|
||||
public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货';
|
||||
|
||||
public const SMALL_AMOUNT_NOTE = '小于50且小于0.4,记账改0';
|
||||
|
||||
/**
|
||||
* 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。
|
||||
*
|
||||
@ -91,7 +93,7 @@ class SupplierBookkeepingDeductionRule
|
||||
/**
|
||||
* @param array<string,mixed> $row 厂家记账明细行
|
||||
* @param array<string,array<string,mixed>|object> $configs 供应商名称 => 生效中的配置
|
||||
* @return array{supplier_config:string,deduction_status:string,deduction_basis:string}
|
||||
* @return array{supplier_config:string,deduction_status:string,deduction_basis:string,amount?:string,remark?:string}
|
||||
*/
|
||||
public static function decide(array $row, array $configs): array
|
||||
{
|
||||
@ -106,6 +108,15 @@ class SupplierBookkeepingDeductionRule
|
||||
}
|
||||
$configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未在配置表(按正常记账)' : self::describe($config), $found));
|
||||
|
||||
if (self::isSmallCompensation($row)) {
|
||||
$remark = (string) ($row['remark'] ?? '');
|
||||
|
||||
return self::result($configText, self::STATUS_SKIP, self::SMALL_AMOUNT_NOTE) + [
|
||||
'amount' => '0.00',
|
||||
'remark' => $remark === '' ? self::SMALL_AMOUNT_NOTE : $remark . "\n" . self::SMALL_AMOUNT_NOTE,
|
||||
];
|
||||
}
|
||||
|
||||
if ($level1 === '仓库记账') {
|
||||
$warehouse = (string) ($row['shipping_warehouse'] ?? '');
|
||||
if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) {
|
||||
@ -133,6 +144,38 @@ class SupplierBookkeepingDeductionRule
|
||||
return self::result($configText, $statuses[0], reset($outcomes)[1]);
|
||||
}
|
||||
|
||||
private static function isSmallCompensation(array $row): bool
|
||||
{
|
||||
if (! in_array(trim((string) ($row['bookkeeping_type'] ?? '')), ['补偿款项', '记账全款', '记账全额'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$amount = trim((string) ($row['amount'] ?? ''));
|
||||
if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $amount)
|
||||
|| bccomp($amount, '0', 6) <= 0 || bccomp($amount, '50', 6) >= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 两轮依次使用商品买家实付合计、工单订单实付;一轮缺失不影响另一轮独立判断。
|
||||
foreach (['buyer_paid_amount', 'actual_paid_amount'] as $field) {
|
||||
$paid = '0';
|
||||
foreach (explode("\n", (string) ($row[$field] ?? '')) as $line) {
|
||||
$line = trim($line);
|
||||
if (! preg_match('/^\d+(?:\.\d{1,6})?$/D', $line)) {
|
||||
continue 2;
|
||||
}
|
||||
$paid = bcadd($paid, $line, 6);
|
||||
}
|
||||
// 交叉相乘避免除零、比例取整和浮点边界误差。
|
||||
if (bccomp($paid, '0', 6) > 0
|
||||
&& bccomp(bcmul($amount, '5', 6), bcmul($paid, '2', 6), 6) < 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} [判定, 说明] */
|
||||
private static function outcome(string $level1, ?array $config, string $date): array
|
||||
{
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\EcommerceFinance\Services;
|
||||
|
||||
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository as Repository;
|
||||
use Throwable;
|
||||
|
||||
/** 两轮免记账之后,按原单发货记录核算实际可记账金额。 */
|
||||
class SupplierBookkeepingValuation
|
||||
{
|
||||
public function __construct(private readonly Repository $repository) {}
|
||||
|
||||
/** @param list<array<string,mixed>> $rows @return list<array<string,mixed>> */
|
||||
public function apply(array $rows): array
|
||||
{
|
||||
$candidates = array_filter($rows, fn ($row) => $this->needsValuation($row));
|
||||
if ($candidates === []) return $rows;
|
||||
try {
|
||||
$shipments = $this->repository->shipmentsFor(array_values($candidates));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
foreach (array_keys($candidates) as $key) $rows[$key] = $this->review($rows[$key], '发货数据暂不可读取,请核对后记账');
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$byTracking = [];
|
||||
$byOrder = [];
|
||||
foreach ($shipments as $shipment) {
|
||||
$byTracking[(string) $shipment['express_no']][] = $shipment;
|
||||
$byOrder[(string) $shipment['trade_no']][] = $shipment;
|
||||
}
|
||||
$skus = [];
|
||||
foreach ($candidates as $row) {
|
||||
if ($row['bookkeeping_type'] !== '运费损耗') array_push($skus, ...Repository::numbers($row['product_code'] ?? ''));
|
||||
}
|
||||
try {
|
||||
$costs = $this->repository->costsFor(array_values(array_unique($skus)));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$costs = [];
|
||||
}
|
||||
foreach (array_keys($candidates) as $key) {
|
||||
$rows[$key] = $this->value($rows[$key], $byTracking, $byOrder, $costs);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function needsValuation(array $row): bool
|
||||
{
|
||||
return ($row['deduction_status'] ?? '') === SupplierBookkeepingDeductionRule::STATUS_DEDUCT
|
||||
&& in_array($row['bookkeeping_type'] ?? '', ['补偿款项', '记账全款', '记账全额', '运费损耗'], true)
|
||||
&& self::decimal($row['amount'] ?? null)
|
||||
&& (bccomp((string) $row['amount'], '0', 6) > 0 || $row['bookkeeping_type'] === '运费损耗');
|
||||
}
|
||||
|
||||
private function value(array $row, array $byTracking, array $byOrder, array $costs): array
|
||||
{
|
||||
[$from, $to] = Repository::window($row);
|
||||
$row['valuation_window'] = substr($from, 0, 7) . ' 至 ' . substr((string) $row['started_at'], 0, 7);
|
||||
$tracking = Repository::numbers($row['tracking_no'] ?? '');
|
||||
$orders = Repository::numbers($row['online_order_no'] ?? '');
|
||||
$pool = [];
|
||||
foreach ($tracking !== [] ? $tracking : $orders as $number) {
|
||||
foreach (($tracking !== [] ? $byTracking : $byOrder)[$number] ?? [] as $shipment) {
|
||||
if ($shipment['ship_time'] < $from || $shipment['ship_time'] >= $to) continue;
|
||||
if ($tracking !== [] && $orders !== [] && !in_array((string) $shipment['trade_no'], $orders, true)) continue;
|
||||
$pool[$shipment['id']] = $shipment;
|
||||
}
|
||||
}
|
||||
if ($pool === []) return $this->review($row, '六个月内未匹配到原订单/快递单号的发货记录');
|
||||
$matchedReferences = array_column($pool, $tracking !== [] ? 'express_no' : 'trade_no');
|
||||
if (array_diff($tracking !== [] ? $tracking : $orders, $matchedReferences) !== []) {
|
||||
return $this->review($row, '原单号包含未匹配发货记录,暂不能按部分金额记账');
|
||||
}
|
||||
|
||||
$packages = [];
|
||||
$quantity = '0';
|
||||
$cost = '0';
|
||||
$wanted = array_fill_keys(array_map([Repository::class, 'sku'], Repository::numbers($row['product_code'] ?? '')), false);
|
||||
$freightOnly = $row['bookkeeping_type'] === '运费损耗';
|
||||
if (!$freightOnly && $wanted === []) return $this->review($row, '工单没有商品编码,无法匹配成本');
|
||||
foreach ($pool as $shipment) {
|
||||
$package = (string) $shipment['source_id'];
|
||||
$packages[$package] ??= ['amount' => '0', 'has_freight' => false];
|
||||
if ($shipment['freight'] !== null && $shipment['freight'] !== '') {
|
||||
if (!self::decimal($shipment['freight'])) return $this->review($row, '原单运费无效,请核对');
|
||||
$packages[$package]['amount'] = bcadd($packages[$package]['amount'], (string) $shipment['freight'], 6);
|
||||
$packages[$package]['has_freight'] = true;
|
||||
}
|
||||
$sku = Repository::sku($shipment['barcode']);
|
||||
if (!$freightOnly && !array_key_exists($sku, $wanted)) continue;
|
||||
if (!self::decimal($shipment['qty']) || bccomp((string) $shipment['qty'], '0', 6) <= 0) {
|
||||
return $this->review($row, '匹配发货数量缺失或无效');
|
||||
}
|
||||
$quantity = bcadd($quantity, (string) $shipment['qty'], 6);
|
||||
if (!$freightOnly) {
|
||||
$price = $costs[$sku] ?? null;
|
||||
if (!self::decimal($price) || bccomp((string) $price, '0', 6) <= 0) return $this->review($row, '商品成本价缺失或为0:' . $sku);
|
||||
$cost = bcadd($cost, bcmul((string) $price, (string) $shipment['qty'], 6), 6);
|
||||
$wanted[$sku] = true;
|
||||
}
|
||||
}
|
||||
if (!$freightOnly && in_array(false, $wanted, true)) return $this->review($row, '原单发货明细未匹配全部工单商品');
|
||||
$freight = '0';
|
||||
foreach ($packages as $package) {
|
||||
if (!$package['has_freight']) return $this->review($row, '原单运费尚未补齐,不能按0记账');
|
||||
$freight = bcadd($freight, $package['amount'], 6);
|
||||
}
|
||||
$row['matched_freight'] = self::money($freight);
|
||||
$row['matched_quantity'] = rtrim(rtrim($quantity, '0'), '.');
|
||||
$row['matched_tracking_no'] = implode("\n", array_values(array_unique(array_column($pool, 'express_no'))));
|
||||
if ($freightOnly) {
|
||||
$row['amount'] = self::money($freight);
|
||||
$basis = '运费损耗按原单实际运费记账';
|
||||
} else {
|
||||
$row['matched_cost'] = self::money($cost);
|
||||
$ceiling = self::money(bcadd($cost, $freight, 6));
|
||||
if (bccomp((string) $row['amount'], $ceiling, 2) > 0) {
|
||||
$row['amount'] = $ceiling;
|
||||
$basis = '原记账金额大于商品成本+实际运费,按成本+运费记账';
|
||||
} else {
|
||||
$basis = '原记账金额不大于商品成本+实际运费,保留记账金额';
|
||||
}
|
||||
}
|
||||
$row['deduction_basis'] .= ';' . $basis;
|
||||
$row['remark'] = trim((string) ($row['remark'] ?? '')) === '' ? $basis : $row['remark'] . "\n" . $basis;
|
||||
if (bccomp((string) $row['amount'], '0', 2) === 0) $row['deduction_status'] = SupplierBookkeepingDeductionRule::STATUS_SKIP;
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function review(array $row, string $reason): array
|
||||
{
|
||||
$row['deduction_status'] = SupplierBookkeepingDeductionRule::STATUS_REVIEW;
|
||||
$row['deduction_basis'] .= ';' . $reason;
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function decimal(mixed $value): bool
|
||||
{
|
||||
return $value !== null && preg_match('/^\d+(?:\.\d{1,6})?$/D', (string) $value) === 1;
|
||||
}
|
||||
|
||||
private static function money(string $value): string
|
||||
{
|
||||
return bcadd($value, '0.005', 2);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository as Repository;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingValuation;
|
||||
|
||||
/** 供应商应付 → 厂家记账(聚工单“记账”工单镜像)。 */
|
||||
class SupplierBookkeepingPageData
|
||||
@ -27,6 +28,9 @@ class SupplierBookkeepingPageData
|
||||
'started_at' => '发起时间', 'work_order_no' => '工单编号', 'initiator' => '发起人',
|
||||
'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项',
|
||||
'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额',
|
||||
'original_amount' => '原记账金额',
|
||||
'matched_cost' => '匹配商品成本', 'matched_freight' => '匹配实际运费', 'matched_quantity' => '匹配发货数量',
|
||||
'matched_tracking_no' => '匹配快递单号', 'valuation_window' => '发货匹配期间',
|
||||
'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明',
|
||||
'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号',
|
||||
'internal_order_no' => '内部单号', 'actual_paid_amount' => '实付金额', 'carrier' => '物流公司',
|
||||
@ -40,11 +44,12 @@ class SupplierBookkeepingPageData
|
||||
'accepting_department' => '受理部门', 'order_shipped_at' => '订单发货时间',
|
||||
];
|
||||
|
||||
public const MONEY_COLUMNS = ['amount', 'actual_paid_amount'];
|
||||
public const MONEY_COLUMNS = ['amount', 'original_amount', 'matched_cost', 'matched_freight', 'actual_paid_amount'];
|
||||
|
||||
public function __construct(
|
||||
private readonly Repository $repository,
|
||||
private readonly SupplierBookkeepingConfigStore $configs,
|
||||
private readonly SupplierBookkeepingValuation $valuation,
|
||||
) {}
|
||||
|
||||
/** @return array<string,string> */
|
||||
@ -99,7 +104,10 @@ class SupplierBookkeepingPageData
|
||||
$to = $filters['started_to'] !== '' ? $filters['started_to'] . ' 23:59:59' : '';
|
||||
$deduction = $filters['deduction'] ?? '';
|
||||
$configs = $this->configs->activeConfigs();
|
||||
$matched = [];
|
||||
foreach ($rows ?? $this->repository->rows() as $row) {
|
||||
$row['original_amount'] = $row['amount'];
|
||||
$row += ['matched_cost' => null, 'matched_freight' => null, 'matched_quantity' => '', 'matched_tracking_no' => '', 'valuation_window' => ''];
|
||||
// 配置随时可改,判定不进镜像结果缓存,每次按当前配置计算。
|
||||
$row = Rule::decide($row, $configs) + $row;
|
||||
if ($from !== '' && $row['started_at'] < $from) continue;
|
||||
@ -107,10 +115,13 @@ class SupplierBookkeepingPageData
|
||||
if ($filters['reason'] !== '' && $row['bookkeeping_reason'] !== $filters['reason'] && $row['reason_level_1'] !== $filters['reason']) continue;
|
||||
if ($filters['status'] !== '' && $row['status'] !== $filters['status']) continue;
|
||||
if ($filters['bookkeeping_type'] !== '' && $row['bookkeeping_type'] !== $filters['bookkeeping_type']) continue;
|
||||
if ($deduction !== '' && $row['deduction_status'] !== $deduction) continue;
|
||||
foreach (self::TEXT_FILTERS as $key => $label) {
|
||||
if ($filters[$key] !== '' && mb_stripos((string) ($row[$key] ?? ''), $filters[$key]) === false) continue 2;
|
||||
}
|
||||
$matched[] = $row;
|
||||
}
|
||||
foreach ($this->valuation->apply($matched) as $row) {
|
||||
if ($deduction !== '' && $row['deduction_status'] !== $deduction) continue;
|
||||
yield $row;
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,8 @@
|
||||
<div><span>待确认</span><strong class="{{ $bookkeepingSummary['review'] > 0 ? 'sb-warn-text' : '' }}">{{ number_format($bookkeepingSummary['review']) }}</strong></div>
|
||||
</div>
|
||||
<details class="sb-rules"><summary>接入口径</summary><p>与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。</p><p>厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定:“正常记账”以及配置表中没有的供应商(含配置不在生效期内)计入扣款,“仅代发仓发错货记账”“不记账”不扣;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。工单未带供应商、或多件商品分属不同供应商且判定不一致的显示“待确认”,不计入扣款金额。</p></details>
|
||||
<p class="sb-rules">小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。</p>
|
||||
<p class="sb-rules">剩余可扣款工单按原快递单号匹配实际发货;未提供快递单号时按线上订单号匹配。查找期间为工单发起月份及前五个月(如10月取5—10月)。商品成本取普通商品资料成本单价×匹配商品的实际发货数量;“补偿款项”“记账全款”取原记账金额与商品成本+实际运费的较小值,“运费损耗”按实际运费记账。包裹运费只累计一次;发货、数量、成本或运费缺失时标为待确认,不计入扣款合计。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。</p>
|
||||
@if($bookkeepingError)<div class="sb-error" role="alert">{{ $bookkeepingError }}</div>@endif
|
||||
<section class="sb-panel" aria-label="厂家记账明细">
|
||||
<div class="sb-table-scroll"><table><thead><tr>
|
||||
|
||||
@ -22,12 +22,17 @@ class SupplierBookkeepingPageTest extends FeatureTestCase
|
||||
], $patch);
|
||||
}
|
||||
|
||||
private function mockRepository(array $rows, string $code = 'ready'): void
|
||||
private function mockRepository(array $rows, string $code = 'ready', bool $realValuation = false): void
|
||||
{
|
||||
$repository = \Mockery::mock(SupplierBookkeepingWorkOrderRepository::class);
|
||||
$repository->shouldReceive('syncStatus')->andReturn(['code' => $code, 'last_succeeded_at' => '2026-10-18 21:00:00']);
|
||||
$repository->shouldReceive('rows')->andReturn($rows);
|
||||
$this->app->instance(SupplierBookkeepingWorkOrderRepository::class, $repository);
|
||||
if (!$realValuation) {
|
||||
$valuation = \Mockery::mock(\Modules\EcommerceFinance\Services\SupplierBookkeepingValuation::class);
|
||||
$valuation->shouldReceive('apply')->andReturnUsing(static fn ($rows) => $rows);
|
||||
$this->app->instance(\Modules\EcommerceFinance\Services\SupplierBookkeepingValuation::class, $valuation);
|
||||
}
|
||||
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
|
||||
}
|
||||
|
||||
@ -95,7 +100,7 @@ class SupplierBookkeepingPageTest extends FeatureTestCase
|
||||
$this->row(['work_order_no' => 'GD-NORMAL', 'supplier' => '鑫呈', 'amount' => '100.00']),
|
||||
$this->row(['work_order_no' => 'GD-NONE', 'supplier' => '尊享', 'amount' => '50.00']),
|
||||
$this->row(['work_order_no' => 'GD-DROP-OTHER', 'supplier' => '曹县洪耀', 'amount' => '30.00']),
|
||||
$this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'reason_level_1' => '仓库记账',
|
||||
$this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'actual_paid_amount' => '40.00', 'reason_level_1' => '仓库记账',
|
||||
'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '曹县洪耀代发仓']),
|
||||
$this->row(['work_order_no' => 'GD-MAIN-WRONG', 'supplier' => '曹县洪耀', 'amount' => '8.00', 'reason_level_1' => '仓库记账',
|
||||
'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '常州主仓']),
|
||||
@ -128,4 +133,65 @@ class SupplierBookkeepingPageTest extends FeatureTestCase
|
||||
|
||||
$this->withExceptionHandling()->loginAs()->get($this->url(['deduction' => 'x']))->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_small_compensation_zeroes_page_totals_and_exports_with_original_amount_and_note(): void
|
||||
{
|
||||
$this->mockRepository([
|
||||
$this->row(['work_order_no' => 'GD-SMALL', 'amount' => '30.00', 'buyer_paid_amount' => '100', 'remark' => '原备注']),
|
||||
$this->row(['work_order_no' => 'GD-EQUAL-RATIO', 'amount' => '40.00', 'buyer_paid_amount' => '100', 'actual_paid_amount' => '100.00']),
|
||||
$this->row(['work_order_no' => 'GD-EQUAL-LIMIT', 'amount' => '50.00', 'buyer_paid_amount' => '200']),
|
||||
]);
|
||||
$response = $this->loginAs()->get($this->url())->assertOk();
|
||||
$data = app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page();
|
||||
$summary = $data['bookkeepingSummary'];
|
||||
$this->assertSame(9000, $summary['amount_cents']);
|
||||
$this->assertSame(9000, $summary['deduction_cents']);
|
||||
$this->assertSame(2, $summary['deduction_count']);
|
||||
$row = $data['bookkeepingRows']->getCollection()->firstWhere('work_order_no', 'GD-SMALL');
|
||||
$this->assertSame('0.00', $row['amount']);
|
||||
$this->assertSame('30.00', $row['original_amount']);
|
||||
$this->assertSame("原备注\n小于50且小于0.4,记账改0", $row['remark']);
|
||||
$response->assertSee('原记账金额')->assertSee('小于50且小于0.4,记账改0');
|
||||
$this->get($this->url(['deduction' => '扣款']))->assertOk()->assertDontSee('GD-SMALL')->assertSee('GD-EQUAL-RATIO');
|
||||
$this->get($this->url(['deduction' => '不扣款']))->assertOk()->assertSee('GD-SMALL')->assertDontSee('GD-EQUAL-RATIO');
|
||||
$csv = $this->get(route('business.supplier-bookkeeping.export', [
|
||||
'company_id' => $this->company->id, 'columns' => 'work_order_no,amount,original_amount,remark',
|
||||
]))->assertOk()->streamedContent();
|
||||
$this->assertStringContainsString('GD-SMALL,0.00,30.00,', $csv);
|
||||
$this->assertStringContainsString('小于50且小于0.4,记账改0', $csv);
|
||||
}
|
||||
|
||||
public function test_real_valuation_keeps_page_totals_deduction_filter_and_export_consistent(): void
|
||||
{
|
||||
$this->mockRepository([
|
||||
$this->row(['work_order_no' => 'GD-CAPPED', 'amount' => '100.00', 'product_code' => 'SKU-A', 'tracking_no' => 'EXP-1']),
|
||||
$this->row(['work_order_no' => 'GD-FREIGHT', 'bookkeeping_type' => '运费损耗', 'amount' => '20.00', 'tracking_no' => 'EXP-1']),
|
||||
$this->row(['work_order_no' => 'GD-PENDING', 'amount' => '100.00', 'tracking_no' => 'MISSING']),
|
||||
$this->row(['work_order_no' => 'GD-ROUND-TWO', 'amount' => '30.00', 'buyer_paid_amount' => '50.00', 'actual_paid_amount' => '100.00']),
|
||||
], 'ready', true);
|
||||
$repository = \Mockery::mock(\Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository::class);
|
||||
$repository->shouldReceive('shipmentsFor')->andReturn([[
|
||||
'id' => 1, 'source_id' => 10, 'express_no' => 'EXP-1', 'trade_no' => '5127787476014026744',
|
||||
'barcode' => 'SKU-A', 'qty' => '2', 'freight' => '8.50', 'ship_time' => '2026-08-01 00:00:00',
|
||||
]]);
|
||||
$repository->shouldReceive('costsFor')->andReturn(['SKU-A' => '20.00']);
|
||||
$this->app->instance(\Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository::class, $repository);
|
||||
|
||||
$this->loginAs()->get($this->url())->assertOk()->assertSee('GD-CAPPED')->assertSee('GD-PENDING');
|
||||
$data = app(\Modules\EcommerceFinance\View\SupplierBookkeepingPageData::class)->page();
|
||||
$this->assertSame(5700, $data['bookkeepingSummary']['deduction_cents']);
|
||||
$this->assertSame(2, $data['bookkeepingSummary']['deduction_count']);
|
||||
$this->assertSame(1, $data['bookkeepingSummary']['review']);
|
||||
$this->get($this->url(['deduction' => '扣款']))->assertOk()->assertSee('GD-CAPPED')->assertSee('GD-FREIGHT')
|
||||
->assertDontSee('GD-PENDING')->assertDontSee('GD-ROUND-TWO');
|
||||
$this->get($this->url(['deduction' => '待确认']))->assertOk()->assertSee('GD-PENDING')->assertDontSee('GD-CAPPED');
|
||||
$csv = $this->get(route('business.supplier-bookkeeping.export', [
|
||||
'company_id' => $this->company->id, 'deduction' => '扣款',
|
||||
'columns' => 'work_order_no,amount,original_amount,matched_cost,matched_freight,matched_quantity',
|
||||
]))->assertOk()->streamedContent();
|
||||
$this->assertStringContainsString('GD-CAPPED,48.50,100.00,40.00,8.50,2', $csv);
|
||||
$this->assertStringContainsString('GD-FREIGHT,8.50,20.00,,8.50,2', $csv);
|
||||
$this->assertStringNotContainsString('GD-PENDING', $csv);
|
||||
$this->assertStringNotContainsString('GD-ROUND-TWO', $csv);
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,4 +84,47 @@ class SupplierBookkeepingDeductionRuleTest extends TestCase
|
||||
$this->assertStringContainsString('甲厂:扣款', $result['deduction_basis']);
|
||||
$this->assertSame("正常记账\n不记账", $result['supplier_config']);
|
||||
}
|
||||
|
||||
public function test_small_compensation_uses_bookkeeping_divided_by_buyer_paid_and_preserves_notes(): void
|
||||
{
|
||||
foreach (['补偿款项', '记账全款', '记账全额'] as $type) {
|
||||
$row = $this->row(['bookkeeping_type' => $type, 'amount' => '30.00', 'buyer_paid_amount' => '100', 'remark' => '协商补偿']);
|
||||
$result = Rule::decide($row, $this->configs());
|
||||
$this->assertSame('0.00', $result['amount']);
|
||||
$this->assertSame(Rule::STATUS_SKIP, $result['deduction_status']);
|
||||
$this->assertSame("协商补偿\n小于50且小于0.4,记账改0", $result['remark']);
|
||||
$this->assertSame('30.00', $row['amount']);
|
||||
}
|
||||
$multiple = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '30.00', 'buyer_paid_amount' => "40\n60"]), []);
|
||||
$this->assertSame('0.00', $multiple['amount']);
|
||||
}
|
||||
|
||||
public function test_small_compensation_requires_both_strict_limits_and_complete_positive_paid_amount(): void
|
||||
{
|
||||
foreach ([
|
||||
['50.00', '200'], ['49.99', '124.975'], ['40.00', '100'], ['39.99', '99'],
|
||||
['30.00', '10'], ['30.00', ''], ['30.00', '0'], ['30.00', '-100'], ['30.00', "100\n"],
|
||||
['30.00', "\n100"], ['30.00', 'invalid'], ['0.00', '100'], ['-1.00', '100'], ['', '100'],
|
||||
] as [$amount, $paid]) {
|
||||
$result = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => $amount, 'buyer_paid_amount' => $paid]), $this->configs());
|
||||
$this->assertArrayNotHasKey('amount', $result, "$amount / $paid");
|
||||
}
|
||||
$hit = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '39.99', 'buyer_paid_amount' => '100']), $this->configs());
|
||||
$this->assertSame('0.00', $hit['amount']);
|
||||
$freight = Rule::decide($this->row(['bookkeeping_type' => '运费损耗', 'amount' => '10.00', 'buyer_paid_amount' => '100']), $this->configs());
|
||||
$this->assertArrayNotHasKey('amount', $freight);
|
||||
}
|
||||
|
||||
public function test_second_round_uses_order_actual_paid_when_buyer_paid_round_does_not_hit(): void
|
||||
{
|
||||
foreach (['10', '', '0', "100\n"] as $buyer) {
|
||||
$result = Rule::decide($this->row(['bookkeeping_type' => '补偿款项', 'amount' => '30.00',
|
||||
'buyer_paid_amount' => $buyer, 'actual_paid_amount' => '100.00']), $this->configs());
|
||||
$this->assertSame('0.00', $result['amount']);
|
||||
$this->assertSame(Rule::STATUS_SKIP, $result['deduction_status']);
|
||||
}
|
||||
$equal = Rule::decide($this->row(['bookkeeping_type' => '记账全款', 'amount' => '40.00',
|
||||
'buyer_paid_amount' => '50', 'actual_paid_amount' => '100.00']), $this->configs());
|
||||
$this->assertArrayNotHasKey('amount', $equal);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository as Repository;
|
||||
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SupplierBookkeepingValuationRepositoryTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['jushuitan.connection' => 'business_mirror', 'upstream_mirrors.connection' => 'business_mirror']);
|
||||
// The production dataset key contains dots; configure the full dataset map directly.
|
||||
config(['upstream_mirrors.datasets' => [ProductCatalogMirrorRepository::DATASET_KEY => [
|
||||
'source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_sku',
|
||||
'target' => 'fixture_valuation_skus', 'keys' => ['autoid'],
|
||||
]]]);
|
||||
$schema = Schema::connection('business_mirror');
|
||||
if (!$schema->hasTable('jst_oms_shipping')) {
|
||||
$schema->create('jst_oms_shipping', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->integer('source_id');
|
||||
foreach (['express_no', 'trade_no', 'barcode', 'qty', 'freight', 'ship_time'] as $column) $table->string($column)->nullable();
|
||||
$table->index('express_no');
|
||||
$table->index('trade_no');
|
||||
});
|
||||
}
|
||||
$schema->create('fixture_valuation_skus', function (Blueprint $table) {
|
||||
$table->integer('autoid');
|
||||
foreach (['_mirror_key', 'sku_id', 'sku_type', 'cost_price'] as $column) $table->string($column)->nullable();
|
||||
});
|
||||
if (!$schema->hasTable('business_mirror_table_states')) {
|
||||
$schema->create('business_mirror_table_states', function (Blueprint $table) {
|
||||
$table->string('dataset_key')->primary();
|
||||
$table->dateTime('initial_completed_at')->nullable();
|
||||
});
|
||||
}
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->updateOrInsert(
|
||||
['dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY], ['initial_completed_at' => '2026-10-01 00:00:00']);
|
||||
}
|
||||
|
||||
public function test_queries_exact_references_and_six_month_dates_without_borrowing_same_order_packages(): void
|
||||
{
|
||||
$db = DB::connection('business_mirror');
|
||||
foreach ([['EXP-1', '2026-05-01'], ['EXP-1', '2026-04-30'], ['EXP-1', '2026-11-01'], ['EXP-2', '2026-10-20']] as $i => [$tracking, $date]) {
|
||||
$db->table('jst_oms_shipping')->insert(['source_id' => $i+1, 'express_no' => $tracking, 'trade_no' => 'ORDER-1',
|
||||
'barcode' => 'SKU-A', 'qty' => 1, 'freight' => '8.50', 'ship_time' => $date . ' 00:00:00']);
|
||||
}
|
||||
$repo = app(Repository::class);
|
||||
$rows = $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => 'EXP-1', 'online_order_no' => 'ORDER-1']]);
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertSame('EXP-1', $rows[0]['express_no']);
|
||||
$all = $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => '', 'online_order_no' => 'ORDER-1']]);
|
||||
$this->assertCount(2, $all);
|
||||
$this->assertSame([], $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => '', 'online_order_no' => '']]));
|
||||
}
|
||||
|
||||
public function test_uses_latest_normal_product_cost_and_decodes_sku_plus_without_treating_missing_as_zero(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('fixture_valuation_skus')->insert([
|
||||
['autoid' => 1, '_mirror_key' => '1', 'sku_id' => 'SKU-A', 'sku_type' => 'normal', 'cost_price' => '10.00'],
|
||||
['autoid' => 2, '_mirror_key' => '2', 'sku_id' => 'SKU-A', 'sku_type' => 'normal', 'cost_price' => '20.00'],
|
||||
['autoid' => 3, '_mirror_key' => '3', 'sku_id' => 'SKU-A', 'sku_type' => 'combination', 'cost_price' => '90.00'],
|
||||
['autoid' => 4, '_mirror_key' => '4', 'sku_id' => 'SKU_X002B_B', 'sku_type' => 'normal', 'cost_price' => '15.00'],
|
||||
]);
|
||||
$costs = app(Repository::class)->costsFor(['SKU-A', 'SKU+B', 'MISSING']);
|
||||
$this->assertSame('20.00', $costs['SKU-A']);
|
||||
$this->assertSame('15.00', $costs['SKU+B']);
|
||||
$this->assertArrayNotHasKey('MISSING', $costs);
|
||||
}
|
||||
|
||||
public function test_latest_cost_wins_when_encoded_sku_variant_falls_in_another_query_batch(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('fixture_valuation_skus')->insert([
|
||||
['autoid' => 1, '_mirror_key' => '1', 'sku_id' => 'SKU+B', 'sku_type' => 'normal', 'cost_price' => '10.00'],
|
||||
['autoid' => 2, '_mirror_key' => '2', 'sku_id' => 'SKU_X002B_B', 'sku_type' => 'normal', 'cost_price' => '25.00'],
|
||||
]);
|
||||
// 74 pairs + one uppercase-only numeric SKU place SKU+B at the 150-value boundary.
|
||||
$skus = array_map(static fn($i) => 'ITEM-'.$i, range(1, 74));
|
||||
$skus[] = '123';
|
||||
$skus[] = 'SKU+B';
|
||||
$this->assertSame('25.00', app(Repository::class)->costsFor($skus)['SKU+B']);
|
||||
}
|
||||
}
|
||||
138
tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php
Normal file
138
tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\EcommerceFinance;
|
||||
|
||||
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository as Repository;
|
||||
use Modules\EcommerceFinance\Services\SupplierBookkeepingValuation;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SupplierBookkeepingValuationTest extends TestCase
|
||||
{
|
||||
private function row(array $patch = []): array
|
||||
{
|
||||
return array_replace(['started_at' => '2026-10-15 10:00:00', 'bookkeeping_type' => '补偿款项', 'amount' => '100.00',
|
||||
'original_amount' => '100.00', 'deduction_status' => '扣款', 'deduction_basis' => '供应商正常记账',
|
||||
'remark' => '原备注', 'tracking_no' => 'EXP-1', 'online_order_no' => 'ORDER-1', 'product_code' => 'SKU-A'], $patch);
|
||||
}
|
||||
|
||||
private function shipment(array $patch = []): array
|
||||
{
|
||||
return array_replace(['id' => 1, 'source_id' => 10, 'express_no' => 'EXP-1', 'trade_no' => 'ORDER-1',
|
||||
'barcode' => 'SKU-A', 'qty' => '2', 'freight' => '8.50', 'ship_time' => '2026-05-01 00:00:00'], $patch);
|
||||
}
|
||||
|
||||
private function service(array $shipments, array $costs = ['SKU-A' => '20.00']): SupplierBookkeepingValuation
|
||||
{
|
||||
$repository = $this->createMock(Repository::class);
|
||||
$repository->method('shipmentsFor')->willReturn($shipments);
|
||||
$repository->method('costsFor')->willReturn($costs);
|
||||
return new SupplierBookkeepingValuation($repository);
|
||||
}
|
||||
|
||||
public function test_caps_by_matched_shipped_quantity_times_cost_plus_actual_freight(): void
|
||||
{
|
||||
$row = $this->service([$this->shipment()])->apply([$this->row()])[0];
|
||||
$this->assertSame('48.50', $row['amount']);
|
||||
$this->assertSame('100.00', $row['original_amount']);
|
||||
$this->assertSame('40.00', $row['matched_cost']);
|
||||
$this->assertSame('8.50', $row['matched_freight']);
|
||||
$this->assertSame('2', $row['matched_quantity']);
|
||||
$this->assertSame('2026-05 至 2026-10', $row['valuation_window']);
|
||||
$this->assertStringStartsWith("原备注\n", $row['remark']);
|
||||
$this->assertSame('扣款', $row['deduction_status']);
|
||||
}
|
||||
|
||||
public function test_keeps_lower_or_equal_bookkeeping_amount_and_rounds_ceiling_once(): void
|
||||
{
|
||||
$service = $this->service([$this->shipment()]);
|
||||
foreach (['40.00', '48.50'] as $amount) {
|
||||
$row = $service->apply([$this->row(['amount' => $amount])])[0];
|
||||
$this->assertSame($amount, $row['amount']);
|
||||
}
|
||||
$rounded = $this->service([$this->shipment(['freight' => '8.505'])])->apply([$this->row()])[0];
|
||||
$this->assertSame('48.51', $rounded['amount']);
|
||||
}
|
||||
|
||||
public function test_multiple_goods_share_parcel_freight_once_and_all_matched_goods_are_costed(): void
|
||||
{
|
||||
$row = $this->service([
|
||||
$this->shipment(),
|
||||
$this->shipment(['id' => 2, 'barcode' => 'SKU-B', 'qty' => 3, 'freight' => null]),
|
||||
], ['SKU-A' => '20.00', 'SKU-B' => '5.00'])->apply([$this->row(['product_code' => "SKU-A\nSKU-B"])])[0];
|
||||
$this->assertSame('63.50', $row['amount']);
|
||||
$this->assertSame('55.00', $row['matched_cost']);
|
||||
$this->assertSame('8.50', $row['matched_freight']);
|
||||
$this->assertSame('5', $row['matched_quantity']);
|
||||
}
|
||||
|
||||
public function test_freight_loss_uses_actual_freight_even_above_original_without_requiring_cost(): void
|
||||
{
|
||||
$row = $this->service([$this->shipment(['freight' => '80.00'])], [])
|
||||
->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => '30.00', 'product_code' => ''])])[0];
|
||||
$this->assertSame('80.00', $row['amount']);
|
||||
$this->assertStringContainsString('运费损耗按原单实际运费记账', $row['remark']);
|
||||
$originalZero = $this->service([$this->shipment(['freight' => '8.50'])], [])
|
||||
->apply([$this->row(['bookkeeping_type' => '运费损耗', 'amount' => '0.00'])])[0];
|
||||
$this->assertSame('8.50', $originalZero['amount']);
|
||||
$this->assertSame('扣款', $originalZero['deduction_status']);
|
||||
$zero = $this->service([$this->shipment(['freight' => '0.00'])], [])
|
||||
->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0];
|
||||
$this->assertSame('0.00', $zero['amount']);
|
||||
$this->assertSame('不扣款', $zero['deduction_status']);
|
||||
}
|
||||
|
||||
public function test_exact_tracking_takes_precedence_and_missing_tracking_does_not_borrow_another_parcel(): void
|
||||
{
|
||||
$shipments = [$this->shipment(), $this->shipment(['id' => 2, 'source_id' => 11, 'express_no' => 'EXP-2', 'freight' => '20.00'])];
|
||||
$service = $this->service($shipments);
|
||||
$this->assertSame('48.50', $service->apply([$this->row()])[0]['amount']);
|
||||
$missing = $service->apply([$this->row(['tracking_no' => 'MISSING'])])[0];
|
||||
$this->assertSame('待确认', $missing['deduction_status']);
|
||||
$this->assertSame('100.00', $missing['amount']);
|
||||
$byOrder = $service->apply([$this->row(['tracking_no' => ''])])[0];
|
||||
$this->assertSame('80.00', $byOrder['matched_cost']);
|
||||
$this->assertSame('28.50', $byOrder['matched_freight']);
|
||||
$this->assertSame('100.00', $byOrder['amount']);
|
||||
}
|
||||
|
||||
public function test_window_excludes_april_and_november_and_crosses_years_correctly(): void
|
||||
{
|
||||
foreach (['2026-04-30 23:59:59', '2026-11-01 00:00:00'] as $date) {
|
||||
$row = $this->service([$this->shipment(['ship_time' => $date])])->apply([$this->row()])[0];
|
||||
$this->assertSame('待确认', $row['deduction_status']);
|
||||
}
|
||||
$this->assertSame(['2025-09-01 00:00:00', '2026-03-01 00:00:00'], Repository::window(['started_at' => '2026-02-28']));
|
||||
}
|
||||
|
||||
public function test_missing_cost_quantity_freight_or_goods_stays_pending_instead_of_silently_zeroing(): void
|
||||
{
|
||||
foreach ([
|
||||
[[$this->shipment()], []], [[$this->shipment()], ['SKU-A' => '0']],
|
||||
[[$this->shipment(['qty' => null])], ['SKU-A' => '20']],
|
||||
[[$this->shipment(['freight' => null])], ['SKU-A' => '20']],
|
||||
[[$this->shipment(['freight' => '-1'])], ['SKU-A' => '20']],
|
||||
[[$this->shipment(['barcode' => 'SKU-B'])], ['SKU-A' => '20']],
|
||||
] as [$shipments, $costs]) {
|
||||
$row = $this->service($shipments, $costs)->apply([$this->row()])[0];
|
||||
$this->assertSame('待确认', $row['deduction_status']);
|
||||
$this->assertSame('100.00', $row['amount']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_supplier_or_warehouse_exclusions_and_small_zeros_are_never_overridden(): void
|
||||
{
|
||||
$repository = $this->createMock(Repository::class);
|
||||
$repository->expects($this->never())->method('shipmentsFor');
|
||||
$service = new SupplierBookkeepingValuation($repository);
|
||||
$rows = [$this->row(['deduction_status' => '不扣款']), $this->row(['deduction_status' => '待确认']),
|
||||
$this->row(['deduction_status' => '不扣款', 'amount' => '0.00'])];
|
||||
$this->assertSame($rows, $service->apply($rows));
|
||||
}
|
||||
|
||||
public function test_partial_multi_tracking_match_is_pending_and_cannot_deduct_only_one_package(): void
|
||||
{
|
||||
$row = $this->service([$this->shipment()])->apply([$this->row(['tracking_no' => "EXP-1\nEXP-2"])])[0];
|
||||
$this->assertSame('待确认', $row['deduction_status']);
|
||||
$this->assertSame('100.00', $row['amount']);
|
||||
}
|
||||
}
|
||||
@ -162,4 +162,19 @@ class SupplierBookkeepingWorkOrderRepositoryTest extends TestCase
|
||||
$this->assertSame(['GD-2'], array_column(iterator_to_array($page->rows(['started_from' => '2026-09-10', 'supplier' => '胜'] + $filters, $rows), false), 'work_order_no'));
|
||||
$this->assertSame([], iterator_to_array($page->rows(['started_to' => '2026-09-01'] + $filters, $rows), false));
|
||||
}
|
||||
|
||||
public function test_keeps_item_alignment_when_a_buyer_paid_amount_is_missing(): void
|
||||
{
|
||||
$this->state();
|
||||
$this->order(1, 'GD-MULTI', [
|
||||
self::REASON => '厂家记账/缺货损失',
|
||||
'itemInfo' => [
|
||||
['skuId' => 'SKU-A', 'qty' => 1, 'buyerPaidAmount' => 100],
|
||||
['skuId' => 'SKU-B', 'qty' => 2],
|
||||
],
|
||||
]);
|
||||
$row = $this->repository()->rows()[0];
|
||||
$this->assertSame("100\n", $row['buyer_paid_amount']);
|
||||
$this->assertSame("SKU-A\nSKU-B", $row['product_code']);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user