供应商应付-厂家记账:新增厂家记账配置并按配置判定厂家扣款

- 新增配置表 business_supplier_bookkeeping_configs(+ 操作日志表),迁移时导入财务提供的 209 家存量配置
- 记账方式:正常记账 / 仅代发仓发错货记账 / 不记账,支持生效期;配置页支持新增、编辑、删除恢复、Excel 导入导出
- 扣款判定:厂家记账按供应商配置,仅正常记账扣款;仓库记账仅订单发货仓库为代发仓且二级原因含“仓库发错货”时作为厂家扣款项,其余不扣
- 未配置/生效期外显示待配置,多供应商判定不一致显示待确认,均不计入扣款金额
- 明细页新增厂家扣款判定筛选、扣款工单数与扣款金额指标、导出列

涉及:数据库迁移(新建配置表并写入存量配置)、金额汇总展示;不涉及上游写入和权限模型。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JouFR6TJ7vGscRJanYScEo
This commit is contained in:
limeng 2026-10-19 10:38:36 +08:00
parent 68b16a95c0
commit 09b4ffcb8a
15 changed files with 2615 additions and 5 deletions

View File

@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
class ImportSupplierBookkeepingConfigs extends Command
{
protected $signature = 'supplier-bookkeeping:import-configs {file} {--dry-run : 仅检查文件并输出条数,不写入}';
protected $description = '导入财务提供的厂家记账配置 Excel(供应商、厂家是否记账);新增/更新/跳过,任一行无法识别整批不写入';
public function handle(SupplierBookkeepingConfigImporter $importer, SupplierBookkeepingConfigStore $store): int
{
$path = (string) $this->argument('file');
$rows = $importer->read($path);
if ($this->option('dry-run')) {
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
return self::SUCCESS;
}
$result = $store->import($rows, '命令行导入', hash_file('sha256', $path));
$this->info("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule as ValidationRule;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
use Modules\EcommerceFinance\View\SupplierBookkeepingConfigPageData;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class SupplierBookkeepingConfigs extends Controller
{
public function __construct(private readonly SupplierBookkeepingConfigStore $store) {}
public function store(Request $request)
{
$this->store->save($this->validated($request), $this->actor());
return $this->back('厂家记账配置已新增,厂家记账明细按新配置重新判定。');
}
public function update(Request $request, int $configId)
{
$this->store->save($this->validated($request), $this->actor(), $configId);
return $this->back('厂家记账配置已更新,厂家记账明细按新配置重新判定。');
}
public function destroy(int $configId)
{
$this->store->changeDeleted($configId, true, $this->actor());
return $this->back('配置已删除,该供应商的厂家记账将显示为“待配置”。');
}
public function restore(int $configId)
{
$this->store->changeDeleted($configId, false, $this->actor());
return $this->back('厂家记账配置已恢复。');
}
public function import(Request $request, SupplierBookkeepingConfigImporter $importer)
{
$request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]);
$path = $request->file('file')->getRealPath();
$result = $this->store->import($importer->read($path), $this->actor(), hash_file('sha256', $path));
return $this->back("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
}
public function export(SupplierBookkeepingConfigPageData $data)
{
$rows = $this->store->filtered($data->filters())->get();
$book = new Spreadsheet();
$sheet = $book->getActiveSheet();
$sheet->setTitle('厂家记账配置');
$sheet->fromArray([['供应商', '记账方式', '生效开始日期', '生效结束日期', '原始说明', '创建人', '更新时间', '删除状态']]);
foreach ($rows as $index => $row) {
$values = [$row->supplier_name, Rule::MODES[$row->mode] ?? $row->mode, $row->effective_from, $row->effective_to, $row->source_text, $row->creator, $row->updated_at, $row->deleted_at ? '已删除' : '正常'];
foreach ($values as $column => $value) {
$sheet->setCellValueExplicit([$column + 1, $index + 2], (string) $value, DataType::TYPE_STRING);
}
}
foreach (['A' => 24, 'B' => 20, 'C' => 14, 'D' => 14, 'E' => 44, 'F' => 16, 'G' => 20, 'H' => 10] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
return response()->streamDownload(function () use ($book): void {
try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); }
}, '厂家记账配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
private function validated(Request $request): array
{
return $request->validate([
'supplier_name' => ['required', 'string', 'max:191'],
'mode' => ['required', ValidationRule::in(array_keys(Rule::MODES))],
'effective_from' => ['nullable', 'date_format:Y-m-d'],
'effective_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'],
'source_text' => ['nullable', 'string', 'max:255'],
], [], ['supplier_name' => '供应商', 'mode' => '记账方式', 'effective_from' => '生效开始日期', 'effective_to' => '生效结束日期', 'source_text' => '备注']);
}
private function actor(): string
{
return mb_substr((string) user()->name, 0, 191);
}
private function back(string $message)
{
return redirect()->route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'])->with('bookkeeping_config_message', $message);
}
}

View File

@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 供应商应付「厂家记账」配置:按供应商记录厂家记账方式(正常记账 / 仅代发仓发错货记账 / 不记账)及生效期。
* 与聚工单镜像一样不区分公司;建表后导入财务提供的存量配置(resources/data/supplier-bookkeeping-configs.json)。
*/
return new class extends Migration {
public function up(): void
{
Schema::create('business_supplier_bookkeeping_configs', function (Blueprint $table): void {
$table->increments('id');
$table->string('supplier_name', 191)->unique('supplier_bookkeeping_config_supplier_unique');
$table->string('mode', 32);
$table->date('effective_from')->nullable();
$table->date('effective_to')->nullable();
$table->string('source_text', 255)->default('');
$table->string('creator', 191);
$table->timestamps();
$table->softDeletes();
});
Schema::create('business_supplier_bookkeeping_config_events', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('config_id')->nullable()->index();
$table->string('actor', 191);
$table->string('action', 32);
$table->text('description');
$table->longText('details');
$table->dateTime('created_at');
});
$payload = json_decode((string) file_get_contents(resource_path('data/supplier-bookkeeping-configs.json')), true, 16, JSON_THROW_ON_ERROR);
$now = now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
$rows = array_map(static fn (array $row): array => [
'supplier_name' => $row['supplier_name'],
'mode' => $row['mode'],
'effective_from' => $row['effective_from'],
'effective_to' => $row['effective_to'],
'source_text' => $row['source_text'],
'creator' => '存量配置导入',
'created_at' => $now,
'updated_at' => $now,
], $payload['configs']);
foreach (array_chunk($rows, 100) as $chunk) {
DB::table('business_supplier_bookkeeping_configs')->insert($chunk);
}
DB::table('business_supplier_bookkeeping_config_events')->insert([
'config_id' => null,
'actor' => '存量配置导入',
'action' => 'import',
'description' => '导入存量厂家记账配置:新增' . count($rows) . '条',
'details' => json_encode(['added' => count($rows), 'source' => $payload['source']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'created_at' => $now,
]);
}
public function down(): void
{
Schema::dropIfExists('business_supplier_bookkeeping_config_events');
Schema::dropIfExists('business_supplier_bookkeeping_configs');
}
};

View File

@ -0,0 +1,121 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
/**
* 读取厂家记账配置 Excel:
* - 财务原表:供应商、厂家是否记账(原文如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”);
* - 本系统导出表:供应商、记账方式、生效开始日期、生效结束日期、原始说明。
*/
class SupplierBookkeepingConfigImporter
{
/** @return list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> */
public function read(string $path): array
{
$reader = IOFactory::createReader('Xlsx');
$info = $reader->listWorksheetInfo($path);
if ($info === [] || $info[0]['totalRows'] > 5001 || $info[0]['totalColumns'] > 20) {
throw ValidationException::withMessages(['file' => '请使用第一个工作表存放配置,最多5000条。']);
}
$reader->setLoadSheetsOnly($info[0]['worksheetName']);
$book = $reader->load($path);
try {
$sheet = $book->getSheet(0);
$headers = [];
for ($column = 1; $column <= $info[0]['totalColumns']; $column++) {
$headers[$this->label($sheet->getCell([$column, 1])->getValue())] = $column;
}
$supplierColumn = $headers['供应商'] ?? $headers['供应商名称'] ?? null;
$ruleColumn = $headers['厂家是否记账'] ?? null;
$modeColumn = $headers['记账方式'] ?? null;
if ($supplierColumn === null || ($ruleColumn === null && $modeColumn === null)) {
throw ValidationException::withMessages(['file' => '缺少列:需要“供应商”和“厂家是否记账”(或“记账方式”)。']);
}
$rows = [];
$seen = [];
for ($number = 2; $number <= $sheet->getHighestDataRow(); $number++) {
$supplier = $this->text($sheet, $supplierColumn, $number);
$ruleText = $ruleColumn !== null ? $this->text($sheet, $ruleColumn, $number) : '';
$modeText = $modeColumn !== null ? $this->text($sheet, $modeColumn, $number) : '';
// 原表中间重复出现的表头行、空行跳过。
if (($supplier === '' && $ruleText === '' && $modeText === '') || $this->label($supplier) === '供应商') {
continue;
}
if ($supplier === '' || mb_strlen($supplier) > 191) {
throw ValidationException::withMessages(['file' => "第{$number}行供应商为空或超长。"]);
}
$key = mb_strtolower($supplier);
if (isset($seen[$key])) {
throw ValidationException::withMessages(['file' => "第{$number}行供应商“{$supplier}”与第{$seen[$key]}行重复。"]);
}
$seen[$key] = $number;
try {
$parsed = Rule::parseRuleText($ruleText !== '' ? $ruleText : $modeText);
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages(['file' => "第{$number}行:" . $exception->getMessage()]);
}
foreach (['effective_from' => '生效开始日期', 'effective_to' => '生效结束日期'] as $field => $label) {
if (isset($headers[$label])) {
$date = $this->date($sheet, $headers[$label], $number);
if ($date !== null) {
$parsed[$field] = $date;
}
}
}
if ($parsed['effective_from'] !== null && $parsed['effective_to'] !== null && $parsed['effective_from'] > $parsed['effective_to']) {
throw ValidationException::withMessages(['file' => "第{$number}行生效结束日期早于开始日期。"]);
}
$source = isset($headers['原始说明']) ? $this->text($sheet, $headers['原始说明'], $number) : '';
$rows[] = ['supplier_name' => $supplier] + $parsed + ['source_text' => $ruleText !== '' ? $ruleText : ($source !== '' ? $source : $modeText)];
}
if ($rows === []) {
throw ValidationException::withMessages(['file' => '配置表没有有效记录。']);
}
return $rows;
} finally {
$book->disconnectWorksheets();
}
}
private function label(mixed $value): string
{
return str_replace([' ', ' ', "\n"], '', trim((string) $value));
}
private function text($sheet, int $column, int $row): string
{
$cell = $sheet->getCell([$column, $row]);
if ($cell->getDataType() === 'f') {
throw ValidationException::withMessages(['file' => "第{$row}行含公式,请使用实际配置值。"]);
}
return trim((string) $cell->getFormattedValue());
}
private function date($sheet, int $column, int $row): ?string
{
$cell = $sheet->getCell([$column, $row]);
$value = $cell->getValue();
if ($value === null || trim((string) $value) === '') {
return null;
}
if (is_numeric($value) && ExcelDate::isDateTime($cell)) {
return ExcelDate::excelToDateTimeObject($value)->format('Y-m-d');
}
$text = str_replace(['/', '.'], '-', trim((string) $cell->getFormattedValue()));
if (! preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m) || ! checkdate((int) $m[2], (int) $m[3], (int) $m[1])) {
throw ValidationException::withMessages(['file' => "第{$row}行日期无效,请使用 2026-01-31 格式。"]);
}
return sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]);
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
/** 供应商应付「厂家记账」配置:每个供应商一条记账方式,删除可恢复,操作留痕。 */
class SupplierBookkeepingConfigStore
{
public const TABLE = 'business_supplier_bookkeeping_configs';
public const EVENTS = 'business_supplier_bookkeeping_config_events';
private const FIELDS = ['mode', 'effective_from', 'effective_to', 'source_text'];
public function query()
{
return DB::table(self::TABLE);
}
/** @return array<string,array<string,mixed>> 供应商名称 => 生效中的配置(未删除) */
public function activeConfigs(): array
{
$result = [];
foreach ($this->query()->whereNull('deleted_at')->get() as $row) {
$result[trim($row->supplier_name)] = (array) $row;
}
return $result;
}
public function filtered(array $filters)
{
$query = $this->query();
$status = $filters['deleted_status'] ?? 'active';
if ($status === 'deleted') {
$query->whereNotNull('deleted_at');
} elseif ($status !== 'all') {
$query->whereNull('deleted_at');
}
if (($filters['supplier_name'] ?? '') !== '') {
$query->where('supplier_name', 'like', '%' . addcslashes($filters['supplier_name'], '\\%_') . '%');
}
if (($filters['mode'] ?? '') !== '') {
$query->where('mode', $filters['mode']);
}
return $query->orderBy('supplier_name')->orderBy('id');
}
/** @param array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text?:string} $data */
public function save(array $data, string $actor, ?int $id = null): int
{
return DB::transaction(function () use ($data, $actor, $id): int {
$before = $id === null ? null : $this->query()->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($id !== null && $before === null, 404);
$name = trim($data['supplier_name']);
$duplicate = $this->query()->where('supplier_name', $name)->when($id !== null, fn ($q) => $q->where('id', '<>', $id))->exists();
if ($duplicate) {
throw ValidationException::withMessages(['supplier_name' => '该供应商已有配置(含已删除),请编辑或恢复原配置。']);
}
$time = $this->time();
$values = ['supplier_name' => $name, 'updated_at' => $time] + $this->values($data);
if ($id === null) {
$id = $this->query()->insertGetId($values + ['creator' => $actor, 'created_at' => $time]);
} else {
$this->query()->where('id', $id)->update($values);
}
$this->event($id, $actor, $before ? 'update' : 'create', ($before ? '修改' : '新增') . '厂家记账配置:' . $name . ' → ' . Rule::describe($values), ['before' => $before, 'after' => $values]);
return $id;
});
}
public function changeDeleted(int $id, bool $deleted, string $actor): void
{
DB::transaction(function () use ($id, $deleted, $actor): void {
$row = $this->query()->where('id', $id)->lockForUpdate()->first();
abort_if($row === null, 404);
if (($row->deleted_at !== null) === $deleted) {
return;
}
$this->query()->where('id', $id)->update(['deleted_at' => $deleted ? $this->time() : null, 'updated_at' => $this->time()]);
$this->event($id, $actor, $deleted ? 'delete' : 'restore', ($deleted ? '删除' : '恢复') . '厂家记账配置:' . $row->supplier_name, ['supplier_name' => $row->supplier_name, 'mode' => $row->mode]);
});
}
/**
* 整批导入:新供应商新增;已有供应商记账方式或生效期不同则按文件更新(已删除的一并恢复);相同则跳过。
*
* @param list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> $rows
* @return array{added:int,updated:int,skipped:int}
*/
public function import(array $rows, string $actor, string $sourceHash): array
{
return DB::transaction(function () use ($rows, $actor, $sourceHash): array {
$added = $updated = $skipped = 0;
$time = $this->time();
foreach ($rows as $row) {
$name = trim($row['supplier_name']);
$values = $this->values($row);
$existing = $this->query()->where('supplier_name', $name)->lockForUpdate()->first();
if ($existing === null) {
$this->query()->insert(['supplier_name' => $name, 'creator' => $actor, 'created_at' => $time, 'updated_at' => $time] + $values);
$added++;
continue;
}
$same = $existing->deleted_at === null;
foreach (['mode', 'effective_from', 'effective_to'] as $field) {
$same = $same && (string) $existing->{$field} === (string) $values[$field];
}
if ($same) {
$skipped++;
continue;
}
$this->query()->where('id', $existing->id)->update($values + ['deleted_at' => null, 'updated_at' => $time]);
$updated++;
}
$this->event(null, $actor, 'import', "导入厂家记账配置:新增{$added}条,更新{$updated}条,未变化{$skipped}条", compact('added', 'updated', 'skipped') + ['source_sha256' => $sourceHash]);
return compact('added', 'updated', 'skipped');
});
}
public function events()
{
return DB::table(self::EVENTS)->orderByDesc('id');
}
private function values(array $data): array
{
$values = [];
foreach (self::FIELDS as $field) {
$value = $data[$field] ?? null;
$values[$field] = $field === 'source_text' ? mb_substr(trim((string) $value), 0, 255) : (($value === '' || $value === null) ? null : (string) $value);
}
abort_unless(isset(Rule::MODES[$values['mode']]), 422, '记账方式无效。');
if ($values['effective_from'] !== null && $values['effective_to'] !== null && $values['effective_from'] > $values['effective_to']) {
throw ValidationException::withMessages(['effective_to' => '生效结束日期不能早于开始日期。']);
}
return $values;
}
private function event(?int $id, string $actor, string $action, string $description, array $details): void
{
DB::table(self::EVENTS)->insert(['config_id' => $id, 'actor' => $actor, 'action' => $action, 'description' => mb_substr($description, 0, 1000), 'details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_at' => $this->time()]);
}
private function time(): string
{
return Carbon::now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace Modules\EcommerceFinance\Services;
use InvalidArgumentException;
/**
* 厂家记账扣款判定(2026-10-19 由财务确认):
*
* - 记账原因一级为“厂家记账”:按供应商的厂家记账配置判定,仅“正常记账”的供应商作为扣款项;
* - 记账原因一级为“仓库记账”:只有订单发货仓库为“某某代发仓”且二级原因含“仓库发错货”时,
* 才可作为厂家记账扣款项(供应商配置为“不记账”时仍不扣);其余仓库记账一律不作为扣款项;
* - 供应商未配置或不在配置生效期内的,标记“待配置”,不计入扣款,提醒财务补齐配置。
*/
class SupplierBookkeepingDeductionRule
{
public const MODE_NONE = 'none';
public const MODE_DROPSHIP_WRONG = 'dropship_wrong';
public const MODE_NORMAL = 'normal';
public const MODES = [
self::MODE_NORMAL => '正常记账',
self::MODE_DROPSHIP_WRONG => '仅代发仓发错货记账',
self::MODE_NONE => '不记账',
];
public const STATUS_DEDUCT = '扣款';
public const STATUS_SKIP = '不扣款';
public const STATUS_UNCONFIGURED = '待配置';
public const STATUS_REVIEW = '待确认';
public const STATUSES = [self::STATUS_DEDUCT, self::STATUS_SKIP, self::STATUS_UNCONFIGURED, self::STATUS_REVIEW];
public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓';
public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货';
/**
* 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。
*
* @return array{mode:string,effective_from:?string,effective_to:?string}
*/
public static function parseRuleText(string $text): array
{
$normalized = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], trim($text));
if ($normalized === '') {
throw new InvalidArgumentException('厂家是否记账为空。');
}
$label = array_search($normalized, self::MODES, true);
if (is_string($label)) {
return ['mode' => $label, 'effective_from' => null, 'effective_to' => null];
}
if (str_contains($normalized, '代发仓') && (str_contains($normalized, '发货错') || str_contains($normalized, '发错货'))) {
$mode = self::MODE_DROPSHIP_WRONG;
} elseif (str_starts_with($normalized, '正常记账')) {
$mode = self::MODE_NORMAL;
} elseif (str_starts_with($normalized, '不记账')) {
$mode = self::MODE_NONE;
} else {
throw new InvalidArgumentException('无法识别的记账方式:' . $text);
}
[$from, $to] = [null, null];
$date = '(\d{4})[.\/年](\d{1,2})月?(?:[.\/](\d{1,2})日?)?';
if (preg_match('/(' . $date . '[-—~至]+' . $date . ')/u', $normalized, $m)) {
$from = self::date((int) $m[1], (int) $m[2], ($m[3] ?? '') === '' ? 1 : (int) $m[3]);
$to = self::date((int) $m[4], (int) $m[5], ($m[6] ?? '') === '' ? null : (int) $m[6]);
if ($from > $to) {
throw new InvalidArgumentException('生效期开始晚于结束:' . $text);
}
} elseif (str_contains($normalized, '(')) {
throw new InvalidArgumentException('无法识别的生效期:' . $text);
}
return ['mode' => $mode, 'effective_from' => $from, 'effective_to' => $to];
}
/** 配置的展示文字,例如“不记账(2025-03-01 至 2026-02-28)”。 */
public static function describe(array|object $config): string
{
$config = (array) $config;
$label = self::MODES[$config['mode']] ?? (string) $config['mode'];
$from = $config['effective_from'] ?? null;
$to = $config['effective_to'] ?? null;
if ($from === null && $to === null) {
return $label;
}
return $label . '(' . ($from ?? '不限') . ' 至 ' . ($to ?? '不限') . ')';
}
/**
* @param array<string,mixed> $row 厂家记账明细行
* @param array<string,array<string,mixed>|object> $configs 供应商名称 => 生效中的配置
* @return array{supplier_config:string,deduction_status:string,deduction_basis:string}
*/
public static function decide(array $row, array $configs): array
{
$level1 = (string) ($row['reason_level_1'] ?? '');
$level2 = (string) ($row['reason_level_2'] ?? '');
$date = substr((string) ($row['started_at'] ?? ''), 0, 10);
$suppliers = array_values(array_unique(array_filter(array_map('trim', explode("\n", (string) ($row['supplier'] ?? '')))), SORT_STRING));
$found = [];
foreach ($suppliers as $supplier) {
$found[$supplier] = isset($configs[$supplier]) ? (array) $configs[$supplier] : null;
}
$configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未配置' : self::describe($config), $found));
if ($level1 === '仓库记账') {
$warehouse = (string) ($row['shipping_warehouse'] ?? '');
if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) {
return self::result($configText, self::STATUS_SKIP, '仓库记账仅“代发仓 + 仓库发错货”作为厂家扣款,本单不扣');
}
} elseif ($level1 !== '厂家记账') {
return self::result($configText, self::STATUS_SKIP, '非厂家记账/仓库记账原因');
}
if ($suppliers === []) {
return self::result('', self::STATUS_UNCONFIGURED, '工单未带供应商,无法匹配厂家记账配置');
}
$outcomes = [];
foreach ($found as $supplier => $config) {
$outcomes[$supplier] = self::outcome($level1, $config, $date);
}
$statuses = array_values(array_unique(array_column($outcomes, 0)));
if (count($statuses) > 1) {
$detail = implode(';', array_map(static fn (string $supplier, array $o): string => $supplier . ':' . $o[0], array_keys($outcomes), $outcomes));
return self::result($configText, self::STATUS_REVIEW, '多个供应商判定不一致,请人工确认(' . $detail . ')');
}
return self::result($configText, $statuses[0], reset($outcomes)[1]);
}
/** @return array{0:string,1:string} [判定, 说明] */
private static function outcome(string $level1, ?array $config, string $date): array
{
if ($config === null) {
return [self::STATUS_UNCONFIGURED, '供应商未配置厂家记账方式'];
}
$from = $config['effective_from'] ?? null;
$to = $config['effective_to'] ?? null;
if (($from !== null && $date < $from) || ($to !== null && $date > $to)) {
return [self::STATUS_UNCONFIGURED, '发起日期不在配置生效期内(' . self::describe($config) . '),请补充配置'];
}
return match ($config['mode']) {
self::MODE_NONE => [self::STATUS_SKIP, '供应商配置为不记账'],
self::MODE_NORMAL => $level1 === '厂家记账'
? [self::STATUS_DEDUCT, '供应商正常记账']
: [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款'],
self::MODE_DROPSHIP_WRONG => $level1 === '仓库记账'
? [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款']
: [self::STATUS_SKIP, '供应商仅代发仓发错货记账,其他原因不记账'],
default => [self::STATUS_UNCONFIGURED, '无法识别的记账方式'],
};
}
private static function result(string $config, string $status, string $basis): array
{
return ['supplier_config' => $config, 'deduction_status' => $status, 'deduction_basis' => $basis];
}
private static function date(int $year, int $month, ?int $day): string
{
if ($month < 1 || $month > 12) {
throw new InvalidArgumentException('生效期月份无效。');
}
$last = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
$day ??= $last;
if ($day < 1 || $day > $last) {
throw new InvalidArgumentException('生效期日期无效。');
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Modules\EcommerceFinance\View;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
/** 供应商应付 → 厂家记账 → 厂家记账配置。 */
class SupplierBookkeepingConfigPageData
{
public function __construct(private readonly SupplierBookkeepingConfigStore $store) {}
/** @return array{supplier_name:string,mode:string,deleted_status:string} */
public function filters(): array
{
$text = static fn (string $key): string => mb_substr(trim((string) request()->query($key, '')), 0, 191);
$filters = ['supplier_name' => $text('supplier_name'), 'mode' => $text('mode'), 'deleted_status' => $text('deleted_status') ?: 'active'];
abort_unless($filters['mode'] === '' || isset(Rule::MODES[$filters['mode']]), 422, '记账方式无效。');
abort_unless(in_array($filters['deleted_status'], ['active', 'deleted', 'all'], true), 422, '删除状态无效。');
return $filters;
}
public function page(): array
{
$filters = $this->filters();
$counts = $this->store->query()->whereNull('deleted_at')->selectRaw('mode, COUNT(*) AS total')->groupBy('mode')->pluck('total', 'mode')->all();
return [
'view' => 'business.pages.supplier-payables.manufacturer-bookkeeping',
'bookkeepingView' => 'config',
'configFilters' => $filters,
'configCounts' => $counts,
'configRows' => $this->store->filtered($filters)->paginate(50, ['*'], 'page')->withQueryString(),
'configEvents' => $this->store->events()->paginate(20, ['*'], 'event_page')->withQueryString(),
];
}
}

View File

@ -4,6 +4,8 @@ namespace Modules\EcommerceFinance\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository as Repository;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
/** 供应商应付 → 厂家记账(聚工单“记账”工单镜像)。 */
class SupplierBookkeepingPageData
@ -25,6 +27,7 @@ class SupplierBookkeepingPageData
'started_at' => '发起时间', 'work_order_no' => '工单编号', 'initiator' => '发起人',
'bookkeeping_reason' => '记账原因', 'reason_level_1' => '记账原因-一级选项', 'reason_level_2' => '记账原因-二级选项',
'bookkeeping_type' => '记账类型', 'executor' => '执行人', 'source' => '记账来源', 'amount' => '记账金额',
'deduction_status' => '厂家扣款判定', 'supplier_config' => '厂家记账配置', 'deduction_basis' => '判定说明',
'remark' => '记账备注说明', 'attachment' => '上传附件', 'online_order_no' => '线上订单号',
'internal_order_no' => '内部单号', 'actual_paid_amount' => '实付金额', 'carrier' => '物流公司',
'tracking_no' => '物流单号', 'shop_name' => '店铺名称', 'platform' => '平台', 'shipping_warehouse' => '订单发货仓库',
@ -39,7 +42,10 @@ class SupplierBookkeepingPageData
public const MONEY_COLUMNS = ['amount', 'actual_paid_amount'];
public function __construct(private readonly Repository $repository) {}
public function __construct(
private readonly Repository $repository,
private readonly SupplierBookkeepingConfigStore $configs,
) {}
/** @return array<string,string> */
public function filters(): array
@ -56,6 +62,9 @@ class SupplierBookkeepingPageData
$status = request()->query('status', '');
abort_unless(is_string($status) && ($status === '' || in_array($status, Repository::STATUS_LABELS, true)), 422, '工单状态无效。');
$filters['status'] = $status;
$deduction = request()->query('deduction', '');
abort_unless(is_string($deduction) && ($deduction === '' || in_array($deduction, Rule::STATUSES, true)), 422, '扣款判定无效。');
$filters['deduction'] = $deduction;
foreach (['bookkeeping_type' => 50, ...array_fill_keys(array_keys(self::TEXT_FILTERS), 200)] as $key => $max) {
$value = request()->query($key, '');
abort_unless(is_string($value) && mb_strlen($value) <= $max, 422, '筛选条件格式无效。');
@ -88,12 +97,17 @@ class SupplierBookkeepingPageData
{
$from = $filters['started_from'] !== '' ? $filters['started_from'] . ' 00:00:00' : '';
$to = $filters['started_to'] !== '' ? $filters['started_to'] . ' 23:59:59' : '';
$deduction = $filters['deduction'] ?? '';
$configs = $this->configs->activeConfigs();
foreach ($rows ?? $this->repository->rows() as $row) {
// 配置随时可改,判定不进镜像结果缓存,每次按当前配置计算。
$row = Rule::decide($row, $configs) + $row;
if ($from !== '' && $row['started_at'] < $from) continue;
if ($to !== '' && $row['started_at'] > $to) continue;
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;
}
@ -113,10 +127,13 @@ class SupplierBookkeepingPageData
public function page(): array
{
if (request()->query('bk_view') === 'config') {
return app(SupplierBookkeepingConfigPageData::class)->page();
}
$filters = $this->filters();
$page = max(1, (int) request('page', 1));
$sync = $this->repository->syncStatus();
$summary = ['count' => 0, 'amount_cents' => 0, 'supplier_reason' => 0, 'warehouse_reason' => 0];
$summary = ['count' => 0, 'amount_cents' => 0, 'supplier_reason' => 0, 'warehouse_reason' => 0, 'deduction_count' => 0, 'deduction_cents' => 0, 'unconfigured' => 0, 'review' => 0];
$items = [];
$types = [];
$error = null;
@ -136,6 +153,14 @@ class SupplierBookkeepingPageData
$summary['count']++;
$summary['amount_cents'] += self::cents($row['amount']);
$summary[$row['reason_level_1'] === '厂家记账' ? 'supplier_reason' : 'warehouse_reason']++;
if ($row['deduction_status'] === Rule::STATUS_DEDUCT) {
$summary['deduction_count']++;
$summary['deduction_cents'] += self::cents($row['amount']);
} elseif ($row['deduction_status'] === Rule::STATUS_UNCONFIGURED) {
$summary['unconfigured']++;
} elseif ($row['deduction_status'] === Rule::STATUS_REVIEW) {
$summary['review']++;
}
}
} catch (\Throwable $exception) {
report($exception);
@ -148,6 +173,7 @@ class SupplierBookkeepingPageData
return [
'view' => 'business.pages.supplier-payables.manufacturer-bookkeeping',
'bookkeepingView' => 'details',
'bookkeepingFilters' => $filters,
'bookkeepingSummary' => $summary,
'bookkeepingSync' => $sync,

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,20 @@
@php
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository as BookkeepingRepository;
use Modules\EcommerceFinance\View\SupplierBookkeepingPageData as BookkeepingPage;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as DeductionRule;
$bookkeepingView = $bookkeepingView ?? 'details';
$columns = BookkeepingPage::COLUMNS;
$deductionClasses = [DeductionRule::STATUS_DEDUCT => 'is-deduct', DeductionRule::STATUS_SKIP => 'is-skip', DeductionRule::STATUS_UNCONFIGURED => 'is-pending', DeductionRule::STATUS_REVIEW => 'is-pending'];
$syncLabels = ['ready' => '镜像已同步', 'failed' => '最近一次同步失败,展示上次成功数据', 'initializing' => '首次同步中', 'initial_failed' => '首次同步失败', 'unavailable' => '镜像未建立'];
@endphp
<div class="sb-shell">
<nav class="sb-tabs" aria-label="厂家记账功能">
<a data-sb-tab href="{{ route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping']) }}" class="{{ $bookkeepingView === 'details' ? 'is-active' : '' }}" @if($bookkeepingView === 'details') aria-current="page" @endif>记账明细</a>
<a data-sb-tab href="{{ route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config']) }}" class="{{ $bookkeepingView === 'config' ? 'is-active' : '' }}" @if($bookkeepingView === 'config') aria-current="page" @endif>厂家记账配置</a>
</nav>
@if($bookkeepingView === 'config')
@include('business.pages.supplier-payables.partials.manufacturer-bookkeeping-configs')
@else
<div class="supplier-bookkeeping-page" data-preference-key="supplier-bookkeeping:{{ company_id() }}:{{ optional(user())->id }}">
<div class="sb-heading">
<p>聚水潭聚工单「记账」工单中,记账原因属于厂家记账、仓库记账的明细,发起时间自 2026-09-01 起。</p>
@ -22,6 +33,7 @@
@endforeach
</select></label>
<label>记账类型<select name="bookkeeping_type"><option value="">全部</option>@foreach($bookkeepingTypes as $type)<option value="{{ $type }}" @selected($bookkeepingFilters['bookkeeping_type'] === $type)>{{ $type }}</option>@endforeach</select></label>
<label>厂家扣款判定<select name="deduction"><option value="">全部</option>@foreach(DeductionRule::STATUSES as $label)<option value="{{ $label }}" @selected($bookkeepingFilters['deduction'] === $label)>{{ $label }}</option>@endforeach</select></label>
<label>工单状态<select name="status"><option value="">全部</option>@foreach(BookkeepingRepository::STATUS_LABELS as $label)<option value="{{ $label }}" @selected($bookkeepingFilters['status'] === $label)>{{ $label }}</option>@endforeach</select></label>
@foreach(BookkeepingPage::TEXT_FILTERS as $key => $label)
<label>{{ $label }}<input type="text" name="{{ $key }}" value="{{ $bookkeepingFilters[$key] }}" maxlength="200" placeholder="筛选{{ $label }}"></label>
@ -36,8 +48,11 @@
<div><span>厂家记账</span><strong>{{ number_format($bookkeepingSummary['supplier_reason']) }}</strong></div>
<div><span>仓库记账</span><strong>{{ number_format($bookkeepingSummary['warehouse_reason']) }}</strong></div>
<div><span>记账金额合计(元)</span><strong>{{ number_format($bookkeepingSummary['amount_cents'] / 100, 2) }}</strong></div>
<div><span>厂家扣款工单</span><strong>{{ number_format($bookkeepingSummary['deduction_count']) }}</strong></div>
<div class="is-key"><span>厂家扣款金额(元)</span><strong>{{ number_format($bookkeepingSummary['deduction_cents'] / 100, 2) }}</strong></div>
<div><span>待配置 / 待确认</span><strong class="{{ $bookkeepingSummary['unconfigured'] + $bookkeepingSummary['review'] > 0 ? 'sb-warn-text' : '' }}">{{ number_format($bookkeepingSummary['unconfigured']) }} / {{ number_format($bookkeepingSummary['review']) }}</strong></div>
</div>
<details class="sb-rules"><summary>接入口径</summary><p>与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。</p></details>
<details class="sb-rules"><summary>接入口径</summary><p>与聚工单「协同工单列表 → 记账」视图一致:工单类型为“记账”;工单状态为待审核、已拒绝、待处理、处理中、已完成(不含已取消);记账原因为“厂家记账”下 12 项与“仓库记账”下 5 项二级原因;发起时间不早于 2026-09-01。同一工单号只取最新一版,状态或原因在聚工单变更后随镜像同步自动更新;多件商品的工单在商品列逐行展示,记账金额只计一次。数据只读,不在此修改。</p><p>厂家扣款判定:记账原因为“厂家记账”的,按「厂家记账配置」中供应商的记账方式判定,仅“正常记账”计入扣款;记账原因为“仓库记账”的,只有订单发货仓库为“某某代发仓”且二级原因为“仓库发错货”时才作为厂家扣款项(供应商配置为“不记账”的除外),其余仓库记账一律不扣。供应商未配置或发起日期不在配置生效期内的显示“待配置”,多件商品分属不同供应商且判定不一致的显示“待确认”,均不计入扣款金额。</p></details>
@if($bookkeepingError)<div class="sb-error" role="alert">{{ $bookkeepingError }}</div>@endif
<section class="sb-panel" aria-label="厂家记账明细">
<div class="sb-table-scroll"><table><thead><tr>
@ -51,6 +66,8 @@
<td>@forelse(array_filter(explode("\n", (string) $row['attachment'])) as $i => $url)<a href="{{ $url }}" target="_blank" rel="noopener noreferrer">附件{{ $i + 1 }}</a> @empty — @endforelse</td>
@elseif($key === 'status')
<td><span class="sb-status">{{ $row['status'] }}</span></td>
@elseif($key === 'deduction_status')
<td><span class="sb-deduction {{ $deductionClasses[$row['deduction_status']] ?? '' }}">{{ $row['deduction_status'] }}</span></td>
@else
<td class="{{ str_contains((string) ($row[$key] ?? ''), "\n") ? 'sb-lines' : '' }}">{{ ($row[$key] ?? '') !== '' ? $row[$key] : '—' }}</td>
@endif
@ -62,14 +79,21 @@
<div class="sb-footer">共 {{ number_format($bookkeepingRows->total()) }} 条,每页 {{ BookkeepingPage::PER_PAGE }} 条。{{ $bookkeepingRows->links() }}</div>
</section>
</div>
@endif
</div>
<style>
.supplier-bookkeeping-page{color:#171717;font-size:13px}.sb-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}.sb-heading p{font-size:12px;color:#595959;margin:0}.sb-tag,.sb-status{display:inline-block;border-radius:4px;padding:4px 8px;background:#F7F0DE;color:#856632;white-space:nowrap;font-size:12px}.sb-tag.warn{color:#935F07}.sb-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin:16px 0}.sb-toolbar label{display:grid;gap:6px;font-size:12px}.sb-toolbar input,.sb-toolbar select,.sb-toolbar button,.sb-button{height:34px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717;padding:0 10px}.sb-toolbar input[type=text]{width:150px}.sb-toolbar select{max-width:240px}.sb-toolbar .sb-primary{background:#856632;border-color:#856632;color:#fff}.sb-button{display:inline-flex;align-items:center;text-decoration:none}.sb-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;margin:16px 0}.sb-metrics>div{background:#fff;border:1px solid #D8D2C7;border-radius:12px;padding:16px}.sb-metrics span{color:#595959;font-size:12px}.sb-metrics strong{display:block;font-size:22px;margin-top:8px;font-variant-numeric:tabular-nums}.sb-metrics>div:last-child strong{color:#856632}.sb-rules{font-size:12px;color:#595959;margin:12px 0}.sb-rules summary{cursor:pointer;color:#856632}.sb-rules p{line-height:1.8;margin:8px 0}.sb-error{background:#F7F0DE;color:#B80000;padding:12px;border-radius:8px;margin:12px 0}.sb-panel{background:#fff;border:1px solid #D8D2C7;border-radius:12px;overflow:hidden}.sb-table-scroll{overflow:auto;max-height:65vh}.sb-panel table{width:max-content;min-width:100%;border-collapse:collapse;font-size:12px}.sb-panel th,.sb-panel td{padding:10px 12px;border-bottom:1px solid #D8D2C7;text-align:left;vertical-align:top}.sb-panel th{background:#F7F0DE;color:#595959;font-weight:500;white-space:nowrap;position:sticky;top:0;z-index:1}.sb-panel td{max-width:280px;overflow-wrap:anywhere}.sb-panel .sb-lines{white-space:pre-line}.sb-panel .sb-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.sb-panel td a{color:#856632}.sb-panel .sb-empty{text-align:center;padding:60px 16px;color:#595959;max-width:none}.sb-footer{padding:12px 16px;font-size:12px;color:#595959}.sb-footer nav{margin-top:12px}.sb-panel tbody tr:hover{background:#faf7ef}
.sb-tabs{display:flex;gap:4px;border-bottom:1px solid #D8D2C7;margin-bottom:16px}.sb-tabs a{padding:10px 16px;color:#595959;text-decoration:none;font-size:13px;border-bottom:2px solid transparent;margin-bottom:-1px}.sb-tabs a.is-active{color:#856632;border-bottom-color:#856632;font-weight:600}.sb-tabs a:hover{color:#856632}.sb-tabs a:focus-visible{outline:2px solid #856632;outline-offset:2px}.sb-shell[aria-busy=true]{opacity:.6;pointer-events:none}
.sb-deduction{display:inline-block;border-radius:4px;padding:4px 8px;font-size:12px;white-space:nowrap;background:#F2F2F2;color:#595959}.sb-deduction.is-deduct{background:#F7F0DE;color:#856632;font-weight:600}.sb-deduction.is-pending{background:#FFF4E5;color:#935F07}.sb-warn-text{color:#935F07}.sb-metrics>div.is-key strong{color:#856632}
.supplier-bookkeeping-page{color:#171717;font-size:13px}.sb-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px}.sb-heading p{font-size:12px;color:#595959;margin:0}.sb-tag,.sb-status{display:inline-block;border-radius:4px;padding:4px 8px;background:#F7F0DE;color:#856632;white-space:nowrap;font-size:12px}.sb-tag.warn{color:#935F07}.sb-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px;margin:16px 0}.sb-toolbar label{display:grid;gap:6px;font-size:12px}.sb-toolbar input,.sb-toolbar select,.sb-toolbar button,.sb-button{height:34px;border:1px solid #D8D2C7;border-radius:8px;background:#fff;color:#171717;padding:0 10px}.sb-toolbar input[type=text]{width:150px}.sb-toolbar select{max-width:240px}.sb-toolbar .sb-primary{background:#856632;border-color:#856632;color:#fff}.sb-button{display:inline-flex;align-items:center;text-decoration:none}.sb-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:16px 0}.sb-metrics>div{background:#fff;border:1px solid #D8D2C7;border-radius:12px;padding:16px}.sb-metrics span{color:#595959;font-size:12px}.sb-metrics strong{display:block;font-size:22px;margin-top:8px;font-variant-numeric:tabular-nums}.sb-rules{font-size:12px;color:#595959;margin:12px 0}.sb-rules summary{cursor:pointer;color:#856632}.sb-rules p{line-height:1.8;margin:8px 0}.sb-error{background:#F7F0DE;color:#B80000;padding:12px;border-radius:8px;margin:12px 0}.sb-panel{background:#fff;border:1px solid #D8D2C7;border-radius:12px;overflow:hidden}.sb-table-scroll{overflow:auto;max-height:65vh}.sb-panel table{width:max-content;min-width:100%;border-collapse:collapse;font-size:12px}.sb-panel th,.sb-panel td{padding:10px 12px;border-bottom:1px solid #D8D2C7;text-align:left;vertical-align:top}.sb-panel th{background:#F7F0DE;color:#595959;font-weight:500;white-space:nowrap;position:sticky;top:0;z-index:1}.sb-panel td{max-width:280px;overflow-wrap:anywhere}.sb-panel .sb-lines{white-space:pre-line}.sb-panel .sb-money{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.sb-panel td a{color:#856632}.sb-panel .sb-empty{text-align:center;padding:60px 16px;color:#595959;max-width:none}.sb-footer{padding:12px 16px;font-size:12px;color:#595959}.sb-footer nav{margin-top:12px}.sb-panel tbody tr:hover{background:#faf7ef}
.sb-column-settings{position:relative}.sb-column-settings summary{cursor:pointer;list-style:none}.sb-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}.sb-column-menu p{font-size:12px;color:#595959;margin:0 0 8px}.sb-column-menu [data-sb-column-list]{max-height:340px;overflow:auto}.sb-column-menu label{display:flex;align-items:center;gap:6px;padding:6px;cursor:grab}.sb-column-menu input{height:auto;accent-color:#856632}.sb-column-menu button{margin-top:8px}.sb-column-menu [role=status]{display:block;font-size:12px;color:#935F07}.sb-toolbar :focus-visible{outline:2px solid #856632;outline-offset:2px}
@media(max-width:700px){.sb-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.sb-heading{flex-direction:column;align-items:flex-start}.sb-toolbar input[type=text],.sb-toolbar select{width:100%;max-width:none}.sb-toolbar label{width:100%}}
@media(max-width:700px){.sb-tabs{overflow-x:auto}.sb-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.sb-heading{flex-direction:column;align-items:flex-start}.sb-toolbar input[type=text],.sb-toolbar select{width:100%;max-width:none}.sb-toolbar label{width:100%}}
</style>
@push('body_end')
<script>
(() => {
if (window.__sbBookkeepingBound) return;
window.__sbBookkeepingBound = true;
const initColumns = () => {
const root = document.querySelector('.supplier-bookkeeping-page');
if (!root) return;
const list = root.querySelector('[data-sb-column-list]');
@ -107,6 +131,57 @@
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-sb-reset]').addEventListener('click', () => { defaults.forEach(item => { item.querySelector('input').checked = true; list.append(item); }); apply(); });
};
initColumns();
// 记账明细 / 厂家记账配置 在当前页面局部切换,保留页面外壳和供应商应付导航。
const swap = async href => {
const current = document.querySelector('.sb-shell');
if (!current || current.getAttribute('aria-busy') === 'true') return;
current.setAttribute('aria-busy', 'true');
try {
const response = await fetch(href, {headers: {'X-Requested-With': 'XMLHttpRequest'}});
if (!response.ok) throw new Error('load');
const next = new DOMParser().parseFromString(await response.text(), 'text/html').querySelector('.sb-shell');
if (!next) throw new Error('load');
current.replaceWith(next);
history.replaceState({}, '', href);
initColumns();
} catch (_) {
current.removeAttribute('aria-busy');
const error = document.createElement('div'); error.className = 'sb-error'; error.setAttribute('role', 'alert'); error.textContent = '加载失败,请重试。';
current.prepend(error);
}
};
document.addEventListener('click', event => {
const action = event.target.closest('[data-bk-config-new], [data-bk-config-edit], [data-bk-config-import], [data-bk-dialog-close]');
if (action) {
if (action.hasAttribute('data-bk-config-import')) {
document.querySelector('[data-bk-config-importer]').showModal();
} else if (action.hasAttribute('data-bk-dialog-close')) {
action.closest('dialog').close();
} else {
const dialog = document.querySelector('[data-bk-config-editor]');
const form = dialog.querySelector('form');
const editing = action.hasAttribute('data-bk-config-edit');
form.reset();
form.action = editing ? action.dataset.url : form.dataset.createUrl;
form.elements._method.value = editing ? 'PATCH' : 'POST';
dialog.querySelector('[data-editor-title]').textContent = editing ? '编辑厂家记账配置' : '新增厂家记账配置';
if (editing) for (const [key, value] of Object.entries(JSON.parse(action.dataset.record))) form.elements[key].value = value ?? '';
dialog.showModal();
}
return;
}
const link = event.target.closest('[data-sb-tab]');
if (!link || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) return;
event.preventDefault();
swap(link.href);
});
document.addEventListener('submit', event => {
const form = event.target.closest('[data-bk-config-delete]');
if (form && !window.confirm('删除后该供应商的厂家记账显示为“待配置”,可在“已删除”中恢复。确定删除?')) event.preventDefault();
});
})();
</script>
@endpush

View File

@ -0,0 +1,95 @@
@php
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as DeductionRule;
$configBase = ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'];
$actionLabels = ['create' => '新增', 'update' => '编辑', 'delete' => '删除', 'restore' => '恢复', 'import' => '导入'];
@endphp
<div class="supplier-bookkeeping-config">
<div class="sb-heading">
<p>按供应商名称(与聚工单商品明细中的供应商完全一致)配置厂家记账方式,记账明细的“厂家扣款判定”按此实时计算。</p>
</div>
@if (session('bookkeeping_config_message'))<div class="sb-notice" role="status">{{ session('bookkeeping_config_message') }}</div>@endif
@if ($errors->any())<div class="sb-error" role="alert">{{ $errors->first() }}</div>@endif
<div class="sb-metrics">
@foreach (DeductionRule::MODES as $mode => $label)
<div><span>{{ $label }}</span><strong>{{ number_format($configCounts[$mode] ?? 0) }}</strong></div>
@endforeach
<div><span>配置供应商合计</span><strong>{{ number_format(array_sum($configCounts)) }}</strong></div>
</div>
<form method="GET" class="sb-toolbar" aria-label="厂家记账配置查询">
<input type="hidden" name="bk_view" value="config">
<label>供应商<input type="text" name="supplier_name" maxlength="191" value="{{ $configFilters['supplier_name'] }}" placeholder="输入供应商名称"></label>
<label>记账方式<select name="mode"><option value="">全部</option>@foreach (DeductionRule::MODES as $mode => $label)<option value="{{ $mode }}" @selected($configFilters['mode'] === $mode)>{{ $label }}</option>@endforeach</select></label>
<label>删除状态<select name="deleted_status"><option value="active" @selected($configFilters['deleted_status'] === 'active')>正常</option><option value="deleted" @selected($configFilters['deleted_status'] === 'deleted')>已删除</option><option value="all" @selected($configFilters['deleted_status'] === 'all')>全部</option></select></label>
<button class="sb-primary" type="submit">查询</button>
<a class="sb-button" data-sb-tab href="{{ route('business.pages.show', $configBase) }}">重置</a>
</form>
<div class="sb-toolbar">
<button class="sb-primary" type="button" data-bk-config-new>新增配置</button>
<button type="button" data-bk-config-import>导入Excel</button>
<a class="sb-button" href="{{ route('business.supplier-bookkeeping-configs.export', array_filter($configFilters, static fn ($v) => $v !== '')) }}">导出配置</a>
<span class="sb-hint">正常记账:厂家记账全部扣款;仅代发仓发错货记账:只扣代发仓“仓库发错货”;不记账:均不扣。生效期外按“待配置”处理。</span>
</div>
<section class="sb-panel" aria-label="厂家记账配置">
<div class="sb-table-scroll"><table><thead><tr><th>供应商</th><th>记账方式</th><th>生效期</th><th>原始说明</th><th>创建人</th><th>更新时间</th><th>删除状态</th><th>操作</th></tr></thead><tbody>
@forelse ($configRows as $config)
<tr>
<td>{{ $config->supplier_name }}</td>
<td><span class="sb-deduction {{ $config->mode === DeductionRule::MODE_NORMAL ? 'is-deduct' : ($config->mode === DeductionRule::MODE_DROPSHIP_WRONG ? 'is-pending' : '') }}">{{ DeductionRule::MODES[$config->mode] ?? $config->mode }}</span></td>
<td>{{ $config->effective_from || $config->effective_to ? ($config->effective_from ?: '不限') . ' 至 ' . ($config->effective_to ?: '不限') : '长期' }}</td>
<td>{{ $config->source_text !== '' ? $config->source_text : '—' }}</td>
<td>{{ $config->creator }}</td>
<td>{{ $config->updated_at }}</td>
<td>{{ $config->deleted_at ? '已删除' : '正常' }}</td>
<td class="sb-config-actions">
@if (! $config->deleted_at)
<button type="button" data-bk-config-edit data-url="{{ route('business.supplier-bookkeeping-configs.update', $config->id) }}" data-record="{{ json_encode(['supplier_name' => $config->supplier_name, 'mode' => $config->mode, 'effective_from' => $config->effective_from, 'effective_to' => $config->effective_to, 'source_text' => $config->source_text], JSON_UNESCAPED_UNICODE) }}">编辑</button>
<form method="POST" action="{{ route('business.supplier-bookkeeping-configs.destroy', $config->id) }}" data-bk-config-delete>@csrf @method('DELETE')<button type="submit">删除</button></form>
@else
<form method="POST" action="{{ route('business.supplier-bookkeeping-configs.restore', $config->id) }}">@csrf<button type="submit">恢复</button></form>
@endif
</td>
</tr>
@empty
<tr><td colspan="8" class="sb-empty">暂无符合条件的厂家记账配置。</td></tr>
@endforelse
</tbody></table></div>
<div class="sb-footer">共 {{ number_format($configRows->total()) }} 条,每页50条。{{ $configRows->links() }}</div>
</section>
<h3 class="sb-log-title">操作日志</h3>
<section class="sb-panel" aria-label="厂家记账配置操作日志"><div class="sb-table-scroll"><table><thead><tr><th>操作时间</th><th>操作用户</th><th>操作类型</th><th>执行结果描述</th></tr></thead><tbody>
@forelse ($configEvents as $event)
<tr><td>{{ $event->created_at }}</td><td>{{ $event->actor }}</td><td>{{ $actionLabels[$event->action] ?? $event->action }}</td><td>{{ $event->description }}</td></tr>
@empty
<tr><td colspan="4" class="sb-empty">暂无操作记录。</td></tr>
@endforelse
</tbody></table></div><div class="sb-footer">{{ $configEvents->links() }}</div></section>
<dialog class="sb-dialog" data-bk-config-editor aria-labelledby="sb-config-editor-title">
<form method="POST" action="{{ route('business.supplier-bookkeeping-configs.store') }}" data-create-url="{{ route('business.supplier-bookkeeping-configs.store') }}">
@csrf<input type="hidden" name="_method" value="POST">
<h3 id="sb-config-editor-title" data-editor-title>新增厂家记账配置</h3>
<label>供应商<input name="supplier_name" required maxlength="191" autocomplete="off" placeholder="与聚工单供应商名称一致"></label>
<label>记账方式<select name="mode" required>@foreach (DeductionRule::MODES as $mode => $label)<option value="{{ $mode }}">{{ $label }}</option>@endforeach</select></label>
<div class="sb-dialog-dates">
<label>生效开始日期<input type="date" name="effective_from"></label>
<label>生效结束日期<input type="date" name="effective_to"></label>
</div>
<label>备注<input name="source_text" maxlength="255" autocomplete="off" placeholder="例如:厂家暂未出货,后期建立代发仓后发货错需记账"></label>
<p>生效日期留空表示长期有效;工单发起日期不在生效期内时按“待配置”处理。</p>
<div class="sb-toolbar"><button type="submit" class="sb-primary">保存</button><button type="button" data-bk-dialog-close>取消</button></div>
</form>
</dialog>
<dialog class="sb-dialog" data-bk-config-importer aria-labelledby="sb-config-import-title">
<form method="POST" enctype="multipart/form-data" action="{{ route('business.supplier-bookkeeping-configs.import') }}">@csrf
<h3 id="sb-config-import-title">导入厂家记账配置</h3>
<p>支持财务原表(列:供应商、厂家是否记账,如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”)或本页导出的配置表。</p>
<input type="file" name="file" accept=".xlsx" required>
<p>新供应商新增;已有供应商记账方式或生效期不同时按文件更新(已删除的一并恢复);相同则跳过。任一行无法识别时整批不导入。</p>
<div class="sb-toolbar"><button class="sb-primary" type="submit">导入</button><button type="button" data-bk-dialog-close>取消</button></div>
</form>
</dialog>
</div>
<style>
.supplier-bookkeeping-config{color:#171717;font-size:13px}.sb-notice{background:#F7F0DE;color:#856632;padding:12px;border-radius:8px;margin:12px 0}.sb-hint{font-size:12px;color:#595959;align-self:center}.sb-config-actions{white-space:nowrap}.sb-config-actions form{display:inline}.sb-config-actions button{border:1px solid #D8D2C7;border-radius:4px;background:#fff;color:#856632;padding:4px 8px;margin-right:6px;cursor:pointer;height:auto}.sb-log-title{font-size:14px;margin:24px 0 12px}
.sb-dialog{width:min(480px,90vw);border:1px solid #D8D2C7;border-radius:12px;background:#fff;color:#171717;padding:24px}.sb-dialog::backdrop{background:#0006}.sb-dialog h3{font-size:16px;margin:0 0 16px}.sb-dialog label{display:grid;gap:8px;margin:12px 0;font-size:13px}.sb-dialog label input,.sb-dialog label select{height:34px;border:1px solid #D8D2C7;border-radius:8px;padding:0 10px;background:#fff}.sb-dialog-dates{display:grid;grid-template-columns:1fr 1fr;gap:12px}.sb-dialog p{font-size:12px;color:#595959;line-height:1.7;margin:12px 0}
</style>

View File

@ -475,6 +475,19 @@ Route::get('workspace/supplier-payables/manufacturer-bookkeeping/export', [\App\
->middleware('business.page:supplier-payables,manufacturer-bookkeeping')
->name('business.supplier-bookkeeping.export');
Route::prefix('workspace/supplier-payables/manufacturer-bookkeeping/configs')
->name('business.supplier-bookkeeping-configs.')
->middleware('business.page:supplier-payables,manufacturer-bookkeeping')
->group(function (): void {
$controller = \App\Http\Controllers\Business\SupplierBookkeepingConfigs::class;
Route::get('export', [$controller, 'export'])->name('export');
Route::post('import', [$controller, 'import'])->name('import');
Route::post('/', [$controller, 'store'])->name('store');
Route::patch('{configId}', [$controller, 'update'])->whereNumber('configId')->name('update');
Route::delete('{configId}', [$controller, 'destroy'])->whereNumber('configId')->name('destroy');
Route::post('{configId}/restore', [$controller, 'restore'])->whereNumber('configId')->name('restore');
});
Route::get('workspace/courier-payables/shipping-data/export', 'Business\Pages@exportShippingData')
->middleware('business.page:courier-payables,shipping-data')
->name('business.courier-payables.shipping-data.export');

View File

@ -0,0 +1,120 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\SupplierBookkeepingWorkOrderRepository;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore as Store;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Tests\Feature\FeatureTestCase;
class SupplierBookkeepingConfigTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = \Mockery::mock(SupplierBookkeepingWorkOrderRepository::class);
$repository->shouldReceive('syncStatus')->andReturn(['code' => 'ready', 'last_succeeded_at' => null]);
$repository->shouldReceive('rows')->andReturn([]);
$this->app->instance(SupplierBookkeepingWorkOrderRepository::class, $repository);
}
private function url(string $name, array $params = []): string
{
return route($name, ['company_id' => $this->company->id] + $params);
}
private function configPage(array $query = []): string
{
return route('business.pages.show', ['company_id' => $this->company->id, 'section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'] + $query);
}
private function xlsx(array $rows): UploadedFile
{
$book = new Spreadsheet();
$book->getActiveSheet()->fromArray($rows);
$path = tempnam(sys_get_temp_dir(), 'sbc') . '.xlsx';
(new Xlsx($book))->save($path);
return new UploadedFile($path, '厂家记账配置.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', null, true);
}
public function test_migration_imports_existing_finance_configs(): void
{
$this->assertSame(209, DB::table(Store::TABLE)->count());
$this->assertSame(4, DB::table(Store::TABLE)->where('mode', 'normal')->count());
$this->assertSame(48, DB::table(Store::TABLE)->where('mode', 'dropship_wrong')->count());
$row = DB::table(Store::TABLE)->where('supplier_name', '景瓴十方')->first();
$this->assertSame(['none', '2025-03-01', '2026-02-28'], [$row->mode, $row->effective_from, $row->effective_to]);
$this->loginAs()->get($this->configPage(['supplier_name' => '曹县洪耀']))
->assertOk()
->assertSee('厂家记账配置')
->assertSee('曹县洪耀')
->assertSee('代发仓发货错需记账,其他原因均不记账')
->assertSee('导入存量厂家记账配置:新增209条');
}
public function test_create_update_delete_restore_with_audit_log(): void
{
$this->loginAs()->post($this->url('business.supplier-bookkeeping-configs.store'), ['supplier_name' => ' 新厂家 ', 'mode' => 'normal'])
->assertRedirect();
$id = DB::table(Store::TABLE)->where('supplier_name', '新厂家')->value('id');
$this->assertNotNull($id);
$this->withExceptionHandling()->loginAs()->post($this->url('business.supplier-bookkeeping-configs.store'), ['supplier_name' => '新厂家', 'mode' => 'none'])
->assertSessionHasErrors('supplier_name');
$this->loginAs()->patch($this->url('business.supplier-bookkeeping-configs.update', ['configId' => $id]), [
'supplier_name' => '新厂家', 'mode' => 'none', 'effective_from' => '2026-01-01', 'effective_to' => '2026-12-31',
])->assertRedirect();
$this->assertSame('none', DB::table(Store::TABLE)->where('id', $id)->value('mode'));
$this->withExceptionHandling()->loginAs()->patch($this->url('business.supplier-bookkeeping-configs.update', ['configId' => $id]), [
'supplier_name' => '新厂家', 'mode' => 'none', 'effective_from' => '2026-12-31', 'effective_to' => '2026-01-01',
])->assertSessionHasErrors('effective_to');
$this->loginAs()->delete($this->url('business.supplier-bookkeeping-configs.destroy', ['configId' => $id]))->assertRedirect();
$this->assertNotNull(DB::table(Store::TABLE)->where('id', $id)->value('deleted_at'));
$this->loginAs()->post($this->url('business.supplier-bookkeeping-configs.restore', ['configId' => $id]))->assertRedirect();
$this->assertNull(DB::table(Store::TABLE)->where('id', $id)->value('deleted_at'));
$this->assertSame(['import', 'create', 'update', 'delete', 'restore'], DB::table(Store::EVENTS)->orderBy('id')->pluck('action')->all());
}
public function test_import_finance_sheet_adds_updates_and_skips(): void
{
$file = $this->xlsx([
['供应商', '厂家是否记账'],
['鑫呈', '正常记账'],
['尊享', '代发仓发货错需记账,其他原因均不记账'],
['供应商', '厂家是否记账'],
['全新厂', '不记账(2025.3月-2027.12.30)'],
]);
$this->loginAs()->post($this->url('business.supplier-bookkeeping-configs.import'), ['file' => $file])
->assertRedirect()
->assertSessionHas('bookkeeping_config_message', '导入完成:新增1条,更新1条,未变化1条。');
$this->assertSame('dropship_wrong', DB::table(Store::TABLE)->where('supplier_name', '尊享')->value('mode'));
$this->assertSame('2027-12-30', DB::table(Store::TABLE)->where('supplier_name', '全新厂')->value('effective_to'));
$before = DB::table(Store::TABLE)->count();
$bad = $this->xlsx([['供应商', '厂家是否记账'], ['再一个厂', '正常记账'], ['坏行厂', '看情况']]);
$this->withExceptionHandling()->loginAs()->post($this->url('business.supplier-bookkeeping-configs.import'), ['file' => $bad])
->assertSessionHasErrors('file');
$this->assertSame($before, DB::table(Store::TABLE)->count());
}
public function test_export_round_trips_through_import(): void
{
$response = $this->loginAs()->get($this->url('business.supplier-bookkeeping-configs.export', ['supplier_name' => '景瓴十方']));
$response->assertOk();
$path = tempnam(sys_get_temp_dir(), 'sbe') . '.xlsx';
file_put_contents($path, $response->streamedContent());
$rows = app(\Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter::class)->read($path);
$this->assertSame([['supplier_name' => '景瓴十方', 'mode' => 'none', 'effective_from' => '2025-03-01', 'effective_to' => '2026-02-28', 'source_text' => '不记账(2025.3月-2026.2月)']], $rows);
}
}

View File

@ -87,4 +87,44 @@ class SupplierBookkeepingPageTest extends FeatureTestCase
$this->assertStringContainsString("GD614939067,'5127787476014026744,382.15", $csv);
$this->assertStringNotContainsString('GD-2', $csv);
}
public function test_seeded_configs_drive_deduction_judgement_totals_and_filter(): void
{
$this->mockRepository([
$this->row(),
$this->row(['work_order_no' => 'GD-NORMAL', 'supplier' => '鑫呈', 'amount' => '100.00']),
$this->row(['work_order_no' => 'GD-NONE', 'supplier' => '尊享', 'amount' => '50.00']),
$this->row(['work_order_no' => 'GD-DROP-OTHER', 'supplier' => '曹县洪耀', 'amount' => '30.00']),
$this->row(['work_order_no' => 'GD-DROP-WRONG', 'supplier' => '曹县洪耀', 'amount' => '20.50', 'reason_level_1' => '仓库记账',
'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '曹县洪耀代发仓']),
$this->row(['work_order_no' => 'GD-MAIN-WRONG', 'supplier' => '曹县洪耀', 'amount' => '8.00', 'reason_level_1' => '仓库记账',
'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'bookkeeping_reason' => '仓库记账/仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '常州主仓']),
]);
$this->loginAs()->get($this->url())
->assertOk()
->assertSee('厂家扣款判定')
->assertSee('仅代发仓发错货记账')
->assertSee('120.50')
->assertSee('1 / 0');
$this->loginAs()->get($this->url(['deduction' => '扣款']))
->assertOk()
->assertSee('GD-NORMAL')
->assertSee('GD-DROP-WRONG')
->assertDontSee('GD-NONE')
->assertDontSee('GD-DROP-OTHER')
->assertDontSee('GD-MAIN-WRONG');
$this->loginAs()->get($this->url(['deduction' => '待配置']))->assertOk()->assertSee('GD614939067')->assertDontSee('GD-NORMAL');
$csv = $this->loginAs()->get(route('business.supplier-bookkeeping.export', [
'company_id' => $this->company->id, 'deduction' => '扣款', 'columns' => 'work_order_no,deduction_status,deduction_basis',
]))->assertOk()->streamedContent();
$this->assertStringContainsString('工单编号,厂家扣款判定,判定说明', $csv);
$this->assertStringContainsString('GD-DROP-WRONG,扣款,代发仓发错货,记厂家扣款', $csv);
$this->assertStringNotContainsString('GD-NONE', $csv);
$this->withExceptionHandling()->loginAs()->get($this->url(['deduction' => 'x']))->assertStatus(422);
}
}

View File

@ -0,0 +1,86 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
use PHPUnit\Framework\TestCase;
require_once dirname(__DIR__, 3) . '/modules/EcommerceFinance/Services/SupplierBookkeepingDeductionRule.php';
class SupplierBookkeepingDeductionRuleTest extends TestCase
{
public function test_parses_every_rule_text_in_finance_sheet(): void
{
$this->assertSame(['mode' => Rule::MODE_NONE, 'effective_from' => null, 'effective_to' => null], Rule::parseRuleText('不记账'));
$this->assertSame(Rule::MODE_NORMAL, Rule::parseRuleText('正常记账')['mode']);
$this->assertSame(Rule::MODE_DROPSHIP_WRONG, Rule::parseRuleText('代发仓发货错需记账,其他原因均不记账')['mode']);
$this->assertSame(Rule::MODE_DROPSHIP_WRONG, Rule::parseRuleText('厂家暂未出货,后期若建立代发仓,发货错需记账,其他原因均不记账')['mode']);
$this->assertSame(['mode' => Rule::MODE_NONE, 'effective_from' => '2025-03-01', 'effective_to' => '2026-02-28'], Rule::parseRuleText('不记账(2025.3月-2026.2月)'));
$this->assertSame(['mode' => Rule::MODE_NONE, 'effective_from' => '2025-03-01', 'effective_to' => '2027-12-30'], Rule::parseRuleText('不记账(2025.3月-2027.12.30)'));
$this->assertSame(Rule::MODE_DROPSHIP_WRONG, Rule::parseRuleText('仅代发仓发错货记账')['mode']);
}
public function test_rejects_unknown_rule_text(): void
{
$this->expectException(InvalidArgumentException::class);
Rule::parseRuleText('看情况');
}
private function row(array $patch = []): array
{
return array_replace([
'started_at' => '2026-09-05 10:00:00', 'reason_level_1' => '厂家记账', 'reason_level_2' => '孔位错误',
'shipping_warehouse' => '常州主仓', 'supplier' => '甲厂',
], $patch);
}
private function configs(): array
{
return [
'甲厂' => ['mode' => Rule::MODE_NORMAL, 'effective_from' => null, 'effective_to' => null],
'乙厂' => ['mode' => Rule::MODE_DROPSHIP_WRONG, 'effective_from' => null, 'effective_to' => null],
'丙厂' => ['mode' => Rule::MODE_NONE, 'effective_from' => null, 'effective_to' => null],
'丁厂' => ['mode' => Rule::MODE_NONE, 'effective_from' => '2025-03-01', 'effective_to' => '2026-02-28'],
];
}
private function judge(array $patch): string
{
return Rule::decide($this->row($patch), $this->configs())['deduction_status'];
}
public function test_supplier_reasons_follow_supplier_config(): void
{
$this->assertSame(Rule::STATUS_DEDUCT, $this->judge([]));
$this->assertSame(Rule::STATUS_SKIP, $this->judge(['supplier' => '乙厂']));
$this->assertSame(Rule::STATUS_SKIP, $this->judge(['supplier' => '丙厂']));
$this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge(['supplier' => '未知厂']));
$this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge(['supplier' => '']));
// 限期不记账:期外不自动推断,提示补配置。
$this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge(['supplier' => '丁厂']));
$this->assertSame(Rule::STATUS_SKIP, $this->judge(['supplier' => '丁厂', 'started_at' => '2026-02-28 23:00:00']));
}
public function test_warehouse_reasons_only_deduct_dropship_wrong_shipment(): void
{
$wrong = ['reason_level_1' => '仓库记账', 'reason_level_2' => '仓库发错货(面单照片,实物照片,条形码照片)', 'shipping_warehouse' => '曹县洪耀代发仓'];
$this->assertSame(Rule::STATUS_DEDUCT, $this->judge($wrong + ['supplier' => '乙厂']));
$this->assertSame(Rule::STATUS_DEDUCT, $this->judge($wrong));
$this->assertSame(Rule::STATUS_SKIP, $this->judge($wrong + ['supplier' => '丙厂']));
$this->assertSame(Rule::STATUS_UNCONFIGURED, $this->judge($wrong + ['supplier' => '未知厂']));
// 非代发仓、或代发仓的其他仓库记账原因:一律不作为厂家扣款项,无需配置。
$this->assertSame(Rule::STATUS_SKIP, $this->judge(['shipping_warehouse' => '常州主仓'] + $wrong + ['supplier' => '乙厂']));
$this->assertSame(Rule::STATUS_SKIP, $this->judge(['reason_level_2' => '仓库双面单'] + $wrong + ['supplier' => '乙厂']));
$this->assertSame(Rule::STATUS_SKIP, $this->judge(['reason_level_2' => '退货运费'] + $wrong + ['supplier' => '未知厂']));
}
public function test_multiple_suppliers_need_review_when_outcomes_differ(): void
{
$this->assertSame(Rule::STATUS_DEDUCT, $this->judge(['supplier' => "甲厂\n甲厂"]));
$result = Rule::decide($this->row(['supplier' => "甲厂\n丙厂"]), $this->configs());
$this->assertSame(Rule::STATUS_REVIEW, $result['deduction_status']);
$this->assertStringContainsString('甲厂:扣款', $result['deduction_basis']);
$this->assertSame("正常记账\n不记账", $result['supplier_config']);
}
}