From 3579e3583cd176f15f4b8c326d0e77e0cda36ff3 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 07:06:07 +0500 Subject: [PATCH] =?UTF-8?q?feat(documents):=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B1=D0=BB=D0=BE=D0=BA=20=C2=AB=D0=A1=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=C2=BB=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=85=20(=D0=BF.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/documents/{type}/{id}/related — основание + связанные (возвраты/оплаты/ опр-спис/возвраты по чеку) для supply/supplier-return/retail-sale/inventory/enter/loss. Компонент RelatedDocsBlock применён на edit-страницах приёмки, возврата поставщику, оприходования, списания, чека (инвентаризация имела свой блок). Integration-тест: приёмка↔возврат (related/basedOn). Co-Authored-By: Claude Opus 4.8 --- .../Controllers/DocumentsController.cs | 100 ++++++++++++++++++ .../src/components/RelatedDocsBlock.tsx | 61 +++++++++++ .../src/pages/EnterEditPage.tsx | 2 + .../src/pages/LossEditPage.tsx | 2 + .../src/pages/RetailSaleEditPage.tsx | 2 + .../src/pages/SupplierReturnEditPage.tsx | 2 + .../src/pages/SupplyEditPage.tsx | 2 + .../SupplierReturnFromSupplyTests.cs | 9 ++ 8 files changed, 180 insertions(+) create mode 100644 src/food-market.api/Controllers/DocumentsController.cs create mode 100644 src/food-market.web/src/components/RelatedDocsBlock.tsx diff --git a/src/food-market.api/Controllers/DocumentsController.cs b/src/food-market.api/Controllers/DocumentsController.cs new file mode 100644 index 0000000..391d959 --- /dev/null +++ b/src/food-market.api/Controllers/DocumentsController.cs @@ -0,0 +1,100 @@ +using foodmarket.Application.Common.Tenancy; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers; + +/// Сквозные связи документов (как «Связанные документы» в МойСкладе): +/// по типу+id документа возвращает «основание» (на основании чего создан) и +/// «связанные» (что создано на основании этого / оплаты / возвраты). Единый +/// источник для блока «Связанные документы» на всех страницах документов. +[ApiController] +[Authorize] +[Route("api/documents")] +public class DocumentsController : ControllerBase +{ + private readonly AppDbContext _db; + public DocumentsController(AppDbContext db) => _db = db; + + public record DocRef(string type, Guid id, string number, DateTime date, string? relation); + public record RelatedResult(IReadOnlyList basedOn, IReadOnlyList related); + + /// type ∈ supply | supplier-return | retail-sale | inventory | enter | loss. + [HttpGet("{type}/{id:guid}/related")] + public async Task> Related(string type, Guid id, CancellationToken ct) + { + var basedOn = new List(); + var related = new List(); + + switch (type) + { + case "supply": + { + // На основании этой приёмки: возвраты поставщику + оплаты. + related.AddRange(await _db.SupplierReturns.AsNoTracking() + .Where(r => r.ReferenceSupplyId == id) + .Select(r => new DocRef("supplier-return", r.Id, r.Number, r.Date, "возврат поставщику")).ToListAsync(ct)); + related.AddRange(await _db.MoneyTransactions.AsNoTracking() + .Where(t => t.Links.Any(l => l.SupplyId == id)) + .Select(t => new DocRef("money", t.Id, t.Number, t.Date, "оплата")).ToListAsync(ct)); + break; + } + case "supplier-return": + { + var refSupplyId = await _db.SupplierReturns.AsNoTracking().Where(r => r.Id == id) + .Select(r => r.ReferenceSupplyId).FirstOrDefaultAsync(ct); + if (refSupplyId is { } rs) + basedOn.AddRange(await _db.Supplies.AsNoTracking().Where(s => s.Id == rs) + .Select(s => new DocRef("supply", s.Id, s.Number, s.Date, "приёмка")).ToListAsync(ct)); + related.AddRange(await _db.MoneyTransactions.AsNoTracking() + .Where(t => t.Links.Any(l => l.SupplierReturnId == id)) + .Select(t => new DocRef("money", t.Id, t.Number, t.Date, "возврат денег")).ToListAsync(ct)); + break; + } + case "retail-sale": + { + var refSaleId = await _db.RetailSales.AsNoTracking().Where(s => s.Id == id) + .Select(s => s.ReferenceSaleId).FirstOrDefaultAsync(ct); + if (refSaleId is { } rsi) + basedOn.AddRange(await _db.RetailSales.AsNoTracking().Where(s => s.Id == rsi) + .Select(s => new DocRef("retail-sale", s.Id, s.Number, s.Date, "чек")).ToListAsync(ct)); + related.AddRange(await _db.RetailSales.AsNoTracking() + .Where(s => s.ReferenceSaleId == id) + .Select(s => new DocRef("retail-sale", s.Id, s.Number, s.Date, "возврат")).ToListAsync(ct)); + break; + } + case "inventory": + { + related.AddRange(await _db.Enters.AsNoTracking().Where(e => e.InventoryDocId == id) + .Select(e => new DocRef("enter", e.Id, e.Number, e.Date, "оприходование")).ToListAsync(ct)); + related.AddRange(await _db.Losses.AsNoTracking().Where(l => l.InventoryDocId == id) + .Select(l => new DocRef("loss", l.Id, l.Number, l.Date, "списание")).ToListAsync(ct)); + break; + } + case "enter": + { + var invId = await _db.Enters.AsNoTracking().Where(e => e.Id == id).Select(e => e.InventoryDocId).FirstOrDefaultAsync(ct); + if (invId is { } iv) + basedOn.AddRange(await _db.InventoryDocs.AsNoTracking().Where(x => x.Id == iv) + .Select(x => new DocRef("inventory", x.Id, x.Number, x.Date, "инвентаризация")).ToListAsync(ct)); + break; + } + case "loss": + { + var invId = await _db.Losses.AsNoTracking().Where(l => l.Id == id).Select(l => l.InventoryDocId).FirstOrDefaultAsync(ct); + if (invId is { } iv) + basedOn.AddRange(await _db.InventoryDocs.AsNoTracking().Where(x => x.Id == iv) + .Select(x => new DocRef("inventory", x.Id, x.Number, x.Date, "инвентаризация")).ToListAsync(ct)); + break; + } + default: + return BadRequest(new { error = $"Неизвестный тип документа «{type}»." }); + } + + return new RelatedResult( + basedOn.OrderBy(x => x.date).ToList(), + related.OrderBy(x => x.date).ToList()); + } +} diff --git a/src/food-market.web/src/components/RelatedDocsBlock.tsx b/src/food-market.web/src/components/RelatedDocsBlock.tsx new file mode 100644 index 0000000..2c88520 --- /dev/null +++ b/src/food-market.web/src/components/RelatedDocsBlock.tsx @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { LinkIcon } from 'lucide-react' +import { api } from '@/lib/api' + +export type DocType = 'supply' | 'supplier-return' | 'retail-sale' | 'inventory' | 'enter' | 'loss' + +interface DocRef { type: string; id: string; number: string; date: string; relation: string | null } +interface Related { basedOn: DocRef[]; related: DocRef[] } + +const PATH: Record string | null> = { + 'supply': (id) => `/purchases/supplies/${id}`, + 'supplier-return': (id) => `/purchases/supplier-returns/${id}`, + 'retail-sale': (id) => `/sales/retail/${id}`, + 'inventory': (id) => `/inventory/inventories/${id}`, + 'enter': (id) => `/inventory/enters/${id}`, + 'loss': (id) => `/inventory/losses/${id}`, + 'money': () => `/money/transactions`, +} + +function Row({ d }: { d: DocRef }) { + const to = PATH[d.type]?.(d.id) + const label = <>{d.relation ? {d.relation}: : null}{d.number} {new Date(d.date).toLocaleDateString('ru')} + return ( +
  • + {to ? {label} : {label}} +
  • + ) +} + +/** + * Блок «Связанные документы» и «На основании …» — как в инвентаризации, единый + * для всех документов (п.5 паритета). Скрывается, если связей нет. + */ +export function RelatedDocsBlock({ type, id }: { type: DocType; id?: string }) { + const { data } = useQuery({ + queryKey: ['/api/documents/related', type, id], + queryFn: async () => (await api.get(`/api/documents/${type}/${id}/related`)).data, + enabled: !!id, + }) + if (!data || (data.basedOn.length === 0 && data.related.length === 0)) return null + return ( +
    +
    + Связанные документы +
    + {data.basedOn.length > 0 && ( +
    +
    На основании
    +
      {data.basedOn.map((d) => )}
    +
    + )} + {data.related.length > 0 && ( +
    +
    Создано на основании этого
    +
      {data.related.map((d) => )}
    +
    + )} +
    + ) +} diff --git a/src/food-market.web/src/pages/EnterEditPage.tsx b/src/food-market.web/src/pages/EnterEditPage.tsx index 8ecaa6d..8b41f1b 100644 --- a/src/food-market.web/src/pages/EnterEditPage.tsx +++ b/src/food-market.web/src/pages/EnterEditPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, type FormEvent } from 'react' +import { RelatedDocsBlock } from '@/components/RelatedDocsBlock' import { useNavigate, useParams, Link } from 'react-router-dom' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react' @@ -255,6 +256,7 @@ export function EnterEditPage() {
    + {!isNew && id && } {error && (
    {error}
    )} diff --git a/src/food-market.web/src/pages/LossEditPage.tsx b/src/food-market.web/src/pages/LossEditPage.tsx index de2b8f6..fbd311f 100644 --- a/src/food-market.web/src/pages/LossEditPage.tsx +++ b/src/food-market.web/src/pages/LossEditPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, type FormEvent } from 'react' +import { RelatedDocsBlock } from '@/components/RelatedDocsBlock' import { useNavigate, useParams, Link } from 'react-router-dom' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react' @@ -258,6 +259,7 @@ export function LossEditPage() {
    + {!isNew && id && } {error && (
    {error}
    )} diff --git a/src/food-market.web/src/pages/RetailSaleEditPage.tsx b/src/food-market.web/src/pages/RetailSaleEditPage.tsx index 66b7b0a..f428023 100644 --- a/src/food-market.web/src/pages/RetailSaleEditPage.tsx +++ b/src/food-market.web/src/pages/RetailSaleEditPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, type FormEvent, type ReactNode } from 'react' +import { RelatedDocsBlock } from '@/components/RelatedDocsBlock' import { useNavigate, useParams, Link } from 'react-router-dom' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react' @@ -301,6 +302,7 @@ export function RetailSaleEditPage() {
    {error &&
    {error}
    } + {!isNew && id && }
    diff --git a/src/food-market.web/src/pages/SupplierReturnEditPage.tsx b/src/food-market.web/src/pages/SupplierReturnEditPage.tsx index 4f3c1e6..c6cb280 100644 --- a/src/food-market.web/src/pages/SupplierReturnEditPage.tsx +++ b/src/food-market.web/src/pages/SupplierReturnEditPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, type FormEvent } from 'react' +import { RelatedDocsBlock } from '@/components/RelatedDocsBlock' import { useNavigate, useParams, Link } from 'react-router-dom' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react' @@ -253,6 +254,7 @@ export function SupplierReturnEditPage() {
    + {!isNew && id && } {error && (
    {error}
    )} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index e44cfbf..cdce480 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, type FormEvent, type ReactNode } from 'react' +import { RelatedDocsBlock } from '@/components/RelatedDocsBlock' import { useNavigate, useParams, Link } from 'react-router-dom' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react' @@ -350,6 +351,7 @@ export function SupplyEditPage() { {/* Scrollable body */}
    + {!isNew && id && } {error && (
    {error}
    )} diff --git a/tests/food-market.IntegrationTests/SupplierReturnFromSupplyTests.cs b/tests/food-market.IntegrationTests/SupplierReturnFromSupplyTests.cs index e6f75f2..854db55 100644 --- a/tests/food-market.IntegrationTests/SupplierReturnFromSupplyTests.cs +++ b/tests/food-market.IntegrationTests/SupplierReturnFromSupplyTests.cs @@ -38,5 +38,14 @@ public async Task CreateFromSupply_presets_lines_and_links_supply() lines.Should().ContainSingle(l => l.GetProperty("productId").GetString() == product); lines.First().GetProperty("quantity").GetDecimal().Should().Be(10m); lines.First().GetProperty("unitPrice").GetDecimal().Should().Be(100m); + var returnId = r.GetProperty("id").GetString(); + + // Связи документов (п.5): приёмка видит возврат как связанный; возврат — приёмку как основание. + var supRel = await api.GetJsonAsync($"/api/documents/supply/{supplyId}/related"); + supRel.GetProperty("related").EnumerateArray() + .Should().Contain(x => x.GetProperty("type").GetString() == "supplier-return" && x.GetProperty("id").GetString() == returnId); + var retRel = await api.GetJsonAsync($"/api/documents/supplier-return/{returnId}/related"); + retRel.GetProperty("basedOn").EnumerateArray() + .Should().Contain(x => x.GetProperty("type").GetString() == "supply" && x.GetProperty("id").GetString() == supplyId); } }