fix: 厂家记账按原单实际运费与商品成本核价 #950

Merged
limeng merged 9 commits from fix/limeng-bookkeeping-small-compensation into main 2026-11-07 09:43:19 +08:00
16 changed files with 1236 additions and 4 deletions

View File

@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\SupplierBookkeepingShippingImporter;
class ImportSupplierBookkeepingShipments extends Command
{
protected $signature = 'supplier-bookkeeping:import-shipments {file} {manifest} {--dry-run}';
protected $description = '流式导入工作平台月度发货精简文件;保留来源摘要,支持断点,整月完成后生效';
public function handle(SupplierBookkeepingShippingImporter $importer): int
{
$start = microtime(true);
$result = $importer->import($this->argument('file'), $this->argument('manifest'), (bool) $this->option('dry-run'));
$this->info(json_encode($result + ['seconds' => round(microtime(true) - $start, 2), 'peak_mb' => round(memory_get_peak_usage(true) / 1048576, 2)], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema->create('supplier_bookkeeping_shipping_months', function (Blueprint $t): void {
$t->date('source_month')->primary();
$t->uuid('active_generation')->nullable();
$t->char('active_sha256', 64)->nullable();
$t->unsignedInteger('active_rows')->default(0);
$t->string('status', 24)->default('pending');
$t->uuid('pending_generation')->nullable();
$t->char('pending_sha256', 64)->nullable();
$t->unsignedInteger('checkpoint')->default(0);
$t->string('source_filename', 255)->nullable();
$t->char('source_sha256', 64)->nullable();
$t->string('freight_column', 32)->nullable();
$t->dateTime('last_succeeded_at')->nullable();
$t->timestamps();
});
$schema->create('supplier_bookkeeping_shipping_rows', function (Blueprint $t): void {
$t->uuid('generation');
$t->unsignedInteger('source_row');
foreach (['express_no', 'old_express_no', 'trade_no', 'barcode', 'package_no'] as $column) $t->text($column);
$t->string('qty', 64);
$t->string('freight', 64);
$t->dateTime('ship_time')->nullable();
$t->string('unshipped', 64);
$t->primary(['generation', 'source_row'], 'sb_shipping_rows_pk');
});
$schema->create('supplier_bookkeeping_shipping_refs', function (Blueprint $t): void {
$t->uuid('generation');
$t->unsignedInteger('source_row');
$t->string('kind', 8);
$t->string('reference', 160);
$t->primary(['generation', 'source_row', 'kind', 'reference'], 'sb_shipping_refs_pk');
$t->index(['kind', 'reference', 'generation', 'source_row'], 'sb_shipping_reference_lookup');
});
}
public function down(): void
{
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('supplier_bookkeeping_shipping_refs');
$schema->dropIfExists('supplier_bookkeeping_shipping_rows');
$schema->dropIfExists('supplier_bookkeeping_shipping_months');
}
};

View File

@ -32,6 +32,7 @@
"product:monthly-weighted-average:base",
"store-violations:import-rules",
"supplier-bookkeeping:import-configs",
"supplier-bookkeeping:import-shipments",
"systemization-todos:import",
"business:import-taobao-alipay-bills",
"taobao-alipay-bills:import-authoritative",

View File

@ -1,5 +1,11 @@
# 上游业务镜像清单
## 厂家记账月度发货导出
厂家记账核价使用工作平台月度导出的受控镜像,2026年10月账期覆盖5—10月,不借用年份不符或覆盖不足的现有发货来源。`supplier_bookkeeping_shipping_months` 保存月覆盖、摘要、可见代次及断点;`supplier_bookkeeping_shipping_rows` 保存最少核价字段和来源行号;`supplier_bookkeeping_shipping_refs` 保存快递号、换单前单号和订单号索引。原件只读,不接入收件人等个人字段。
预处理脚本为 `scripts/prepare-supplier-bookkeeping-shipping.py`,导入命令为受限批入口下的 `supplier-bookkeeping:import-shipments`。完整月导入成功后原子切换代次,失败保留旧版,可续传。运费取“运费”,5月取经用户确认的“系统运费”,不使用“结算运费”。普通商品当前成本读取既有商品资料镜像。规则和验收范围见 [厂家记账金额规则](厂家记账金额规则.md)。
## 结论
项目不是只使用 `rpt.Bulk_Price_Limit` 一张表。当前主线代码直接读取两套上游中的约 43 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、快递资料、板材、封边条和库存。

View File

@ -0,0 +1,45 @@
# 厂家记账金额规则
本轮为只读计算,保留工单原金额及原备注,不回写上游工单。页面、扣款筛选、合计、CSV 导出共用同一计算结果。
## 判定顺序
1. 对“补偿款项”“记账全款”(兼容“记账全额”),原金额大于0且小于50元,先判断原金额÷商品买家实付合计是否小于40%。命中后金额为0,不扣款,追加备注 `小于50且小于0.4,记账改0`。
2. 未命中的,再用工单 `actual_paid_amount`(页面“实付金额”)作分母,独立判断同样的严格边界。任一轮实付缺失、无效或为0时,只跳过该轮;另一轮仍可正常判断。多商品买家实付缺一项时,不把部分金额当作完整合计。
3. 保留厂家记账配置、代发仓发错货限制和多供应商待确认规则。只有已判定可扣款的剩余工单进入核价,不用核价推翻“不扣款”或“待确认”。
4. 补偿、全款按原单商品的实际发货数量乘普通商品资料当前成本单价,再加实际包裹运费,与原金额取较小值。成本为当前 `cost_price`,不代表历史月份成本。
5. 运费损耗按实际运费记账,不受原金额限制;原金额为0也需要匹配。匹配结果确为0时不扣款,不要求商品成本和数量。
金额等于50元或比例等于40%均不满足小额免记账。金额计算使用十进制运算,在最终金额处四舍五入到分。
## 发货匹配
- 运费取工作平台月度导出“运费”;5月“系统运费”经用户确认是相同口径。不使用“结算运费”,不借用覆盖不足的现有发货表或其他年份数据。
- 查找时间为工单发起月份及前五个月,右端不含下月1日。例如10月工单读取5月1日至11月1日前的发货记录。六个月未完整导入时待确认。
- 有物流单号时精确匹配该单号或换单前单号,同时核对工单提供的线上订单号;无物流单号时按线上订单号匹配全部相关包裹。合并订单按拆分后的完整单号建立索引。
- 不以平均运费替代原单运费,不借同订单其他快递单的运费;多个单号只匹配部分时待确认。
- 工单商品须全部匹配。一个单元格合并多个商品编码、仅有总数量时,不推测每个商品的数量,标为待确认。
- 包裹运费只取一次;同包裹同商品的重复行不重复累计数量。运费或数量冲突时待确认,不相加、不擅自选值。
- 缺发货、有效数量、成本价或运费时待确认,保留原金额,不计扣款合计。明确0运费有效,空运费不能当0。
- 原记账金额、匹配成本/运费/数量/快递号/期间供页面核对与导出。
## 月度发货导出镜像
来源为用户提供的2026年5—10月六份工作平台导出。原件只读,`scripts/prepare-supplier-bookkeeping-shipping.py` 按表头生成精简 CSV.GZ 和清单;保留文本单号、来源行号、原文件摘要、数量及运费,不接入姓名、电话、地址及留言。共享字符串使用磁盘 SQLite,工作表和输出均流式处理。
`supplier-bookkeeping:import-shipments` 导入共享业务库:`supplier_bookkeeping_shipping_months` 保存月覆盖、摘要、可见代次和游标;`supplier_bookkeeping_shipping_rows` 保存核价字段与来源行号;`supplier_bookkeeping_shipping_refs` 保存单号索引。每500行在同一事务写明细、索引和游标,整月校验成功后切换可见代次;失败保留旧版并可续传。同清单已生效则不重复写入;摘要、行数、月份或运费字段不符拒绝生效,旧代次保留作回退依据。
生产只通过受限批入口执行:
```sh
sh /opt/caiwuxitong/run-finance-batch.sh supplier-bookkeeping:import-shipments /private/month.csv.gz /private/month.csv.gz.json
```
命令已登记导入清单,沿用1核、1GiB容器、PHP 512MiB、1小时上限和同步互斥锁。页面每批最多150个单号定点读取,不扫描六个月全表。9月、11月工单需要各自完整窗口,不能据此宣称其历史数据已齐。
## 验证范围
- 金额与页面测试覆盖两轮严格边界、成本封顶、实际运费、原金额为0的运费损耗、重复包裹、多商品总数量、缺项及缺月份、换单/合并订单、厂家与仓库限制、页面/筛选/CSV一致性。
- 导入测试覆盖摘要、错误运费列、月份和行数不符、失败保留旧代次、断点续传、同清单幂等。预处理测试覆盖长单号、富文本、公式缓存、排除个人信息及来源只读。
- 本地隔离 SQLite 实测导入296,540行、587,476条单号索引:19.39秒,PHP峰值82.5MiB,数据库执行约5.85秒;单号查询使用覆盖索引。此数据不代替生产耗时验收。
- 生产试算、真实财务明细、日志和截图只保存于忽略的私有目录。生产状态以实际导入和页面验收为准。

View File

@ -0,0 +1,131 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\SupplierBookkeepingShippingImporter as Shipping;
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 = $this->mirror->connection();
$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 (['tracking' => $tracking, 'order' => $orders] as $kind => $values) {
foreach (array_chunk(array_values(array_unique($values)), 150) as $chunk) {
$query = $db->table(Shipping::REFS . ' AS f')
->join(Shipping::ROWS . ' AS r', function ($join) {
$join->on('r.generation', '=', 'f.generation')->on('r.source_row', '=', 'f.source_row');
})
->join(Shipping::MONTHS . ' AS m', 'm.active_generation', '=', 'r.generation')
->where('f.kind', $kind)->whereIn('f.reference', $chunk)
->where('m.source_month', '>=', substr($from, 0, 10))->where('m.source_month', '<', substr($to, 0, 10))
->where(function ($q) use ($from, $to) {
$q->whereNull('r.ship_time')->orWhere(function ($q) use ($from, $to) {
$q->where('r.ship_time', '>=', $from)->where('r.ship_time', '<', $to);
});
})->select('r.*');
foreach ($query->cursor() as $record) {
$shipment = (array) $record;
$shipment['id'] = $record->generation . ':' . $record->source_row;
$shipment['tracking_numbers'] = array_values(array_unique(array_merge(self::numbers($record->express_no), self::numbers($record->old_express_no))));
$shipment['order_numbers'] = self::numbers($record->trade_no);
$shipment['source_id'] = hash('sha256', $record->express_no . '|' . $record->package_no);
$found[$shipment['id']] = $shipment;
if (count($found) > 20000) throw new RuntimeException('匹配发货记录过多,须缩小工单范围');
}
}
}
return array_values($found);
}
public function readyMonths(): array
{
return $this->mirror->connection()->table(Shipping::MONTHS)->whereNotNull('active_generation')
->pluck('source_month')->map(static fn ($date) => substr($date, 0, 7))->all();
}
/** @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)));
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingValuationRepository as Valuation;
use RuntimeException;
use Throwable;
/** 工作平台月度导出的精简只读镜像;分批提交游标,整月完成后才切换可见代次。 */
class SupplierBookkeepingShippingImporter
{
public const MONTHS = 'supplier_bookkeeping_shipping_months';
public const ROWS = 'supplier_bookkeeping_shipping_rows';
public const REFS = 'supplier_bookkeeping_shipping_refs';
public const HEADERS = ['source_row', 'express_no', 'old_express_no', 'trade_no', 'product_id', 'barcode', 'qty', 'freight', 'ship_time', 'package_no', 'unshipped'];
public function __construct(private readonly DatabaseManager $databases) {}
public function import(string $path, string $manifestPath, bool $dryRun = false): array
{
if (!is_file($path) || !is_file($manifestPath) || filesize($manifestPath) > 65536) throw new RuntimeException('导入文件或清单无效。');
$meta = json_decode(file_get_contents($manifestPath), true, 32, JSON_THROW_ON_ERROR);
foreach (['source_month', 'source_filename', 'source_sha256', 'data_sha256', 'row_count', 'freight_column'] as $field) {
if (!isset($meta[$field])) throw new RuntimeException('导入清单缺少字段:' . $field);
}
if (($meta['schema_version'] ?? null) !== 1
|| !preg_match('/^\d{4}-(0[1-9]|1[0-2])$/D', $meta['source_month'])
|| !preg_match('/^[a-f0-9]{64}$/D', $meta['source_sha256'])
|| !preg_match('/^[a-f0-9]{64}$/D', $meta['data_sha256'])
|| !is_int($meta['row_count']) || $meta['row_count'] < 1
|| !in_array($meta['freight_column'], ['运费', '系统运费'], true)
|| !hash_equals($meta['data_sha256'], hash_file('sha256', $path))) {
throw new RuntimeException('导入清单或文件摘要校验失败。');
}
$db = $this->databases->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$month = $meta['source_month'] . '-01';
$hash = hash('sha256', json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$lock = fopen(storage_path('framework/sb-shipping-import.lock'), 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) throw new RuntimeException('另一个发货数据导入正在运行。');
$stream = null;
$generation = null;
try {
$state = $db->table(self::MONTHS)->where('source_month', $month)->first();
if (!$dryRun && $state && $state->active_sha256 === $hash) return ['status' => 'unchanged', 'rows' => (int) $state->active_rows];
$generation = $state && $state->pending_sha256 === $hash && $state->pending_generation ? $state->pending_generation : (string) Str::uuid();
$checkpoint = $state && $state->pending_generation === $generation ? (int) $state->checkpoint : 0;
if (!$dryRun) {
$db->table(self::MONTHS)->updateOrInsert(['source_month' => $month], [
'status' => 'running', 'pending_generation' => $generation, 'pending_sha256' => $hash,
'checkpoint' => $checkpoint, 'updated_at' => now(),
]);
}
$stream = fopen('compress.zlib://' . $path, 'rb');
if ($stream === false || fgetcsv($stream) !== self::HEADERS) throw new RuntimeException('精简发货文件表头无效。');
$count = 0;
$previous = 1;
$batch = [];
$refs = [];
while (($values = fgetcsv($stream)) !== false) {
if (count($values) !== count(self::HEADERS)) throw new RuntimeException('精简发货文件列数无效。');
$record = array_combine(self::HEADERS, $values);
$sourceRow = filter_var($record['source_row'], FILTER_VALIDATE_INT);
if ($sourceRow === false || $sourceRow <= $previous) throw new RuntimeException('来源行号必须递增。');
$previous = $sourceRow;
$count++;
if ($count > $meta['row_count']) throw new RuntimeException('文件行数超过清单。');
foreach (['qty', 'freight', 'unshipped'] as $field) {
if (mb_strlen($record[$field]) > 64) throw new RuntimeException('来源字段超长:' . $field);
}
// 空值和错误值原样保存,核价时待确认,不把缺失金额改成0。
try {
$date = $record['ship_time'] !== '' ? Carbon::parse($record['ship_time'])->format('Y-m-d H:i:s') : null;
} catch (Throwable) { $date = null; }
if ($date !== null && substr($date, 0, 7) !== $meta['source_month']) throw new RuntimeException('发货日期超出清单月份,须核对来源。');
$recordRefs = [];
foreach (['tracking' => array_merge(Valuation::numbers($record['express_no']), Valuation::numbers($record['old_express_no'])), 'order' => Valuation::numbers($record['trade_no'])] as $kind => $numbers) {
if (count($numbers) > 100) throw new RuntimeException('单行关联单号过多,须核对来源。');
foreach (array_unique($numbers) as $number) {
if (mb_strlen($number) > 160) throw new RuntimeException('关联单号超长。');
$recordRefs[] = ['generation' => $generation, 'source_row' => $sourceRow, 'kind' => $kind, 'reference' => $number];
}
}
if ($dryRun || $count <= $checkpoint) continue;
unset($record['product_id']);
$record['generation'] = $generation;
$record['source_row'] = $sourceRow;
$record['ship_time'] = $date;
$batch[] = $record;
array_push($refs, ...$recordRefs);
if (count($batch) >= 500) {
$this->flush($db, $month, $count, $batch, $refs);
$batch = $refs = [];
}
}
if ($count !== $meta['row_count']) throw new RuntimeException('文件行数与清单不一致,未切换可见数据。');
if (!$dryRun) {
if ($batch !== []) $this->flush($db, $month, $count, $batch, $refs);
$db->table(self::MONTHS)->where('source_month', $month)->update([
'status' => 'succeeded', 'active_generation' => $generation, 'active_sha256' => $hash, 'active_rows' => $count,
'pending_generation' => null, 'pending_sha256' => null, 'checkpoint' => 0,
'source_filename' => basename($meta['source_filename']), 'source_sha256' => $meta['source_sha256'],
'freight_column' => $meta['freight_column'], 'last_succeeded_at' => now(), 'updated_at' => now(),
]);
}
return ['status' => $dryRun ? 'validated' : 'succeeded', 'rows' => $count, 'resumed_from' => $checkpoint];
} catch (Throwable $e) {
if (!$dryRun && $generation !== null) $db->table(self::MONTHS)->where('source_month', $month)->where('pending_generation', $generation)->update(['status' => 'failed', 'updated_at' => now()]);
throw $e;
} finally {
if (is_resource($stream)) fclose($stream);
flock($lock, LOCK_UN);
fclose($lock);
}
}
private function flush($db, string $month, int $checkpoint, array $rows, array $refs): void
{
$db->transaction(function () use ($db, $month, $checkpoint, $rows, $refs): void {
$db->table(self::ROWS)->insert($rows);
foreach (array_chunk($refs, 1000) as $chunk) $db->table(self::REFS)->insert($chunk);
$db->table(self::MONTHS)->where('source_month', $month)->update(['checkpoint' => $checkpoint, 'updated_at' => now()]);
});
}
}

View File

@ -0,0 +1,171 @@
<?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));
$readyMonths = $this->repository->readyMonths();
} 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) {
foreach ($shipment['tracking_numbers'] ?? [(string) $shipment['express_no']] as $reference) $byTracking[$reference][] = $shipment;
foreach ($shipment['order_numbers'] ?? [(string) $shipment['trade_no']] as $reference) $byOrder[$reference][] = $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, $readyMonths);
}
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 $readyMonths): array
{
[$from, $to] = Repository::window($row);
$row['valuation_window'] = substr($from, 0, 7) . ' 至 ' . substr((string) $row['started_at'], 0, 7);
$month = \Illuminate\Support\Carbon::parse($from);
while ($month->format('Y-m-d H:i:s') < $to) {
if (!in_array($month->format('Y-m'), $readyMonths, true)) return $this->review($row, '六个月发货文件尚未补齐:' . $month->format('Y-m'));
$month->addMonth();
}
$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'] !== null && ($shipment['ship_time'] < $from || $shipment['ship_time'] >= $to)) continue;
if ($tracking !== [] && $orders !== [] && array_intersect($shipment['order_numbers'] ?? [(string) $shipment['trade_no']], $orders) === []) continue;
$pool[$shipment['id']] = $shipment;
}
}
if ($pool === []) return $this->review($row, '六个月内未匹配到原订单/快递单号的发货记录');
$matchedReferences = [];
foreach ($pool as $shipment) array_push($matchedReferences, ...($tracking !== [] ? ($shipment['tracking_numbers'] ?? [$shipment['express_no']]) : ($shipment['order_numbers'] ?? [$shipment['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'] === '运费损耗';
$lines = [];
if (!$freightOnly && $wanted === []) return $this->review($row, '工单没有商品编码,无法匹配成本');
foreach ($pool as $shipment) {
if ($shipment['ship_time'] === null) return $this->review($row, '匹配发货日期无效,请核对');
if (trim((string) $shipment['express_no']) === '' || count(Repository::numbers($shipment['express_no'])) !== 1) return $this->review($row, '包裹快递单号缺失或不唯一,请核对');
$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, '原单运费无效,请核对');
// 汇总表的包裹运费可能随每件商品重复关联,只取一次;冲突值不能相加。
if ($packages[$package]['has_freight']
&& bccomp($packages[$package]['amount'], (string) $shipment['freight'], 6) !== 0) {
return $this->review($row, '同一包裹运费不一致,请核对');
}
$packages[$package]['amount'] = (string) $shipment['freight'];
$packages[$package]['has_freight'] = true;
}
$sku = Repository::sku($shipment['barcode']);
if ($freightOnly) continue;
$components = array_map([Repository::class, 'sku'], Repository::numbers($shipment['barcode']));
if (count($components) > 1 && array_intersect(array_keys($wanted), $components) !== []) return $this->review($row, '多商品包裹只有合计数量,缺少逐商品发货数量');
if (!$freightOnly && !array_key_exists($sku, $wanted)) continue;
if (!self::decimal($shipment['qty']) || bccomp((string) $shipment['qty'], '0', 6) <= 0) {
return $this->review($row, '匹配发货数量缺失或无效');
}
$lineKey = $package . '|' . $sku;
if (isset($lines[$lineKey])) {
if (bccomp($lines[$lineKey], (string) $shipment['qty'], 6) !== 0) return $this->review($row, '同一包裹商品数量不一致,请核对');
continue;
}
$lines[$lineKey] = (string) $shipment['qty'];
$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'] = $freightOnly ? '' : 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);
}
}

View File

@ -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
@ -28,6 +29,8 @@ class SupplierBookkeepingPageData
'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' => '物流公司',
@ -41,11 +44,12 @@ class SupplierBookkeepingPageData
'accepting_department' => '受理部门', 'order_shipped_at' => '订单发货时间',
];
public const MONEY_COLUMNS = ['amount', 'original_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> */
@ -100,8 +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;
@ -109,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;
}
}

View File

@ -53,7 +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">小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于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>

View File

@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Stream a work-platform XLSX into a minimal, auditable monthly shipping input.
Uses only the Python standard library. Shared strings stay in a temporary SQLite
file with an 8MiB page cache; worksheet rows and CSV output are streamed. No
customer names, phone numbers, addresses, notes or settlement freight are copied.
"""
import argparse
import csv
import gzip
import hashlib
import json
import re
import sqlite3
import tempfile
import time
import zipfile
from datetime import datetime, timedelta
from contextlib import ExitStack
from functools import lru_cache
from pathlib import Path
from xml.etree import ElementTree as ET
NS = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'
FIELDS = {
'express_no': '快递单号', 'old_express_no': '换单前单号',
'trade_no': '交易单号', 'product_id': '商品ID', 'barcode': '条形码',
'qty': '数量', 'freight': '运费', 'ship_time': '发货时间',
'package_no': '配货单号', 'unshipped': '未发出',
}
def digest(path):
h = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
def elements(stream, tag):
"""Clear parent references as well as cell contents, keeping memory bounded."""
stack = []
with stream:
for event, element in ET.iterparse(stream, events=('start', 'end')):
if event == 'start':
stack.append(element)
else:
if element.tag == NS + tag:
yield element
element.clear()
if len(stack) > 1:
stack[-2].remove(element)
stack.pop()
def date_value(value):
if re.fullmatch(r'\d+(?:\.\d+)?', value):
return (datetime(1899, 12, 30) + timedelta(days=float(value))).strftime('%Y-%m-%d %H:%M:%S')
value = value.replace('/', '-').replace('T', ' ')
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'):
try:
return datetime.strptime(value, fmt).strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
pass
return value # Invalid/missing dates remain explicit and cannot be valued.
def prepare(source, month, output, freight_column):
started = time.perf_counter()
if not re.fullmatch(r'\d{4}-(0[1-9]|1[0-2])', month):
raise ValueError('月份须为YYYY-MM')
if output.exists() or Path(str(output) + '.json').exists():
raise ValueError('输出已存在,请使用新路径以保留既有导入凭据')
output.parent.mkdir(parents=True, exist_ok=True)
temp_output = Path(str(output) + '.partial')
source_hash = digest(source)
with tempfile.TemporaryDirectory(prefix='sb-shipping-') as temp, zipfile.ZipFile(source) as book, ExitStack() as cleanup:
db = sqlite3.connect(Path(temp) / 'strings.sqlite')
cleanup.callback(db.close)
db.execute('PRAGMA journal_mode=OFF')
db.execute('PRAGMA synchronous=OFF')
db.execute('PRAGMA cache_size=-8192')
db.execute('CREATE TABLE strings(id INTEGER PRIMARY KEY,value TEXT)')
if 'xl/sharedStrings.xml' in book.namelist():
batch = []
for index, element in enumerate(elements(book.open('xl/sharedStrings.xml'), 'si')):
batch.append((index, ''.join(e.text or '' for e in element.iter(NS + 't'))))
if len(batch) >= 2000:
db.executemany('INSERT INTO strings VALUES (?,?)', batch)
batch = []
if batch:
db.executemany('INSERT INTO strings VALUES (?,?)', batch)
db.commit()
@lru_cache(maxsize=30000)
def shared_string(index):
result = db.execute('SELECT value FROM strings WHERE id=?', (int(index),)).fetchone()
if result is None:
raise ValueError('共享字符串索引无效')
return result[0]
def cell(element):
if element.get('t') == 'inlineStr':
return ''.join(e.text or '' for e in element.iter(NS + 't'))
value = element.find(NS + 'v')
if value is None or value.text is None:
return ''
return shared_string(value.text) if element.get('t') == 's' else value.text
workbook = ET.fromstring(book.read('xl/workbook.xml'))
sheets = workbook.findall(NS + 'sheets/' + NS + 'sheet')
matching = [sheet for sheet in sheets if sheet.get('name') == '导出结果']
if len(matching) != 1:
raise ValueError('须包含唯一的“导出结果”工作表')
rel_id = matching[0].get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id')
relationships = ET.fromstring(book.read('xl/_rels/workbook.xml.rels'))
target = next(rel.get('Target') for rel in relationships if rel.get('Id') == rel_id).lstrip('/')
if not target.startswith('xl/'):
target = 'xl/' + target
count = 0
missing_dates = 0
with gzip.open(temp_output, 'wt', encoding='utf-8', newline='') as stream:
writer = csv.writer(stream)
writer.writerow(['source_row', *FIELDS])
mapping = None
worksheet_rows = elements(book.open(target), 'row')
cleanup.callback(worksheet_rows.close)
for row in worksheet_rows:
row_number = int(row.get('r'))
if mapping is None:
headers = {}
for c in row.findall(NS + 'c'):
label = cell(c)
headers.setdefault(label, []).append(re.sub(r'\d+$', '', c.get('r')))
fields = dict(FIELDS, freight=freight_column)
required = ['express_no', 'trade_no', 'barcode', 'qty', 'freight', 'ship_time']
if any(len(headers.get(fields[key], [])) != 1 for key in required):
raise ValueError('必需表头缺失或重复')
mapping = {headers[label][0]: key for key, label in fields.items() if label in headers}
continue
values = {}
for c in row.findall(NS + 'c'):
key = mapping.get(re.sub(r'\d+$', '', c.get('r')))
if key:
values[key] = cell(c).strip()
if not any(values.values()):
continue
date = date_value(values.get('ship_time', ''))
if re.fullmatch(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', date):
if date[:7] != month:
raise ValueError('发货日期超出所选月份')
else:
missing_dates += 1
values['ship_time'] = date
writer.writerow([row_number, *[values.get(key, '') for key in FIELDS]])
count += 1
shared_string.cache_clear()
db.close()
if count == 0:
raise ValueError('工作表没有发货记录')
temp_output.rename(output)
meta = dict(schema_version=1, source_month=month, source_filename=source.name,
source_sha256=source_hash, data_sha256=digest(output), row_count=count,
freight_column=freight_column)
Path(str(output) + '.json').write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
return dict(rows=count, missing_dates=missing_dates, seconds=round(time.perf_counter()-started, 2), output_bytes=output.stat().st_size)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('source', type=Path)
parser.add_argument('month')
parser.add_argument('output', type=Path)
parser.add_argument('--freight-column', choices=['运费', '系统运费'], default='运费')
args = parser.parse_args()
print(json.dumps(prepare(args.source, args.month, args.output, args.freight_column), ensure_ascii=False))

View File

@ -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());
}
@ -156,6 +161,41 @@ class SupplierBookkeepingPageTest extends FeatureTestCase
$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('readyMonths')->andReturn(['2026-04', '2026-05', '2026-06', '2026-07', '2026-08', '2026-09', '2026-10']);
$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,', $csv);
$this->assertStringNotContainsString('GD-PENDING', $csv);
$this->assertStringNotContainsString('GD-ROUND-TWO', $csv);
}
public function test_second_round_zeroes_by_order_paid_when_buyer_paid_ratio_does_not_hit(): void
{
$this->mockRepository([

View File

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\SupplierBookkeepingShippingImporter as Importer;
use Tests\TestCase;
class SupplierBookkeepingShippingImporterTest extends TestCase
{
private array $files = [];
protected function setUp(): void
{
parent::setUp();
if (!\Illuminate\Support\Facades\Schema::connection('business_mirror')->hasTable(Importer::MONTHS)) {
(require base_path('database/migrations/2026_11_07_100000_create_supplier_bookkeeping_shipping_mirror.php'))->up();
}
}
protected function tearDown(): void
{
foreach ($this->files as $file) @unlink($file);
parent::tearDown();
}
private function input(int $count = 2, array $patch = []): array
{
$file = tempnam(sys_get_temp_dir(), 'sb-shipping-');
$manifest = $file . '.json';
$this->files = array_merge($this->files, [$file, $manifest]);
$out = fopen('compress.zlib://' . $file, 'wb');
fputcsv($out, Importer::HEADERS);
for ($i = 2; $i < $count+2; $i++) {
fputcsv($out, [$i, 'EXP-'.$i, 'OLD-'.$i, 'ORDER-'.$i.',MERGED-'.$i, '', 'SKU-A', '2', $i === 2 ? '' : '8.50', '2026/10/1 1:02:03', '', '']);
}
fclose($out);
$meta = array_replace(['schema_version' => 1, 'source_month' => '2026-10', 'source_filename' => 'source.xlsx',
'source_sha256' => str_repeat('a',64), 'data_sha256' => hash_file('sha256', $file), 'row_count' => $count, 'freight_column' => '运费'], $patch);
file_put_contents($manifest, json_encode($meta, JSON_THROW_ON_ERROR));
return [$file, $manifest];
}
public function test_streams_exact_references_preserves_missing_freight_and_is_idempotent(): void
{
[$file, $manifest] = $this->input();
$importer = app(Importer::class);
$this->assertSame('validated', $importer->import($file,$manifest,true)['status']);
$db = DB::connection('business_mirror');
$this->assertSame(0, $db->table(Importer::MONTHS)->count());
$this->assertSame('succeeded', $importer->import($file,$manifest)['status']);
$this->assertSame(2, $db->table(Importer::ROWS)->count());
$this->assertSame(8, $db->table(Importer::REFS)->count());
$this->assertSame('', $db->table(Importer::ROWS)->where('source_row',2)->value('freight'));
$this->assertSame('2026-10-01 01:02:03', $db->table(Importer::ROWS)->where('source_row',2)->value('ship_time'));
$this->assertSame('unchanged', $importer->import($file,$manifest)['status']);
$this->assertSame(2, $db->table(Importer::ROWS)->count());
}
public function test_failed_replacement_retains_active_generation_and_resumes_last_committed_batch(): void
{
$importer = app(Importer::class);
$importer->import(...$this->input());
$db = DB::connection('business_mirror');
$active = $db->table(Importer::MONTHS)->value('active_generation');
[$file,$manifest] = $this->input(501);
$inserts = 0; $interrupted = false;
$db->listen(function ($event) use (&$inserts, &$interrupted): void {
if (str_starts_with($event->sql, 'insert into ') && str_contains($event->sql, Importer::ROWS) && ++$inserts === 2 && !$interrupted) {
$interrupted = true;
throw new \RuntimeException('simulated interruption');
}
});
try { $importer->import($file,$manifest); $this->fail('Expected interruption'); }
catch (\RuntimeException $e) { $this->assertSame('simulated interruption', $e->getMessage()); }
$state = $db->table(Importer::MONTHS)->first();
$this->assertSame($active, $state->active_generation);
$this->assertSame(500, (int) $state->checkpoint);
$result = $importer->import($file,$manifest);
$this->assertSame(500, $result['resumed_from']);
$state = $db->table(Importer::MONTHS)->first();
$this->assertNotSame($active, $state->active_generation);
$this->assertSame(501, $db->table(Importer::ROWS)->where('generation',$state->active_generation)->count());
}
public function test_rejects_wrong_column_hash_month_and_incomplete_row_count(): void
{
foreach ([['freight_column' => '结算运费'], ['data_sha256' => str_repeat('b',64)], ['source_month' => '2026-09'], ['row_count' => 3]] as $patch) {
try { app(Importer::class)->import(...$this->input(2,$patch)); $this->fail('Expected invalid source'); }
catch (\RuntimeException $e) { $this->assertNotSame('', $e->getMessage()); }
}
$this->assertNull(DB::connection('business_mirror')->table(Importer::MONTHS)->value('active_generation'));
}
}

View File

@ -0,0 +1,90 @@
<?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('supplier_bookkeeping_shipping_months')) {
(require base_path('database/migrations/2026_11_07_100000_create_supplier_bookkeeping_shipping_mirror.php'))->up();
}
$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');
$db->table('supplier_bookkeeping_shipping_months')->insert(['source_month' => '2026-10-01', 'active_generation' => 'active']);
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('supplier_bookkeeping_shipping_rows')->insert(['generation' => 'active', 'source_row' => $i+2,
'express_no' => $tracking, 'old_express_no' => 'OLD-'.$tracking, 'trade_no' => 'ORDER-1', 'package_no' => '', 'unshipped' => '',
'barcode' => 'SKU-A', 'qty' => 1, 'freight' => '8.50', 'ship_time' => $date . ' 00:00:00']);
foreach (['tracking' => [$tracking, 'OLD-'.$tracking], 'order' => ['ORDER-1']] as $kind => $refs) foreach ($refs as $ref) {
$db->table('supplier_bookkeeping_shipping_refs')->insert(['generation' => 'active', 'source_row' => $i+2, 'kind' => $kind, 'reference' => $ref]);
}
}
$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']);
$this->assertCount(1, $repo->shipmentsFor([['started_at' => '2026-10-15', 'tracking_no' => 'OLD-EXP-1']]));
$this->assertSame(['2026-10'], $repo->readyMonths());
$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']);
}
}

View File

@ -0,0 +1,202 @@
<?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('readyMonths')->willReturn(['2026-05', '2026-06', '2026-07', '2026-08', '2026-09', '2026-10']);
$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_repeated_package_freight_is_counted_once_for_multiple_skus(): void
{
$service = $this->service([
$this->shipment(),
$this->shipment(['id' => 2, 'barcode' => 'SKU-B', 'qty' => 3, 'freight' => '8.500']),
], ['SKU-A' => '20.00', 'SKU-B' => '5.00']);
$row = $service->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']);
$freightOnly = $service->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0];
$this->assertSame('8.50', $freightOnly['amount']);
}
public function test_conflicting_freight_for_one_package_requires_confirmation(): void
{
$row = $this->service([
$this->shipment(),
$this->shipment(['id' => 2, 'barcode' => 'SKU-B', 'freight' => '9.00']),
])->apply([$this->row()])[0];
$this->assertSame('待确认', $row['deduction_status']);
$this->assertSame('100.00', $row['amount']);
$this->assertStringContainsString('同一包裹运费不一致', $row['deduction_basis']);
}
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']);
}
public function test_aggregate_multi_sku_quantity_cannot_be_applied_to_each_product(): void
{
$row = $this->service([$this->shipment(['barcode' => 'SKU-A,SKU-B', 'qty' => '3'])])
->apply([$this->row()])[0];
$this->assertSame('待确认', $row['deduction_status']);
$this->assertStringContainsString('缺少逐商品发货数量', $row['deduction_basis']);
$freight = $this->service([$this->shipment(['barcode' => 'SKU-A,SKU-B', 'qty' => null])])
->apply([$this->row(['bookkeeping_type' => '运费损耗'])])[0];
$this->assertSame('8.50', $freight['amount']);
}
public function test_duplicate_package_sku_rows_do_not_double_quantity_and_conflicts_require_review(): void
{
$row = $this->service([$this->shipment(), $this->shipment(['id' => 2])])->apply([$this->row()])[0];
$this->assertSame('48.50', $row['amount']);
$conflict = $this->service([$this->shipment(), $this->shipment(['id' => 2, 'qty' => '3'])])->apply([$this->row()])[0];
$this->assertSame('待确认', $conflict['deduction_status']);
}
public function test_missing_month_never_allows_partial_valuation_and_auxiliary_lookup_errors_are_not_freight_errors(): void
{
$repo = $this->createMock(Repository::class);
$repo->method('shipmentsFor')->willReturn([$this->shipment()]);
$repo->method('readyMonths')->willReturn(['2026-10']);
$repo->method('costsFor')->willReturn(['SKU-A' => '20']);
$row = (new SupplierBookkeepingValuation($repo))->apply([$this->row()])[0];
$this->assertStringContainsString('尚未补齐:2026-05', $row['deduction_basis']);
$row = $this->service([$this->shipment(['unshipped' => '#N/A'])])->apply([$this->row()])[0];
$this->assertSame('48.50', $row['amount']);
}
public function test_replaced_tracking_and_merged_order_references_match_exactly(): void
{
$row = $this->service([$this->shipment(['express_no' => 'EXP-NEW', 'trade_no' => 'ORDER-1,ORDER-2',
'tracking_numbers' => ['EXP-NEW', 'EXP-1'], 'order_numbers' => ['ORDER-1', 'ORDER-2']])])->apply([$this->row()])[0];
$this->assertSame('48.50', $row['amount']);
}
}

View File

@ -0,0 +1,64 @@
import csv
import gzip
import importlib.util
import json
import tempfile
import unittest
import zipfile
from pathlib import Path
SPEC = importlib.util.spec_from_file_location('prepare_shipping', Path(__file__).resolve().parents[2] / 'scripts/prepare-supplier-bookkeeping-shipping.py')
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class PrepareShippingTest(unittest.TestCase):
def workbook(self, root, date='2026/10/1 0:09:50'):
source = root / 'source.xlsx'
ns = MODULE.NS[1:-1]
headers = ['快递单号', '交易单号', '条形码', '数量', '运费', '发货时间', '结算运费', '收货姓名']
strings = ''.join('<si><t>'+v+'</t></si>' for v in headers)
strings += '<si><r><t>SKU</t></r><r><t>+A</t></r></si>'
head = ''.join(f'<c r="{chr(65+i)}1" t="s"><v>{i}</v></c>' for i in range(8))
row = f'''<c r="A2" t="inlineStr"><is><t>00012345678901234567</t></is></c>
<c r="B2" t="inlineStr"><is><t>ORDER-1,ORDER-2</t></is></c>
<c r="C2" t="s"><v>8</v></c><c r="D2"><v>2</v></c>
<c r="E2"><f>1+2</f><v>3.8</v></c>
<c r="F2" t="inlineStr"><is><t>{date}</t></is></c>
<c r="G2"><v>900</v></c><c r="H2" t="inlineStr"><is><t>PRIVATE</t></is></c>'''
with zipfile.ZipFile(source,'w') as book:
book.writestr('xl/workbook.xml', f'<workbook xmlns="{ns}" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="导出结果" sheetId="1" r:id="rId1"/></sheets></workbook>')
book.writestr('xl/_rels/workbook.xml.rels','<Relationships><Relationship Id="rId1" Target="worksheets/export.xml"/></Relationships>')
book.writestr('xl/sharedStrings.xml', f'<sst xmlns="{ns}">{strings}</sst>')
book.writestr('xl/worksheets/export.xml',f'<worksheet xmlns="{ns}"><sheetData><row r="1">{head}</row><row r="2">{row}</row></sheetData></worksheet>')
return source
def test_preserves_long_identifiers_reads_formula_cache_and_omits_private_fields(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.workbook(root)
original = source.read_bytes()
output = root/'shipping.csv.gz'
result = MODULE.prepare(source,'2026-10',output,'运费')
self.assertEqual(1,result['rows'])
with gzip.open(output,'rt',encoding='utf-8',newline='') as stream:
row = next(csv.DictReader(stream))
self.assertEqual('00012345678901234567',row['express_no'])
self.assertEqual('SKU+A',row['barcode'])
self.assertEqual('3.8',row['freight'])
self.assertEqual('2026-10-01 00:09:50',row['ship_time'])
self.assertNotIn('PRIVATE',str(row))
meta = json.loads(Path(str(output)+'.json').read_text(encoding='utf-8'))
self.assertEqual(MODULE.digest(output),meta['data_sha256'])
self.assertEqual(original,source.read_bytes())
with self.assertRaises(ValueError): MODULE.prepare(source,'2026-10',output,'运费')
def test_rejects_wrong_month_and_wrong_freight_column(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = self.workbook(root)
with self.assertRaises(ValueError): MODULE.prepare(source,'2026-09',root/'wrong-month.csv.gz','运费')
with self.assertRaises(ValueError): MODULE.prepare(source,'2026-10',root/'wrong-column.csv.gz','系统运费')
if __name__ == '__main__': unittest.main()