diff --git a/app/Console/Commands/ImportSupplierBookkeepingShipments.php b/app/Console/Commands/ImportSupplierBookkeepingShipments.php new file mode 100644 index 00000000..1957735e --- /dev/null +++ b/app/Console/Commands/ImportSupplierBookkeepingShipments.php @@ -0,0 +1,20 @@ +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; + } +} diff --git a/database/migrations/2026_11_07_100000_create_supplier_bookkeeping_shipping_mirror.php b/database/migrations/2026_11_07_100000_create_supplier_bookkeeping_shipping_mirror.php new file mode 100644 index 00000000..d4af4454 --- /dev/null +++ b/database/migrations/2026_11_07_100000_create_supplier_bookkeeping_shipping_mirror.php @@ -0,0 +1,54 @@ +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'); + } +}; diff --git a/deploy/finance-batch-imports.json b/deploy/finance-batch-imports.json index 9840abaa..a38b883c 100644 --- a/deploy/finance-batch-imports.json +++ b/deploy/finance-batch-imports.json @@ -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", diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index 711e8d44..2013034d 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -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 个表或视图,覆盖组织资料、订单、退款、营销账户、供应商应付、快递资料、板材、封边条和库存。 diff --git a/docs/厂家记账金额规则.md b/docs/厂家记账金额规则.md new file mode 100644 index 00000000..099dd6f5 --- /dev/null +++ b/docs/厂家记账金额规则.md @@ -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秒;单号查询使用覆盖索引。此数据不代替生产耗时验收。 +- 生产试算、真实财务明细、日志和截图只保存于忽略的私有目录。生产状态以实际导入和页面验收为准。 diff --git a/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php new file mode 100644 index 00000000..d73894a0 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/SupplierBookkeepingValuationRepository.php @@ -0,0 +1,131 @@ +> */ + 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 规范化 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))); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingShippingImporter.php b/modules/EcommerceFinance/Services/SupplierBookkeepingShippingImporter.php new file mode 100644 index 00000000..7774f9bd --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingShippingImporter.php @@ -0,0 +1,127 @@ + 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()]); + }); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php new file mode 100644 index 00000000..74b3c606 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierBookkeepingValuation.php @@ -0,0 +1,171 @@ +> $rows @return list> */ + 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); + } +} diff --git a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php index 821a3334..5f059f16 100644 --- a/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php +++ b/modules/EcommerceFinance/View/SupplierBookkeepingPageData.php @@ -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 */ @@ -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; } } diff --git a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php index 713c69f0..620ef721 100644 --- a/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php +++ b/resources/views/business/pages/supplier-payables/manufacturer-bookkeeping.blade.php @@ -53,7 +53,8 @@
待确认{{ number_format($bookkeepingSummary['review']) }}
接入口径

与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。

厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定:“正常记账”以及配置表中没有的供应商(含配置不在生效期内)计入扣款,“仅代发仓发错货记账”“不记账”不扣;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。工单未带供应商、或多件商品分属不同供应商且判定不一致的显示“待确认”,不计入扣款金额。

-

小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。

+

小额免记账:“补偿款项”“记账全款”依次用买家实付合计、订单实付金额判断,原记账金额小于50元且原记账金额 ÷ 对应实付小于40%时改为0,备注“小于50且小于0.4,记账改0”。金额等于50元、比例等于40%时不免记账;实付缺失、无效或为0的一轮不自动改0。

+

剩余可扣款工单按原快递单号匹配实际发货;未提供快递单号时按线上订单号匹配。查找期间为工单发起月份及前五个月(如10月取5—10月)。商品成本取普通商品资料成本单价×匹配商品的实际发货数量;“补偿款项”“记账全款”取原记账金额与商品成本+实际运费的较小值,“运费损耗”按实际运费记账。包裹运费只累计一次;发货、数量、成本或运费缺失时标为待确认,不计入扣款合计。原金额及原备注保留,页面、筛选、合计和导出使用同一结果。

@if($bookkeepingError)@endif
diff --git a/scripts/prepare-supplier-bookkeeping-shipping.py b/scripts/prepare-supplier-bookkeeping-shipping.py new file mode 100644 index 00000000..60d58b76 --- /dev/null +++ b/scripts/prepare-supplier-bookkeeping-shipping.py @@ -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)) diff --git a/tests/Feature/Business/SupplierBookkeepingPageTest.php b/tests/Feature/Business/SupplierBookkeepingPageTest.php index f3d4ce82..8bf2822c 100644 --- a/tests/Feature/Business/SupplierBookkeepingPageTest.php +++ b/tests/Feature/Business/SupplierBookkeepingPageTest.php @@ -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([ diff --git a/tests/Feature/Console/SupplierBookkeepingShippingImporterTest.php b/tests/Feature/Console/SupplierBookkeepingShippingImporterTest.php new file mode 100644 index 00000000..0ba5094b --- /dev/null +++ b/tests/Feature/Console/SupplierBookkeepingShippingImporterTest.php @@ -0,0 +1,94 @@ +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')); + } +} diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php new file mode 100644 index 00000000..6dae6c5d --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationRepositoryTest.php @@ -0,0 +1,90 @@ + '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']); + } +} diff --git a/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php new file mode 100644 index 00000000..46194192 --- /dev/null +++ b/tests/Unit/EcommerceFinance/SupplierBookkeepingValuationTest.php @@ -0,0 +1,202 @@ + '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']); + } +} diff --git a/tests/scripts/test_prepare_supplier_bookkeeping_shipping.py b/tests/scripts/test_prepare_supplier_bookkeeping_shipping.py new file mode 100644 index 00000000..6f0de7fb --- /dev/null +++ b/tests/scripts/test_prepare_supplier_bookkeeping_shipping.py @@ -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(''+v+'' for v in headers) + strings += 'SKU+A' + head = ''.join(f'{i}' for i in range(8)) + row = f'''00012345678901234567 + ORDER-1,ORDER-2 + 82 + 1+23.8 + {date} + 900PRIVATE''' + with zipfile.ZipFile(source,'w') as book: + book.writestr('xl/workbook.xml', f'') + book.writestr('xl/_rels/workbook.xml.rels','') + book.writestr('xl/sharedStrings.xml', f'{strings}') + book.writestr('xl/worksheets/export.xml',f'{head}{row}') + 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()