feat(种菜数据汇总): 接入聚工单种菜报销,新增种菜损耗扣款页面

- 种菜报销:复用已登记镜像 jiansun.dbo.jst_workOrderInfo,工单类型“种菜报销”、状态已完成,
  按完成时间归属报销月份(M 月 = [M-02 00:00, M+1-02 00:00)),自 2026-09-02 起;同一工单只取最新一版
- 种菜损耗:当月种菜报销的线上订单号匹配上月+本月发货数据(jst_oms_shipping)交易单号;
  任务备注/卖家备注含“拍A发B/拍A发A”的不扣款(单独导出发运营),只发信誉订单虚拟条码/透明挂钩的不扣款,
  其余按发货条码在普通商品资料(ops_jst_sku)的成本价 × 数量 × 1.2 扣款,成本价缺失单独提示
- 两页均支持筛选、列设置、CSV 导出;按月缓存 10 分钟
- 测试:窗口口径、免扣字样、虚拟条码/挂钩、成本价取数与缺失、页面筛选和导出

涉及:金额计算展示(只读);不涉及数据库迁移、权限模型和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
This commit is contained in:
limeng 2026-10-19 14:37:09 +08:00
parent 566a3ef4db
commit ea479733ab
14 changed files with 1559 additions and 0 deletions

View File

@ -3342,6 +3342,8 @@ class Pages extends Controller
'preview-statistics' => $this->contentForPreviewStatistics(),
'planting-details' => $this->contentForPlantingDetails(),
'jst-order-labels' => $this->contentForJstOrderLabels(),
'planting-reimbursements' => app(\Modules\EcommerceFinance\View\PlantingReimbursementPageData::class)->page(),
'planting-loss' => app(\Modules\EcommerceFinance\View\PlantingLossPageData::class)->page(),
'finance-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->financeData(),
'supplier-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->supplierData(),
'store-report-data-logic' => $this->prepareStoreReportLogic(),

View File

@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\PlantingLossCalculator;
use Modules\EcommerceFinance\View\PlantingLossPageData;
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
/** 种菜数据汇总:种菜报销 / 种菜损耗 导出(与页面同一筛选口径)。 */
class PlantingWorkOrders extends Controller
{
public function exportReimbursements(PlantingReimbursementPageData $data)
{
$filters = $data->filters();
return $this->csv(
PlantingReimbursementPageData::TITLE . '-' . $filters['month'],
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
public function exportLoss(PlantingLossPageData $data)
{
$filters = $data->filters();
$suffix = match ($filters['result']) {
PlantingLossCalculator::RESULT_EXEMPT => '-不扣款拍A发B',
PlantingLossCalculator::RESULT_FREE_GOODS => '-不扣款虚拟条码挂钩',
PlantingLossCalculator::RESULT_UNMATCHED => '-未匹配发货',
'all' => '-全部',
default => '-扣款',
};
return $this->csv(
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
/** @param array<string,string> $columns @param callable():iterable<array<string,mixed>> $rows */
private function csv(string $name, array $columns, callable $rows)
{
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, array_values($columns));
foreach ($rows() as $row) {
$values = [];
foreach (array_keys($columns) as $key) {
$values[] = $row[$key] ?? '';
}
fputcsv($file, array_map(static function (mixed $value): string {
$value = (string) $value;
// 长数字单号/账号保留文本,避免 Excel 截断;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '聚工单镜像或发货数据未就绪,未生成不完整的导出文件,请稍后重试。');
}
return response()->streamDownload(static function () use ($file): void {
fpassthru($file);
fclose($file);
}, $name . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -540,6 +540,8 @@ $navigation = [
'pages' => [
['slug' => 'planting-details', 'title' => '种菜明细表'],
['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'],
['slug' => 'planting-reimbursements', 'title' => '种菜报销'],
['slug' => 'planting-loss', 'title' => '种菜损耗'],
],
],
[

View File

@ -0,0 +1,367 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/**
* 聚水潭聚工单「打款工单 → 种菜报销」:只读工单镜像 jiansun.dbo.jst_workOrderInfo。
*
* 口径(2026-10-19 财务确认):工单类型“种菜报销”、工单状态“已完成”,按完成时间筛选,
* 自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的明细,所以 M 月种菜报销明细
* = 完成时间 [M 月 2 日 00:00, M+1 月 2 日 00:00)。同一工单号只取最新一版。
*/
class PlantingReimbursementRepository
{
public const DATASET = 'jiansun.dbo.jst_workOrderInfo';
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
public const STATUS_CONFIRMED = 'CONFIRMED';
public const START_AT = '2026-09-02 00:00:00';
public const FIRST_MONTH = '2026-09';
/** 聚工单只返回 fieldId;对应关系由历史种菜明细逐列对撞及打款工单列表核对得出。 */
public const FIELDS = [
'online_order_no' => 'orderField',
'internal_order_no' => 'o_id',
'platform' => 'platform',
'shop_name' => 'shopField',
'actual_paid_amount' => 'actualPaidAmountField',
'task_remark' => 'TEXTField1413856620251113150546',
'seller_remark' => 'businessRemarkField',
'task_payment' => 'NUMBERField1413856620250924131615',
'task_freight' => 'NUMBERField1413856620251113150537',
'task_commission' => 'NUMBERField1413856620251230154457',
'jingxi_price_diff' => 'NUMBERField1413856620250924131623',
'task_deduction' => 'NUMBERField1413856620251113150612',
'task_empty_package_fee' => 'NUMBERField1413856620251113145620',
'reimbursement_amount' => 'NUMBERField1413856620251117101250',
'payee_name' => 'TEXTField1413856620251117180931',
'payee_account' => 'TEXTField1413856620251117113451',
'payee_bank' => 'TEXTField1413856620251117112729',
'payee_mobile' => 'TEXTField1413856620260618171005',
'erp_order_status' => 'erpOrderStatus',
'platform_order_status' => 'platformOrderStatus',
'paid_at' => 'payDateField',
];
public const MONEY_FIELDS = [
'actual_paid_amount', 'task_payment', 'task_freight', 'task_commission', 'jingxi_price_diff',
'task_deduction', 'task_empty_package_fee', 'reimbursement_amount',
];
private const ERP_ORDER_STATUS = [
'Sent' => '已发货', 'Merged' => '被合并', 'Cancelled' => '已取消', 'Split' => '被拆分',
'WaitConfirm' => '待审核', 'WaitFConfirm' => '待财审', 'Delivering' => '发货中',
'Question' => '异常', 'WaitOuterSent' => '等供销商发货',
];
private const ROWS_CACHE_SECONDS = 600;
private const QUERY_TIMEOUT_SECONDS = 90;
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
/**
* 报销月份 → 完成时间窗口 [M-02 00:00, M+1-02 00:00)。
*
* @return array{from:string,to:string}
*/
public static function window(string $month): array
{
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1 || $month < self::FIRST_MONTH) {
throw new InvalidArgumentException('报销月份无效。');
}
$start = CarbonImmutable::createFromFormat('!Y-m-d', $month . '-02');
return ['from' => $start->format('Y-m-d H:i:s'), 'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s')];
}
/** 默认月份:完成时间窗口已开始的最近一个报销月(每月 1 日仍算上月)。 */
public static function defaultMonth(?CarbonImmutable $now = null): string
{
$now ??= CarbonImmutable::now();
$month = $now->subDay()->format('Y-m');
return max($month, self::FIRST_MONTH);
}
/** @return list<string> 可选报销月份(新到旧) */
public static function months(?CarbonImmutable $now = null): array
{
$months = [];
$cursor = CarbonImmutable::createFromFormat('!Y-m', self::defaultMonth($now));
while ($cursor->format('Y-m') >= self::FIRST_MONTH) {
$months[] = $cursor->format('Y-m');
$cursor = $cursor->subMonthNoOverflow();
}
return $months;
}
/** @return array{code:string,last_succeeded_at:?string} */
public function syncStatus(): array
{
try {
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('business_mirror_table_states') || ! $schema->hasTable($this->dataset()->targetTable())) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$state = $this->mirror->state($this->dataset());
} catch (Throwable) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
if (! is_array($state)) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$last = isset($state['last_succeeded_at']) ? (string) $state['last_succeeded_at'] : null;
if (($state['initial_completed_at'] ?? null) === null) {
return ['code' => ($state['status'] ?? '') === 'failed' ? 'initial_failed' : 'initializing', 'last_succeeded_at' => $last];
}
return ['code' => ($state['status'] ?? '') === 'failed' ? 'failed' : 'ready', 'last_succeeded_at' => $last];
}
/**
* 某报销月份的全部种菜报销(已完成,每个工单号取最新一版),按完成时间倒序。
* 结果按月份缓存 10 分钟;单月约 1.5~2 万行。
*
* @return list<array<string,mixed>>
*/
public function rows(string $month): array
{
$window = self::window($month);
$status = $this->syncStatus();
if (! in_array($status['code'], ['ready', 'failed'], true)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
// 镜像每几分钟增量一次,按版本做键会让缓存几乎每次失效;固定按月份缓存 10 分钟。
$key = 'planting-reimbursements:rows:v2:' . $month;
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$rows = $this->load($window['from'], $window['to']);
try {
Cache::put($key, $rows, self::ROWS_CACHE_SECONDS);
} catch (Throwable $exception) {
report($exception);
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function load(string $from, string $to): array
{
$table = $this->dataset()->targetTable();
$query = $this->connection->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->where('completeTime', '>=', $from)
->where('completeTime', '<', $to)
->select([
'Id', 'woNo', 'status', 'createby', 'executors', 'completer',
'completeTime', 'created', 'businessFieldList',
])
->orderByDesc('completeTime')
->orderByDesc('woNo');
/** @var array<string,object> $latest 窗口内每个工单号的最新一版 */
$latest = [];
foreach ($this->boundedGet($query) as $record) {
$woNo = (string) $record->woNo;
if (! isset($latest[$woNo]) || (int) $record->Id > (int) $latest[$woNo]->Id) {
$latest[$woNo] = $record;
}
}
// 同一工单若有更新的一版(可能已改完成时间或被作废)落在窗口外,以最新一版为准,这里剔除。
$newestIds = [];
foreach (array_chunk(array_map('strval', array_keys($latest)), 1000) as $chunk) {
$versions = $this->connection->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->whereIn('woNo', $chunk)
->groupBy('woNo')
->selectRaw('woNo, MAX(Id) AS newest_id')
->get();
foreach ($versions as $version) {
$newestIds[(string) $version->woNo] = (int) $version->newest_id;
}
}
$rows = [];
foreach ($latest as $woNo => $record) {
if (($newestIds[(string) $woNo] ?? (int) $record->Id) > (int) $record->Id) {
continue;
}
// 最新一版已不是“已完成”(如完成后被作废)的工单不计入。
if ((string) $record->status !== self::STATUS_CONFIRMED) {
continue;
}
$rows[] = $this->decorate($record);
}
return $rows;
}
/** @return array<string,mixed> */
private function decorate(object $record): array
{
$fields = $this->fields((string) ($record->businessFieldList ?? ''));
$row = [
'work_order_no' => (string) $record->woNo,
'started_at' => $this->time($record->created ?? null),
'completed_at' => $this->time($record->completeTime ?? null),
'status' => '已完成',
'initiator' => (string) ($record->createby ?? ''),
'executor' => (string) ($record->executors ?? ''),
'completer' => (string) ($record->completer ?? ''),
];
foreach (self::FIELDS as $key => $fieldId) {
$row[$key] = $this->display($fields[$fieldId] ?? null);
}
foreach (self::MONEY_FIELDS as $key) {
$row[$key] = $this->money($row[$key]);
}
$row['erp_order_status'] = self::ERP_ORDER_STATUS[$row['erp_order_status']] ?? $row['erp_order_status'];
$items = $this->items($fields['itemInfo']['raw'] ?? null);
$row['product_code'] = implode("\n", array_filter(array_column($items, 'skuId')));
$row['product_name'] = implode("\n", array_filter(array_column($items, 'name')));
$row['product_quantity'] = implode("\n", array_filter(array_column($items, 'qty')));
$row['platform_product_id'] = implode("\n", array_filter(array_column($items, 'platformItemId')));
return $row;
}
/** @return array<string,array{value:mixed,label:mixed,raw:array<mixed>}> */
private function fields(string $json): array
{
if (trim($json) === '') {
return [];
}
try {
$decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
if (isset($decoded['businessField']) && is_array($decoded['businessField'])) {
$decoded = $decoded['businessField'];
}
$fields = [];
foreach (is_array($decoded) ? $decoded : [] as $item) {
if (is_array($item) && isset($item['fieldId'])) {
$fields[(string) $item['fieldId']] = [
'value' => $item['value'] ?? ($item['valueList'] ?? null),
'label' => $item['valueLabel'] ?? null,
'raw' => $item,
];
}
}
return $fields;
}
/** @param array{value:mixed,label:mixed}|null $field */
private function display(?array $field): string
{
if ($field === null) {
return '';
}
foreach ([$field['label'], $field['value']] as $value) {
if (is_array($value)) {
$value = implode('、', array_filter(array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $value)));
}
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return '';
}
/**
* @param array<mixed>|null $raw
* @return list<array<string,string>>
*/
private function items(?array $raw): array
{
$items = [];
foreach ((array) ($raw['childFieldList'] ?? []) as $childRow) {
$item = [];
foreach ((array) $childRow as $child) {
if (is_array($child) && isset($child['fieldId'])) {
$v = $child['valueLabel'] ?? $child['value'] ?? '';
$item[(string) $child['fieldId']] = is_scalar($v) ? trim((string) $v) : '';
}
}
if ($item !== []) {
$items[] = $item;
}
}
foreach ((array) ($raw['value'] ?? []) as $item) {
if (is_array($item) && ! array_is_list($item)) {
$items[] = array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $item);
}
}
return $items;
}
private function money(string $value): ?string
{
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value));
if (! preg_match('/^-?\d+(\.\d+)?$/', $normalized)) {
return null;
}
return number_format(round((float) $normalized, 2), 2, '.', '');
}
private function time(mixed $value): string
{
return substr(trim((string) $value), 0, 19);
}
/** @return iterable<int,object> */
private function boundedGet(Builder $query): iterable
{
$connection = $query->getConnection();
if ($connection->getDriverName() !== 'mysql') {
return $query->get();
}
return $connection->select(
'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
$query->getBindings(),
);
}
private function dataset(): BusinessMirrorDataset
{
return $this->datasets->get(self::DATASET);
}
}

View File

@ -0,0 +1,292 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
/**
* 种菜损耗(2026-10-19 财务确认的口径):
* 1. 取 M 月种菜报销明细(完成时间 [M-02, M+1-02));
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
* 3. 匹配到的单里,任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,单独列出发给运营;
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 × 1.2(成本价+成本价×20%),
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
*/
class PlantingLossCalculator
{
public const RESULT_DEDUCT = '扣款';
public const RESULT_EXEMPT = '不扣款(拍A发B/拍A发A)';
public const RESULT_FREE_GOODS = '不扣款(虚拟条码/透明挂钩)';
public const RESULT_UNMATCHED = '未匹配发货';
public const RESULTS = [self::RESULT_DEDUCT, self::RESULT_EXEMPT, self::RESULT_FREE_GOODS, self::RESULT_UNMATCHED];
/** 发货商品名称或条码含这些字样的不计扣款。 */
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
/** 扣款 = 成本价 × (1 + 20%)。 */
public const MARKUP_PERCENT = 20;
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
private const CHUNK = 1000;
private const CACHE_SECONDS = 600;
public function __construct(
private readonly PlantingReimbursementRepository $reimbursements,
private readonly ShippingOrderRepository $shipping,
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {}
/** 发货数据同步状态(与「发货数据」页面同一来源)。 */
public function shippingStatus(): array
{
return $this->shipping->syncStatus();
}
/**
* 发货数据匹配窗口:报销月份的上月 1 日 00:00 至下月 1 日 00:00。
*
* @return array{from:string,to:string}
*/
public static function shippingWindow(string $month): array
{
PlantingReimbursementRepository::window($month);
$start = CarbonImmutable::createFromFormat('!Y-m', $month);
return [
'from' => $start->subMonthNoOverflow()->format('Y-m-d H:i:s'),
'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s'),
];
}
/** 例:2026-09 → “2026-08~2026-09”。 */
public static function shippingLabel(string $month): string
{
$window = self::shippingWindow($month);
return substr($window['from'], 0, 7) . '~' . $month;
}
/** 命中的免扣字样(拍A发B / 拍A发A),未命中返回 null。全角、大小写、空格均兼容。 */
public static function exemptionKeyword(string ...$remarks): ?string
{
foreach ($remarks as $remark) {
$text = mb_strtoupper((string) preg_replace('/\s+/u', '', mb_convert_kana($remark, 'as')));
if (preg_match('/拍A发([AB])/u', $text, $match) === 1) {
return '拍A发' . $match[1];
}
}
return null;
}
public static function isFreeGoods(string $barcode, string $productName): bool
{
foreach (self::FREE_GOODS_KEYWORDS as $keyword) {
if (str_contains($productName, $keyword) || str_contains($barcode, $keyword)) {
return true;
}
}
return false;
}
/** 成本价(元,字符串)× 数量 × 1.2 → 分。 */
public static function deductionCents(string $costPrice, int $qty): int
{
return (int) round((float) $costPrice * 100 * $qty * (100 + self::MARKUP_PERCENT) / 100);
}
/**
* 当月全部种菜报销及其发货匹配、扣款判定(含未匹配,页面按结果筛选)。
*
* @return list<array<string,mixed>>
*/
public function rows(string $month): array
{
$key = 'planting-loss:rows:v3:' . $month;
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$reimbursements = $this->reimbursements->rows($month);
$window = self::shippingWindow($month);
$shipments = $this->shipments(array_column($reimbursements, 'online_order_no'), $window['from'], $window['to']);
$costs = $this->costPrices(array_merge(...array_map(
static fn (array $shipment): array => array_column($shipment['lines'], 'barcode'),
array_values($shipments),
) ?: [[]]));
$rows = [];
foreach ($reimbursements as $row) {
$rows[] = $this->judge($row, $shipments[$row['online_order_no']] ?? null, $costs, $month);
}
try {
Cache::put($key, $rows, self::CACHE_SECONDS);
} catch (\Throwable $exception) {
report($exception);
}
return $rows;
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed>|null $shipment
* @param array<string,string> $costs 条码 => 成本价
* @return array<string,mixed>
*/
private function judge(array $row, ?array $shipment, array $costs, string $month): array
{
$deductLines = [];
$missingCost = [];
$cents = 0;
foreach ($shipment['lines'] ?? [] as $line) {
if (self::isFreeGoods($line['barcode'], $line['product_name'])) {
continue;
}
$cost = $costs[$line['barcode']] ?? null;
if ($cost === null) {
$missingCost[] = $line['barcode'] !== '' ? $line['barcode'] : '(无条码)';
$deductLines[] = $line['barcode'] . ' × ' . $line['qty'] . ' 成本价缺失';
continue;
}
$lineCents = self::deductionCents($cost, $line['qty']);
$cents += $lineCents;
$deductLines[] = sprintf('%s × %d × %s × 1.2 = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', ''));
}
$keyword = $shipment === null ? null : self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
[$result, $basis] = match (true) {
$shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'],
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”'],
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩'],
$missingCost !== [] => [self::RESULT_DEDUCT, '成本价缺失:' . implode('、', array_unique($missingCost)) . ',扣款金额未含该商品'],
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×1.2扣款'],
};
$row['loss_result'] = $result;
$row['loss_basis'] = $basis;
$row['deduction_amount'] = $result === self::RESULT_DEDUCT ? number_format($cents / 100, 2, '.', '') : null;
$row['deduction_detail'] = $result === self::RESULT_DEDUCT ? implode("\n", $deductLines) : '';
$row['cost_missing'] = $result === self::RESULT_DEDUCT && $missingCost !== [];
$row['shipped_at'] = $shipment['shipped_at'] ?? '';
$row['express_no'] = $shipment['express_no'] ?? '';
$row['warehouse_name'] = $shipment['warehouse_name'] ?? '';
$row['shipped_barcode'] = implode("\n", array_column($shipment['lines'] ?? [], 'barcode'));
$row['shipped_product_name'] = implode("\n", array_column($shipment['lines'] ?? [], 'product_name'));
$row['shipped_qty'] = $shipment === null ? '' : (string) array_sum(array_column($shipment['lines'], 'qty'));
$row['shipped_amount'] = $shipment['amount'] ?? null;
$row['shipped_freight'] = $shipment['freight'] ?? null;
return $row;
}
/**
* @param list<string> $tradeNos
* @return array<string,array{shipped_at:string,express_no:string,warehouse_name:string,amount:?string,freight:?string,lines:list<array{barcode:string,product_name:string,qty:int}>}>
*/
private function shipments(array $tradeNos, string $from, string $to): array
{
$tradeNos = array_values(array_unique(array_filter(array_map('trim', $tradeNos), static fn (string $v): bool => $v !== '')));
$db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$acc = [];
foreach (array_chunk($tradeNos, self::CHUNK) as $chunk) {
$records = $db->table('jst_oms_shipping')
->whereIn('trade_no', $chunk)
->where('ship_time', '>=', $from)
->where('ship_time', '<', $to)
->orderBy('id')
->get(['trade_no', 'ship_time', 'express_no', 'warehouse_name', 'barcode', 'product_name', 'qty', 'amount', 'freight']);
foreach ($records as $record) {
$trade = (string) $record->trade_no;
$item = &$acc[$trade];
$shipTime = substr((string) $record->ship_time, 0, 19);
$item['shipped_at'] = isset($item['shipped_at']) ? min($item['shipped_at'], $shipTime) : $shipTime;
foreach (['express_no', 'warehouse_name'] as $key) {
$value = trim((string) $record->{$key});
if ($value !== '') {
$item[$key][$value] = true;
}
}
foreach (['amount', 'freight'] as $key) {
if ($record->{$key} !== null) {
$item[$key] = ($item[$key] ?? 0) + (int) round((float) $record->{$key} * 100);
}
}
$barcode = trim((string) $record->barcode);
$lineKey = $barcode . '|' . trim((string) $record->product_name);
$item['lines'][$lineKey] ??= ['barcode' => $barcode, 'product_name' => trim((string) $record->product_name), 'qty' => 0];
$item['lines'][$lineKey]['qty'] += max(1, (int) $record->qty);
unset($item);
}
}
$result = [];
foreach ($acc as $trade => $item) {
$result[$trade] = [
'shipped_at' => $item['shipped_at'],
'express_no' => implode("\n", array_keys($item['express_no'] ?? [])),
'warehouse_name' => implode("\n", array_keys($item['warehouse_name'] ?? [])),
'amount' => isset($item['amount']) ? number_format($item['amount'] / 100, 2, '.', '') : null,
'freight' => isset($item['freight']) ? number_format($item['freight'] / 100, 2, '.', '') : null,
'lines' => array_values($item['lines']),
];
}
return $result;
}
/**
* 普通商品资料的成本价:条形码(商品编码)= ops_jst_sku.sku_id,同编码取最新一条。
*
* @param list<string> $barcodes
* @return array<string,string>
*/
private function costPrices(array $barcodes): array
{
$barcodes = array_values(array_unique(array_filter($barcodes, static fn (string $v): bool => $v !== '')));
if ($barcodes === []) {
return [];
}
$connection = $this->mirror->connection();
$table = $this->datasets->get(self::SKU_DATASET)->targetTable();
$costs = [];
foreach (array_chunk($barcodes, self::CHUNK) as $chunk) {
$records = $connection->table($table)
->whereIn('sku_id', $chunk)
->where('sku_type', 'normal')
->orderByDesc('autoid')
->get(['sku_id', 'cost_price']);
foreach ($records as $record) {
$sku = (string) $record->sku_id;
if (isset($costs[$sku])) {
continue;
}
// 成本价为空或 0 视为缺失,避免把未维护成本的商品算成 0 元扣款。
if (is_numeric($record->cost_price) && (float) $record->cost_price > 0) {
$costs[$sku] = rtrim(rtrim(number_format((float) $record->cost_price, 4, '.', ''), '0'), '.');
}
}
}
return $costs;
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
/** 种菜数据汇总 → 种菜损耗:种菜报销明细匹配近两个月发货数据,拍A发B/拍A发A、只发虚拟条码/透明挂钩的不扣款,其余按成本价×1.2 扣款。 */
class PlantingLossPageData
{
use PlantingPageFilters;
public const TITLE = '种菜损耗';
public const PER_PAGE = 50;
public const TEXT_FILTERS = [
'work_order_no' => '工单编号',
'online_order_no' => '线上订单号',
'shop_name' => '店铺名称',
'task_remark' => '任务备注',
'seller_remark' => '卖家备注',
'express_no' => '快递单号',
];
public const COLUMNS = [
'loss_result' => '损耗判定', 'deduction_amount' => '扣款金额', 'loss_basis' => '判定说明', 'deduction_detail' => '扣款计算(条码×数量×成本价×1.2)',
'completed_at' => '报销完成时间', 'work_order_no' => '工单编号', 'platform' => '平台', 'shop_name' => '店铺名称',
'online_order_no' => '线上订单号', 'task_remark' => '任务备注', 'seller_remark' => '卖家备注',
'actual_paid_amount' => '实付金额', 'task_payment' => '任务付款金额', 'reimbursement_amount' => '最终报销金额',
'shipped_at' => '发货时间', 'express_no' => '快递单号', 'warehouse_name' => '发货仓库',
'shipped_barcode' => '发货条码', 'shipped_product_name' => '发货商品名称', 'shipped_qty' => '发货数量',
'shipped_amount' => '发货金额', 'shipped_freight' => '发货运费',
'initiator' => '发起人', 'payee_name' => '信誉账号名称', 'product_code' => '报销商品编码', 'product_name' => '报销商品名称',
'internal_order_no' => '内部单号', 'erp_order_status' => 'ERP订单状态',
];
public const MONEY_COLUMNS = ['deduction_amount', 'actual_paid_amount', 'task_payment', 'reimbursement_amount', 'shipped_amount', 'shipped_freight'];
private const SUM_KEYS = ['deduction_amount', 'task_payment', 'reimbursement_amount', 'shipped_amount', 'shipped_freight'];
public function __construct(
private readonly Calculator $calculator,
private readonly Repository $repository,
) {}
/** @return array<string,string> */
public function filters(): array
{
$filters = $this->baseFilters();
$result = request()->query('result', Calculator::RESULT_DEDUCT);
abort_unless(is_string($result) && ($result === 'all' || in_array($result, Calculator::RESULTS, true)), 422, '损耗判定无效。');
$filters['result'] = $result;
return $filters;
}
/**
* @param array<string,string> $filters
* @param list<array<string,mixed>>|null $rows
* @return \Generator<int,array<string,mixed>>
*/
public function rows(array $filters, ?array $rows = null): \Generator
{
foreach ($rows ?? $this->calculator->rows($filters['month']) as $row) {
if ($filters['result'] !== 'all' && $row['loss_result'] !== $filters['result']) {
continue;
}
if ($this->matchesText($row, $filters)) {
yield $row;
}
}
}
public function page(): array
{
$filters = $this->filters();
$page = max(1, (int) request('page', 1));
$sync = $this->repository->syncStatus();
$shippingSync = $this->calculator->shippingStatus();
$empty = ['count' => 0, 'cost_missing' => 0] + array_fill_keys(self::SUM_KEYS, 0);
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$items = [];
$error = $this->syncError($sync);
if ($error === null) {
try {
$all = $this->calculator->rows($filters['month']);
foreach ($all as $row) {
$summary['total']++;
$this->add($summary[$row['loss_result']], $row);
}
foreach ($this->rows($filters, $all) as $row) {
if ($summary['filtered']['count'] >= ($page - 1) * self::PER_PAGE && count($items) < self::PER_PAGE) {
$items[] = $row;
}
$this->add($summary['filtered'], $row);
}
} catch (\Throwable $exception) {
report($exception);
$error = '种菜损耗暂时无法计算(工单镜像或发货数据读取失败),请稍后重试。';
$summary = ['total' => 0, 'filtered' => $empty] + array_fill_keys(Calculator::RESULTS, $empty);
$items = [];
}
}
return [
'view' => 'business.pages.billing-transactions.planting-loss',
'plantingFilters' => $filters,
'plantingWindow' => Repository::window($filters['month']),
'plantingShippingWindow' => Calculator::shippingWindow($filters['month']),
'plantingMonths' => Repository::months(),
'plantingSummary' => $summary,
'plantingSync' => $sync,
'plantingShippingSync' => $shippingSync,
'plantingError' => $error,
'plantingRows' => new LengthAwarePaginator($items, $summary['filtered']['count'], self::PER_PAGE, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]),
];
}
/** @param array<string,int> $bucket @param array<string,mixed> $row */
private function add(array &$bucket, array $row): void
{
$bucket['count']++;
$bucket['cost_missing'] += ! empty($row['cost_missing']) ? 1 : 0;
foreach (self::SUM_KEYS as $key) {
$bucket[$key] += self::cents($row[$key] ?? null);
}
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Modules\EcommerceFinance\View;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
/** 种菜报销 / 种菜损耗 共用:报销月份、文本筛选、导出列。 */
trait PlantingPageFilters
{
/** @return array<string,string> */
protected function baseFilters(): array
{
$month = request()->query('month', '');
abort_unless(is_string($month), 422, '报销月份无效。');
$month = $month === '' ? Repository::defaultMonth() : $month;
abort_unless(in_array($month, Repository::months(), true), 422, '报销月份无效。');
$filters = ['month' => $month];
foreach (array_keys(static::TEXT_FILTERS) as $key) {
$value = request()->query($key, '');
abort_unless(is_string($value) && mb_strlen($value) <= 200, 422, '筛选条件格式无效。');
$filters[$key] = trim($value);
}
return $filters;
}
/** @param array<string,mixed> $row @param array<string,string> $filters */
protected function matchesText(array $row, array $filters): bool
{
foreach (array_keys(static::TEXT_FILTERS) as $key) {
if ($filters[$key] !== '' && mb_stripos((string) ($row[$key] ?? ''), $filters[$key]) === false) {
return false;
}
}
return true;
}
/** @return array<string,string> 可导出列(按请求顺序) */
public function exportColumns(): array
{
$requested = request()->query('columns');
if ($requested === null || $requested === '') {
return static::COLUMNS;
}
abort_unless(is_string($requested) && strlen($requested) <= 1500, 422, '导出列无效。');
$keys = explode(',', $requested);
abort_unless(count($keys) === count(array_unique($keys)) && array_diff($keys, array_keys(static::COLUMNS)) === [], 422, '导出列无效。');
return array_replace(array_flip($keys), array_intersect_key(static::COLUMNS, array_flip($keys)));
}
public static function cents(mixed $amount): int
{
if ($amount === null || ! preg_match('/^(-?)(\d+)\.(\d{2})$/', (string) $amount, $m)) {
return 0;
}
$value = ((int) $m[2]) * 100 + (int) $m[3];
return $m[1] === '-' ? -$value : $value;
}
/** @param array{code:string} $sync */
protected function syncError(array $sync): ?string
{
return match ($sync['code']) {
'ready', 'failed' => null,
'initial_failed' => '聚工单工单镜像首次同步失败,暂不能展示种菜报销,请联系管理员重新同步。',
default => '聚工单工单镜像尚未完成首次同步,完成后自动展示;当前为空不代表没有种菜报销。',
};
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
/** 种菜数据汇总 → 种菜报销(聚工单打款工单“种菜报销”,已完成,完成时间自 2026-09-02 起)。 */
class PlantingReimbursementPageData
{
use PlantingPageFilters;
public const TITLE = '种菜报销';
public const PER_PAGE = 50;
public const TEXT_FILTERS = [
'work_order_no' => '工单编号',
'online_order_no' => '线上订单号',
'shop_name' => '店铺名称',
'task_remark' => '任务备注',
'seller_remark' => '卖家备注',
'payee_name' => '信誉账号名称',
];
public const COLUMNS = [
'completed_at' => '完成时间', 'started_at' => '发起时间', 'work_order_no' => '工单编号', 'status' => '工单状态',
'platform' => '平台', 'shop_name' => '店铺名称', 'online_order_no' => '线上订单号', 'internal_order_no' => '内部单号',
'task_remark' => '任务备注', 'seller_remark' => '卖家备注',
'actual_paid_amount' => '实付金额', 'task_payment' => '任务付款金额', 'task_freight' => '任务快递费',
'task_commission' => '任务佣金', 'jingxi_price_diff' => '京喜差价', 'task_deduction' => '任务扣款费用',
'task_empty_package_fee' => '任务空包费', 'reimbursement_amount' => '最终报销金额',
'payee_name' => '信誉账号名称', 'payee_account' => '信誉账号', 'payee_bank' => '信誉账号开户行', 'payee_mobile' => '收款人手机号',
'initiator' => '发起人', 'executor' => '执行人', 'completer' => '完成人',
'product_code' => '商品编码', 'product_name' => '商品名称', 'product_quantity' => '商品数量', 'platform_product_id' => '平台商品ID',
'erp_order_status' => 'ERP订单状态', 'platform_order_status' => '平台订单状态', 'paid_at' => '付款时间',
];
public const MONEY_COLUMNS = Repository::MONEY_FIELDS;
public function __construct(private readonly Repository $repository) {}
/** @return array<string,string> */
public function filters(): array
{
return $this->baseFilters();
}
/**
* @param array<string,string> $filters
* @param list<array<string,mixed>>|null $rows
* @return \Generator<int,array<string,mixed>>
*/
public function rows(array $filters, ?array $rows = null): \Generator
{
foreach ($rows ?? $this->repository->rows($filters['month']) as $row) {
if ($this->matchesText($row, $filters)) {
yield $row;
}
}
}
public function page(): array
{
$filters = $this->filters();
$page = max(1, (int) request('page', 1));
$sync = $this->repository->syncStatus();
$summary = ['count' => 0, 'task_payment' => 0, 'reimbursement_amount' => 0, 'task_commission' => 0, 'task_empty_package_fee' => 0];
$items = [];
$error = $this->syncError($sync);
if ($error === null) {
try {
foreach ($this->rows($filters) as $row) {
if ($summary['count'] >= ($page - 1) * self::PER_PAGE && count($items) < self::PER_PAGE) {
$items[] = $row;
}
$summary['count']++;
foreach (['task_payment', 'reimbursement_amount', 'task_commission', 'task_empty_package_fee'] as $key) {
$summary[$key] += self::cents($row[$key]);
}
}
} catch (\Throwable $exception) {
report($exception);
$error = '种菜报销数据暂时无法读取,请稍后重试。';
$summary = array_fill_keys(array_keys($summary), 0);
$items = [];
}
}
return [
'view' => 'business.pages.billing-transactions.planting-reimbursements',
'plantingFilters' => $filters,
'plantingWindow' => Repository::window($filters['month']),
'plantingMonths' => Repository::months(),
'plantingSummary' => $summary,
'plantingSync' => $sync,
'plantingError' => $error,
'plantingRows' => new LengthAwarePaginator($items, $summary['count'], self::PER_PAGE, $page, [
'path' => request()->url(),
'query' => request()->except('page'),
]),
];
}
}

View File

@ -0,0 +1,52 @@
<style>
.pw-page{color:#171717;font-size:13px}.pw-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}.pw-heading p{font-size:12px;color:#595959;margin:0;line-height:1.7}.pw-tags{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end}.pw-tag{display:inline-block;border-radius:4px;padding:4px 8px;background:#F7F0DE;color:#856632;white-space:nowrap;font-size:12px}.pw-tag.warn{color:#935F07}
.pw-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin:16px 0}.pw-toolbar label{display:grid;gap:6px;font-size:12px}.pw-toolbar input,.pw-toolbar select,.pw-toolbar button,.pw-button{height:34px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717;padding:0 10px;font-size:12px}.pw-toolbar input[type=text]{width:150px}.pw-toolbar select{max-width:260px}.pw-toolbar .pw-primary{background:#856632;border-color:#856632;color:#fff}.pw-button{display:inline-flex;align-items:center;text-decoration:none;cursor:pointer}.pw-toolbar :focus-visible,.pw-metrics a:focus-visible{outline:2px solid #856632;outline-offset:2px}
.pw-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin:16px 0}.pw-metrics>div,.pw-metrics>a{background:#fff;border:1px solid #D8D2C7;border-radius:12px;padding:16px;color:inherit;text-decoration:none;display:block}.pw-metrics>a:hover{border-color:#B9954F}.pw-metrics>a.is-active{border-color:#856632;box-shadow:inset 0 0 0 1px #856632}.pw-metrics span{color:#595959;font-size:12px}.pw-metrics strong{display:block;font-size:22px;margin-top:8px;font-variant-numeric:tabular-nums}.pw-metrics small{display:block;margin-top:6px;color:#595959;font-size:12px;font-variant-numeric:tabular-nums}.pw-metrics .is-key strong{color:#856632}.pw-warn-text{color:#935F07;font-weight:600}
.pw-rules{font-size:12px;color:#595959;margin:12px 0}.pw-rules summary{cursor:pointer;color:#856632}.pw-rules p{line-height:1.8;margin:8px 0}.pw-error{background:#F7F0DE;color:#B80000;padding:12px;border-radius:8px;margin:12px 0}.pw-warn{background:#FFF4E5;color:#935F07;padding:10px 12px;border-radius:8px;margin:12px 0;font-size:12px}
.pw-panel{background:#fff;border:1px solid #D8D2C7;border-radius:12px;overflow:hidden}.pw-table-scroll{overflow:auto;max-height:65vh}.pw-panel table{width:max-content;min-width:100%;border-collapse:collapse;font-size:12px}.pw-panel th,.pw-panel td{padding:10px 12px;border-bottom:1px solid #D8D2C7;text-align:left;vertical-align:top}.pw-panel th{background:#F7F0DE;color:#595959;font-weight:500;white-space:nowrap;position:sticky;top:0;z-index:1}.pw-panel td{max-width:280px;overflow-wrap:anywhere}.pw-panel .pw-lines{white-space:pre-line}.pw-panel .pw-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.pw-panel .pw-empty{text-align:center;padding:60px 16px;color:#595959;max-width:none}.pw-panel tbody tr:hover{background:#faf7ef}.pw-footer{padding:12px 16px;font-size:12px;color:#595959}.pw-footer nav{margin-top:12px}
.pw-result{display:inline-block;border-radius:4px;padding:4px 8px;font-size:12px;white-space:nowrap;background:#F2F2F2;color:#595959}.pw-result.is-deduct{background:#F7F0DE;color:#856632;font-weight:600}.pw-result.is-exempt{background:#EEF4EA;color:#53793E}
.pw-column-settings{position:relative}.pw-column-settings summary{cursor:pointer;list-style:none}.pw-column-menu{position:absolute;top:40px;right:0;width:240px;padding:12px;background:#fff;border:1px solid #D8D2C7;border-radius:8px;z-index:10;box-shadow:0 4px 16px #17171714}.pw-column-menu p{font-size:12px;color:#595959;margin:0 0 8px}.pw-column-menu [data-pw-column-list]{max-height:340px;overflow:auto}.pw-column-menu label{display:flex;align-items:center;gap:6px;padding:6px;cursor:grab}.pw-column-menu input{height:auto;accent-color:#856632}.pw-column-menu button{margin-top:8px}.pw-column-menu [role=status]{display:block;font-size:12px;color:#935F07}
@media(max-width:700px){.pw-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.pw-heading{flex-direction:column;align-items:flex-start}.pw-tags{justify-content:flex-start}.pw-toolbar input[type=text],.pw-toolbar select{width:100%;max-width:none}.pw-toolbar label{width:100%}}
</style>
@push('body_end')
<script>
(() => {
const root = document.querySelector('.pw-page');
if (!root) return;
const list = root.querySelector('[data-pw-column-list]');
const defaults = Array.from(list.children);
const keys = defaults.map(item => item.dataset.columnKey);
const tableRows = Array.from(root.querySelectorAll('table tr')).filter(row => row.children.length === keys.length && !row.querySelector('[colspan]'));
const cells = tableRows.map(row => Object.fromEntries(keys.map((key, i) => [key, row.children[i]])));
const storageKey = root.dataset.preferenceKey;
const scrollArea = root.querySelector('.pw-table-scroll');
const fitTable = () => scrollArea.style.maxHeight = Math.max(180, window.innerHeight - scrollArea.getBoundingClientRect().top - 80) + 'px';
fitTable(); window.addEventListener('resize', fitTable);
const apply = (persist = true) => {
const order = Array.from(list.children).map(item => item.dataset.columnKey);
const visible = Array.from(list.querySelectorAll('input:checked')).map(input => input.value);
tableRows.forEach((row, i) => order.forEach(key => { cells[i][key].hidden = !visible.includes(key); row.append(cells[i][key]); }));
root.querySelectorAll('td[colspan]').forEach(cell => cell.colSpan = visible.length);
root.querySelectorAll('[data-pw-export]').forEach(link => { const url = new URL(link.href); url.searchParams.set('columns', order.filter(key => visible.includes(key)).join(',')); link.href = url.toString(); });
if (persist) try { localStorage.setItem(storageKey, JSON.stringify({order, visible})); } catch (_) {}
};
try {
const saved = JSON.parse(localStorage.getItem(storageKey));
if (saved && Array.isArray(saved.order) && Array.isArray(saved.visible) && saved.order.length === keys.length && new Set(saved.order).size === keys.length && saved.order.every(key => keys.includes(key)) && saved.visible.some(key => keys.includes(key))) {
saved.order.forEach(key => list.append(defaults.find(item => item.dataset.columnKey === key)));
list.querySelectorAll('input').forEach(input => input.checked = saved.visible.includes(input.value));
}
} catch (_) {}
apply(false);
list.addEventListener('change', event => {
if (!list.querySelector('input:checked')) { event.target.checked = true; root.querySelector('[data-pw-column-message]').textContent = '请至少保留一列。'; return; }
root.querySelector('[data-pw-column-message]').textContent = ''; apply();
});
let dragging = null;
list.addEventListener('dragstart', event => { dragging = event.target.closest('[data-column-key]'); if (dragging) { event.dataTransfer.setData('text/plain', dragging.dataset.columnKey); event.dataTransfer.effectAllowed = 'move'; } });
list.addEventListener('dragover', event => event.preventDefault());
list.addEventListener('drop', event => { event.preventDefault(); const target = event.target.closest('[data-column-key]'); if (dragging && target && dragging !== target) { list.insertBefore(dragging, target); apply(); } dragging = null; });
root.querySelector('[data-pw-reset]').addEventListener('click', () => { defaults.forEach(item => { item.querySelector('input').checked = true; list.append(item); }); apply(); });
})();
</script>
@endpush

View File

@ -0,0 +1,69 @@
@php
use Modules\EcommerceFinance\View\PlantingLossPageData as PageData;
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
$columns = PageData::COLUMNS;
$syncLabels = ['ready' => '工单镜像已同步', 'failed' => '工单镜像最近同步失败,展示上次成功数据', 'initializing' => '工单镜像首次同步中', 'initial_failed' => '工单镜像首次同步失败', 'unavailable' => '工单镜像未建立'];
$shippingLabels = ['succeeded' => '发货数据已同步', 'partial' => '发货数据部分待核对', 'running' => '发货数据同步中', 'failed' => '发货数据最近同步失败', 'unavailable' => '发货数据不可用'];
$resultClasses = [Calculator::RESULT_DEDUCT => 'is-deduct', Calculator::RESULT_EXEMPT => 'is-exempt', Calculator::RESULT_FREE_GOODS => 'is-exempt'];
$shippingLabel = Calculator::shippingLabel($plantingFilters['month']);
$baseQuery = array_filter(\Illuminate\Support\Arr::except($plantingFilters, ['result']), static fn ($v) => $v !== '');
$resultUrl = static fn (string $result) => request()->url() . '?' . http_build_query($baseQuery + ['result' => $result]);
$exportQuery = array_filter($plantingFilters, static fn ($v) => $v !== '');
$money = static fn (int $cents) => number_format($cents / 100, 2);
@endphp
<div class="pw-page" data-preference-key="planting-loss:{{ company_id() }}:{{ optional(user())->id }}">
<div class="pw-heading">
<p>{{ $plantingFilters['month'] }} 月种菜报销明细(完成时间 {{ substr($plantingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subDay()->format('Y-m-d') }})的线上订单号,匹配 {{ $shippingLabel }} 发货数据(发货时间 {{ substr($plantingShippingWindow['from'], 0, 10) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingShippingWindow['to'])->subDay()->format('Y-m-d') }})中的交易单号。匹配到的单中,任务备注或卖家备注写了“拍A发B / 拍A发A”的、以及只发了信誉订单虚拟条码/透明挂钩的不扣款,其余按发货商品成本价×1.2 扣款。</p>
<div class="pw-tags">
<span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
<span class="pw-tag {{ ($plantingShippingSync['code'] ?? '') === 'succeeded' ? '' : 'warn' }}">{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}@if($plantingShippingSync['last_succeeded_at'] ?? null) · {{ \Illuminate\Support\Str::of($plantingShippingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span>
</div>
</div>
<form method="get" class="pw-toolbar">
<label>报销月份<select name="month">@foreach($plantingMonths as $month)<option value="{{ $month }}" @selected($plantingFilters['month'] === $month)>{{ $month }}</option>@endforeach</select></label>
<label>损耗判定<select name="result">
@foreach(Calculator::RESULTS as $result)<option value="{{ $result }}" @selected($plantingFilters['result'] === $result)>{{ $result }}</option>@endforeach
<option value="all" @selected($plantingFilters['result'] === 'all')>全部</option>
</select></label>
@foreach(PageData::TEXT_FILTERS as $key => $label)
<label>{{ $label }}<input type="text" name="{{ $key }}" value="{{ $plantingFilters[$key] }}" maxlength="200" placeholder="筛选{{ $label }}"></label>
@endforeach
<button type="submit" class="pw-primary">查询</button>
<a class="pw-button" href="{{ request()->url() }}">重置</a>
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', $exportQuery) }}">导出当前筛选</a>
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-loss.export', ['month' => $plantingFilters['month'], 'result' => Calculator::RESULT_EXEMPT]) }}">导出拍A发B(发运营)</a>
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
</form>
@php $deduct = $plantingSummary[Calculator::RESULT_DEDUCT]; $exempt = $plantingSummary[Calculator::RESULT_EXEMPT]; $free = $plantingSummary[Calculator::RESULT_FREE_GOODS]; $unmatched = $plantingSummary[Calculator::RESULT_UNMATCHED]; @endphp
<div class="pw-metrics">
<div><span>当月种菜报销</span><strong>{{ number_format($plantingSummary['total']) }}</strong><small>匹配到发货 {{ number_format($deduct['count'] + $exempt['count'] + $free['count']) }} 单</small></div>
<a href="{{ $resultUrl(Calculator::RESULT_DEDUCT) }}" class="is-key {{ $plantingFilters['result'] === Calculator::RESULT_DEDUCT ? 'is-active' : '' }}"><span>扣款金额(元)</span><strong>{{ $money($deduct['deduction_amount']) }}</strong><small>扣款 {{ number_format($deduct['count']) }} 单@if($deduct['cost_missing'] > 0) · <b class="pw-warn-text">{{ number_format($deduct['cost_missing']) }} 单成本价缺失</b>@endif</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_EXEMPT) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_EXEMPT ? 'is-active' : '' }}"><span>不扣款(拍A发B/拍A发A)</span><strong>{{ number_format($exempt['count']) }}</strong><small>需发运营 · 最终报销 {{ $money($exempt['reimbursement_amount']) }}</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_FREE_GOODS) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_FREE_GOODS ? 'is-active' : '' }}"><span>不扣款(虚拟条码/透明挂钩)</span><strong>{{ number_format($free['count']) }}</strong><small>最终报销 {{ $money($free['reimbursement_amount']) }}</small></a>
<a href="{{ $resultUrl(Calculator::RESULT_UNMATCHED) }}" class="{{ $plantingFilters['result'] === Calculator::RESULT_UNMATCHED ? 'is-active' : '' }}"><span>未匹配发货(不计入)</span><strong>{{ number_format($unmatched['count']) }}</strong><small>最终报销 {{ $money($unmatched['reimbursement_amount']) }}</small></a>
</div>
<details class="pw-rules"><summary>计算口径</summary><p>① 取该月种菜报销明细:工单类型“种菜报销”、状态“已完成”,完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59(与「种菜报销」页同一口径)。② 用线上订单号(交易单号)精确匹配「快递应付 → 发货数据」中发货时间在上月 1 日至本月末的交易单号,匹配到的保留,同一单多个包裹/商品合并为一行。③ 匹配到的单中,任务备注或卖家备注写了“拍A发B”或“拍A发A”(不区分全半角、大小写和空格)的不做扣款,单独列出发给运营。④ 实际只发了“信誉订单虚拟条码”“透明挂钩”这类商品的不扣款;同一单里这类商品也不计入扣款。⑤ 其余匹配到的为扣款项:扣款金额 = Σ 发货条形码(商品编码)在「普通商品资料」中的成本价 × 发货数量 × 1.2(成本价 + 成本价×20%);普通商品资料里没有成本价(空或 0)的商品标为“成本价缺失”,不计入扣款金额,需补齐成本价后自动重算。未匹配到发货的单不计入种菜损耗,仅供核对。</p></details>
@if(($plantingShippingSync['code'] ?? '') !== 'succeeded' && ! $plantingError)<div class="pw-warn">发货数据当前状态为“{{ $shippingLabels[$plantingShippingSync['code'] ?? 'unavailable'] ?? $plantingShippingSync['code'] }}”{{ ($plantingShippingSync['warning'] ?? '') !== '' ? ':' . $plantingShippingSync['warning'] : '' }},匹配结果可能不完整。</div>@endif
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
<section class="pw-panel" aria-label="种菜损耗明细">
<div class="pw-table-scroll"><table><thead><tr>
@foreach($columns as $key => $label)<th data-column-key="{{ $key }}">{{ $label }}</th>@endforeach
</tr></thead><tbody>
@forelse($plantingRows as $row)
<tr>@foreach($columns as $key => $label)
@if(in_array($key, PageData::MONEY_COLUMNS, true))
<td class="pw-money">{{ $row[$key] === null ? '—' : number_format((float) $row[$key], 2) }}</td>
@elseif($key === 'loss_result')
<td><span class="pw-result {{ $resultClasses[$row['loss_result']] ?? '' }}">{{ $row['loss_result'] }}</span></td>
@else
<td class="{{ str_contains((string) ($row[$key] ?? ''), "\n") ? 'pw-lines' : '' }}">{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}</td>
@endif
@endforeach</tr>
@empty
<tr><td colspan="{{ count($columns) }}" class="pw-empty">{{ $plantingError ?: '当前筛选条件下没有种菜损耗明细。' }}</td></tr>
@endforelse
</tbody></table></div>
<div class="pw-footer">共 {{ number_format($plantingRows->total()) }} 条,每页 {{ PageData::PER_PAGE }} 条。@if($plantingFilters['result'] !== 'all' && $plantingRows->total() > 0)当前筛选:扣款金额 {{ $money($plantingSummary['filtered']['deduction_amount']) }} 元,最终报销 {{ $money($plantingSummary['filtered']['reimbursement_amount']) }} 元。@endif{{ $plantingRows->links() }}</div>
</section>
</div>
@include('business.pages.billing-transactions.partials.planting-work-order-assets')

View File

@ -0,0 +1,50 @@
@php
use Modules\EcommerceFinance\View\PlantingReimbursementPageData as PageData;
$columns = PageData::COLUMNS;
$syncLabels = ['ready' => '工单镜像已同步', 'failed' => '最近一次同步失败,展示上次成功数据', 'initializing' => '首次同步中', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像未建立'];
$exportQuery = array_filter($plantingFilters, static fn ($v) => $v !== '');
@endphp
<div class="pw-page" data-preference-key="planting-reimbursements:{{ company_id() }}:{{ optional(user())->id }}">
<div class="pw-heading">
<p>聚水潭聚工单「打款工单 → 种菜报销」中工单状态为已完成的明细,按完成时间归属报销月份:{{ $plantingFilters['month'] }} 月 = 完成时间 {{ substr($plantingWindow['from'], 0, 16) }} 至 {{ \Illuminate\Support\Carbon::parse($plantingWindow['to'])->subSecond()->format('Y-m-d H:i:s') }}(当天报销前一天的种菜)。</p>
<div class="pw-tags"><span class="pw-tag {{ $plantingSync['code'] === 'ready' ? '' : 'warn' }}">{{ $syncLabels[$plantingSync['code']] ?? $plantingSync['code'] }}@if($plantingSync['last_succeeded_at']) · {{ \Illuminate\Support\Str::of($plantingSync['last_succeeded_at'])->substr(0, 16) }}@endif</span></div>
</div>
<form method="get" class="pw-toolbar">
<label>报销月份<select name="month">@foreach($plantingMonths as $month)<option value="{{ $month }}" @selected($plantingFilters['month'] === $month)>{{ $month }}</option>@endforeach</select></label>
@foreach(PageData::TEXT_FILTERS as $key => $label)
<label>{{ $label }}<input type="text" name="{{ $key }}" value="{{ $plantingFilters[$key] }}" maxlength="200" placeholder="筛选{{ $label }}"></label>
@endforeach
<button type="submit" class="pw-primary">查询</button>
<a class="pw-button" href="{{ request()->url() }}">重置</a>
<a class="pw-button" data-pw-export href="{{ route('business.planting-data.planting-reimbursements.export', $exportQuery) }}">导出当前筛选</a>
<details class="pw-column-settings"><summary class="pw-button">列设置</summary><div class="pw-column-menu"><p>勾选显示,拖动调整顺序</p><div data-pw-column-list>@foreach($columns as $key => $label)<label draggable="true" data-column-key="{{ $key }}"><input type="checkbox" value="{{ $key }}" checked> {{ $label }}</label>@endforeach</div><button type="button" class="pw-button" data-pw-reset>恢复默认</button><span data-pw-column-message role="status"></span></div></details>
</form>
<div class="pw-metrics">
<div><span>种菜报销工单</span><strong>{{ number_format($plantingSummary['count']) }}</strong></div>
<div><span>任务付款金额(元)</span><strong>{{ number_format($plantingSummary['task_payment'] / 100, 2) }}</strong></div>
<div><span>任务佣金(元)</span><strong>{{ number_format($plantingSummary['task_commission'] / 100, 2) }}</strong></div>
<div><span>任务空包费(元)</span><strong>{{ number_format($plantingSummary['task_empty_package_fee'] / 100, 2) }}</strong></div>
<div class="is-key"><span>最终报销金额(元)</span><strong>{{ number_format($plantingSummary['reimbursement_amount'] / 100, 2) }}</strong></div>
</div>
<details class="pw-rules"><summary>接入口径</summary><p>工单类型为“种菜报销”、工单状态为“已完成”,按完成时间筛选,自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的种菜明细,所以 M 月的种菜报销明细 = 完成时间 M 月 2 日 00:00 至次月 1 日 23:59:59。同一工单号只取最新一版,完成后被作废的不计入。数据只读自聚工单镜像,约每 10 分钟随镜像同步更新,不在此修改。</p></details>
@if($plantingError)<div class="pw-error" role="alert">{{ $plantingError }}</div>@endif
<section class="pw-panel" aria-label="种菜报销明细">
<div class="pw-table-scroll"><table><thead><tr>
@foreach($columns as $key => $label)<th data-column-key="{{ $key }}">{{ $label }}</th>@endforeach
</tr></thead><tbody>
@forelse($plantingRows as $row)
<tr>@foreach($columns as $key => $label)
@if(in_array($key, PageData::MONEY_COLUMNS, true))
<td class="pw-money">{{ $row[$key] === null ? '—' : number_format((float) $row[$key], 2) }}</td>
@else
<td class="{{ str_contains((string) ($row[$key] ?? ''), "\n") ? 'pw-lines' : '' }}">{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}</td>
@endif
@endforeach</tr>
@empty
<tr><td colspan="{{ count($columns) }}" class="pw-empty">{{ $plantingError ?: '当前筛选条件下暂无种菜报销,请调整筛选条件。' }}</td></tr>
@endforelse
</tbody></table></div>
<div class="pw-footer">共 {{ number_format($plantingRows->total()) }} 条,每页 {{ PageData::PER_PAGE }} 条。{{ $plantingRows->links() }}</div>
</section>
</div>
@include('business.pages.billing-transactions.partials.planting-work-order-assets')

View File

@ -270,6 +270,14 @@ Route::get('workspace/planting-data/jst-order-labels/export', 'Business\Pages@ex
->middleware('business.page:planting-data,jst-order-labels')
->name('business.planting-data.jst-order-labels.export');
Route::get('workspace/planting-data/planting-reimbursements/export', [\App\Http\Controllers\Business\PlantingWorkOrders::class, 'exportReimbursements'])
->middleware('business.page:planting-data,planting-reimbursements')
->name('business.planting-data.planting-reimbursements.export');
Route::get('workspace/planting-data/planting-loss/export', [\App\Http\Controllers\Business\PlantingWorkOrders::class, 'exportLoss'])
->middleware('business.page:planting-data,planting-loss')
->name('business.planting-data.planting-loss.export');
Route::prefix('workspace/product-inventory/inventory-report/warehouse-mappings')
->middleware('business.page:product-inventory,inventory-report')
->name('business.product-inventory.inventory-report.warehouse-mappings.')

View File

@ -0,0 +1,113 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
use Modules\EcommerceFinance\View\PlantingLossPageData;
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
use Tests\Feature\FeatureTestCase;
class PlantingWorkOrderPagesTest extends FeatureTestCase
{
private function row(array $patch = []): array
{
$row = array_fill_keys(array_keys(PlantingLossPageData::COLUMNS + PlantingReimbursementPageData::COLUMNS), '');
return array_replace($row, [
'completed_at' => '2026-09-02 11:47:53', 'work_order_no' => 'GD565967381', 'status' => '已完成',
'shop_name' => '天猫-卓禾家具旗舰店', 'online_order_no' => '3316392626048007769', 'task_remark' => 'CCDD',
'seller_remark' => 'AABB', 'actual_paid_amount' => '37.79', 'task_payment' => '37.79', 'task_freight' => '0.00',
'task_commission' => '2.00', 'jingxi_price_diff' => '0.00', 'task_deduction' => '0.00',
'task_empty_package_fee' => '0.00', 'reimbursement_amount' => '39.79', 'payee_name' => '黄文梳',
'loss_result' => Calculator::RESULT_DEDUCT, 'loss_basis' => '已匹配发货,备注无拍A发B/拍A发A',
'shipped_amount' => '40.60', 'shipped_freight' => '3.20', 'express_no' => 'YT001',
], $patch);
}
private function mockData(array $rows): void
{
$repository = \Mockery::mock(PlantingReimbursementRepository::class);
$repository->shouldReceive('syncStatus')->andReturn(['code' => 'ready', 'last_succeeded_at' => '2026-10-19 13:00:00']);
$repository->shouldReceive('rows')->andReturn($rows);
$this->app->instance(PlantingReimbursementRepository::class, $repository);
$calculator = \Mockery::mock(Calculator::class);
$calculator->shouldReceive('rows')->andReturn($rows);
$calculator->shouldReceive('shippingStatus')->andReturn(['code' => 'succeeded', 'last_succeeded_at' => '2026-10-19 12:00:00']);
$this->app->instance(Calculator::class, $calculator);
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
private function url(string $page, array $query = []): string
{
return route('business.pages.show', ['company_id' => $this->company->id, 'section' => 'planting-data', 'page' => $page])
. ($query ? '?' . http_build_query($query) : '');
}
public function test_reimbursement_page_lists_month_rows_and_totals(): void
{
$this->mockData([$this->row(), $this->row(['work_order_no' => 'GD-2', 'shop_name' => '拼多多-归来舍下', 'reimbursement_amount' => '0.56', 'task_payment' => '0.06'])]);
$this->loginAs()->get($this->url('planting-reimbursements', ['month' => '2026-09']))
->assertOk()
->assertSee('种菜报销')
->assertSee('GD565967381')
->assertSee('GD-2')
->assertSee('40.35')
->assertSee('2026-09-02 00:00');
$this->loginAs()->get($this->url('planting-reimbursements', ['month' => '2026-09', 'shop_name' => '拼多多']))
->assertOk()->assertSee('GD-2')->assertDontSee('GD565967381');
$this->withExceptionHandling()->loginAs()->get($this->url('planting-reimbursements', ['month' => '2026-08']))->assertStatus(422);
}
public function test_loss_page_defaults_to_deduct_rows_and_splits_exempt(): void
{
$this->mockData([
$this->row(),
$this->row(['work_order_no' => 'GD-EXEMPT', 'task_remark' => '拍A发B+GG003', 'loss_result' => Calculator::RESULT_EXEMPT, 'loss_basis' => '备注含“拍A发B”']),
$this->row(['work_order_no' => 'GD-NONE', 'loss_result' => Calculator::RESULT_UNMATCHED, 'shipped_amount' => null]),
]);
$this->loginAs()->get($this->url('planting-loss', ['month' => '2026-09']))
->assertOk()
->assertSee('种菜损耗')
->assertSee('GD565967381')
->assertDontSee('GD-EXEMPT')
->assertDontSee('GD-NONE')
->assertSee('2026-08-01');
$this->loginAs()->get($this->url('planting-loss', ['month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT]))
->assertOk()->assertSee('GD-EXEMPT')->assertDontSee('GD565967381');
$this->withExceptionHandling()->loginAs()->get($this->url('planting-loss', ['month' => '2026-09', 'result' => 'x']))->assertStatus(422);
}
public function test_loss_export_for_operations_contains_only_exempt_rows(): void
{
$this->mockData([
$this->row(),
$this->row(['work_order_no' => 'GD-EXEMPT', 'task_remark' => '拍A发B', 'loss_result' => Calculator::RESULT_EXEMPT]),
]);
$response = $this->loginAs()->get(route('business.planting-data.planting-loss.export', [
'company_id' => $this->company->id, 'month' => '2026-09', 'result' => Calculator::RESULT_EXEMPT,
'columns' => 'work_order_no,online_order_no,task_remark,loss_result',
]));
$response->assertOk();
$csv = $response->streamedContent();
$this->assertStringContainsString('工单编号,线上订单号,任务备注,损耗判定', $csv);
$this->assertStringContainsString("GD-EXEMPT,'3316392626048007769,拍A发B", $csv);
$this->assertStringNotContainsString('GD565967381', $csv);
$reimbursements = $this->loginAs()->get(route('business.planting-data.planting-reimbursements.export', [
'company_id' => $this->company->id, 'month' => '2026-09',
]));
$reimbursements->assertOk();
$this->assertStringContainsString('最终报销金额', $reimbursements->streamedContent());
}
}

View File

@ -0,0 +1,217 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Carbon\CarbonImmutable;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository as Repository;
use Modules\EcommerceFinance\Services\PlantingLossCalculator as Calculator;
use Tests\TestCase;
class PlantingLossCalculatorTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
'database.connections.planting_shipping' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => ''],
'jushuitan.connection' => 'planting_shipping',
'upstream_mirrors.connection' => 'business_mirror',
'upstream_mirrors.datasets' => [
Repository::DATASET => [
'source_key' => 'jiansun', 'schema' => 'dbo', 'object' => 'jst_workOrderInfo',
'target' => 'fixture_work_orders', 'keys' => ['Id'], 'watermark' => 'updateTime',
'indexes' => [['woTypeId', 'woNo', 'Id']],
],
Calculator::SKU_DATASET => [
'source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_sku',
'target' => 'fixture_sku', 'keys' => ['autoid'], 'watermark' => 'modified',
],
],
]);
Cache::setDefaultDriver('array');
Cache::flush();
DB::purge('business_mirror');
DB::purge('planting_shipping');
$schema = Schema::connection('business_mirror');
$schema->create('business_mirror_table_states', function (Blueprint $table): void {
$table->string('dataset_key')->primary();
$table->string('status')->nullable();
$table->dateTime('initial_completed_at')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
$schema->create('fixture_work_orders', function (Blueprint $table): void {
$table->integer('Id');
foreach (['woNo', 'woTypeId', 'status', 'createby', 'executors', 'completer', 'completeTime', 'created', 'updateTime', 'businessFieldList'] as $column) {
$table->text($column)->nullable();
}
});
$schema->create('fixture_sku', function (Blueprint $table): void {
$table->integer('autoid');
$table->string('sku_id');
$table->string('sku_type');
$table->decimal('cost_price', 18, 4)->nullable();
});
DB::connection('business_mirror')->table('fixture_sku')->insert([
['autoid' => 1, 'sku_id' => 'DNZ-01', 'sku_type' => 'normal', 'cost_price' => 90],
['autoid' => 2, 'sku_id' => 'DNZ-01', 'sku_type' => 'normal', 'cost_price' => 100.5],
['autoid' => 3, 'sku_id' => 'DNZ-02', 'sku_type' => 'combine', 'cost_price' => 50],
['autoid' => 4, 'sku_id' => 'ZERO', 'sku_type' => 'normal', 'cost_price' => 0],
]);
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
'dataset_key' => Repository::DATASET, 'status' => 'succeeded',
'initial_completed_at' => '2026-09-08 16:00:00', 'last_succeeded_at' => '2026-10-19 13:00:00',
]);
$shipping = Schema::connection('planting_shipping');
$shipping->create('jst_oms_shipping', function (Blueprint $table): void {
$table->increments('id');
foreach (['express_no', 'trade_no', 'barcode', 'product_name', 'warehouse_name'] as $column) {
$table->string($column)->nullable();
}
$table->integer('qty')->nullable();
$table->decimal('amount', 18, 2)->nullable();
$table->decimal('freight', 18, 2)->nullable();
$table->dateTime('ship_time')->nullable();
});
$shipping->create('jst_sync_state', function (Blueprint $table): void {
$table->string('dataset_key');
$table->string('status')->nullable();
$table->dateTime('last_succeeded_at')->nullable();
});
DB::connection('planting_shipping')->table('jst_sync_state')->insert(['dataset_key' => 'jst.sctrade', 'status' => 'succeeded', 'last_succeeded_at' => '2026-10-19 12:00:00']);
}
private function workOrder(int $id, string $woNo, string $orderNo, string $completeTime, array $patch = [], string $taskRemark = '', string $sellerRemark = ''): void
{
$fields = [
['fieldId' => 'orderField', 'type' => 'TEXT', 'value' => $orderNo],
['fieldId' => 'shopField', 'type' => 'SELECT', 'value' => '18501511', 'valueLabel' => '天猫-亿家达旗舰店'],
['fieldId' => 'platform', 'type' => 'SELECT', 'value' => '淘宝天猫'],
['fieldId' => 'actualPaidAmountField', 'type' => 'MONEY', 'value' => '37.79'],
['fieldId' => 'NUMBERField1413856620250924131615', 'type' => 'NUMBER', 'value' => '37.79'],
['fieldId' => 'NUMBERField1413856620251230154457', 'type' => 'NUMBER', 'value' => '2'],
['fieldId' => 'NUMBERField1413856620251117101250', 'type' => 'NUMBER', 'value' => '39.79'],
['fieldId' => 'TEXTField1413856620251117180931', 'type' => 'TEXT', 'value' => '黄文梳'],
['fieldId' => 'TEXTField1413856620251117112729', 'type' => 'TEXT', 'value' => '招商银行'],
['fieldId' => 'TEXTField1413856620251113150546', 'type' => 'TEXT', 'value' => $taskRemark],
['fieldId' => 'businessRemarkField', 'type' => 'TEXTAREA', 'value' => $sellerRemark],
['fieldId' => 'erpOrderStatus', 'type' => 'SELECT', 'value' => 'Sent'],
['fieldId' => 'itemInfo', 'type' => 'FORM', 'childFieldList' => [[
['fieldId' => 'skuId', 'type' => 'TEXT', 'value' => 'GG003-SZ'],
['fieldId' => 'name', 'type' => 'TEXT', 'value' => '透明挂钩3个'],
['fieldId' => 'qty', 'type' => 'NUMBER', 'value' => '1'],
]]],
];
DB::connection('business_mirror')->table('fixture_work_orders')->insert(array_replace([
'Id' => $id, 'woNo' => $woNo, 'woTypeId' => Repository::WORK_TYPE_ID, 'status' => 'CONFIRMED',
'createby' => '怀德', 'executors' => '李斯', 'completer' => '李斯', 'completeTime' => $completeTime,
'created' => '2026-09-01 09:00:00', 'updateTime' => $completeTime,
'businessFieldList' => json_encode($fields, JSON_UNESCAPED_UNICODE),
], $patch));
}
private function ship(string $tradeNo, string $shipTime, string $expressNo = 'YT001', float $amount = 40.6, string $barcode = 'DNZ-01', string $name = '儿童学习椅', int $qty = 1): void
{
DB::connection('planting_shipping')->table('jst_oms_shipping')->insert([
'trade_no' => $tradeNo, 'express_no' => $expressNo, 'barcode' => $barcode, 'product_name' => $name,
'warehouse_name' => '常州仓', 'qty' => $qty, 'amount' => $amount, 'freight' => 3.2, 'ship_time' => $shipTime,
]);
}
public function test_windows_follow_completion_and_two_month_shipping_rules(): void
{
$this->assertSame(['from' => '2026-09-02 00:00:00', 'to' => '2026-10-02 00:00:00'], Repository::window('2026-09'));
$this->assertSame(['from' => '2026-12-02 00:00:00', 'to' => '2027-01-02 00:00:00'], Repository::window('2026-12'));
$this->assertSame(['from' => '2026-08-01 00:00:00', 'to' => '2026-10-01 00:00:00'], Calculator::shippingWindow('2026-09'));
$this->assertSame('2026-09', Repository::defaultMonth(CarbonImmutable::parse('2026-10-01 18:00:00')));
$this->assertSame('2026-10', Repository::defaultMonth(CarbonImmutable::parse('2026-10-19 10:00:00')));
$this->assertSame(['2026-10', '2026-09'], Repository::months(CarbonImmutable::parse('2026-10-19')));
$this->expectException(\InvalidArgumentException::class);
Repository::window('2026-08');
}
public function test_exemption_keyword_tolerates_width_case_and_spaces(): void
{
$this->assertSame('拍A发B', Calculator::exemptionKeyword('拍A发B+GG003-SZ'));
$this->assertSame('拍A发A', Calculator::exemptionKeyword('', '客户要求 拍a 发A'));
$this->assertSame('拍A发B', Calculator::exemptionKeyword('CCDD', '拍a发b,供货价一致'));
$this->assertNull(Calculator::exemptionKeyword('AABB', '拍B发A'));
$this->assertNull(Calculator::exemptionKeyword('转挂钩发货', ''));
$this->assertTrue(Calculator::isFreeGoods('XN001', '信誉订单虚拟条码'));
$this->assertTrue(Calculator::isFreeGoods('GG003-SZ', '透明挂钩3个'));
$this->assertFalse(Calculator::isFreeGoods('DNZ-01', '儿童学习椅'));
$this->assertSame(12060, Calculator::deductionCents('100.5', 1));
$this->assertSame(24120, Calculator::deductionCents('100.5', 2));
}
public function test_loss_rows_match_shipments_and_split_exempt_from_deduct(): void
{
$this->workOrder(1, 'GD-DEDUCT', '3316392626048007769', '2026-09-02 00:00:00', [], 'CCDD', 'AABB');
$this->workOrder(2, 'GD-EXEMPT', '3604408018648198', '2026-09-15 11:47:53', [], '拍A发B+GG003-SZ');
$this->workOrder(3, 'GD-SELLER', '260901-000000000000001', '2026-10-01 23:59:59', [], '', '拍A发A');
$this->workOrder(4, 'GD-UNMATCHED', '9999', '2026-09-20 10:00:00');
$this->workOrder(5, 'GD-OUTSIDE', '3316392626048007769', '2026-10-02 00:00:00');
$this->workOrder(6, 'GD-AUG', '1111', '2026-09-01 23:59:59');
$this->workOrder(7, 'GD-OTHER-TYPE', '2222', '2026-09-10 10:00:00', ['woTypeId' => 'OTHER']);
// 同一工单两版:取最新一版;最新一版作废则不计入。
$this->workOrder(8, 'GD-DISABLED', '3333', '2026-09-10 10:00:00');
$this->workOrder(9, 'GD-DISABLED', '3333', '2026-09-10 10:00:00', ['status' => 'DISABLED']);
$this->workOrder(10, 'GD-VIRTUAL', '5555', '2026-09-20 10:00:00');
$this->workOrder(11, 'GD-NOCOST', '6666', '2026-09-21 10:00:00');
// 一单两个包裹:学习椅 ×2 + 透明挂钩(挂钩不扣)。
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT001', 40.6, 'DNZ-01', '儿童学习椅', 2);
$this->ship('3316392626048007769', '2026-08-28 10:00:00', 'YT002', 10, 'GG003-SZ', '透明挂钩3个');
$this->ship('3604408018648198', '2026-09-30 23:59:59');
$this->ship('260901-000000000000001', '2026-09-01 08:00:00');
$this->ship('9999', '2026-07-31 23:59:59'); // 早于上月 1 日
$this->ship('9999', '2026-10-01 00:00:00'); // 晚于本月末
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'XN-001', '信誉订单虚拟条码');
$this->ship('5555', '2026-09-10 08:00:00', 'YT555', 0, 'GG003-SZ', '透明挂钩3个');
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'ZERO', '无成本商品');
$this->ship('6666', '2026-09-10 08:00:00', 'YT666', 0, 'DNZ-02', '组合商品');
$rows = collect($this->app->make(Calculator::class)->rows('2026-09'))->keyBy('work_order_no');
$this->assertSame(['GD-DEDUCT', 'GD-EXEMPT', 'GD-NOCOST', 'GD-SELLER', 'GD-UNMATCHED', 'GD-VIRTUAL'], $rows->keys()->sort()->values()->all());
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-DEDUCT']['loss_result']);
$this->assertSame("YT001\nYT002", $rows['GD-DEDUCT']['express_no']);
$this->assertSame('50.60', $rows['GD-DEDUCT']['shipped_amount']);
$this->assertSame('3', $rows['GD-DEDUCT']['shipped_qty']);
// 取最新一条普通商品成本价 100.5:100.5 × 2 × 1.2 = 241.20,挂钩不计。
$this->assertSame('241.20', $rows['GD-DEDUCT']['deduction_amount']);
$this->assertSame('DNZ-01 × 2 × 100.5 × 1.2 = 241.20', $rows['GD-DEDUCT']['deduction_detail']);
$this->assertFalse($rows['GD-DEDUCT']['cost_missing']);
$this->assertSame(Calculator::RESULT_FREE_GOODS, $rows['GD-VIRTUAL']['loss_result']);
$this->assertNull($rows['GD-VIRTUAL']['deduction_amount']);
// 成本价为 0、或只有组合装资料的条码视为成本价缺失。
$this->assertSame(Calculator::RESULT_DEDUCT, $rows['GD-NOCOST']['loss_result']);
$this->assertSame('0.00', $rows['GD-NOCOST']['deduction_amount']);
$this->assertTrue($rows['GD-NOCOST']['cost_missing']);
$this->assertStringContainsString('ZERO、DNZ-02', $rows['GD-NOCOST']['loss_basis']);
$this->assertNull($rows['GD-EXEMPT']['deduction_amount']);
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-EXEMPT']['loss_result']);
$this->assertSame('备注含“拍A发B”', $rows['GD-EXEMPT']['loss_basis']);
$this->assertSame(Calculator::RESULT_EXEMPT, $rows['GD-SELLER']['loss_result']);
$this->assertSame(Calculator::RESULT_UNMATCHED, $rows['GD-UNMATCHED']['loss_result']);
$row = $rows['GD-DEDUCT'];
$this->assertSame('天猫-亿家达旗舰店', $row['shop_name']);
$this->assertSame('37.79', $row['task_payment']);
$this->assertSame('39.79', $row['reimbursement_amount']);
$this->assertSame('已发货', $row['erp_order_status']);
$this->assertSame('GG003-SZ', $row['product_code']);
$this->assertSame('2026-09-02 00:00:00', $row['completed_at']);
}
}