fix: 厂家记账按原单实际运费与商品成本核价 #950
20
app/Console/Commands/ImportSupplierBookkeepingShipments.php
Normal file
20
app/Console/Commands/ImportSupplierBookkeepingShipments.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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",
|
||||
|
||||
@ -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 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、快递资料、板材、封边条和库存。
|
||||
|
||||
45
docs/厂家记账金额规则.md
Normal file
45
docs/厂家记账金额规则.md
Normal 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秒;单号查询使用覆盖索引。此数据不代替生产耗时验收。
|
||||
- 生产试算、真实财务明细、日志和截图只保存于忽略的私有目录。生产状态以实际导入和页面验收为准。
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
@ -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()]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
177
scripts/prepare-supplier-bookkeeping-shipping.py
Normal file
177
scripts/prepare-supplier-bookkeeping-shipping.py
Normal 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))
|
||||
@ -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([
|
||||
|
||||
@ -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'));
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
202
tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php
Normal file
202
tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
64
tests/scripts/test_prepare_supplier_bookkeeping_shipping.py
Normal file
64
tests/scripts/test_prepare_supplier_bookkeeping_shipping.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user