供应商应付-厂家记账:新增厂家记账配置并按配置判定厂家扣款
- 新增配置表 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:
parent
68b16a95c0
commit
09b4ffcb8a
28
app/Console/Commands/ImportSupplierBookkeepingConfigs.php
Normal file
28
app/Console/Commands/ImportSupplierBookkeepingConfigs.php
Normal 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;
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Business/SupplierBookkeepingConfigs.php
Normal file
100
app/Http/Controllers/Business/SupplierBookkeepingConfigs.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
1468
resources/data/supplier-bookkeeping-configs.json
Normal file
1468
resources/data/supplier-bookkeeping-configs.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -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');
|
||||
|
||||
120
tests/Feature/Business/SupplierBookkeepingConfigTest.php
Normal file
120
tests/Feature/Business/SupplierBookkeepingConfigTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user