feat: end-point for deal document generation
This commit is contained in:
20
constants.py
20
constants.py
@@ -1,5 +1,15 @@
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
from utils.code128 import encode128
|
||||
|
||||
ENV = Environment(loader=FileSystemLoader(Path("templates") / Path("documents")))
|
||||
ENV.globals['now'] = datetime.now
|
||||
ENV.globals['encode128'] = encode128
|
||||
|
||||
APP_PATH = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(__file__)
|
||||
|
||||
@@ -29,3 +39,13 @@ MONTHS = (
|
||||
'ноября',
|
||||
'декабря'
|
||||
)
|
||||
|
||||
DEAL_STATUS_STR = [
|
||||
'Создан',
|
||||
'Ожидает принятия',
|
||||
'Упаковка',
|
||||
'Ожидание отправки',
|
||||
'Ожидание оплаты',
|
||||
'Завершено',
|
||||
'Отменено',
|
||||
]
|
||||
|
||||
@@ -189,6 +189,20 @@ async def get_deal_document(
|
||||
pdf_file: BytesIO = await BillingService(session).create_billing_document_pdf(deal_id)
|
||||
return Response(pdf_file.getvalue(), media_type='application/pdf')
|
||||
|
||||
|
||||
@deal_router.get(
|
||||
'/detailedDocument/{deal_id}',
|
||||
operation_id='get_deal_document_detailed',
|
||||
# dependencies=[Depends(authorized_user)],
|
||||
)
|
||||
async def get_detailed_deal_document(
|
||||
deal_id: int,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
pdf_file: BytesIO = await DealService(session).create_detailed_deal_document_pdf(deal_id)
|
||||
return Response(pdf_file.getvalue(), media_type='application/pdf')
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region Deal services
|
||||
|
||||
@@ -11,7 +11,7 @@ from starlette import status
|
||||
from weasyprint import HTML
|
||||
|
||||
import backend.config
|
||||
from constants import MONTHS
|
||||
from constants import MONTHS, ENV
|
||||
from external.billing import BillingClient, CreateBillingRequestValue, CreateBillRequestSchema, CreateBillRequestItems, \
|
||||
BillStatusUpdateRequest, NotificationChannel, NotifyReceivedBillRequestSchema, DeleteBillRequestSchema
|
||||
from models import DealBillRequest, Deal, DealProduct, DealService as DealServiceModel
|
||||
@@ -20,8 +20,6 @@ from services.base import BaseService
|
||||
from services.deal import DealService
|
||||
from utils.list_utils import to_locale_number
|
||||
|
||||
env = Environment(loader=FileSystemLoader(Path("templates") / Path("documents")))
|
||||
|
||||
|
||||
class BillingService(BaseService):
|
||||
async def _process_update_details(
|
||||
@@ -182,7 +180,7 @@ class BillingService(BaseService):
|
||||
deal_price = sum((service.price * service.amount for service in services))
|
||||
deal_price_words = get_string_by_number(deal_price)[0:-10]
|
||||
deal_price = to_locale_number(deal_price)
|
||||
template = env.get_template("bill-of-payment.html")
|
||||
template = ENV.get_template("bill-of-payment.html")
|
||||
|
||||
now = datetime.datetime.now()
|
||||
curr_date = f"{now.day} {MONTHS[now.month - 1]} {now.year} г."
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
from io import BytesIO
|
||||
|
||||
import lexorank
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, func, update, delete, insert
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from starlette import status
|
||||
from weasyprint import HTML, CSS
|
||||
|
||||
import constants
|
||||
import models.deal
|
||||
import models.secondary
|
||||
from models import User, Service, Client, DealProduct
|
||||
from constants import ENV, DEAL_STATUS_STR
|
||||
from models import User, Service, Client, DealProduct, DealService as DealServiceModel, Product
|
||||
from models.deal import *
|
||||
from schemas.client import ClientDetailsSchema
|
||||
from schemas.deal import *
|
||||
@@ -15,6 +20,7 @@ from services.base import BaseService
|
||||
from services.client import ClientService
|
||||
from services.service import ServiceService
|
||||
from services.shipping_warehouse import ShippingWarehouseService
|
||||
from utils.images_fetcher import fetch_images
|
||||
|
||||
|
||||
class DealService(BaseService):
|
||||
@@ -987,3 +993,56 @@ class DealService(BaseService):
|
||||
except Exception as e:
|
||||
await self.session.rollback()
|
||||
return DealCompleteResponse(ok=False, message=str(e))
|
||||
|
||||
# region Deal documents
|
||||
|
||||
@staticmethod
|
||||
async def _get_product_services_totals(deal: Deal) -> List[int]:
|
||||
totals: List[int] = []
|
||||
|
||||
for product in deal.products:
|
||||
total = sum((service.price * product.quantity for service in product.services))
|
||||
totals.append(total)
|
||||
|
||||
return totals
|
||||
|
||||
async def _create_detailed_deal_document_html(self, deal_id: int):
|
||||
deal: Deal | None = await self.session.scalar(
|
||||
select(Deal)
|
||||
.where(Deal.id == deal_id)
|
||||
.options(
|
||||
selectinload(Deal.products).selectinload(DealProduct.services),
|
||||
selectinload(Deal.products).selectinload(DealProduct.product).selectinload(Product.barcodes),
|
||||
selectinload(Deal.services).selectinload(DealServiceModel.service),
|
||||
selectinload(Deal.status_history),
|
||||
joinedload(Deal.client),
|
||||
joinedload(Deal.shipping_warehouse),
|
||||
)
|
||||
)
|
||||
|
||||
if not deal:
|
||||
return ""
|
||||
|
||||
last_status: DealStatusHistory = max(deal.status_history, key=lambda status: status.changed_at)
|
||||
general_services_total = sum((service.price * service.quantity for service in deal.services))
|
||||
product_services_totals = await self._get_product_services_totals(deal)
|
||||
template = ENV.get_template("deal.html")
|
||||
product_images = await fetch_images([product.product.images[0].image_url for product in deal.products])
|
||||
|
||||
result = template.render({
|
||||
"general_services_total": general_services_total,
|
||||
"product_services_totals": product_services_totals,
|
||||
"deal": deal,
|
||||
"current_status_str": DEAL_STATUS_STR[deal.current_status],
|
||||
"last_status": last_status,
|
||||
"product_images": product_images,
|
||||
})
|
||||
return result
|
||||
|
||||
async def create_detailed_deal_document_pdf(self, deal_id) -> BytesIO:
|
||||
doc = await self._create_detailed_deal_document_html(deal_id)
|
||||
pdf_file = BytesIO()
|
||||
HTML(string=doc).write_pdf(pdf_file, stylesheets=[CSS(constants.APP_PATH + '/static/css/deal.css')])
|
||||
return pdf_file
|
||||
|
||||
# endregion
|
||||
|
||||
167
static/css/deal.css
Normal file
167
static/css/deal.css
Normal file
@@ -0,0 +1,167 @@
|
||||
@media print {
|
||||
#header,
|
||||
#footer,
|
||||
#nav {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
background-color: black;
|
||||
height: 2px;
|
||||
border: none;
|
||||
margin-left: -100px;
|
||||
margin-right: -100px;
|
||||
}
|
||||
|
||||
.doc-container {
|
||||
margin: -30px 0;
|
||||
padding: 0 15px;
|
||||
font-size: 13px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.deal-name-id-container {
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.deal-id {
|
||||
border: black solid 2px;
|
||||
padding: 3px 2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.total {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.medium-text {
|
||||
font-size: 15px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.large-text {
|
||||
font-size: 21px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-data {
|
||||
height: 160px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-data > * {
|
||||
width: 170px;
|
||||
padding-right: 170px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: solid gray 2px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
|
||||
td:nth-child(5) {
|
||||
border-right: 1px solid white;
|
||||
}
|
||||
|
||||
table tbody tr:nth-child(odd) {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: 10px 13px;
|
||||
}
|
||||
|
||||
td:first-child, th:first-child {
|
||||
padding-left: 50px;
|
||||
}
|
||||
|
||||
td:last-child, th:last-child {
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
td:first-child, th:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
td:not(:first-child), th:not(:first-child) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
td:not(:first-child) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
table tfoot {
|
||||
border-top: 2px solid black;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.img-container {
|
||||
margin: -30px 30px -45px 0;
|
||||
}
|
||||
|
||||
.product-img {
|
||||
height: 300px;
|
||||
width: 200px;
|
||||
object-fit: cover;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: black solid 2px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.barcode-container {
|
||||
margin: 15px 0 -5px 385px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.barcode {
|
||||
font-family: "Libre Barcode 128", cursive;
|
||||
font-size: 30pt;
|
||||
transform: scale(1, 1.5);
|
||||
}
|
||||
|
||||
.barcode-text {
|
||||
margin-top: -10px;
|
||||
}
|
||||
2
static/js/JsBarcode.all.min.js
vendored
Normal file
2
static/js/JsBarcode.all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
162
templates/documents/deal.html
Normal file
162
templates/documents/deal.html
Normal file
@@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<script src="/static/js/JsBarcode.all.min.js"></script>
|
||||
<title>Сделка</title>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css?family=Libre+Barcode+128");
|
||||
@import url("https://fonts.googleapis.com/css?family=Barlow+Condensed:300,400,700");
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
JsBarcode(".barcode").init();
|
||||
</script>
|
||||
<div class="doc-container">
|
||||
<div>
|
||||
<div>
|
||||
<!--#region Header -->
|
||||
<div class="deal-name-id-container">
|
||||
<div class="medium-text bold">
|
||||
Название сделки: {{ deal.name }}
|
||||
</div>
|
||||
<div class="medium-text deal-id">
|
||||
ID:{{ deal.id }}
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="medium-text">
|
||||
Дата создания: {{ now().strftime("%d.%m.%Y, %H:%M") }}
|
||||
</div>
|
||||
<div class="medium-text">
|
||||
Текущий статус: {{ current_status_str }}
|
||||
</div>
|
||||
<div class="medium-text">
|
||||
{% if deal.comment %}
|
||||
Комментарий к сделке: {{ deal.comment }}
|
||||
{% else %}
|
||||
Комментарий к сделке отсутствует
|
||||
{% endif %}
|
||||
</div>
|
||||
<hr/>
|
||||
<!--#endregion -->
|
||||
<!--#region General services -->
|
||||
<div class="large-text bold">
|
||||
Клиент: {{ deal.client.name }}
|
||||
</div>
|
||||
<div class="medium-text bold">
|
||||
Дата отгрузки: {{ last_status.next_status_deadline.strftime("%d.%m.%Y") }}
|
||||
</div>
|
||||
{% if deal.base_marketplace.name %}
|
||||
<div class="medium-text bold">Маркетплейс: {{ deal.base_marketplace.name }}</div>
|
||||
{% endif %}
|
||||
{% if deal.shipping_warehouse.name %}
|
||||
<div class="medium-text bold">Склад отгрузки: {{ deal.shipping_warehouse.name }}</div>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Общие услуги</th>
|
||||
<th>Количество</th>
|
||||
<th>Сумма</th>
|
||||
</tr>
|
||||
<tfoot>
|
||||
</tfoot>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for service in deal.services %}
|
||||
<tr>
|
||||
<td>{{ service.service.name }}</td>
|
||||
<td>{{ '{:,}'.format(service.quantity) }} шт.</td>
|
||||
<td>{{ '{:,}'.format(service.price * service.quantity).replace(',', ' ') }} Р</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="medium-text total align-right bold">
|
||||
Итого: {{ general_services_total }} Р
|
||||
</div>
|
||||
<!--#endregion -->
|
||||
<!--#region Product services -->
|
||||
{% for product in deal.products %}
|
||||
<div style="page-break-inside: avoid">
|
||||
<hr/>
|
||||
<div style="display: flex">
|
||||
{% if product.product.images|length != 0 %}
|
||||
<div class="img-container">
|
||||
<img class="product-img" src="data:image/jpeg;base64,{{ product_images[loop.index0] }}"
|
||||
alt="Фото товара"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="margin: 12px 0">
|
||||
<div class="medium-text bold">
|
||||
{{ loop.index }}. {{ product.product.name }}
|
||||
</div>
|
||||
<div class="product-data">
|
||||
<div class="small-text">Артикул: {{ product.product.article }}</div>
|
||||
{% if product.product.brand %}
|
||||
<div class="small-text">Бренд: {{ product.product.brand }}</div>
|
||||
{% endif %}
|
||||
{% if product.product.color %}
|
||||
<div class="small-text">Цвет: {{ product.product.color }}</div>
|
||||
{% endif %}
|
||||
{% if product.product.size %}
|
||||
<div class="small-text">Размер: {{ product.product.size }}</div>
|
||||
{% endif %}
|
||||
{% if product.quantity %}
|
||||
<div class="small-text">Количество: {{ product.quantity }}</div>
|
||||
{% endif %}
|
||||
{% if product.product.additional_info %}
|
||||
<div class="small-text">
|
||||
Доп. информация: {{ product.product.additional_info }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div style="text-align: right">
|
||||
<div class="barcode-container">
|
||||
<div class="barcode">
|
||||
{{ encode128(product.product.barcodes[0].barcode, "A") }}
|
||||
</div>
|
||||
<div class="barcode-text">
|
||||
{{ product.product.barcodes[0].barcode }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Наименование услуги</th>
|
||||
<th>Цена</th>
|
||||
<th>Сумма</th>
|
||||
</tr>
|
||||
<tfoot>
|
||||
</tfoot>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for service in product.services %}
|
||||
<tr>
|
||||
<td>{{ service.service.name }}</td>
|
||||
<td>{{ '{:,}'.format(product.quantity) }} Р</td>
|
||||
<td>{{ '{:,}'.format(service.price * product.quantity).replace(',', ' ') }} Р</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="medium-text total align-right bold">
|
||||
Итого: {{ product_services_totals[loop.index0] }} Р
|
||||
</div>
|
||||
{% endfor %}
|
||||
<!--#endregion -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
35
utils/code128.py
Normal file
35
utils/code128.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import re
|
||||
|
||||
def to_set_c(text):
|
||||
# Match pairs of digits and map them to the appropriate ASCII characters
|
||||
return ''.join([
|
||||
chr(int(ascii_code) + 100 if int(ascii_code) > 94 else int(ascii_code) + 32)
|
||||
for ascii_code in re.findall(r'\d{2}', text)
|
||||
])
|
||||
|
||||
def check_sum_128(data, start_code):
|
||||
sum_value = start_code
|
||||
for i, char in enumerate(data):
|
||||
code = ord(char)
|
||||
value = code - 100 if code > 199 else code - 32
|
||||
sum_value += (i + 1) * value
|
||||
|
||||
checksum = (sum_value % 103) + 32
|
||||
if checksum > 126:
|
||||
checksum += 68
|
||||
return chr(checksum)
|
||||
|
||||
def encode128(text, code_abc="B"):
|
||||
start_code = chr(ord(code_abc.upper()) + 138)
|
||||
stop = chr(206)
|
||||
|
||||
if code_abc.upper() == 'C':
|
||||
text = to_set_c(text)
|
||||
|
||||
check = check_sum_128(text, ord(start_code) - 100)
|
||||
|
||||
# Replace spaces with ASCII 194
|
||||
text = text.replace(" ", chr(194))
|
||||
|
||||
return start_code + text + check + stop
|
||||
|
||||
20
utils/images_fetcher.py
Normal file
20
utils/images_fetcher.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import asyncio
|
||||
import base64
|
||||
from typing import List, Tuple
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
async def fetch_image(url: str) -> str:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
image_data = await response.read()
|
||||
return base64.b64encode(image_data).decode("utf-8")
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
async def fetch_images(urls: List[str]) -> Tuple[str]:
|
||||
tasks = [fetch_image(url) for url in urls]
|
||||
return await asyncio.gather(*tasks)
|
||||
Reference in New Issue
Block a user