PDF生成・エディタサービス
カラーピッカーで選択
#d9d9d9
各列のラベルと幅を設定できます
※幅の合計が515pt程度になるよう設定してください
/api/health
ヘルスチェック
/api/quote/demo
デモPDF(サンプルデータ)
/api/quote/pdf
PDF生成(Salesforceから呼び出し)
ヘッダー:
Content-Type: application/json
X-API-Key: {your-api-key}
リクエストボディ例:
{
"quoteNumber": "P2502672",
"quoteDate": "2026-02-02",
"title": "RaySheetライセンス費用",
"issuerCompanyName": "thomas株式会社",
"issuerPostalCode": "106-0032",
"issuerAddress": "東京都港区六本木7-21-24 THE MODULE roppongi 2F",
"issuerTel": "03-6773-7830",
"recipientCompanyName": "株式会社サンプルコーポレーション",
"recipientPostalCode": "100-0001",
"recipientAddress": "東京都千代田区丸の内1-1-1 サンプルビル10F",
"subtotal": 2091600,
"taxRate": 10,
"tax": 209160,
"totalAmount": 2300760,
"remarks": "RaySheet:月額700円(税別)/1ライセンス",
"paymentTerms": "・支払い条件:発注月の翌月末日に一括前払いとなります",
"lineItems": [
{
"productName": "RaySheet",
"quantity": 249,
"unitPrice": 8400,
"amount": 2091600
}
]
}
見積レコードページに配置するボタンコンポーネントです。クリックするとPDFプレビューを表示し、ダウンロードまたはSalesforceファイルに保存を選択できます。
ファイル構成
<!-- thomasPdfViewer.html -->
<template>
<lightning-card title="帳票出力" icon-name="doctype:pdf">
<div class="slds-card__body slds-card__body_inner">
<!-- ボタン行 -->
<div class="slds-grid slds-gutters slds-m-bottom_medium">
<div class="slds-col slds-no-flex">
<!-- PDFを生成してSalesforceのファイルビューアで開く -->
<lightning-button
label="帳票を生成・プレビュー"
icon-name="utility:preview"
variant="brand"
onclick={handlePreview}
disabled={isLoading}>
</lightning-button>
</div>
<template if:true={contentDocumentId}>
<div class="slds-col slds-no-flex">
<!-- 生成済みPDFをレコードのファイルに正式紐付け -->
<lightning-button
label="このPDFをファイルに残す"
icon-name="utility:save"
variant="neutral"
onclick={handleKeepFile}
disabled={isSaving}>
</lightning-button>
</div>
</template>
</div>
<!-- ローディング -->
<template if:true={isLoading}>
<div class="slds-align_absolute-center slds-p-around_medium">
<lightning-spinner alternative-text="PDF生成中..." size="medium"></lightning-spinner>
<p class="slds-m-top_small slds-text-color_weak">PDF生成中...</p>
</div>
</template>
<!-- エラー表示 -->
<template if:true={errorMessage}>
<div class="slds-notify slds-notify_alert slds-alert_error" role="alert">
<lightning-icon icon-name="utility:error" alternative-text="Error" size="x-small"></lightning-icon>
<h2 class="slds-m-left_small">{errorMessage}</h2>
<div class="slds-notify__close">
<lightning-button-icon icon-name="utility:close" alternative-text="閉じる" onclick={clearError}>
</lightning-button-icon>
</div>
</div>
</template>
<!-- 生成完了通知 -->
<template if:true={contentDocumentId}>
<div class="slds-notify slds-notify_toast slds-theme_info" role="status"
style="margin-bottom:0.75rem;">
<lightning-icon icon-name="utility:pdf" size="x-small"></lightning-icon>
<div class="slds-notify__content slds-m-left_small">
<p class="slds-text-heading_small">PDFを生成しました</p>
<p class="slds-text-body_small">
「このPDFをファイルに残す」をクリックするとレコードのファイル欄に保存されます。
</p>
</div>
</div>
</template>
<!-- 保存完了メッセージ -->
<template if:true={savedFileName}>
<div class="slds-notify slds-notify_toast slds-theme_success" role="status">
<lightning-icon icon-name="utility:success" size="x-small"></lightning-icon>
<div class="slds-notify__content slds-m-left_small">
<p class="slds-text-heading_small">{savedFileName} を保存しました</p>
<p class="slds-text-body_small">レコードの「ファイル」欄から確認・ダウンロードできます。</p>
</div>
</div>
</template>
</div>
</lightning-card>
</template>
// thomasPdfViewer.js
import { LightningElement, api, track } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import previewPdf from '@salesforce/apex/ThomasPdfService.previewPdf';
import keepPdfFile from '@salesforce/apex/ThomasPdfService.keepPdfFile';
export default class ThomasPdfViewer extends NavigationMixin(LightningElement) {
@api recordId;
@track isLoading = false;
@track isSaving = false;
@track contentDocumentId = null; // 生成したPDFのContentDocumentId
@track errorMessage = null;
@track savedFileName = null;
// 帳票種別 — Lightning App Builder のプロパティパネルで設定
// 設定値: 'quote'(見積書兼発注書) | 'delivery'(納品書) | 'receipt'(請求書)
@api templateType = 'quote';
// ============================================================
// 「帳票を生成・プレビュー」ボタン押下
// 1. ApexでPDF生成 → ContentVersionに一時保存
// 2. NavigationMixinでSalesforceのファイルビューアを開く
// → Salesforce標準のプレビュー画面なのでCSP制限なし
// ============================================================
async handlePreview() {
this.isLoading = true;
this.contentDocumentId = null;
this.errorMessage = null;
this.savedFileName = null;
try {
// Apex: PDF生成 → ContentVersion保存 → Id返却
const result = await previewPdf({ quoteId: this.recordId, templateType: this.templateType });
this.contentDocumentId = result.contentDocumentId;
// NavigationMixin でSalesforce標準ファイルビューアを開く
// → 別タブで開くが、Salesforceのビューアなので文字化けなし・高品質
this[NavigationMixin.Navigate]({
type: 'standard__namedPage',
attributes: {
pageName: 'filePreview'
},
state: {
selectedRecordId: result.contentDocumentId
}
});
} catch (error) {
this.errorMessage =
error?.body?.message ?? error?.message ?? 'PDF生成中にエラーが発生しました';
} finally {
this.isLoading = false;
}
}
// ============================================================
// 「このPDFをファイルに残す」ボタン押下
// ContentDocumentLinkでレコードに正式紐付け
// ============================================================
async handleKeepFile() {
this.isSaving = true;
this.savedFileName = null;
try {
const fileName = await keepPdfFile({
quoteId: this.recordId,
contentDocumentId: this.contentDocumentId
});
this.savedFileName = fileName;
this.dispatchEvent(new ShowToastEvent({
title: '保存完了',
message: fileName + ' をファイルに保存しました',
variant: 'success',
mode: 'sticky',
}));
} catch (error) {
this.errorMessage = error?.body?.message ?? '保存中にエラーが発生しました';
this.dispatchEvent(new ShowToastEvent({
title: '保存エラー',
message: this.errorMessage,
variant: 'error',
}));
} finally {
this.isSaving = false;
}
}
clearError() {
this.errorMessage = null;
}
}
// ThomasPdfService.cls
public with sharing class ThomasPdfService {
private static final String API_ENDPOINT =
'https://sf-quote-pdf.pages.dev/api/quote/pdf';
// ============================================================
// LWC向け: PDFを生成してContentVersionに一時保存
// → ContentDocumentId を返す(Salesforceファイルビューア用)
// templateType: 'quote'(見積書兼発注書) | 'delivery'(納品書) | 'receipt'(請求書)
// ============================================================
@AuraEnabled
public static Map<String,String> previewPdf(Id quoteId, String templateType) {
if (String.isBlank(templateType)) templateType = 'quote';
Blob pdfBlob = callPdfApi(quoteId, templateType);
Quote__c q = [SELECT QuoteNumber__c FROM Quote__c WHERE Id = :quoteId LIMIT 1];
// テンプレート種別に応じたファイル名
String prefix = templateType == 'delivery' ? '納品書'
: templateType == 'receipt' ? '請求書'
: '見積書兼発注書';
String fileName = prefix + '_' + q.QuoteNumber__c + '.pdf';
ContentVersion cv = new ContentVersion();
cv.Title = fileName.replace('.pdf', '') + '_preview';
cv.PathOnClient = fileName;
cv.VersionData = pdfBlob;
cv.IsMajorVersion = false;
insert cv;
cv = [SELECT Id, ContentDocumentId FROM ContentVersion WHERE Id = :cv.Id LIMIT 1];
return new Map<String,String>{
'contentDocumentId' => cv.ContentDocumentId,
'contentVersionId' => cv.Id,
'fileName' => fileName
};
}
// ============================================================
// LWC向け: プレビュー済みファイルをレコードに正式紐付け
// ============================================================
@AuraEnabled
public static String keepPdfFile(Id quoteId, Id contentDocumentId) {
ContentDocument cd = [SELECT Title FROM ContentDocument WHERE Id = :contentDocumentId LIMIT 1];
String fileName = cd.Title.replace('_preview', '') + '.pdf';
ContentDocumentLink link = new ContentDocumentLink();
link.ContentDocumentId = contentDocumentId;
link.LinkedEntityId = quoteId;
link.ShareType = 'V';
link.Visibility = 'AllUsers';
insert link;
return fileName;
}
// ============================================================
// 共通: 外部API呼び出し(Blob返却)
// ============================================================
private static Blob callPdfApi(Id quoteId, String templateType) {
Quote__c quote = [
SELECT Id, QuoteNumber__c, QuoteDate__c, Subject__c,
IssuerCompanyName__c, IssuerPostalCode__c,
IssuerAddress__c, IssuerTel__c,
RecipientCompanyName__c, RecipientPostalCode__c,
RecipientAddress__c,
Subtotal__c, TaxRate__c, Tax__c, TotalAmount__c,
Remarks__c, PaymentTerms__c
FROM Quote__c WHERE Id = :quoteId LIMIT 1
];
List<QuoteLineItem__c> lineItems = [
SELECT ProductName__c, Quantity__c, UnitPrice__c, Amount__c
FROM QuoteLineItem__c
WHERE Quote__c = :quoteId
ORDER BY SortOrder__c ASC
];
Map<String,Object> payload = new Map<String,Object>{
'quoteNumber' => quote.QuoteNumber__c,
'quoteDate' => String.valueOf(quote.QuoteDate__c),
'title' => quote.Subject__c,
'issuerCompanyName' => quote.IssuerCompanyName__c,
'issuerPostalCode' => quote.IssuerPostalCode__c,
'issuerAddress' => quote.IssuerAddress__c,
'issuerTel' => quote.IssuerTel__c,
'recipientCompanyName' => quote.RecipientCompanyName__c,
'recipientPostalCode' => quote.RecipientPostalCode__c,
'recipientAddress' => quote.RecipientAddress__c,
'subtotal' => quote.Subtotal__c,
'taxRate' => quote.TaxRate__c,
'tax' => quote.Tax__c,
'totalAmount' => quote.TotalAmount__c,
'remarks' => quote.Remarks__c,
'paymentTerms' => quote.PaymentTerms__c,
'lineItems' => buildLineItems(lineItems),
'templateType' => templateType
};
String apiKey = [
SELECT Value__c FROM ApiSetting__mdt
WHERE DeveloperName = 'QuotePdfApiKey' LIMIT 1
].Value__c;
HttpRequest req = new HttpRequest();
req.setEndpoint(API_ENDPOINT);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setHeader('X-API-Key', apiKey);
req.setBody(JSON.serialize(payload));
req.setTimeout(30000);
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new AuraHandledException(
'PDF生成APIエラー(' + res.getStatusCode() + '): ' + res.getBody()
);
}
return res.getBodyAsBlob();
}
private static List<Map<String,Object>> buildLineItems(List<QuoteLineItem__c> items) {
List<Map<String,Object>> result = new List<Map<String,Object>>();
for (QuoteLineItem__c item : items) {
result.add(new Map<String,Object>{
'productName' => item.ProductName__c,
'quantity' => item.Quantity__c,
'unitPrice' => item.UnitPrice__c,
'amount' => item.Amount__c
});
}
return result;
}
}
<?xml version="1.0" encoding="UTF-8"?>
<!-- thomasPdfViewer.js-meta.xml -->
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>61.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
<target>lightning__AppPage</target>
</targets>
<targetConfigs>
<targetConfig targets="lightning__RecordPage,lightning__AppPage">
<objects>
<object>Quote__c</object>
</objects>
<!--
App Builder 右パネルに「帳票種別」プロパティが表示される。
ドロップダウンから選択した値が @api templateType に渡される。
-->
<property
name="templateType"
type="String"
label="帳票種別"
description="出力する帳票の種類を選択してください"
default="quote">
<datasource>
<value label="見積書兼発注書" value="quote"/>
<value label="納品書" value="delivery"/>
<value label="請求書" value="receipt"/>
</datasource>
</property>
</targetConfig>
</targetConfigs>
</LightningComponentBundle>
https://sf-quote-pdf.pages.dev を追加| 項目名 | API名 | 型 |
|---|---|---|
| 見積番号 | QuoteNumber__c | Text(20) |
| 見積日付 | QuoteDate__c | Date |
| 件名 | Subject__c | Text(200) |
| 発注元会社名 | IssuerCompanyName__c | Text(100) |
| 発注元郵便番号 | IssuerPostalCode__c | Text(8) |
| 発注元住所 | IssuerAddress__c | Text(200) |
| 発注元TEL | IssuerTel__c | Phone |
| 宛先会社名 | RecipientCompanyName__c | Text(100) |
| 宛先郵便番号 | RecipientPostalCode__c | Text(8) |
| 宛先住所 | RecipientAddress__c | Text(200) |
| 小計 | Subtotal__c | Currency |
| 税率 | TaxRate__c | Number(5,2) |
| 消費税 | Tax__c | Currency |
| 合計金額 | TotalAmount__c | Currency |
| 発注書備考 | Remarks__c | Long Text |
| 支払い条件 | PaymentTerms__c | Long Text |
| 項目名 | API名 | 型 |
|---|---|---|
| 見積(親) | Quote__c | Lookup(Quote__c) |
| 商品名 | ProductName__c | Text(200) |
| 数量 | Quantity__c | Number(18,2) |
| 単価 | UnitPrice__c | Currency |
| 金額 | Amount__c | Currency |
| 説明 | Description__c | Text(500) |
| 並び順 | SortOrder__c | Number(3,0) |