Compare commits

..

3 Commits

Author SHA1 Message Date
bc12448d03 fix: JsBarcode removed 2024-09-20 20:15:18 +04:00
e6cb771db0 fix: bill of payment generation refactoring 2024-09-20 20:14:02 +04:00
5316f7f9ce feat: end-point for deal document generation 2024-09-20 20:02:17 +04:00
10 changed files with 611 additions and 140 deletions

View File

@@ -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 = [
'Создан',
'Ожидает принятия',
'Упаковка',
'Ожидание отправки',
'Ожидание оплаты',
'Завершено',
'Отменено',
]

View File

@@ -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

View File

@@ -1,17 +1,16 @@
from io import BytesIO
from pathlib import Path
from typing import List
from fastapi import HTTPException
from jinja2 import Environment, FileSystemLoader
from number_to_string import get_string_by_number
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from starlette import status
from weasyprint import HTML
from weasyprint import HTML, CSS
import backend.config
from constants import MONTHS
import constants
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 +19,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 +179,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} г."
@@ -197,6 +194,6 @@ class BillingService(BaseService):
async def create_billing_document_pdf(self, deal_id) -> BytesIO:
doc = await self._create_billing_document_html(deal_id)
pdf_file = BytesIO()
HTML(string=doc).write_pdf(pdf_file)
HTML(string=doc).write_pdf(pdf_file, stylesheets=[CSS(constants.APP_PATH + '/static/css/bill-of-payment.css')])
return pdf_file

View File

@@ -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

View File

@@ -0,0 +1,122 @@
@media print {
#header,
#footer,
#nav {
display: none !important;
}
}
.doc-container {
padding: 10px 15px;
font-size: 13px;
font-family: sans-serif;
}
.info-line {
padding-bottom: 6px;
}
.title {
font-size: 16px;
margin-top: 20px;
margin-bottom: 2px;
}
.footer {
margin-top: 8px;
display: flex;
justify-content: space-between;
}
.footer-block {
justify-content: space-between;
}
.footer-line {
font-size: medium;
font-weight: bolder;
margin: 2px 0;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: solid black 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: #dbdbdb;
}
tr {
page-break-inside: avoid;
}
td, th {
padding: 16px 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;
}
img {
width: 170px;
margin-bottom: 15px;
}
table tfoot {
border-top: 2px solid black;
}
.footer-line-right {
font-size: medium;
font-weight: bolder;
margin: 2px 0;
width: 100%;
text-align: right;
}
.footer-words {
width: 100%;
flex-direction: column-reverse;
justify-content: flex-end;
}

167
static/css/deal.css Normal file
View 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;
}

File diff suppressed because one or more lines are too long

View 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
View 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
View 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)